feat: enhance learn-number with dynamic SVG icons, multi-page support, and modular UI
Build and Push Docker Image / build (push) Successful in 2m26s

This commit is contained in:
2026-02-25 23:29:35 -08:00
parent 0be94026c5
commit 7cd611140e
12 changed files with 4330 additions and 279 deletions
+162 -111
View File
@@ -16,10 +16,21 @@ import (
type CountingRequest struct {
TotalCount int `json:"total_count"`
IconTypes int `json:"icon_types"`
PageCount int `json:"page_count"`
Category string `json:"category"`
PaperSize string `json:"paper_size"`
}
var reSVG = regexp.MustCompile(`([MLQCZmlqcz])|(-?\d+\.?\d*)`)
type PlacedIcon struct {
X, Y, Radius float64
}
type PathResult struct {
Points []gopdf.Point
Closed bool
}
var reSVGToken = regexp.MustCompile(`[a-zA-Z]|-?\d+\.?\d*`)
func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
rand.Seed(time.Now().UnixNano())
@@ -27,142 +38,182 @@ func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
rect := gopdf.Rect{W: 595.28, H: 841.89}
if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
pdf.Start(gopdf.Config{PageSize: rect})
pdf.AddPage()
drawProblem(pdf, req, 0, rect.W, rect.H/2)
drawProblem(pdf, req, rect.H/2, rect.W, rect.H/2)
// 硬性限制:页数 1-10 页,防止资源耗尽
if req.PageCount < 1 { req.PageCount = 1 }
if req.PageCount > 10 { req.PageCount = 10 }
for i := 0; i < req.PageCount; i++ {
pdf.AddPage()
drawProblemV4(pdf, req, 0, rect.W, rect.H/2)
drawProblemV4(pdf, req, rect.H/2, rect.W, rect.H/2)
}
var buf bytes.Buffer
_, err := pdf.WriteTo(&buf)
return buf.Bytes(), err
}
func drawProblem(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) {
func drawProblemV4(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) {
margin := 40.0
boxW := (pW - 2*margin) * 0.7
boxH := pH - 60.0
x, y := margin, startY + 30.0
boxW, boxH := (pW-2*margin)*0.7, pH-60.0
xBase, yBase := margin, startY + 30.0
// 1. 绘制方框
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.0)
pdf.RectFromUpperLeft(x, y, boxW, boxH)
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.5)
pdf.RectFromUpperLeft(xBase, yBase, boxW, boxH)
// 2. 分配图标数量
numTypes := req.IconTypes; if numTypes < 1 { numTypes = 1 }
if numTypes > len(data.CountingIcons) { numTypes = len(data.CountingIcons) }
catIcons := data.IconCategories[req.Category]
if len(catIcons) == 0 { catIcons = data.IconCategories["shapes"] }
total := req.TotalCount; if total < numTypes { total = numTypes }
if total > 30 { total = 30 }
// 随机选出图标种类
allIcons := rand.Perm(len(data.CountingIcons))
selectedIcons := []data.Icon{}
counts := []int{}
numTypes := req.IconTypes
if numTypes < 1 { numTypes = 1 }; if numTypes > 6 { numTypes = 6 }
if numTypes > len(catIcons) { numTypes = len(catIcons) }
// 先每种分配1个
rem := total - numTypes
total := req.TotalCount
if total < numTypes { total = numTypes }; if total > 30 { total = 30 }
allIconsPerm := rand.Perm(len(catIcons))
var selectedIcons []data.Icon
counts := make([]int, numTypes)
// 随机分配逻辑
for i := 0; i < numTypes; i++ {
selectedIcons = append(selectedIcons, data.CountingIcons[allIcons[i]])
counts = append(counts, 1)
selectedIcons = append(selectedIcons, catIcons[allIconsPerm[i]])
counts[i] = 1
}
// 随机分配剩下的
for i := 0; i < rem; i++ {
remaining := total - numTypes
for i := 0; i < remaining; i++ {
counts[rand.Intn(numTypes)]++
}
// 3. 布局计划 (使用网格,增加 Padding 避开边框)
rows, cols := 5, 6
cellW, cellH := boxW/float64(cols), boxH/float64(rows)
indices := rand.Perm(rows * cols)
// 严格图标大小:单元格的 70%,确保不溢出单元格边界
iconSize := math.Min(cellW, cellH) * 0.7
paddingX, paddingY := (cellW - iconSize)/2, (cellH - iconSize)/2
avgRadius := math.Sqrt((boxW * boxH * 0.22) / (float64(total) * math.Pi))
if avgRadius > 35.0 { avgRadius = 35.0 }
iconTypeIdx := 0
countInCurrentType := 0
var placed []PlacedIcon
iconTypeIdx, currentInType := 0, 0
for i := 0; i < total; i++ {
cellIdx := indices[i]
rIdx, cIdx := cellIdx/cols, cellIdx%cols
// 基础位置
baseX := x + float64(cIdx)*cellW + paddingX
baseY := y + float64(rIdx)*cellH + paddingY
// 渲染图标
drawIcon(pdf, selectedIcons[iconTypeIdx], baseX + iconSize/2, baseY + iconSize/2, iconSize)
countInCurrentType++
if countInCurrentType >= counts[iconTypeIdx] {
iconTypeIdx++
countInCurrentType = 0
}
}
// 4. 绘制右侧对照表
legendX := x + boxW + 20.0
legendStepY := boxH / float64(numTypes + 1)
for i := 0; i < numTypes; i++ {
lY := y + float64(i+1)*legendStepY
drawIcon(pdf, selectedIcons[i], legendX + 20, lY, 30)
pdf.SetStrokeColor(150, 150, 150); pdf.SetLineWidth(0.8)
pdf.RectFromUpperLeft(legendX + 50, lY - 15, 30, 30)
}
}
func drawIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
scale := size / 1024.0
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.5) // 加粗线条
ox, oy := cX - size/2, cY - size/2
for _, pStr := range icon.Paths {
pts := parseSmoothPath(pStr, scale, ox, oy)
if len(pts) > 1 {
// 如果是闭合路径
if strings.Contains(strings.ToUpper(pStr), "Z") {
pdf.Polygon(pts, "D")
} else {
for i := 0; i < len(pts)-1; i++ {
pdf.Line(pts[i].X, pts[i].Y, pts[i+1].X, pts[i+1].Y)
scaleVar := 0.9 + rand.Float64()*0.2; r := avgRadius * scaleVar
for retry := 0; retry < 200; retry++ {
randX := xBase + r + 5 + rand.Float64()*(boxW-2*r-10)
randY := yBase + r + 5 + rand.Float64()*(boxH-2*r-10)
collision := false
for _, p := range placed {
if math.Sqrt(math.Pow(randX-p.X, 2)+math.Pow(randY-p.Y, 2)) < (r + p.Radius + 10.0) {
collision = true; break
}
}
if !collision {
drawSimpleIcon(pdf, selectedIcons[iconTypeIdx], randX, randY, r*2)
placed = append(placed, PlacedIcon{X: randX, Y: randY, Radius: r}); break
}
}
currentInType++; if currentInType >= counts[iconTypeIdx] { iconTypeIdx++; currentInType = 0 }
}
legendX, legendStepY := xBase+boxW+20.0, boxH/float64(numTypes+1)
for i := 0; i < numTypes; i++ {
lY := yBase + float64(i+1)*legendStepY
drawSimpleIcon(pdf, selectedIcons[i], legendX+25, lY, 35)
pdf.SetStrokeColor(150, 150, 150); pdf.SetLineWidth(1.0); pdf.RectFromUpperLeft(legendX+60, lY-15, 35, 35)
}
}
func drawSimpleIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
rawResults := parseMultiPath(icon, 1.0, 0, 0)
if len(rawResults) == 0 { return }
var minX, minY, maxX, maxY float64 = 1e9, 1e9, -1e9, -1e9
for _, res := range rawResults {
for _, p := range res.Points {
if p.X < minX { minX = p.X }; if p.X > maxX { maxX = p.X }
if p.Y < minY { minY = p.Y }; if p.Y > maxY { maxY = p.Y }
}
}
curW, curH := maxX-minX, maxY-minY
if curW <= 0 { curW = 1 }; if curH <= 0 { curH = 1 }
scale := size / math.Max(curW, curH)
ox, oy := cX-(curW*scale)/2-minX*scale, cY-(curH*scale)/2-minY*scale
pdf.SetStrokeColor(0, 0, 0)
if strings.Contains(strings.ToLower(icon.Name), "item") || len(icon.Paths) > 1 {
pdf.SetLineWidth(size / 60.0) // 复杂素材用细线 (30% less than 45 is approx 60)
} else {
pdf.SetLineWidth(size / 22.0) // 基础图形用粗线
}
for _, res := range rawResults {
scaledPts := make([]gopdf.Point, len(res.Points))
for i, p := range res.Points { scaledPts[i] = gopdf.Point{X: ox + p.X*scale, Y: oy + p.Y*scale} }
if len(scaledPts) > 1 {
if res.Closed { pdf.Polygon(scaledPts, "D") } else {
for j := 0; j < len(scaledPts)-1; j++ { pdf.Line(scaledPts[j].X, scaledPts[j].Y, scaledPts[j+1].X, scaledPts[j+1].Y) }
}
}
}
}
// 支持贝塞尔曲线采样,确保图标圆润
func parseSmoothPath(path string, scale, ox, oy float64) []gopdf.Point {
var pts []gopdf.Point
matches := reSVG.FindAllStringSubmatch(path, -1)
var lx, ly float64
for i := 0; i < len(matches); {
cmd := matches[i][0]
if cmd == "M" || cmd == "L" {
x, _ := strconv.ParseFloat(matches[i+1][0], 64); y, _ := strconv.ParseFloat(matches[i+2][0], 64)
lx, ly = x, y
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
i += 3
} else if cmd == "C" {
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
x2, _ := strconv.ParseFloat(matches[i+3][0], 64); y2, _ := strconv.ParseFloat(matches[i+4][0], 64)
x, _ := strconv.ParseFloat(matches[i+5][0], 64); y, _ := strconv.ParseFloat(matches[i+6][0], 64)
for t := 0.25; t <= 1.0; t += 0.25 {
tx := math.Pow(1-t, 3)*lx + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
ty := math.Pow(1-t, 3)*ly + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
func parseMultiPath(icon data.Icon, scale, ox, oy float64) []PathResult {
var results []PathResult
for _, path := range icon.Paths {
tokens := reSVGToken.FindAllString(path, -1)
var lx, ly, startX, startY float64
var pts []gopdf.Point
var currentCmd string
var isRel bool
for i := 0; i < len(tokens); {
token := tokens[i]
if (token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z') {
cmd := strings.ToUpper(token)
if cmd == "M" {
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: false}); pts = nil }
} else if cmd == "Z" {
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: true}); pts = nil }
lx, ly = startX, startY; i++; continue
}
currentCmd = cmd; isRel = (token[0] >= 'a' && token[0] <= 'z'); i++
continue
}
lx, ly = x, y; i += 7
} else if cmd == "Q" {
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
x, _ := strconv.ParseFloat(matches[i+3][0], 64); y, _ := strconv.ParseFloat(matches[i+4][0], 64)
for t := 0.25; t <= 1.0; t += 0.25 {
tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
switch currentCmd {
case "M", "L":
if i+1 >= len(tokens) { i = len(tokens); break }
x, _ := strconv.ParseFloat(tokens[i], 64); y, _ := strconv.ParseFloat(tokens[i+1], 64)
if isRel { x += lx; y += ly }
if currentCmd == "M" { startX, startY = x, y }
lx, ly = x, y
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}); i += 2
if currentCmd == "M" { currentCmd = "L" }
case "H":
x, _ := strconv.ParseFloat(tokens[i], 64); if isRel { x += lx }; lx = x
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + ly*scale}); i++
case "V":
y, _ := strconv.ParseFloat(tokens[i], 64); if isRel { y += ly }; ly = y
pts = append(pts, gopdf.Point{X: ox + lx*scale, Y: oy + y*scale}); i++
case "C":
if i+5 >= len(tokens) { i = len(tokens); break }
x1, _ := strconv.ParseFloat(tokens[i], 64); y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x2, _ := strconv.ParseFloat(tokens[i+2], 64); y2, _ := strconv.ParseFloat(tokens[i+3], 64)
x, _ := strconv.ParseFloat(tokens[i+4], 64); y, _ := strconv.ParseFloat(tokens[i+5], 64)
if isRel { x1 += lx; y1 += ly; x2 += lx; y2 += ly; x += lx; y += ly }
for t := 0.2; t <= 1.0; t += 0.2 {
tx := math.Pow(1-t, 3)*lx + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
ty := math.Pow(1-t, 3)*ly + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y; i += 6
case "Q":
if i+3 >= len(tokens) { i = len(tokens); break }
x1, _ := strconv.ParseFloat(tokens[i], 64); y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x, _ := strconv.ParseFloat(tokens[i+2], 64); y, _ := strconv.ParseFloat(tokens[i+3], 64)
if isRel { x1 += lx; y1 += ly; x += lx; y += ly }
for t := 0.25; t <= 1.0; t += 0.25 {
tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y; i += 4
case "A": i += 7
default: i++
}
lx, ly = x, y; i += 5
} else { i++ }
}
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: false}) }
}
return pts
return results
}