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:
+27
-20
@@ -913,11 +913,11 @@ const (
|
|||||||
QAnswerMaxLine = 7
|
QAnswerMaxLine = 7
|
||||||
|
|
||||||
// 右侧二维码卡片
|
// 右侧二维码卡片
|
||||||
QQRCardW = 210
|
QQRCardW = 236
|
||||||
QQRCardH = 255
|
QQRCardH = 300
|
||||||
QQRCardX = QContentX + QContentW - QQRCardW
|
QQRCardX = QContentX + QContentW - QQRCardW
|
||||||
QQRCardY = QContentY + (QContentH-QQRCardH)/2
|
QQRCardY = QContentY + (QContentH-QQRCardH)/2
|
||||||
QQRSize = 150
|
QQRSize = 190
|
||||||
|
|
||||||
// 内容区最大宽度(左侧,避开二维码卡片)
|
// 内容区最大宽度(左侧,避开二维码卡片)
|
||||||
QTextMaxW = QQRCardX - QContentX - 30
|
QTextMaxW = QQRCardX - QContentX - 30
|
||||||
@@ -925,6 +925,29 @@ const (
|
|||||||
QWatermarkY = CardHeight - 30
|
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 内存友好)。
|
// 单题分享图结果缓存,TTL 30 分钟,上限 16 条(1C1G 内存友好)。
|
||||||
var questionShareCache = newTTLCache(16)
|
var questionShareCache = newTTLCache(16)
|
||||||
|
|
||||||
@@ -1066,27 +1089,11 @@ func QuestionShareCard(ctx context.Context) {
|
|||||||
_ = textY
|
_ = textY
|
||||||
|
|
||||||
// 右侧二维码卡片
|
// 右侧二维码卡片
|
||||||
cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
|
if err := drawQuestionQRCard(canvas, user, question, avImg, fontCtx); err != nil {
|
||||||
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 {
|
|
||||||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
|
||||||
return
|
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)
|
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, QWatermarkY, FontSizeFooter, ColorFooterGray, fontCtx)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
qrcode "github.com/skip2/go-qrcode"
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 分享卡测试需要一份真实字体;中文站点部署时 conf.App.FontPath 必然存在,
|
// 分享卡测试需要一份真实字体;中文站点部署时 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,人工确认断行与对齐。
|
// 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。
|
||||||
func TestShareCardLinkPreview(t *testing.T) {
|
func TestShareCardLinkPreview(t *testing.T) {
|
||||||
if os.Getenv("TEST_PREVIEW") == "" {
|
if os.Getenv("TEST_PREVIEW") == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user