fix(share): 分享卡链接按真实字宽居中,长链接按 host/path 收敛

原实现用字符宽度系数估算文本宽度,把标点/符号一律按 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 出图)。
This commit is contained in:
tamakyi
2026-10-22 21:42:50 +08:00
parent 90c0cfd27f
commit 0054e508b7
3 changed files with 298 additions and 12 deletions
+87 -11
View File
@@ -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))