diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index 5344352..4635ad6 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -381,5 +381,6 @@ 三条用例全 FAIL(偏移 39/59px,且文字真的溢出卡片 615 < 660)。 - 删掉了临时原型 `dev/tmpcard`(结论已进测试与 outputs 图)。 - P1 未做(二维码模块整数倍、间距 4px 栅格、水印相对左侧内容区居中)—— 等用户看完效果再定。 +- 已推送 main:`0054e50`(修复 + 测试 + go.mod 依赖转正)、`80e042d`(记忆/忽略规则),等测试环境部署复验。 diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 255155a..3bf0e70 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -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] diff --git a/route/question/share.go b/route/question/share.go index 79f76d9..8d37cb2 100644 --- a/route/question/share.go +++ b/route/question/share.go @@ -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) + fontMu.Lock() + defer fontMu.Unlock() + if fontCache != nil { + return fontCache, nil + } + var tried []string + for _, path := range fontCandidates() { + tried = append(tried, path) + data, err := os.ReadFile(path) if err != nil { - fontErr = err - return + continue } - fontCache, fontErr = freetype.ParseFont(fontBytes) - }) - return fontCache, fontErr + 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) diff --git a/route/question/share_layout_test.go b/route/question/share_layout_test.go index 6f2ff98..7b79fb6 100644 --- a/route/question/share_layout_test.go +++ b/route/question/share_layout_test.go @@ -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)