perf(share): 单题卡二维码同样提到 5px/模块

单题卡右侧卡片只有 210×255,二维码最大只能到 148px(37 模块 = 4px/模块),
缩到 480 宽照样解不出。

- QQRCardW 210 → 236、QQRCardH 255 → 300、QQRSize 150 → 190(37 模块 → 185px)
  卡片仍居中对齐(x 679、y 120),左侧文字区随 QQRCardX 派生,自动收窄 26px
- 二维码卡片绘制抽成 drawQuestionQRCard,可在测试里直接调用出图
- 新增 TestQuestionQRCardLayout:二维码必须正方形、模块 ≥5px、四边留白 ≥20px、
  居中偏差 ≤1px,TEST_PREVIEW=1 输出整卡 PNG;把 QQRSize 改回 150 立刻 FAIL

实测:二维码 185px(5px/模块)、居中偏差 0.5px、上下留白 34px;
gozxing 在 960 / 720 / 480 / 320 宽下都能解出正确链接。
This commit is contained in:
tamakyi
2026-10-23 00:27:07 +08:00
parent d0a1bb8435
commit 310f85156a
2 changed files with 123 additions and 20 deletions
+96
View File
@@ -14,6 +14,7 @@ import (
qrcode "github.com/skip2/go-qrcode"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
// 分享卡测试需要一份真实字体;中文站点部署时 conf.App.FontPath 必然存在,
@@ -253,6 +254,101 @@ func TestGenerateQRModuleSizeAtLeast5(t *testing.T) {
}
}
// 单题卡的二维码卡片:模块必须够大、整块落在卡内、提示语不越界。
func TestQuestionQRCardLayout(t *testing.T) {
requireFont(t)
saved := conf.App.ExternalURL
conf.App.ExternalURL = "https://box-dev-api.337845818.best"
t.Cleanup(func() { conf.App.ExternalURL = saved })
user := &db.User{Domain: "tbunread1022181904"}
question := &db.Question{ID: 113}
question.UserID = user.ID
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)
if err := drawQuestionQRCard(canvas, user, question, nil, fctx); err != nil {
t.Fatal(err)
}
// 二维码:着墨边界必须落在卡片内且四周留白 ≥20px,模块 ≥5px
card := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
minX, minY, maxX, maxY := CardWidth, CardHeight, -1, -1
for y := card.Min.Y; y < card.Min.Y+220; y++ {
for x := card.Min.X; x < card.Max.X; x++ {
if r, _, _, _ := canvas.At(x, y).RGBA(); r < 0x4000 {
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
}
if maxX < 0 {
t.Fatal("nothing drawn in the QR card")
}
size := maxX - minX + 1
if size != maxY-minY+1 {
t.Fatalf("QR is not square: %dx%d", size, maxY-minY+1)
}
if per := size / qrModules(t, conf.App.ExternalURL+"/_/"+user.Domain+"/113"); per < 5 {
t.Errorf("QR module is %dpx, want >=5", per)
}
for _, gap := range []struct {
name string
got, limit int
}{
{"left", minX - card.Min.X, 20},
{"right", card.Max.X - maxX - 1, 20},
{"top", minY - card.Min.Y, 20},
{"bottom", card.Max.Y - maxY - 1, 20},
} {
if gap.got < gap.limit {
t.Errorf("%s margin %dpx < %dpx", gap.name, gap.got, gap.limit)
}
}
if os.Getenv("TEST_PREVIEW") != "" {
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)
}
f, err := os.Create(filepath.Join(outDir, "question-card.png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, canvas); err != nil {
t.Fatal(err)
}
f.Close()
}
}
func qrModules(t *testing.T, content string) int {
t.Helper()
q, err := qrcode.New(content, qrcode.High)
if err != nil {
t.Fatal(err)
}
q.DisableBorder = true
return len(q.Bitmap())
}
// 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。
func TestShareCardLinkPreview(t *testing.T) {
if os.Getenv("TEST_PREVIEW") == "" {