diff --git a/dev/overlay.json b/dev/overlay.json index ebb1692..2477d70 100644 --- a/dev/overlay.json +++ b/dev/overlay.json @@ -1 +1 @@ -{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/Users/TAMAKY~1/AppData/Local/Temp/tamabox_s3_stub.go"}} +{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/OtherFiles/Github/TamaBox-v4-mainline/dev/s3stub/s3_stub.go"}} diff --git a/route/question/share.go b/route/question/share.go index 8d37cb2..c8f57a9 100644 --- a/route/question/share.go +++ b/route/question/share.go @@ -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) // 底部水印 diff --git a/route/question/share_layout_test.go b/route/question/share_layout_test.go index 7b79fb6..5766c7d 100644 --- a/route/question/share_layout_test.go +++ b/route/question/share_layout_test.go @@ -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})