feat: Add Learn-Number tool for preschool math practice and refactor HTML components
Build and Push Docker Image / build (push) Successful in 2m55s

This commit is contained in:
2026-02-25 19:22:58 -08:00
parent e320f9ee50
commit 0be94026c5
9 changed files with 396 additions and 82 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Binaries
toolbox
/toolbox
*.exe
*.exe~
*.dll
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"os"
"strconv"
"toolbox/pkg/base"
_ "toolbox/pkg/learnnumber"
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
"github.com/gin-gonic/gin"
@@ -55,8 +56,8 @@ func main() {
r.StaticFS("/static", http.FS(subFS))
// 3. 模板引擎初始化
// 关键修复:先 Sub 再 Parse,确保路径匹配
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html")
// 递归加载 web 目录下所有的 .html 文件
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
+43 -70
View File
@@ -1,51 +1,10 @@
{{define "content"}}
<!-- 汉字字帖面板 -->
<div id="panel-zitie" class="tool-panel">
<header class="page-header">
<h1>汉字字帖生成器</h1>
<p>生成高颜值的硬笔/毛笔书法练习帖,支持 A4/Letter 高清矢量打印。</p>
</header>
<div class="tabs-container">
<div id="tab-teaching" class="tab-btn active" onclick="switchZitieTab('teaching')">2x3 教学方格</div>
<div id="tab-step" class="tab-btn" onclick="switchZitieTab('step')">步进式分解</div>
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieTab('manuscript')">古风竖排</div>
</div>
<!-- 注入各个工具面板 -->
{{template "zitie" .}}
{{template "learn_number" .}}
<div class="card">
<div style="display: flex; flex-direction: column; gap: 24px;">
<div class="input-group">
<label id="input-label">输入汉字内容 (2x3 教学方格模式下标点将被自动过滤)</label>
<textarea id="chars" placeholder="支持多行输入...">永和九年,岁在癸丑。</textarea>
</div>
<div style="display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 8px;">纸张大小</label>
<select id="paper_size">
<option value="A4">A4 (210x297mm)</option>
<option value="Letter">Letter (8.5x11in)</option>
</select>
</div>
<div id="font-select-group" style="flex: 1; min-width: 200px; display: none;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 8px;">书法字体</label>
<select id="font_type">
<option value="kaiti">华光楷体</option>
<option value="xingshu">华光行草</option>
<option value="lishu">华光隶变</option>
<option value="songti">华光书宋</option>
</select>
</div>
<button onclick="generatePDF()">生成高清预览</button>
</div>
</div>
</div>
<div id="pdf-preview-container" style="display:none; width: 100%; height: 850px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
<iframe id="pdf-frame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
<!-- 默认欢迎面板 -->
<!-- 欢迎面板 -->
<div id="panel-welcome" class="tool-panel">
<header class="page-header">
<h1>欢迎使用个人工具箱</h1>
@@ -55,53 +14,67 @@
<div style="margin-top: 40px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px;">
<div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/zitie')">
<h3 style="margin-top:0;">🖋️ 汉字字帖生成</h3>
<p style="color: #86868b; line-height: 1.5;">支持多种书法字体的 2x3 教学方格、步进分解和古风竖排信纸。专为硬笔和毛笔练习设计。</p>
<p style="color: #86868b; line-height: 1.5;">支持多种书法字体的 2x3 教学方格、步进分解和古风竖排信纸。</p>
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
</div>
<div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/learn-number')">
<h3 style="margin-top:0;">🔢 幼儿数学助手</h3>
<p style="color: #86868b; line-height: 1.5;">数图形、基础算术等趣味练习。培养孩子的数感与逻辑。</p>
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
</div>
</div>
</div>
<script>
let currentMode = 'teaching';
// --- Zitie Tool Logic ---
let currentZitieMode = 'teaching';
function switchZitieTab(mode) {
currentMode = mode;
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
currentZitieMode = mode;
document.querySelectorAll('#panel-zitie .tab-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById(`tab-${mode}`).classList.add('active');
document.getElementById('font-select-group').style.display = (mode === 'manuscript') ? 'block' : 'none';
const labels = {
'teaching': '输入汉字内容 (2x3 教学方格模式下标点将被自动过滤)',
'step': '输入汉字内容 (步进式分解模式下标点将被自动过滤)',
'manuscript': '输入汉字内容 (古风竖排支持换行,标点将被过滤)'
};
document.getElementById('input-label').innerText = labels[mode];
}
async function generatePDF() {
async function generateZitiePDF() {
const chars = document.getElementById('chars').value;
const paper_size = document.getElementById('paper_size').value;
const font_type = document.getElementById('font_type').value;
const btn = document.querySelector('button');
const originalText = btn.innerText;
btn.innerText = '正在绘图中...'; btn.disabled = true;
const btn = document.querySelector('#panel-zitie button');
btn.innerText = '绘图中...'; btn.disabled = true;
try {
const response = await fetch(`/api/zitie/${currentMode}`, {
const response = await fetch(`/api/zitie/${currentZitieMode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chars, paper_size, font_type })
});
if (response.ok) {
const blob = await response.blob();
document.getElementById('pdf-preview-container').style.display = 'block';
document.getElementById('pdf-frame').src = URL.createObjectURL(blob);
if(window.innerWidth < 768) document.getElementById('pdf-preview-container').scrollIntoView({behavior: 'smooth'});
} else { alert('生成失败'); }
} catch (e) { alert('网络错误'); } finally {
btn.innerText = originalText;
btn.disabled = false;
document.getElementById('zitie-preview').style.display = 'block';
document.getElementById('zitie-frame').src = URL.createObjectURL(blob);
}
} catch (e) { alert('错误'); } finally { btn.innerText = '生成字帖预览'; btn.disabled = false; }
}
// --- Math Tool Logic ---
async function generateMathPDF() {
const total_count = parseInt(document.getElementById('total_count').value);
const icon_types = parseInt(document.getElementById('icon_types').value);
const paper_size = document.getElementById('math_paper_size').value;
const btn = document.querySelector('#panel-learn-number button');
btn.innerText = '生成中...'; btn.disabled = true;
try {
const response = await fetch('/api/learn-number/counting', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ total_count, icon_types, paper_size })
});
if (response.ok) {
const blob = await response.blob();
document.getElementById('math-preview').style.display = 'block';
document.getElementById('math-frame').src = URL.createObjectURL(blob);
if(window.innerWidth < 768) document.getElementById('math-preview').scrollIntoView({behavior: 'smooth'});
}
} catch (e) { alert('错误'); } finally { btn.innerText = '生成数学练习帖'; btn.disabled = false; }
}
</script>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "learn_number"}}
<div id="panel-learn-number" class="tool-panel">
<header class="page-header">
<h1>幼儿数学助手</h1>
<p>通过趣味图形和基础练习,培养孩子的数感与逻辑。</p>
</header>
<div class="tabs-container">
<div id="tab-counting" class="tab-btn active">数图形练习</div>
</div>
<div class="card">
<div style="display: flex; flex-direction: column; gap: 24px;">
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 250px;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">图标种类数 (1 - 5)</label>
<input type="range" id="icon_types" min="1" max="5" value="3" style="width: 100%;" oninput="this.nextElementSibling.value = this.value">
<output style="font-size: 18px; font-weight: 700; color: var(--apple-blue); margin-top: 10px; display: block;">3</output>
</div>
<div style="flex: 1; min-width: 250px;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">总图案数量 (种类数 - 30)</label>
<input type="range" id="total_count" min="5" max="30" value="15" style="width: 100%;" oninput="this.nextElementSibling.value = this.value">
<output style="font-size: 18px; font-weight: 700; color: var(--apple-blue); margin-top: 10px; display: block;">15</output>
</div>
<div style="width: 240px;">
<label>纸张大小</label>
<select id="math_paper_size">
<option value="A4">A4</option>
<option value="Letter">Letter</option>
</select>
</div>
</div>
<button onclick="generateMathPDF()">生成数学练习帖</button>
</div>
</div>
<div id="math-preview" style="display:none; width: 100%; height: 800px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
<iframe id="math-frame" style="width:100%; height:100%; border:none;"></iframe>
</div>
</div>
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "zitie"}}
<div id="panel-zitie" class="tool-panel">
<header class="page-header">
<h1>汉字字帖生成器</h1>
<p>生成高颜值的硬笔/毛笔书法练习帖。</p>
</header>
<div class="tabs-container">
<div id="tab-teaching" class="tab-btn active" onclick="switchZitieTab('teaching')">2x3 教学方格</div>
<div id="tab-step" class="tab-btn" onclick="switchZitieTab('step')">步进式分解</div>
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieTab('manuscript')">古风竖排</div>
</div>
<div class="card">
<div style="display: flex; flex-direction: column; gap: 24px;">
<div class="input-group">
<label id="input-label">输入汉字内容</label>
<textarea id="chars" placeholder="支持多行输入...">永和九年,岁在癸丑。</textarea>
</div>
<div style="display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;">
<label>纸张大小</label>
<select id="paper_size">
<option value="A4">A4 (210x297mm)</option>
<option value="Letter">Letter (8.5x11in)</option>
</select>
</div>
<div id="font-select-group" style="flex: 1; min-width: 200px; display: none;">
<label>书法字体</label>
<select id="font_type">
<option value="kaiti">华光楷体</option>
<option value="xingshu">华光行草</option>
<option value="lishu">华光隶变</option>
<option value="songti">华光书宋</option>
</select>
</div>
<button onclick="generateZitiePDF()">生成字帖预览</button>
</div>
</div>
</div>
<div id="zitie-preview" style="display:none; width: 100%; height: 800px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
<iframe id="zitie-frame" style="width:100%; height:100%; border:none;"></iframe>
</div>
</div>
{{end}}
+10 -8
View File
@@ -1,8 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: toolbox
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: own-tools
namespace: default
namespace: toolbox
spec:
replicas: 1
selector:
@@ -13,9 +18,6 @@ spec:
labels:
app: own-tools
spec:
# 如果你的 Gitea 仓库是私有的,请取消下面注释并创建对应的 Secret
# imagePullSecrets:
# - name: gitea-registry-secret
containers:
- name: toolbox
image: git.pengzhan.dev/haopengzhan/own-tools:latest
@@ -52,8 +54,8 @@ spec:
apiVersion: v1
kind: Service
metadata:
name: own-tools
namespace: default
name: own-tools-service
namespace: toolbox
spec:
selector:
app: own-tools
@@ -67,7 +69,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: own-tools-ingress
namespace: default
namespace: toolbox
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
@@ -80,7 +82,7 @@ spec:
pathType: Prefix
backend:
service:
name: own-tools
name: own-tools-service
port:
number: 80
tls:
+41
View File
@@ -0,0 +1,41 @@
package data
type Icon struct {
Name string
Paths []string
}
// 升级版:更卡通、更饱满的简笔画
var CountingIcons = []Icon{
{Name: "Bear", Paths: []string{
"M 512 800 C 300 800 200 700 200 500 C 200 300 350 200 512 200 C 674 200 824 300 824 500 C 824 700 724 800 512 800 Z", // Body
"M 300 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear L
"M 724 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear R
"M 400 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye L
"M 624 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye R
"M 512 550 Q 512 650 400 650 M 512 550 Q 512 650 624 650", // Mouth
}},
{Name: "Cat", Paths: []string{
"M 200 800 L 300 400 L 400 200 L 512 350 L 624 200 L 724 400 L 824 800 Z", // Head
"M 400 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye L
"M 624 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye R
"M 512 650 L 450 700 M 512 650 L 574 700", // Nose
}},
{Name: "Car", Paths: []string{
"M 100 700 L 100 500 Q 100 400 300 400 L 700 400 Q 900 400 900 500 L 900 700 Z", // Body
"M 250 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel L
"M 750 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel R
"M 300 400 L 400 250 L 624 250 L 724 400", // Roof
}},
{Name: "Bird", Paths: []string{
"M 512 512 m -300 0 a 300 300 0 1 0 600 0 a 300 300 0 1 0 -600 0", // Body
"M 812 512 L 950 450 L 812 400 Z", // Beak
"M 400 400 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye
"M 212 512 Q 100 400 212 300", // Wing
}},
{Name: "Rocket", Paths: []string{
"M 512 100 Q 700 400 700 800 L 324 800 Q 324 400 512 100 Z", // Body
"M 512 400 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Window
"M 324 800 L 200 950 L 324 900 M 700 800 L 824 950 L 700 900", // Fins
}},
}
+168
View File
@@ -0,0 +1,168 @@
package logic
import (
"bytes"
"math"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"toolbox/pkg/learnnumber/data"
"github.com/signintech/gopdf"
)
type CountingRequest struct {
TotalCount int `json:"total_count"`
IconTypes int `json:"icon_types"`
PaperSize string `json:"paper_size"`
}
var reSVG = regexp.MustCompile(`([MLQCZmlqcz])|(-?\d+\.?\d*)`)
func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
rand.Seed(time.Now().UnixNano())
pdf := &gopdf.GoPdf{}
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)
var buf bytes.Buffer
_, err := pdf.WriteTo(&buf)
return buf.Bytes(), err
}
func drawProblem(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
// 1. 绘制方框
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.0)
pdf.RectFromUpperLeft(x, y, boxW, boxH)
// 2. 分配图标数量
numTypes := req.IconTypes; if numTypes < 1 { numTypes = 1 }
if numTypes > len(data.CountingIcons) { numTypes = len(data.CountingIcons) }
total := req.TotalCount; if total < numTypes { total = numTypes }
if total > 30 { total = 30 }
// 随机选出图标种类
allIcons := rand.Perm(len(data.CountingIcons))
selectedIcons := []data.Icon{}
counts := []int{}
// 先每种分配1个
rem := total - numTypes
for i := 0; i < numTypes; i++ {
selectedIcons = append(selectedIcons, data.CountingIcons[allIcons[i]])
counts = append(counts, 1)
}
// 随机分配剩下的
for i := 0; i < rem; 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
iconTypeIdx := 0
countInCurrentType := 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)
}
}
}
}
}
// 支持贝塞尔曲线采样,确保图标圆润
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})
}
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})
}
lx, ly = x, y; i += 5
} else { i++ }
}
return pts
}
+48
View File
@@ -0,0 +1,48 @@
package learnnumber
import (
"fmt"
"net/http"
"toolbox/pkg/base"
"toolbox/pkg/learnnumber/logic"
"github.com/gin-gonic/gin"
)
type learnNumberTool struct{}
func init() {
base.Register(&learnNumberTool{})
}
func (t *learnNumberTool) ID() string { return "learn-number" }
func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
func (t *learnNumberTool) Description() string { return "包含数图形、基础加减法等趣味数学练习" }
func (t *learnNumberTool) Init() error {
fmt.Println("Initializing Learn-Number tool...")
return nil
}
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
r.POST("/counting", t.handleCounting)
}
func (t *learnNumberTool) handleCounting(c *gin.Context) {
var req logic.CountingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.TotalCount <= 0 { req.TotalCount = 20 }
if req.TotalCount > 30 { req.TotalCount = 30 }
pdfBytes, err := logic.GenerateCountingPDF(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}