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),头像测试改判四角必须是定位图案而非
白色(防止符号被裁切)。
This commit is contained in:
tamakyi
2026-10-22 23:22:34 +08:00
parent a215c2ba2b
commit b35c2a8f45
3 changed files with 78 additions and 61 deletions
+29 -22
View File
@@ -76,7 +76,6 @@ const (
// 二维码尺寸
const (
QRCodeSize = 165
QRCodeX = (QRCardW - QRCodeSize) / 2
)
// 社交信息
@@ -505,23 +504,25 @@ func ShareCard(ctx context.Context) {
return
}
qrSize := qr.Bounds().Dx()
const (
qrToHintGap = 26
qrToHintGap = 24
hintLineHeight = 17
hintToSepGap = 21
hintToSepGap = 20
sepHeight = 1
sepToLinkGap = 21
linkLineHeight = 15
sepToLinkGap = 20
linkLineHeight = 16
)
linkLines, linkFontSize := layoutLink(displayURL(boxURL))
totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(linkLines)
totalContentH := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(linkLines)
startY := QRCardY + (QRCardH-totalContentH)/2
qrDrawX := QRCardX + QRCodeX
qrDrawX := QRCardX + (QRCardW-qrSize)/2
qrDrawY := startY
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qr, image.Point{}, draw.Over)
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+qrSize, qrDrawY+qrSize), qr, image.Point{}, draw.Over)
hintY := qrDrawY + QRCodeSize + qrToHintGap + hintLineHeight
hintY := qrDrawY + qrSize + qrToHintGap + hintLineHeight
drawTextCentered(canvas, "扫码向我匿名提问", cardCenterX, hintY, FontSizeQRHint, ColorHintGray, fontCtx)
sepY := hintY + hintToSepGap
@@ -857,24 +858,28 @@ func generateQR(content string, logoImg image.Image, size int) (image.Image, err
qr.BackgroundColor = color.RGBA{255, 255, 255, 255}
qr.ForegroundColor = color.RGBA{0, 0, 0, 255}
modules := len(qr.Bitmap()) // 含静默区
// 静默区由承载二维码的白色卡片提供(四周留白远超 4 模块),图像本身只输出符号:
// 若把静默区画进图里,再叠加居中留白,目标 165px 实际只剩 111px,模块被压到 3px,
// 微信/QQ 二次压缩后极易扫不动。
qr.DisableBorder = true
modules := len(qr.Bitmap())
perModule := size / modules
if perModule < 1 {
perModule = 1
}
symbol := qr.Image(-perModule)
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.Draw(dst, dst.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
offset := image.Pt((size-symbol.Bounds().Dx())/2, (size-symbol.Bounds().Dy())/2)
draw.Draw(dst, symbol.Bounds().Add(offset), symbol, image.Point{}, draw.Src)
dst := image.NewRGBA(symbol.Bounds())
draw.Draw(dst, dst.Bounds(), symbol, image.Point{}, draw.Src)
if logoImg != nil {
logoSize := size * 48 / 256
w := dst.Bounds().Dx()
logoSize := w * 3 / 16
logo := imaging.Fill(logoImg, logoSize, logoSize, imaging.Center, imaging.Lanczos)
// 白边保证头像与模块之间有对比度,H 级容错(30%)足以覆盖这点面积
holder := image.Rect(0, 0, logoSize+4, logoSize+4)
holder = holder.Add(image.Pt((size-holder.Dx())/2, (size-holder.Dy())/2))
holder = holder.Add(image.Pt((w-logoSize-4)/2, (w-logoSize-4)/2))
draw.Draw(dst, holder, &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
draw.Draw(dst, logo.Bounds().Add(holder.Min.Add(image.Pt(2, 2))), logo, image.Point{}, draw.Over)
}
@@ -911,8 +916,7 @@ const (
QQRCardH = 255
QQRCardX = QContentX + QContentW - QQRCardW
QQRCardY = QContentY + (QContentH-QQRCardH)/2
QQRSize = 128
QQRCodeX = (QQRCardW - QQRSize) / 2
QQRSize = 150
// 内容区最大宽度(左侧,避开二维码卡片)
QTextMaxW = QQRCardX - QContentX - 30
@@ -1072,12 +1076,15 @@ func QuestionShareCard(ctx context.Context) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
return
}
qrDrawX := QQRCardX + QQRCodeX
qrSize := qr.Bounds().Dx()
qrDrawX := QQRCardX + (QQRCardW-qrSize)/2
// 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁)
qrDrawY := QQRCardY + 40
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qr, image.Point{}, draw.Over)
const qrToHintGap, hintLineHeight = 30, 17
contentH := qrSize + qrToHintGap + hintLineHeight
qrDrawY := QQRCardY + (QQRCardH-contentH)/2
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+qrSize, qrDrawY+qrSize), qr, image.Point{}, draw.Over)
hintY := qrDrawY + QQRSize + 30
hintY := qrDrawY + qrSize + qrToHintGap + hintLineHeight
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx)
// 底部水印
+48 -38
View File
@@ -141,7 +141,7 @@ 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)
@@ -150,14 +150,15 @@ func TestGenerateQRWithLogo(t *testing.T) {
if err != nil {
t.Fatal(err)
}
r, _, _, _ := img.At(QRCodeSize/2, QRCodeSize/2).RGBA()
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}, {QRCodeSize - 1, 0}, {0, QRCodeSize - 1}, {QRCodeSize - 1, QRCodeSize - 1}} {
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("quiet zone at %v is not white: %#x %#x %#x", p, r, g, b)
if r == 0xffff && g == 0xffff && b == 0xffff {
t.Errorf("corner %v is white: finder pattern missing, symbol likely cropped", p)
}
}
}
@@ -170,7 +171,8 @@ func abs(v int) int {
}
// 二维码必须按模块整数倍绘制:任何宽度落差的游程都意味着模块被拉伸,
// 小尺寸(微信/QQ 二次压缩后)最容易出现「扫不动」。
// 小尺寸(微信/QQ 二次压缩后)最容易出现「扫不动」。同时必须把目标尺寸用满,
// 否则模块像素偏小,实际可扫距离大幅缩水。
func TestGenerateQRIsModuleAligned(t *testing.T) {
const url = "https://box.shiroko.one/_/tamakyi"
for _, size := range []int{QRCodeSize, QQRSize} {
@@ -178,44 +180,50 @@ func TestGenerateQRIsModuleAligned(t *testing.T) {
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)
}
probe.DisableBorder = true
modules := len(probe.Bitmap())
perModule := size / modules
if perModule < 1 {
t.Fatalf("size %d cannot hold %d modules", 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)))
}
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)
// 逐行扫描:所有黑白游程都必须是模块宽度的整数倍
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
}
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)
}
runLen++
}
if runLen%perModule != 0 {
t.Fatalf("size %d: trailing run %dpx not a multiple of %d", size, runLen, perModule)
}
}
}
@@ -250,17 +258,19 @@ func TestShareCardLinkPreview(t *testing.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
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)
}
draw.Draw(canvas, image.Rect(QRCodeX, startY, QRCodeX+QRCodeSize, startY+QRCodeSize), qr, image.Point{}, draw.Over)
qrSize := qr.Bounds().Dx()
total := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines)
startY := (QRCardH - total) / 2
hintY := startY + QRCodeSize + qrToHintGap + hintLineHeight
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})