Files
tamabox/route/question/share_layout_test.go
T
tamakyi b35c2a8f45 fix(share): 二维码不再被静默区吃掉三分之一尺寸;卡内间距收到 4px 栅格
上一版改成按模块整数倍出图时,静默区仍然画在图像里:49 个模块(41 + 4×2)
挤进 165px 只剩 3px/模块、符号 111px,比改造前的 135px 还小 —— 这是自己引入的
回归。测试卡上验证时才发现:二维码在 960 宽的卡里只占 111px,微信/QQ 二次
压缩到 480 宽后模块不足 1.5px,gozxing 直接解不出来。

- generateQR 改 qr.DisableBorder:图像只输出符号,静默区交给承载它的白色卡片
  (四周留白 30px 以上,远超 4 模块)。符号尺寸按 size/modules 取整,最多
  浪费一个模块宽度
- 调用方按实际符号尺寸居中与排版,不再假设等于目标尺寸
- 单题卡 QQRSize 128 → 150:37 模块的题目链接因此拿到 4px/模块(原 3px)
- 卡内垂直间距 26/21/21/15 → 24/20/20/16,落在 4px 栅格上

验证:profile 卡二维码 111px → 148px(37 模块 × 4px,用满 90%),单题卡
111px → 148px;gozxing 解码 960 原图、75% 缩放图均能读出正确 URL(含内嵌
头像)。守门测试加了两条:符号宽度必须等于 modules×perModule 且吃掉目标尺寸
≥80%(变异成「含静默区」立刻 FAIL),头像测试改判四角必须是定位图案而非
白色(防止符号被裁切)。
2026-10-22 23:22:34 +08:00

292 lines
9.2 KiB
Go

package question
import (
"image"
"image/color"
"image/draw"
"image/png"
"os"
"path/filepath"
"testing"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
qrcode "github.com/skip2/go-qrcode"
"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 TestGenerateQRWithLogo(t *testing.T) {
logo := image.NewRGBA(image.Rect(0, 0, 64, 40))
draw.Draw(logo, logo.Bounds(), &image.Uniform{color.RGBA{200, 40, 40, 255}}, image.Point{}, draw.Src)
img, err := generateQR("https://box.shiroko.one/_/tamakyi", logo, QRCodeSize)
if err != nil {
t.Fatal(err)
}
cx, cy := img.Bounds().Dx()/2, img.Bounds().Dy()/2
r, _, _, _ := img.At(cx, cy).RGBA()
if r < 0x8000 || r > 0xf000 {
t.Errorf("center pixel should be the logo color, got %#x", r)
}
for _, p := range []image.Point{{0, 0}, {img.Bounds().Dx() - 1, 0}, {0, img.Bounds().Dy() - 1}} {
r, g, b, _ := img.At(p.X, p.Y).RGBA()
if r == 0xffff && g == 0xffff && b == 0xffff {
t.Errorf("corner %v is white: finder pattern missing, symbol likely cropped", p)
}
}
}
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
// 二维码必须按模块整数倍绘制:任何宽度落差的游程都意味着模块被拉伸,
// 小尺寸(微信/QQ 二次压缩后)最容易出现「扫不动」。同时必须把目标尺寸用满,
// 否则模块像素偏小,实际可扫距离大幅缩水。
func TestGenerateQRIsModuleAligned(t *testing.T) {
const url = "https://box.shiroko.one/_/tamakyi"
for _, size := range []int{QRCodeSize, QQRSize} {
img, err := generateQR(url, nil, size)
if err != nil {
t.Fatal(err)
}
probe, err := qrcode.New(url, qrcode.High)
if err != nil {
t.Fatal(err)
}
probe.DisableBorder = true
modules := len(probe.Bitmap())
perModule := size / modules
want := modules * perModule
if got := img.Bounds().Dx(); got != want {
t.Fatalf("size %d: got width %d, want %d (%d modules x %dpx)", size, got, want, modules, perModule)
}
if got := img.Bounds().Dy(); got != want {
t.Fatalf("size %d: got height %d, want %d", size, got, want)
}
// 至少用掉目标尺寸的 80%,防止静默区/留白再次吃掉模块像素
if float64(want) < 0.8*float64(size) {
t.Fatalf("size %d: symbol only %dpx, wastes %.0f%% of the target", size, want, 100*(1-float64(want)/float64(size)))
}
// 逐行扫描:所有黑白游程都必须是模块宽度的整数倍
for _, y := range []int{want / 2, want / 3} {
runLen := 0
for x := 0; x < want; x++ {
r, _, _, _ := img.At(x, y).RGBA()
black := r < 0x8000
if x == 0 {
runLen = 1
continue
}
prevR, _, _, _ := img.At(x-1, y).RGBA()
if (prevR < 0x8000) != black {
if runLen%perModule != 0 {
t.Fatalf("size %d: run of %dpx at x=%d is not a multiple of module %d",
size, runLen, x-runLen, perModule)
}
runLen = 1
continue
}
runLen++
}
if runLen%perModule != 0 {
t.Fatalf("size %d: trailing run %dpx not a multiple of %d", size, runLen, perModule)
}
}
}
}
// 视觉自检: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 = 24, 17, 20, 1, 20, 16
qr, err := generateQR("https://"+display, nil, QRCodeSize)
if err != nil {
t.Fatal(err)
}
qrSize := qr.Bounds().Dx()
total := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines)
startY := (QRCardH - total) / 2
draw.Draw(canvas, image.Rect((QRCardW-qrSize)/2, startY, (QRCardW+qrSize)/2, startY+qrSize), qr, image.Point{}, draw.Over)
hintY := startY + qrSize + 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()
}
}