原实现用字符宽度系数估算文本宽度,把标点/符号一律按 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 出图)。
211 lines
6.5 KiB
Go
211 lines
6.5 KiB
Go
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()
|
|
}
|
|
}
|