From 310f85156a109a216a787b15f86e167b325a017f Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 23 Oct 2026 00:27:07 +0800 Subject: [PATCH] =?UTF-8?q?perf(share):=20=E5=8D=95=E9=A2=98=E5=8D=A1?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=90=8C=E6=A0=B7=E6=8F=90=E5=88=B0?= =?UTF-8?q?=205px/=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单题卡右侧卡片只有 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 宽下都能解出正确链接。 --- route/question/share.go | 47 ++++++++------ route/question/share_layout_test.go | 96 +++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/route/question/share.go b/route/question/share.go index c02e3e7..9e6189e 100644 --- a/route/question/share.go +++ b/route/question/share.go @@ -913,11 +913,11 @@ const ( QAnswerMaxLine = 7 // 右侧二维码卡片 - QQRCardW = 210 - QQRCardH = 255 + QQRCardW = 236 + QQRCardH = 300 QQRCardX = QContentX + QContentW - QQRCardW QQRCardY = QContentY + (QContentH-QQRCardH)/2 - QQRSize = 150 + QQRSize = 190 // 内容区最大宽度(左侧,避开二维码卡片) QTextMaxW = QQRCardX - QContentX - 30 @@ -925,6 +925,29 @@ const ( QWatermarkY = CardHeight - 30 ) +// drawQuestionQRCard 绘制单题卡右侧的二维码卡片:二维码与提示语整体垂直居中。 +func drawQuestionQRCard(canvas *image.RGBA, user *db.User, question *db.Question, logo image.Image, fontCtx *freetype.Context) error { + cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH) + drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 20) + drawRoundedRect(canvas, cardRect, ColorWhite, 20) + + questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID) + qr, err := generateQR(questionURL, logo, QQRSize) + if err != nil { + return err + } + + const qrToHintGap, hintLineHeight = 30, 17 + qrSize := qr.Bounds().Dx() + contentH := qrSize + qrToHintGap + hintLineHeight + qrX := QQRCardX + (QQRCardW-qrSize)/2 + qrY := QQRCardY + (QQRCardH-contentH)/2 + draw.Draw(canvas, image.Rect(qrX, qrY, qrX+qrSize, qrY+qrSize), qr, image.Point{}, draw.Over) + + drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, qrY+qrSize+qrToHintGap+hintLineHeight, FontSizeQRHint, ColorHintGray, fontCtx) + return nil +} + // 单题分享图结果缓存,TTL 30 分钟,上限 16 条(1C1G 内存友好)。 var questionShareCache = newTTLCache(16) @@ -1066,27 +1089,11 @@ func QuestionShareCard(ctx context.Context) { _ = textY // 右侧二维码卡片 - cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH) - drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 20) - drawRoundedRect(canvas, cardRect, ColorWhite, 20) - - questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID) - qr, err := generateQR(questionURL, avImg, QQRSize) - if err != nil { + if err := drawQuestionQRCard(canvas, user, question, avImg, fontCtx); err != nil { http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError) logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card") return } - qrSize := qr.Bounds().Dx() - qrDrawX := QQRCardX + (QQRCardW-qrSize)/2 - // 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁) - 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 + qrSize + qrToHintGap + hintLineHeight - drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx) // 底部水印 drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, QWatermarkY, FontSizeFooter, ColorFooterGray, fontCtx) diff --git a/route/question/share_layout_test.go b/route/question/share_layout_test.go index a40133f..8d54e21 100644 --- a/route/question/share_layout_test.go +++ b/route/question/share_layout_test.go @@ -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") == "" {