fix(share): 字体缺失不再永久失败;二维码按模块整数倍绘制

测试环境部署后分享卡整站 500「字体文件未配置或无法加载」。根因不是代码
回退,而是字体从来就不在仓库里:static/fonts/ 既没有入库(git 历史为空),
也没有 embed,凡是按仓库新建的环境都缺这个文件,只有手工补过字体的老环境
才正常。

- getFont 不再用 sync.Once 永久缓存失败:补上字体文件即生效,无需重启;
  失败时错误信息带上「已尝试的路径」,排障不用再猜是配置还是文件
- 候选路径:font_path → static/fonts/*.{otf,ttf,ttc} → 常见系统 CJK 字体,
  换过字体文件名也能用
- app.sample.ini 注明字体必须自备、缺失的后果

顺带做 P1 的第一项:generateQR 改为按模块整数倍出图(41 模块 @165px →
每模块 4px、符号 164px 居中)。原先是 256px 生成后最近邻缩到 165px,
每模块 4.02px,模块宽窄相差 1px,小尺寸二次压缩后更容易扫不动。
内嵌头像改为按目标尺寸等比(size*48/256)+ 2px 白边,静默区不受影响。

守门测试新增两条:TestGenerateQRIsModuleAligned(扫描线游程必须是模块
宽度的整数倍,变异成等比缩放后立刻 FAIL)、TestGenerateQRWithLogo
(中心是头像、四角静默区纯白)。
This commit is contained in:
tamakyi
2026-10-22 22:33:22 +08:00
parent 80e042d154
commit d51629ef2f
4 changed files with 150 additions and 32 deletions
+75 -4
View File
@@ -9,9 +9,9 @@ import (
"path/filepath"
"testing"
"github.com/disintegration/imaging"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
qrcode "github.com/skip2/go-qrcode"
"github.com/tamakyi/TamaBox/internal/conf"
)
@@ -141,6 +141,27 @@ func TestDrawTextCenteredIsCentered(t *testing.T) {
}
}
// 内嵌头像不能吃掉静默区(否则扫码器找不到边界),且必须真的画在中心。
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)
}
r, _, _, _ := img.At(QRCodeSize/2, QRCodeSize/2).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}, {QRCodeSize - 1, 0}, {0, QRCodeSize - 1}, {QRCodeSize - 1, QRCodeSize - 1}} {
r, g, b, _ := img.At(p.X, p.Y).RGBA()
if r != 0xffff || g != 0xffff || b != 0xffff {
t.Errorf("quiet zone at %v is not white: %#x %#x %#x", p, r, g, b)
}
}
}
func abs(v int) int {
if v < 0 {
return -v
@@ -148,6 +169,57 @@ func abs(v int) int {
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)
}
if got := img.Bounds().Dx(); got != size {
t.Fatalf("size %d: got width %d", size, got)
}
probe, err := qrcode.New(url, qrcode.High)
if err != nil {
t.Fatal(err)
}
modules := len(probe.Bitmap())
perModule := size / modules
if perModule < 1 {
t.Fatalf("size %d cannot hold %d modules", size, modules)
}
start := (size - modules*perModule) / 2
// 中间一行:所有黑白游程(限定在符号区域内)都必须是模块宽度的整数倍
y := size / 2
runLen := 0
for x := start; x < start+modules*perModule; x++ {
r, _, _, _ := img.At(x, y).RGBA()
black := r < 0x8000
if x == start {
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") == "" {
@@ -182,12 +254,11 @@ func TestShareCardLinkPreview(t *testing.T) {
total := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines)
startY := (QRCardH - total) / 2
qr, err := generateQR("https://"+display, nil, nil)
qr, err := generateQR("https://"+display, nil, QRCodeSize)
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)
draw.Draw(canvas, image.Rect(QRCodeX, startY, QRCodeX+QRCodeSize, startY+QRCodeSize), qr, image.Point{}, draw.Over)
hintY := startY + QRCodeSize + qrToHintGap + hintLineHeight
drawTextCentered(canvas, "扫码向我匿名提问", QRCardW/2, hintY, FontSizeQRHint, ColorHintGray, fctx)