Compare commits
2
Commits
90c0cfd27f
...
80e042d154
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80e042d154 | ||
|
|
0054e508b7 |
@@ -41,3 +41,7 @@ go.work.sum
|
||||
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
|
||||
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
|
||||
/.workbuddy/local/
|
||||
|
||||
@@ -353,3 +353,33 @@
|
||||
- 水印在右下角("AI生成 WORKBUDDY"),统一裁掉底部 10%,切口平直,正好可以贴页面下沿。
|
||||
- 提示词关键:no outlines / no gradients、眼睛写死 `solid black vertically elongated rounded rectangles`
|
||||
(否则出白眼球+高光+睫毛)、无嘴要重复强调。3/4 侧脸那版还是画出了嘴唇,弃用。
|
||||
|
||||
## 分享卡「二维码下链接不对齐 + 长链接乱掉」(已定位,待批准落地)
|
||||
|
||||
- 根因在 `route/question/share.go` 的**字宽估算**:`charPixelWidth` 把 `unicode.Punct/Symbol`
|
||||
一律按 1.0em 计,而 URL 里的 `: / . _ -` 在 Noto Sans SC 里只有 0.25~0.35em。
|
||||
- 实测(NotoSansSC + 本机原型 `dev/tmpcard`):`https://box.shiroko.one/_/tamakyi` 15px 真实
|
||||
227.9px、估算 352.5px(+55%);卡片可用宽仅 225px → 触发缩字号到 **9.57px**,实际只画 145px 宽,
|
||||
且起点按估算算 → 整段左偏 **40px**。dev 域名 7.24px / 偏 39px;长域名 5.26px / 偏 34px。
|
||||
- 纯中文不受影响(CJK 就是 1em,偏差 0);底部水印 `Powered by 狼的提问箱` 偏 9.8px。
|
||||
- 正确测法:`Font.HMetric(scale).AdvanceWidth` + `Font.Kern` 累加,scale=size*64(DPI 72)。
|
||||
实测与 `freetype.Context.DrawString` 返回笔位完全一致(227.88 vs 227.88)—— 以后居中文字都用它,
|
||||
别再靠字符宽度系数猜。
|
||||
- 只修测量还不够:等比缩字号没有下限也没有断行/省略兜底,长链接必然掉到 5px 以下。
|
||||
- 另记:QR 用 `qr.Image(256)` 再 NearestNeighbor 到 165,41 模块 → 每模块 4.02px,模块宽窄不一;
|
||||
`qr.Image(-k)` 按模块整数倍直接出图更锐(41*4=164)。
|
||||
- 交付:`outputs/sharecard-link-compare-2026-10-22.png`(现状/修复后对照)。
|
||||
|
||||
**已落地(用户选方案 A:单行优先,放不下才拆 host/path)**
|
||||
- `measureTextWidth` 改为 `HMetric.AdvanceWidth + Kern` 累加,字体不可用时才回退估算;
|
||||
所有 `drawTextCentered` 自动吃到修正(链接、水印、提示语)。
|
||||
- 新增 `displayURL`(去 `https://`)/ `layoutLink`(单行 15→13 → host+path 两行 13→11 →
|
||||
host 中间省略 @11)/ `ellipsizeMiddle`;`LinkMaxWidth = QRCardW-40 = 230`。
|
||||
- 卡片内容高度改为按行数算,两行时 startY 自动下移(281 ≤ 315,顶距 17)。
|
||||
- 守门测试 `route/question/share_layout_test.go`:测量=笔位(0.01px 级)、限宽、
|
||||
居中(按着墨边界 ±3px)、`TEST_PREVIEW=1` 出图。**变异验证过**:把 measure 换回估算,
|
||||
三条用例全 FAIL(偏移 39/59px,且文字真的溢出卡片 615 < 660)。
|
||||
- 删掉了临时原型 `dev/tmpcard`(结论已进测试与 outputs 图)。
|
||||
- P1 未做(二维码模块整数倍、间距 4px 栅格、水印相对左侧内容区居中)—— 等用户看完效果再定。
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user