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:
+87
-11
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user