feat: Add Learn-Number tool for preschool math practice and refactor HTML components
Build and Push Docker Image / build (push) Successful in 2m55s
Build and Push Docker Image / build (push) Successful in 2m55s
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Binaries
|
||||
toolbox
|
||||
/toolbox
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
|
||||
+44
-71
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user