From 0054e508b770600428b5ac3873a34c52e6602f15 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 22 Oct 2026 21:42:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(share):=20=E5=88=86=E4=BA=AB=E5=8D=A1?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=8C=89=E7=9C=9F=E5=AE=9E=E5=AD=97=E5=AE=BD?= =?UTF-8?q?=E5=B1=85=E4=B8=AD=EF=BC=8C=E9=95=BF=E9=93=BE=E6=8E=A5=E6=8C=89?= =?UTF-8?q?=20host/path=20=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现用字符宽度系数估算文本宽度,把标点/符号一律按 1em 计, 而 URL 里的 : / . _ - 实际只有 0.25~0.35em,整句高估 55%: 15px 的 box.shiroko.one/_/tamakyi 估算 352.5px、真实 227.9px, 越过卡片可用宽 230px 后触发等比缩字号,最终只画到 9.6px 且左偏 40px; 链接越长字号越小(长域名实测 5.3px),整段挤在卡片左侧。 - measureTextWidth 改为逐字累加 HMetric.AdvanceWidth + Kern, scale = fontSize*64 与 freetype 在 72 DPI 下的换算一致,测量结果与 DrawString 返回笔位逐位相等;字体不可用时回退估算 - drawTextCentered 随之修正,底部水印原本偏 9.8px 一并归位 - 新增 displayURL(去协议前缀)/ layoutLink / ellipsizeMiddle: 单行 15→13px;放不下按 host/path 拆两行 13→11px;host 超宽才中间省略; 字号下限 11px,行长恒定不超过 QRCardW-40 - 卡片内容高度按链接行数计算,两行时整体重新居中 守门测试见 route/question/share_layout_test.go(测量=笔位、限宽、按着墨 边界判定居中 ±3px、TEST_PREVIEW=1 出图)。 --- go.mod | 2 +- route/question/share.go | 98 +++++++++++-- route/question/share_layout_test.go | 210 ++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 route/question/share_layout_test.go diff --git a/go.mod b/go.mod index 6f687f7..93673f6 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( go.opentelemetry.io/otel/sdk v1.9.0 go.opentelemetry.io/otel/trace v1.10.0 golang.org/x/crypto v0.54.0 + golang.org/x/image v0.13.0 golang.org/x/net v0.56.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/ini.v1 v1.67.0 @@ -151,7 +152,6 @@ require ( go.opentelemetry.io/proto/otlp v0.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/image v0.13.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/route/question/share.go b/route/question/share.go index af9f989..79f76d9 100644 --- a/route/question/share.go +++ b/route/question/share.go @@ -24,6 +24,7 @@ import ( "github.com/golang/freetype/truetype" "github.com/sirupsen/logrus" qrcode "github.com/skip2/go-qrcode" + "golang.org/x/image/math/fixed" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/context" @@ -99,6 +100,15 @@ const ( FontSizeFooter = 14 ) +// 链接区排版:可用宽度按卡片内边距 20px 计算;字号下限保证长域名仍然可读, +// 单行放不下时改为 host/path 两行,而不是把字号一路缩到看不清。 +const ( + LinkMaxWidth = QRCardW - 40 + LinkSingleMinSize = 13.0 + LinkMinSize = 11.0 + LinkSizeStep = 0.5 +) + // 颜色系统 var ( ColorWhite = color.RGBA{255, 255, 255, 255} @@ -468,7 +478,8 @@ func ShareCard(ctx context.Context) { sepToLinkGap = 21 linkLineHeight = 15 ) - totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight + linkLines, linkFontSize := layoutLink(displayURL(boxURL)) + totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(linkLines) startY := QRCardY + (QRCardH-totalContentH)/2 qrDrawX := QRCardX + QRCodeX @@ -482,14 +493,9 @@ func ShareCard(ctx context.Context) { drawLine(canvas, cardCenterX-90, sepY, cardCenterX+90, sepY, color.NRGBA{200, 200, 200, 180}) linkY := sepY + sepToLinkGap + linkLineHeight - linkText := boxURL - linkFontSize := float64(FontSizeLink) - availableW := QRCardW - 45 - actualW := measureTextWidth(linkText, linkFontSize) - if actualW > float64(availableW) { - linkFontSize = linkFontSize * float64(availableW) / actualW + for i, line := range linkLines { + drawTextCentered(canvas, line, cardCenterX, linkY+i*linkLineHeight, linkFontSize, ColorDarkText, fontCtx) } - drawTextCentered(canvas, linkText, cardCenterX, linkY, linkFontSize, ColorDarkText, fontCtx) // 7. 底部水印 drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, FooterY, FontSizeFooter, ColorFooterGray, fontCtx) @@ -707,12 +713,32 @@ func charPixelWidth(r rune, fontSize float64) float64 { return fontSize * 0.62 } -// measureTextWidth 用逐字符宽度估算(与 splitText 的 charPixelWidth 一致), -// 避免每次测量都创建 freetype.Context 并执行 DrawString(显著降低生成耗时)。 +// measureTextWidth 返回文本在给定字号下的真实推进宽度(像素)。 +// 逐字累加字形 advance 与 kern,scale 与 freetype.Context 在 72 DPI 下的换算一致 +// (fontSize * 64);用它做居中,误差从原来估算的几十像素降到亚像素。 +// 字体不可用时回退到字符宽度估算,保证分享卡不会因字体缺失而完全不可用。 func measureTextWidth(text string, fontSize float64) float64 { - return measureTextEstimate(text, fontSize) + ft, err := getFont() + if err != nil || ft == nil { + return measureTextEstimate(text, fontSize) + } + scale := fixed.Int26_6(fontSize * 64.0) + total := fixed.Int26_6(0) + var prev truetype.Index + hasPrev := false + for _, r := range text { + idx := ft.Index(r) + if hasPrev { + total += ft.Kern(scale, prev, idx) + } + total += ft.HMetric(scale, idx).AdvanceWidth + prev, hasPrev = idx, true + } + return float64(total) / 64.0 } +// measureTextEstimate 按字符宽度系数粗估,仅作字体不可用时的回退。 +// 注意:标点/符号按 1em 计,对 URL 会高估 50% 以上,不能用于居中定位。 func measureTextEstimate(text string, fontSize float64) float64 { total := 0.0 for _, r := range text { @@ -721,6 +747,56 @@ func measureTextEstimate(text string, fontSize float64) float64 { return total } +// displayURL 去掉协议前缀与结尾斜杠:二维码里仍编码完整地址, +// 人眼需要辨认的只是 host 与路径。 +func displayURL(rawURL string) string { + s := strings.TrimPrefix(rawURL, "https://") + s = strings.TrimPrefix(s, "http://") + return strings.TrimSuffix(s, "/") +} + +// layoutLink 把展示用地址收进卡片宽度,返回逐行文本与统一字号。 +// 顺序:单行 15→13px;仍放不下按 host/path 拆两行(13→11px,而不是在 +// host 中间的连字符处断开);host 自身超宽才做中间省略。 +func layoutLink(text string) ([]string, float64) { + maxW := float64(LinkMaxWidth) + for size := float64(FontSizeLink); size >= LinkSingleMinSize; size -= LinkSizeStep { + if measureTextWidth(text, size) <= maxW { + return []string{text}, size + } + } + if i := strings.Index(text, "/"); i > 0 { + host, path := text[:i], text[i:] + for size := LinkSingleMinSize; size >= LinkMinSize; size -= LinkSizeStep { + if measureTextWidth(host, size) <= maxW && measureTextWidth(path, size) <= maxW { + return []string{host, path}, size + } + } + return []string{ + ellipsizeMiddle(host, maxW, LinkMinSize), + ellipsizeMiddle(path, maxW, LinkMinSize), + }, LinkMinSize + } + return []string{ellipsizeMiddle(text, maxW, LinkMinSize)}, LinkMinSize +} + +// ellipsizeMiddle 中间省略:保住开头与结尾(域名结尾通常是 TLD,最需要留下)。 +func ellipsizeMiddle(text string, maxWidth, fontSize float64) string { + if measureTextWidth(text, fontSize) <= maxWidth { + return text + } + r := []rune(text) + for keep := len(r) - 1; keep >= 3; keep-- { + head := keep * 6 / 10 + tail := keep - head + s := string(r[:head]) + "…" + string(r[len(r)-tail:]) + if measureTextWidth(s, fontSize) <= maxWidth { + return s + } + } + return text +} + func drawTextCentered(dst *image.RGBA, text string, cx, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) { ftCtx.SetClip(dst.Bounds()) ftCtx.SetSrc(image.NewUniform(col)) diff --git a/route/question/share_layout_test.go b/route/question/share_layout_test.go new file mode 100644 index 0000000..6f2ff98 --- /dev/null +++ b/route/question/share_layout_test.go @@ -0,0 +1,210 @@ +package question + +import ( + "image" + "image/color" + "image/draw" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/disintegration/imaging" + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + + "github.com/tamakyi/TamaBox/internal/conf" +) + +// 分享卡测试需要一份真实字体;中文站点部署时 conf.App.FontPath 必然存在, +// 本机开发/CI 缺失时跳过,避免把「没有字体」当成排版错误。 +func TestMain(m *testing.M) { + if _, err := os.Stat(conf.App.FontPath); err != nil { + for _, candidate := range []string{ + "C:/Windows/Fonts/NotoSansSC-VF.ttf", + "C:/Windows/Fonts/msyh.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + } { + if _, err := os.Stat(candidate); err == nil { + conf.App.FontPath = candidate + break + } + } + } + os.Exit(m.Run()) +} + +func requireFont(t *testing.T) *truetype.Font { + t.Helper() + ft, err := getFont() + if err != nil { + t.Skipf("no usable font (%v), skip share card layout tests", err) + } + return ft +} + +// measureTextWidth 必须与 freetype 实际笔位推进一致,否则居中会重新出现偏移。 +func TestMeasureTextWidthMatchesAdvance(t *testing.T) { + requireFont(t) + cases := []struct { + text string + size float64 + }{ + {"https://box.shiroko.one/_/tamakyi", FontSizeLink}, + {"box.shiroko.one/_/tamakyi", 13}, + {"ask.some-quite-long-domain-name.example.com", LinkMinSize}, + {"Powered by 狼的提问箱", FontSizeFooter}, + {"扫码向我匿名提问", FontSizeQRHint}, + } + for _, c := range cases { + dst := image.NewRGBA(image.Rect(0, 0, 1400, 80)) + fctx := freetype.NewContext() + fctx.SetFont(requireFont(t)) + fctx.SetDPI(72) + fctx.SetFontSize(c.size) + fctx.SetSrc(image.NewUniform(color.Black)) + fctx.SetDst(dst) + pen, err := fctx.DrawString(c.text, freetype.Pt(0, 60)) + if err != nil { + t.Fatalf("draw %q: %v", c.text, err) + } + want := float64(pen.X) / 64.0 + if got := measureTextWidth(c.text, c.size); got != want { + t.Errorf("%q @%.1f: measure=%.2f, advance=%.2f", c.text, c.size, got, want) + } + } +} + +// 任意长度的链接都必须收进卡片,且不得缩到不可读。 +func TestLayoutLinkFitsCard(t *testing.T) { + requireFont(t) + urls := []string{ + "box.shiroko.one/_/tamakyi", + "box-dev-api.337845818.best/_/tamakyi", + "ask.some-quite-long-domain-name.example.com/_/tamakyi", + "questions.tamabox-community.example.org/anon/box/_/a-rather-long-handle-name", + "averyveryveryverylongdomainnamethatkeepsgoingandonandonforever.example/_/x", + } + for _, u := range urls { + lines, size := layoutLink(u) + if size < LinkMinSize { + t.Errorf("%q: font size %.2f below floor %.2f", u, size, LinkMinSize) + } + if len(lines) > 2 { + t.Errorf("%q: got %d lines, want <=2", u, len(lines)) + } + for _, line := range lines { + if w := measureTextWidth(line, size); w > float64(LinkMaxWidth) { + t.Errorf("%q: line %q is %.1fpx, card allows %dpx", u, line, w, LinkMaxWidth) + } + } + } +} + +// 居中必须落在二维码卡片中线上(按真实着墨边界判断,允许左右侧边距带来的 3px 内偏差)。 +func TestDrawTextCenteredIsCentered(t *testing.T) { + requireFont(t) + cx := QRCardX + QRCardW/2 + for _, text := range []string{ + "box.shiroko.one/_/tamakyi", + "box-dev-api.337845818.best/_/tamakyi", + "Powered by 狼的提问箱", + } { + canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) + draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src) + fctx := freetype.NewContext() + fctx.SetFont(requireFont(t)) + fctx.SetDPI(72) + drawTextCentered(canvas, text, cx, 300, FontSizeLink, ColorDarkText, fctx) + + minX, maxX := -1, -1 + for x := 0; x < CardWidth; x++ { + for y := 250; y < 320; y++ { + if r, _, _, a := canvas.At(x, y).RGBA(); a > 40 && r < 0x8000 { + if minX < 0 { + minX = x + } + maxX = x + break + } + } + } + if minX < 0 { + t.Fatalf("%q: nothing drawn", text) + } + if center := (minX + maxX) / 2; abs(center-cx) > 3 { + t.Errorf("%q: ink center %d, want %d (±3)", text, center, cx) + } + if maxX > QRCardX+QRCardW || minX < QRCardX { + t.Errorf("%q: ink [%d,%d] escapes card [%d,%d]", text, minX, maxX, QRCardX, QRCardX+QRCardW) + } + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。 +func TestShareCardLinkPreview(t *testing.T) { + if os.Getenv("TEST_PREVIEW") == "" { + t.Skip("set TEST_PREVIEW=1 to write preview images") + } + requireFont(t) + outDir := os.Getenv("TEST_PREVIEW_DIR") + if outDir == "" { + outDir = filepath.Join("..", "..", "outputs", "sharecard-preview") + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + t.Fatal(err) + } + + cases := map[string]string{ + "short": "box.shiroko.one/_/tamakyi", + "dev": "box-dev-api.337845818.best/_/tamakyi", + "long": "ask.some-quite-long-domain-name.example.com/_/tamakyi", + "verylong": "questions.tamabox-community.example.org/anon/box/_/a-rather-long-handle-name", + } + for name, display := range cases { + canvas := image.NewRGBA(image.Rect(0, 0, QRCardW, QRCardH)) + draw.Draw(canvas, canvas.Bounds(), &image.Uniform{color.NRGBA{246, 246, 246, 255}}, image.Point{}, draw.Src) + drawRoundedRect(canvas, canvas.Bounds(), ColorWhite, 18) + + fctx := freetype.NewContext() + fctx.SetFont(requireFont(t)) + fctx.SetDPI(72) + + lines, size := layoutLink(display) + const qrToHintGap, hintLineHeight, hintToSepGap, sepHeight, sepToLinkGap, linkLineHeight = 26, 17, 21, 1, 21, 15 + total := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines) + startY := (QRCardH - total) / 2 + + qr, err := generateQR("https://"+display, nil, nil) + 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) + + hintY := startY + QRCodeSize + qrToHintGap + hintLineHeight + drawTextCentered(canvas, "扫码向我匿名提问", QRCardW/2, hintY, FontSizeQRHint, ColorHintGray, fctx) + sepY := hintY + hintToSepGap + drawLine(canvas, QRCardW/2-90, sepY, QRCardW/2+90, sepY, color.NRGBA{200, 200, 200, 180}) + linkY := sepY + sepToLinkGap + linkLineHeight + for i, line := range lines { + drawTextCentered(canvas, line, QRCardW/2, linkY+i*linkLineHeight, size, ColorDarkText, fctx) + } + + f, err := os.Create(filepath.Join(outDir, name+".png")) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, canvas); err != nil { + t.Fatal(err) + } + f.Close() + } +}