fix(share): 字体缺失不再永久失败;二维码按模块整数倍绘制
测试环境部署后分享卡整站 500「字体文件未配置或无法加载」。根因不是代码
回退,而是字体从来就不在仓库里:static/fonts/ 既没有入库(git 历史为空),
也没有 embed,凡是按仓库新建的环境都缺这个文件,只有手工补过字体的老环境
才正常。
- getFont 不再用 sync.Once 永久缓存失败:补上字体文件即生效,无需重启;
失败时错误信息带上「已尝试的路径」,排障不用再猜是配置还是文件
- 候选路径:font_path → static/fonts/*.{otf,ttf,ttc} → 常见系统 CJK 字体,
换过字体文件名也能用
- app.sample.ini 注明字体必须自备、缺失的后果
顺带做 P1 的第一项:generateQR 改为按模块整数倍出图(41 模块 @165px →
每模块 4px、符号 164px 居中)。原先是 256px 生成后最近邻缩到 165px,
每模块 4.02px,模块宽窄相差 1px,小尺寸二次压缩后更容易扫不动。
内嵌头像改为按目标尺寸等比(size*48/256)+ 2px 白边,静默区不受影响。
守门测试新增两条:TestGenerateQRIsModuleAligned(扫描线游程必须是模块
宽度的整数倍,变异成等比缩放后立刻 FAIL)、TestGenerateQRWithLogo
(中心是头像、四角静默区纯白)。
This commit is contained in:
@@ -381,5 +381,6 @@
|
||||
三条用例全 FAIL(偏移 39/59px,且文字真的溢出卡片 615 < 660)。
|
||||
- 删掉了临时原型 `dev/tmpcard`(结论已进测试与 outputs 图)。
|
||||
- P1 未做(二维码模块整数倍、间距 4px 栅格、水印相对左侧内容区居中)—— 等用户看完效果再定。
|
||||
- 已推送 main:`0054e50`(修复 + 测试 + go.mod 依赖转正)、`80e042d`(记忆/忽略规则),等测试环境部署复验。
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@ aliyun_access_key = ""
|
||||
aliyun_access_key_secret = ""
|
||||
; 默认分享图背景图链接(1280x720)
|
||||
share_background = "https://your-default-background.jpg"
|
||||
; 中文字体文件路径(相对项目根目录)
|
||||
; 分享卡绘图用的中文字体(相对项目根目录)。字体文件不在仓库里,必须自备一份,
|
||||
; 否则分享卡接口会返回 500「字体文件未配置或无法加载」。可用 Noto Sans SC / 思源黑体,
|
||||
; 支持 .otf/.ttf/.ttc;放好后无需重启生效。
|
||||
font_path = "static/fonts/NotoSansSC-Regular.otf"
|
||||
|
||||
[security]
|
||||
|
||||
+72
-28
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
stdctx "context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -123,21 +125,55 @@ var (
|
||||
|
||||
// 全局字体缓存
|
||||
var (
|
||||
fontOnce sync.Once
|
||||
fontMu sync.Mutex
|
||||
fontCache *truetype.Font
|
||||
fontErr error
|
||||
)
|
||||
|
||||
// getFont 返回绘图字体。失败结果不缓存:字体文件属于部署产物、不在仓库里,
|
||||
// 新环境最常见的就是漏放;补上文件后无需重启即可恢复分享卡。
|
||||
func getFont() (*truetype.Font, error) {
|
||||
fontOnce.Do(func() {
|
||||
fontBytes, err := os.ReadFile(conf.App.FontPath)
|
||||
if err != nil {
|
||||
fontErr = err
|
||||
return
|
||||
fontMu.Lock()
|
||||
defer fontMu.Unlock()
|
||||
if fontCache != nil {
|
||||
return fontCache, nil
|
||||
}
|
||||
fontCache, fontErr = freetype.ParseFont(fontBytes)
|
||||
})
|
||||
return fontCache, fontErr
|
||||
var tried []string
|
||||
for _, path := range fontCandidates() {
|
||||
tried = append(tried, path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ft, err := freetype.ParseFont(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fontCache = ft
|
||||
return ft, nil
|
||||
}
|
||||
if len(tried) == 0 {
|
||||
return nil, errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc")
|
||||
}
|
||||
return nil, fmt.Errorf("没有可用字体,已尝试:%s", strings.Join(tried, "、"))
|
||||
}
|
||||
|
||||
// fontCandidates 字体候选路径:配置项优先,其次是部署目录里的字体文件
|
||||
// (换过文件名也能用上),最后是常见系统 CJK 字体路径。
|
||||
func fontCandidates() []string {
|
||||
var paths []string
|
||||
if p := strings.TrimSpace(conf.App.FontPath); p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
for _, pattern := range []string{"static/fonts/*.otf", "static/fonts/*.ttf", "static/fonts/*.ttc"} {
|
||||
if matches, err := filepath.Glob(pattern); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
}
|
||||
return append(paths,
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
)
|
||||
}
|
||||
|
||||
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
|
||||
@@ -462,13 +498,12 @@ func ShareCard(ctx context.Context) {
|
||||
drawRoundedRect(canvas, cardRect, ColorWhite, 18)
|
||||
|
||||
boxURL := fmt.Sprintf("%s/_/%s", conf.App.ExternalURL, user.Domain)
|
||||
qr, err := generateQR(boxURL, user, avImg)
|
||||
qr, err := generateQR(boxURL, avImg, QRCodeSize)
|
||||
if err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code")
|
||||
return
|
||||
}
|
||||
qrResized := imaging.Resize(qr, QRCodeSize, QRCodeSize, imaging.NearestNeighbor)
|
||||
|
||||
const (
|
||||
qrToHintGap = 26
|
||||
@@ -484,7 +519,7 @@ func ShareCard(ctx context.Context) {
|
||||
|
||||
qrDrawX := QRCardX + QRCodeX
|
||||
qrDrawY := startY
|
||||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qrResized, image.Point{}, draw.Over)
|
||||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qr, image.Point{}, draw.Over)
|
||||
|
||||
hintY := qrDrawY + QRCodeSize + qrToHintGap + hintLineHeight
|
||||
drawTextCentered(canvas, "扫码向我匿名提问", cardCenterX, hintY, FontSizeQRHint, ColorHintGray, fontCtx)
|
||||
@@ -810,7 +845,9 @@ func drawTextCentered(dst *image.RGBA, text string, cx, y int, fontSize float64,
|
||||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||||
}
|
||||
|
||||
func generateQR(content string, user *db.User, logoImg image.Image) (image.Image, error) {
|
||||
// generateQR 生成边长固定为 size 的二维码:按模块整数倍出图再居中留白,
|
||||
// 避免直接缩放到任意尺寸造成的模块宽窄相差 1px(小尺寸下最影响可扫性)。
|
||||
func generateQR(content string, logoImg image.Image, size int) (image.Image, error) {
|
||||
qr, err := qrcode.New(content, qrcode.High)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -820,20 +857,28 @@ func generateQR(content string, user *db.User, logoImg image.Image) (image.Image
|
||||
qr.BackgroundColor = color.RGBA{255, 255, 255, 255}
|
||||
qr.ForegroundColor = color.RGBA{0, 0, 0, 255}
|
||||
|
||||
qrPaletted := qr.Image(256)
|
||||
rgba := image.NewRGBA(qrPaletted.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), qrPaletted, image.Point{}, draw.Src)
|
||||
modules := len(qr.Bitmap()) // 含静默区
|
||||
perModule := size / modules
|
||||
if perModule < 1 {
|
||||
perModule = 1
|
||||
}
|
||||
symbol := qr.Image(-perModule)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.Draw(dst, dst.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
||||
offset := image.Pt((size-symbol.Bounds().Dx())/2, (size-symbol.Bounds().Dy())/2)
|
||||
draw.Draw(dst, symbol.Bounds().Add(offset), symbol, image.Point{}, draw.Src)
|
||||
|
||||
if logoImg != nil {
|
||||
logo := imaging.Fill(logoImg, 48, 48, imaging.Center, imaging.Lanczos)
|
||||
bounds := rgba.Bounds()
|
||||
logoPos := image.Pt(
|
||||
bounds.Min.X+(bounds.Dx()-logo.Bounds().Dx())/2,
|
||||
bounds.Min.Y+(bounds.Dy()-logo.Bounds().Dy())/2,
|
||||
)
|
||||
draw.Draw(rgba, logo.Bounds().Add(logoPos), logo, image.Point{}, draw.Over)
|
||||
logoSize := size * 48 / 256
|
||||
logo := imaging.Fill(logoImg, logoSize, logoSize, imaging.Center, imaging.Lanczos)
|
||||
// 白边保证头像与模块之间有对比度,H 级容错(30%)足以覆盖这点面积
|
||||
holder := image.Rect(0, 0, logoSize+4, logoSize+4)
|
||||
holder = holder.Add(image.Pt((size-holder.Dx())/2, (size-holder.Dy())/2))
|
||||
draw.Draw(dst, holder, &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
||||
draw.Draw(dst, logo.Bounds().Add(holder.Min.Add(image.Pt(2, 2))), logo, image.Point{}, draw.Over)
|
||||
}
|
||||
return rgba, nil
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// ---------- 单题分享图 ----------
|
||||
@@ -1021,17 +1066,16 @@ func QuestionShareCard(ctx context.Context) {
|
||||
drawRoundedRect(canvas, cardRect, ColorWhite, 20)
|
||||
|
||||
questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID)
|
||||
qr, err := generateQR(questionURL, user, avImg)
|
||||
qr, err := generateQR(questionURL, avImg, QQRSize)
|
||||
if err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
|
||||
return
|
||||
}
|
||||
qrResized := imaging.Resize(qr, QQRSize, QQRSize, imaging.NearestNeighbor)
|
||||
qrDrawX := QQRCardX + QQRCodeX
|
||||
// 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁)
|
||||
qrDrawY := QQRCardY + 40
|
||||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qrResized, image.Point{}, draw.Over)
|
||||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qr, image.Point{}, draw.Over)
|
||||
|
||||
hintY := qrDrawY + QQRSize + 30
|
||||
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx)
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
@@ -141,6 +141,27 @@ func TestDrawTextCenteredIsCentered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 内嵌头像不能吃掉静默区(否则扫码器找不到边界),且必须真的画在中心。
|
||||
func TestGenerateQRWithLogo(t *testing.T) {
|
||||
logo := image.NewRGBA(image.Rect(0, 0, 64, 40))
|
||||
draw.Draw(logo, logo.Bounds(), &image.Uniform{color.RGBA{200, 40, 40, 255}}, image.Point{}, draw.Src)
|
||||
|
||||
img, err := generateQR("https://box.shiroko.one/_/tamakyi", logo, QRCodeSize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _, _, _ := img.At(QRCodeSize/2, QRCodeSize/2).RGBA()
|
||||
if r < 0x8000 || r > 0xf000 {
|
||||
t.Errorf("center pixel should be the logo color, got %#x", r)
|
||||
}
|
||||
for _, p := range []image.Point{{0, 0}, {QRCodeSize - 1, 0}, {0, QRCodeSize - 1}, {QRCodeSize - 1, QRCodeSize - 1}} {
|
||||
r, g, b, _ := img.At(p.X, p.Y).RGBA()
|
||||
if r != 0xffff || g != 0xffff || b != 0xffff {
|
||||
t.Errorf("quiet zone at %v is not white: %#x %#x %#x", p, r, g, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
@@ -148,6 +169,57 @@ func abs(v int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// 二维码必须按模块整数倍绘制:任何宽度落差的游程都意味着模块被拉伸,
|
||||
// 小尺寸(微信/QQ 二次压缩后)最容易出现「扫不动」。
|
||||
func TestGenerateQRIsModuleAligned(t *testing.T) {
|
||||
const url = "https://box.shiroko.one/_/tamakyi"
|
||||
for _, size := range []int{QRCodeSize, QQRSize} {
|
||||
img, err := generateQR(url, nil, size)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := img.Bounds().Dx(); got != size {
|
||||
t.Fatalf("size %d: got width %d", size, got)
|
||||
}
|
||||
|
||||
probe, err := qrcode.New(url, qrcode.High)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modules := len(probe.Bitmap())
|
||||
perModule := size / modules
|
||||
if perModule < 1 {
|
||||
t.Fatalf("size %d cannot hold %d modules", size, modules)
|
||||
}
|
||||
start := (size - modules*perModule) / 2
|
||||
|
||||
// 中间一行:所有黑白游程(限定在符号区域内)都必须是模块宽度的整数倍
|
||||
y := size / 2
|
||||
runLen := 0
|
||||
for x := start; x < start+modules*perModule; x++ {
|
||||
r, _, _, _ := img.At(x, y).RGBA()
|
||||
black := r < 0x8000
|
||||
if x == start {
|
||||
runLen = 1
|
||||
continue
|
||||
}
|
||||
prevR, _, _, _ := img.At(x-1, y).RGBA()
|
||||
if (prevR < 0x8000) != black {
|
||||
if runLen%perModule != 0 {
|
||||
t.Fatalf("size %d: run of %dpx at x=%d is not a multiple of module %d",
|
||||
size, runLen, x-runLen, perModule)
|
||||
}
|
||||
runLen = 1
|
||||
continue
|
||||
}
|
||||
runLen++
|
||||
}
|
||||
if runLen%perModule != 0 {
|
||||
t.Fatalf("size %d: trailing run %dpx not a multiple of %d", size, runLen, perModule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。
|
||||
func TestShareCardLinkPreview(t *testing.T) {
|
||||
if os.Getenv("TEST_PREVIEW") == "" {
|
||||
@@ -182,12 +254,11 @@ func TestShareCardLinkPreview(t *testing.T) {
|
||||
total := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines)
|
||||
startY := (QRCardH - total) / 2
|
||||
|
||||
qr, err := generateQR("https://"+display, nil, nil)
|
||||
qr, err := generateQR("https://"+display, nil, QRCodeSize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qrResized := imaging.Resize(qr, QRCodeSize, QRCodeSize, imaging.NearestNeighbor)
|
||||
draw.Draw(canvas, image.Rect(QRCodeX, startY, QRCodeX+QRCodeSize, startY+QRCodeSize), qrResized, image.Point{}, draw.Over)
|
||||
draw.Draw(canvas, image.Rect(QRCodeX, startY, QRCodeX+QRCodeSize, startY+QRCodeSize), qr, image.Point{}, draw.Over)
|
||||
|
||||
hintY := startY + QRCodeSize + qrToHintGap + hintLineHeight
|
||||
drawTextCentered(canvas, "扫码向我匿名提问", QRCardW/2, hintY, FontSizeQRHint, ColorHintGray, fctx)
|
||||
|
||||
Reference in New Issue
Block a user