Compare commits
3 Commits
0be94026c5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c3d0dee806 | |||
| 35cd040122 | |||
| 7cd611140e |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Binaries
|
||||
/toolbox
|
||||
toolbox
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
+8
-1
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"toolbox/pkg/base"
|
||||
_ "toolbox/pkg/learnnumber"
|
||||
@@ -81,11 +82,17 @@ func main() {
|
||||
r.GET("/", serveIndex)
|
||||
r.GET("/api/tools", func(c *gin.Context) {
|
||||
var list []map[string]string
|
||||
for _, t := range base.Registry {
|
||||
ids := make([]string, 0, len(base.Registry))
|
||||
for id := range base.Registry { ids = append(ids, id) }
|
||||
sort.Strings(ids)
|
||||
|
||||
for _, id := range ids {
|
||||
t := base.Registry[id]
|
||||
list = append(list, map[string]string{
|
||||
"id": t.ID(),
|
||||
"name": t.Name(),
|
||||
"desc": t.Description(),
|
||||
"emoji": t.Emoji(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
|
||||
+35
-67
@@ -1,80 +1,48 @@
|
||||
{{define "content"}}
|
||||
|
||||
<!-- 注入各个工具面板 -->
|
||||
{{template "zitie" .}}
|
||||
{{template "learn_number" .}}
|
||||
|
||||
<!-- 欢迎面板 -->
|
||||
<div id="panel-welcome" class="tool-panel">
|
||||
<!-- Dashboard / Welcome Panel -->
|
||||
<div id="panel-welcome" class="tool-panel active">
|
||||
<header class="page-header">
|
||||
<h1>欢迎使用个人工具箱</h1>
|
||||
<p>这是您的私人效率基地。请从侧边栏或下方列表选择一个功能开始。</p>
|
||||
<h1>探索工具箱</h1>
|
||||
<p>欢迎使用个人生产力助手,点击下方卡片开始工作。</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<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 id="tools-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px;">
|
||||
<!-- JS 动态注入 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools Panels Containers -->
|
||||
{{template "zitie" .}}
|
||||
{{template "learn_number" .}}
|
||||
|
||||
<script>
|
||||
// --- Zitie Tool Logic ---
|
||||
let currentZitieMode = 'teaching';
|
||||
function switchZitieTab(mode) {
|
||||
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';
|
||||
// 首页 Tile 渲染
|
||||
async function renderDashboard() {
|
||||
try {
|
||||
const response = await fetch('/api/tools');
|
||||
const tools = await response.json();
|
||||
const grid = document.getElementById('tools-grid');
|
||||
grid.innerHTML = '';
|
||||
tools.forEach(tool => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.style.cssText = 'cursor: pointer; transition: all 0.3s ease; display: flex; flex-direction: column; gap: 12px; height: 100%;';
|
||||
card.innerHTML = `
|
||||
<div style="font-size: 40px;">${tool.emoji || '🛠️'}</div>
|
||||
<h3 style="margin: 0; font-size: 20px; font-weight: 700;">${tool.name}</h3>
|
||||
<p style="margin: 0; color: #86868b; font-size: 15px; line-height: 1.4;">${tool.desc}</p>
|
||||
`;
|
||||
card.onclick = () => navigateTo(`/${tool.id}`);
|
||||
card.onmouseover = () => card.style.transform = 'translateY(-5px)';
|
||||
card.onmouseout = () => card.style.transform = 'translateY(0)';
|
||||
grid.appendChild(card);
|
||||
});
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
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('#panel-zitie button');
|
||||
btn.innerText = '绘图中...'; btn.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/api/zitie/${currentZitieMode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chars, paper_size, font_type })
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderDashboard();
|
||||
});
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
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}}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
tools.forEach(tool => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'nav-item'; item.id = `nav-${tool.id}`;
|
||||
item.innerHTML = `<span>🛠️</span> ${tool.name}`;
|
||||
item.innerHTML = `<span>${tool.emoji || '🛠️'}</span> ${tool.name}`;
|
||||
item.onclick = () => { navigateTo(`/${tool.id}`); document.getElementById('menu-toggle').checked = false; };
|
||||
navList.appendChild(item);
|
||||
});
|
||||
|
||||
@@ -2,37 +2,190 @@
|
||||
<div id="panel-learn-number" class="tool-panel">
|
||||
<header class="page-header">
|
||||
<h1>幼儿数学助手</h1>
|
||||
<p>通过趣味图形和基础练习,培养孩子的数感与逻辑。</p>
|
||||
<p>通过趣味游戏,培养孩子的数感、逻辑思维与书写能力。</p>
|
||||
</header>
|
||||
<div class="tabs-container">
|
||||
<div id="tab-counting" class="tab-btn active">数图形练习</div>
|
||||
<div id="tab-counting" class="tab-btn active" onclick="switchMathTab('counting')">数图形练习</div>
|
||||
<div id="tab-writing" class="tab-btn" onclick="switchMathTab('writing')">数字连连看</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>
|
||||
|
||||
<!-- 数图形面板 -->
|
||||
<div id="math-counting-controls" class="card">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px; align-items: flex-end;">
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">练习主题</label>
|
||||
<select id="math_category" onchange="updateIconPreview()" class="apple-select">
|
||||
<option value="fruits">素材库 (SVG)</option>
|
||||
<option value="shapes">基础几何图形</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">种类 (Max 6)</label>
|
||||
<input type="number" id="icon_types" min="1" max="6" value="3" class="apple-input">
|
||||
</div>
|
||||
<button onclick="generateMathPDF()">生成数学练习帖</button>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">总量 (Max 30)</label>
|
||||
<input type="number" id="total_count" min="1" max="30" value="15" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">页数 (1-10)</label>
|
||||
<input type="number" id="page_count" min="1" max="10" value="1" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张</label>
|
||||
<select id="math_paper_size" class="apple-select">
|
||||
<option value="Letter" selected>Letter</option>
|
||||
<option value="A4">A4</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="generateMathPDF()" id="btn-math-counting">生成练习帖</button>
|
||||
</div>
|
||||
<div id="icon-preview-container" style="margin-top: 24px; padding: 20px; background: #fbfbfd; border-radius: 12px; border: 1px solid #e5e5e7;">
|
||||
<div id="icon-list" style="display: flex; gap: 12px; flex-wrap: wrap;"></div>
|
||||
</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;">
|
||||
|
||||
<!-- 数字连连看面板 (已简化页数选择) -->
|
||||
<div id="math-writing-controls" class="card" style="display:none;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 24px; align-items: flex-end;">
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">起始数字</label>
|
||||
<input type="number" id="start_num" min="0" value="1" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">结束数字</label>
|
||||
<input type="number" id="end_num" min="1" value="15" class="apple-input">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张大小</label>
|
||||
<select id="write_paper_size" class="apple-select">
|
||||
<option value="Letter" selected>Letter (8.5x11in)</option>
|
||||
<option value="A4">A4 (210x297mm)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="generateWritingPDF()" id="btn-math-writing">生成闯关地图</button>
|
||||
</div>
|
||||
<p style="margin-top: 16px; font-size: 13px; color: #86868b;">💡 规则:系统将根据数字范围自动分页(每页约 15 个)。描红数字,并寻找下一个数字进行连线。</p>
|
||||
</div>
|
||||
|
||||
<div id="math-error-msg" style="color: #ff3b30; font-size: 14px; display: none; font-weight: 500; text-align: center; padding: 10px; background: #fff2f2; border-radius: 8px; margin-bottom: 20px;"></div>
|
||||
|
||||
<div id="math-preview" style="display:none; width: 100%; height: 850px; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.12); background: #fff; border: 1px solid #d2d2d7;">
|
||||
<iframe id="math-frame" style="width:100%; height:100%; border:none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.apple-input, .apple-select {
|
||||
width: 100%; height: 46px; padding: 0 12px; border: 1px solid #d2d2d7; border-radius: 10px;
|
||||
font-size: 15px; font-weight: 500; background-color: #ffffff; transition: all 0.2s ease;
|
||||
outline: none; box-shadow: inset 0 1px 2px rgba(0,0,0,0.05); line-height: 46px;
|
||||
}
|
||||
.apple-input:focus, .apple-select:focus { border-color: #0071e3; box-shadow: 0 0 0 4px rgba(0,113,227,0.15); }
|
||||
.icon-thumbnail {
|
||||
width: 52px; height: 52px; background: white; border-radius: 10px; border: 1px solid #e5e5e7;
|
||||
display: flex; align-items: center; justify-content: center; padding: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.04); transition: all 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let mathTab = 'counting';
|
||||
function switchMathTab(tab) {
|
||||
mathTab = tab;
|
||||
document.querySelectorAll('#panel-learn-number .tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(`tab-${tab}`).classList.add('active');
|
||||
document.getElementById('math-counting-controls').style.display = (tab === 'counting') ? 'block' : 'none';
|
||||
document.getElementById('math-writing-controls').style.display = (tab === 'writing') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
let allCategories = {};
|
||||
async function initMathTool() {
|
||||
try {
|
||||
const res = await fetch('/api/learn-number/categories');
|
||||
allCategories = await res.json();
|
||||
updateIconPreview();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function calculateBBox(paths) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const numRegex = /-?\d+\.?\d*/g;
|
||||
paths.forEach(path => {
|
||||
const matches = path.match(numRegex);
|
||||
if (matches) {
|
||||
for (let i=0; i<matches.length; i+=2) {
|
||||
const x = parseFloat(matches[i]); const y = parseFloat(matches[i+1]);
|
||||
if (!isNaN(x) && !isNaN(y)) {
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (minX === Infinity) return "0 0 1024 1024";
|
||||
const w = maxX - minX, h = maxY - minY;
|
||||
const padding = Math.max(w, h) * 0.15;
|
||||
return `${minX - padding} ${minY - padding} ${w + padding*2} ${h + padding*2}`;
|
||||
}
|
||||
|
||||
function updateIconPreview() {
|
||||
const cat = document.getElementById('math_category').value;
|
||||
const icons = allCategories[cat] || [];
|
||||
const container = document.getElementById('icon-list');
|
||||
container.innerHTML = '';
|
||||
icons.forEach(icon => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'icon-thumbnail';
|
||||
const viewBox = calculateBBox(icon.Paths);
|
||||
const pathsHtml = icon.Paths.map(p => `<path d="${p}" fill="none" stroke="black" stroke-width="2%" stroke-linecap="round" stroke-linejoin="round" />`).join('');
|
||||
div.innerHTML = `<svg viewBox="${viewBox}" style="width: 100%; height: 100%; overflow: visible;">${pathsHtml}</svg>`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function generateMathPDF() {
|
||||
const total_count = parseInt(document.getElementById('total_count').value);
|
||||
const icon_types = parseInt(document.getElementById('icon_types').value);
|
||||
const page_count = parseInt(document.getElementById('page_count').value || 1);
|
||||
const category = document.getElementById('math_category').value;
|
||||
const paper_size = document.getElementById('math_paper_size').value;
|
||||
if (total_count < icon_types) { alert('总量必须大于种类'); return; }
|
||||
|
||||
const btn = document.getElementById('btn-math-counting');
|
||||
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, page_count, category, paper_size })
|
||||
});
|
||||
const blob = await response.blob();
|
||||
document.getElementById('math-preview').style.display = 'block';
|
||||
document.getElementById('math-frame').src = URL.createObjectURL(blob);
|
||||
} finally { btn.innerText = '生成练习帖'; btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function generateWritingPDF() {
|
||||
const start_num = parseInt(document.getElementById('start_num').value);
|
||||
const end_num = parseInt(document.getElementById('end_num').value);
|
||||
const paper_size = document.getElementById('write_paper_size').value;
|
||||
if (end_num <= start_num) { alert('结束数字必须大于起始数字'); return; }
|
||||
|
||||
const btn = document.getElementById('btn-math-writing');
|
||||
btn.innerText = '生成中...'; btn.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/learn-number/writing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_num, end_num, paper_size })
|
||||
});
|
||||
const blob = await response.blob();
|
||||
document.getElementById('math-preview').style.display = 'block';
|
||||
document.getElementById('math-frame').src = URL.createObjectURL(blob);
|
||||
} finally { btn.innerText = '生成闯关地图'; btn.disabled = false; }
|
||||
}
|
||||
|
||||
setTimeout(initMathTool, 200);
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -1,43 +1,77 @@
|
||||
{{define "zitie"}}
|
||||
<div id="panel-zitie" class="tool-panel">
|
||||
<header class="page-header">
|
||||
<h1>汉字字帖生成器</h1>
|
||||
<p>生成高颜值的硬笔/毛笔书法练习帖。</p>
|
||||
<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 id="tab-teaching" class="tab-btn active" onclick="switchZitieMode('teaching')">教学版 (2x3)</div>
|
||||
<div id="tab-step" class="tab-btn" onclick="switchZitieMode('step')">临摹版 (9列)</div>
|
||||
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieMode('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="display: flex; flex-direction: column; gap: 20px;">
|
||||
<textarea id="chars" rows="5" placeholder="请输入汉字" style="resize: vertical; min-height: 120px;">天道酬勤 厚德载物</textarea>
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label>纸张大小</label>
|
||||
<select id="paper_size">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张大小</label>
|
||||
<select id="paper_size" class="apple-select">
|
||||
<option value="Letter" selected>Letter (8.5x11in)</option>
|
||||
<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>
|
||||
<div id="font-type-container" style="flex: 1; min-width: 200px; display: none;">
|
||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">书法字体</label>
|
||||
<select id="font_type" class="apple-select">
|
||||
<option value="kaiti">华光楷体 (推荐)</option>
|
||||
<option value="songti">华光书宋</option>
|
||||
<option value="lishu">华光隶变</option>
|
||||
<option value="xingshu">华光行草</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="generateZitiePDF()">生成字帖预览</button>
|
||||
<button id="btn-generate-zitie" onclick="generateZitiePDF()">生成 PDF 字帖</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 id="zitie-preview" style="display:none; width: 100%; height: 850px; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.12); background: #fff; border: 1px solid #d2d2d7;">
|
||||
<iframe id="pdf-frame" style="width:100%; height:100%; border:none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentMode = 'teaching';
|
||||
function switchZitieMode(mode) {
|
||||
currentMode = mode;
|
||||
document.querySelectorAll('#panel-zitie .tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(`tab-${mode}`).classList.add('active');
|
||||
document.getElementById('font-type-container').style.display = (mode === 'manuscript') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
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;
|
||||
if (!chars.trim()) { alert('请输入汉字'); return; }
|
||||
|
||||
const btn = document.getElementById('btn-generate-zitie');
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = '生成中...'; btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/zitie/${currentMode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chars, paper_size, font_type })
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
document.getElementById('zitie-preview').style.display = 'block';
|
||||
document.getElementById('pdf-frame').src = url;
|
||||
} catch (e) {
|
||||
alert('生成失败: ' + e);
|
||||
} finally {
|
||||
btn.innerText = originalText; btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
+7
-11
@@ -1,22 +1,18 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Tool 定义了工具箱中每个子工具必须实现的接口
|
||||
type Tool interface {
|
||||
ID() string // 工具的唯一标识,用于路由前缀,如 "zitie"
|
||||
Name() string // 工具的显示名称
|
||||
Description() string // 工具的描述
|
||||
Init() error // 初始化逻辑,如加载 embed 的数据
|
||||
RegisterRoutes(r *gin.RouterGroup) // 注册该工具的 API 路由
|
||||
ID() string
|
||||
Name() string
|
||||
Description() string
|
||||
Emoji() string
|
||||
Init() error
|
||||
RegisterRoutes(r *gin.RouterGroup)
|
||||
}
|
||||
|
||||
// Registry 存储所有已注册的工具
|
||||
var Registry = make(map[string]Tool)
|
||||
|
||||
// Register 用于工具在 init() 函数中注册自己
|
||||
func Register(t Tool) {
|
||||
Registry[t.ID()] = t
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 404 KiB |
@@ -1,41 +1,96 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed fruit.svg
|
||||
var fruitSVGContent []byte
|
||||
|
||||
//go:embed font.ttf
|
||||
var FontContent []byte
|
||||
|
||||
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
|
||||
}},
|
||||
var IconCategories = map[string][]Icon{
|
||||
"shapes": {
|
||||
{Name: "Circle", Paths: []string{"M 512 112 C 291 112 112 291 112 512 C 112 733 291 912 512 912 C 733 912 912 733 912 512 C 912 291 733 112 512 112 Z"}},
|
||||
{Name: "Square", Paths: []string{"M 150 150 L 874 150 L 874 874 L 150 874 Z"}},
|
||||
{Name: "Rectangle", Paths: []string{"M 100 300 L 924 300 L 924 724 L 100 724 Z"}},
|
||||
{Name: "Triangle", Paths: []string{"M 512 150 L 900 850 L 124 850 Z"}},
|
||||
{Name: "Star", Paths: []string{"M 512 100 L 612 400 L 924 400 L 674 600 L 774 900 L 512 700 L 250 900 L 350 600 L 100 400 L 412 400 Z"}},
|
||||
{Name: "Heart", Paths: []string{"M 512 900 C 200 700 100 500 100 300 C 100 100 400 100 512 250 C 624 100 924 100 924 300 C 924 500 824 700 512 900 Z"}},
|
||||
{Name: "Diamond", Paths: []string{"M 512 100 L 900 512 L 512 924 L 124 512 Z"}},
|
||||
{Name: "Oval", Paths: []string{"M 512 350 C 200 350 100 420 100 512 C 100 604 200 674 512 674 C 824 674 924 604 924 512 C 924 420 824 350 512 350 Z"}},
|
||||
{Name: "Trapezoid", Paths: []string{"M 300 200 L 724 200 L 924 800 L 100 800 Z"}},
|
||||
{Name: "Hexagon", Paths: []string{"M 512 100 L 858 300 L 858 724 L 512 924 L 166 724 L 166 300 Z"}},
|
||||
},
|
||||
"fruits": {},
|
||||
}
|
||||
|
||||
type SVG struct {
|
||||
Groups []G `xml:"g"`
|
||||
}
|
||||
|
||||
type G struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Groups []G `xml:"g"`
|
||||
Paths []Path `xml:"path"`
|
||||
}
|
||||
|
||||
type Path struct {
|
||||
D string `xml:"d,attr"`
|
||||
}
|
||||
|
||||
func LoadIconsFromEmbed() error {
|
||||
var svg SVG
|
||||
if err := xml.Unmarshal(fruitSVGContent, &svg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var objectsG *G
|
||||
for i := range svg.Groups {
|
||||
if svg.Groups[i].ID == "objects" {
|
||||
objectsG = &svg.Groups[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if objectsG == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fruitIcons []Icon
|
||||
for i, g := range objectsG.Groups {
|
||||
paths := collectPaths(g)
|
||||
if len(paths) > 0 {
|
||||
fruitIcons = append(fruitIcons, Icon{
|
||||
Name: fmt.Sprintf("Item %d", i+1),
|
||||
Paths: paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
IconCategories["fruits"] = fruitIcons
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectPaths(g G) []string {
|
||||
var paths []string
|
||||
for _, p := range g.Paths {
|
||||
d := strings.TrimSpace(p.D)
|
||||
if d != "" {
|
||||
paths = append(paths, d)
|
||||
}
|
||||
}
|
||||
for _, subG := range g.Groups {
|
||||
paths = append(paths, collectPaths(subG)...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
numTypes := req.IconTypes
|
||||
if numTypes < 1 { numTypes = 1 }; if numTypes > 6 { numTypes = 6 }
|
||||
if numTypes > len(catIcons) { numTypes = len(catIcons) }
|
||||
|
||||
// 随机选出图标种类
|
||||
allIcons := rand.Perm(len(data.CountingIcons))
|
||||
selectedIcons := []data.Icon{}
|
||||
counts := []int{}
|
||||
total := req.TotalCount
|
||||
if total < numTypes { total = numTypes }; if total > 30 { total = 30 }
|
||||
|
||||
// 先每种分配1个
|
||||
rem := total - numTypes
|
||||
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
|
||||
|
||||
iconTypeIdx := 0
|
||||
countInCurrentType := 0
|
||||
avgRadius := math.Sqrt((boxW * boxH * 0.22) / (float64(total) * math.Pi))
|
||||
if avgRadius > 35.0 { avgRadius = 35.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
|
||||
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 }
|
||||
}
|
||||
|
||||
// 4. 绘制右侧对照表
|
||||
legendX := x + boxW + 20.0
|
||||
legendStepY := boxH / float64(numTypes + 1)
|
||||
legendX, legendStepY := xBase+boxW+20.0, 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)
|
||||
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 drawIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
|
||||
scale := size / 1024.0
|
||||
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.5) // 加粗线条
|
||||
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
|
||||
|
||||
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")
|
||||
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 {
|
||||
for i := 0; i < len(pts)-1; i++ {
|
||||
pdf.Line(pts[i].X, pts[i].Y, pts[i+1].X, pts[i+1].Y)
|
||||
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 {
|
||||
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
|
||||
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)
|
||||
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
|
||||
}
|
||||
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 += 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 {
|
||||
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 += 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)
|
||||
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 += 5
|
||||
} else { i++ }
|
||||
lx, ly = x, y; i += 4
|
||||
case "A": i += 7
|
||||
default: i++
|
||||
}
|
||||
return pts
|
||||
}
|
||||
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: false}) }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"toolbox/pkg/learnnumber/data"
|
||||
|
||||
"github.com/signintech/gopdf"
|
||||
)
|
||||
|
||||
type WritingRequest struct {
|
||||
StartNum int `json:"start_num"`
|
||||
EndNum int `json:"end_num"`
|
||||
PaperSize string `json:"paper_size"`
|
||||
}
|
||||
|
||||
type WritingNode struct {
|
||||
X, Y, R float64
|
||||
AngleA, AngleB float64
|
||||
BulgeR, Dist float64
|
||||
Number int
|
||||
}
|
||||
|
||||
func GenerateWritingPDF(req WritingRequest) ([]byte, error) {
|
||||
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})
|
||||
|
||||
err := pdf.AddTTFFontData("basic", data.FontContent)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
currentNum := req.StartNum
|
||||
// 只要还没达到 EndNum,就继续生成“整页”内容
|
||||
for currentNum <= req.EndNum {
|
||||
pdf.AddPage()
|
||||
lastPlaced := drawOneFullPage(pdf, currentNum, rect.W, rect.H)
|
||||
currentNum = lastPlaced + 1
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = pdf.WriteTo(&buf)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
// 核心改变:不再受限于 end 参数,而是尝试填满整页
|
||||
func drawOneFullPage(pdf *gopdf.GoPdf, start int, pW, pH float64) int {
|
||||
margin := 80.0
|
||||
boxW, boxH := pW-2*margin, pH-160.0
|
||||
xBase, yBase := margin, 100.0
|
||||
|
||||
nodeR := 28.0
|
||||
bulgeR := nodeR * 0.32
|
||||
dist := nodeR + bulgeR + 18.0
|
||||
checkR := nodeR * 1.3
|
||||
|
||||
var nodes []WritingNode
|
||||
num := start
|
||||
|
||||
// 无限循环,直到页面塞不下为止
|
||||
for {
|
||||
placed := false
|
||||
for retry := 0; retry < 1000; retry++ {
|
||||
rx := xBase + dist + 20 + rand.Float64()*(boxW - 2*dist - 40)
|
||||
ry := yBase + dist + 20 + rand.Float64()*(boxH - 2*dist - 40)
|
||||
|
||||
if isColliding(rx, ry, checkR, nodes) { continue }
|
||||
|
||||
angleA := rand.Float64() * 2 * math.Pi
|
||||
angleB := angleA + math.Pi + (rand.Float64()-0.5)*1.5
|
||||
nodes = append(nodes, WritingNode{X: rx, Y: ry, R: nodeR, AngleA: angleA, AngleB: angleB, BulgeR: bulgeR, Dist: dist, Number: num})
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
|
||||
if !placed {
|
||||
// 页面已满
|
||||
break
|
||||
}
|
||||
num++
|
||||
}
|
||||
|
||||
// 空间优化
|
||||
optimizeSpace(nodes, xBase, yBase, boxW, boxH, dist, checkR)
|
||||
|
||||
// 绘制
|
||||
for _, n := range nodes {
|
||||
drawFullNode(pdf, n)
|
||||
}
|
||||
|
||||
// 返回本页最后一个数字
|
||||
if len(nodes) > 0 {
|
||||
return nodes[len(nodes)-1].Number
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR float64) {
|
||||
if len(nodes) < 2 { return }
|
||||
for iter := 0; iter < 120; iter++ {
|
||||
for i := range nodes {
|
||||
bestX, bestY := nodes[i].X, nodes[i].Y
|
||||
maxMinDist := calcMinDist(bestX, bestY, i, nodes)
|
||||
for k := 0; k < 8; k++ {
|
||||
moveAngle := rand.Float64() * 2 * math.Pi
|
||||
nx := nodes[i].X + math.Cos(moveAngle)*5.0
|
||||
ny := nodes[i].Y + math.Sin(moveAngle)*5.0
|
||||
if nx-dist < xBase || nx+dist > xBase+boxW || ny-dist < yBase || ny+dist > yBase+boxH { continue }
|
||||
if isColliding(nx, ny, checkR, nodes[:i]) || isColliding(nx, ny, checkR, nodes[i+1:]) { continue }
|
||||
newMinDist := calcMinDist(nx, ny, i, nodes)
|
||||
if newMinDist > maxMinDist {
|
||||
maxMinDist = newMinDist; bestX, bestY = nx, ny
|
||||
}
|
||||
}
|
||||
nodes[i].X, nodes[i].Y = bestX, bestY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isColliding(x, y, r float64, existing []WritingNode) bool {
|
||||
for _, e := range existing {
|
||||
d := math.Sqrt(math.Pow(x-e.X, 2) + math.Pow(y-e.Y, 2))
|
||||
if d < (r + e.R*1.3 + 30.0) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func calcMinDist(x, y float64, idx int, nodes []WritingNode) float64 {
|
||||
minD := 10000.0
|
||||
for i, n := range nodes {
|
||||
if i == idx { continue }
|
||||
d := math.Sqrt(math.Pow(x-n.X, 2) + math.Pow(y-n.Y, 2))
|
||||
if d < minD { minD = d }
|
||||
}
|
||||
return minD
|
||||
}
|
||||
|
||||
func getEdgePoint(x, y, r, angle float64, isCircle bool) (float64, float64) {
|
||||
gap := 2.0
|
||||
er := r + gap
|
||||
if isCircle { return x + math.Cos(angle)*er, y + math.Sin(angle)*er }
|
||||
absCos, absSin := math.Abs(math.Cos(angle)), math.Abs(math.Sin(angle))
|
||||
var d float64
|
||||
if absCos > absSin { d = er / absCos } else { d = er / absSin }
|
||||
return x + math.Cos(angle)*d, y + math.Sin(angle)*d
|
||||
}
|
||||
|
||||
func drawCenteredText(pdf *gopdf.GoPdf, cx, cy float64, text string, fontSize float64) {
|
||||
pdf.SetFont("basic", "", fontSize)
|
||||
tw, _ := pdf.MeasureTextWidth(text)
|
||||
pdf.SetXY(cx - tw/2, cy + fontSize*0.38)
|
||||
pdf.Text(text)
|
||||
}
|
||||
|
||||
func drawFullNode(pdf *gopdf.GoPdf, n WritingNode) {
|
||||
isOdd := n.Number%2 != 0
|
||||
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.8)
|
||||
if isOdd { pdf.Oval(n.X-n.R, n.Y-n.R, n.X+n.R, n.Y+n.R) } else { pdf.RectFromUpperLeft(n.X-n.R, n.Y-n.R, n.R*2, n.R*2) }
|
||||
pdf.SetTextColor(220, 220, 220)
|
||||
drawCenteredText(pdf, n.X, n.Y, strconv.Itoa(n.Number), n.R*1.35)
|
||||
|
||||
pdf.SetStrokeColor(180, 180, 180); pdf.SetLineWidth(1.0)
|
||||
ax, ay := n.X + math.Cos(n.AngleA)*n.Dist, n.Y + math.Sin(n.AngleA)*n.Dist
|
||||
lx1, ly1 := getEdgePoint(n.X, n.Y, n.R, n.AngleA, isOdd)
|
||||
lx2, ly2 := getEdgePoint(ax, ay, n.BulgeR, n.AngleA+math.Pi, !isOdd)
|
||||
pdf.Line(lx1, ly1, lx2, ly2)
|
||||
if isOdd { pdf.RectFromUpperLeft(ax-n.BulgeR, ay-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(ax-n.BulgeR, ay-n.BulgeR, ax+n.BulgeR, ay+n.BulgeR) }
|
||||
|
||||
bx, by := n.X + math.Cos(n.AngleB)*n.Dist, n.Y + math.Sin(n.AngleB)*n.Dist
|
||||
lx3, ly3 := getEdgePoint(n.X, n.Y, n.R, n.AngleB, isOdd)
|
||||
lx4, ly4 := getEdgePoint(bx, by, n.BulgeR, n.AngleB+math.Pi, !isOdd)
|
||||
pdf.Line(lx3, ly3, lx4, ly4)
|
||||
if isOdd { pdf.RectFromUpperLeft(bx-n.BulgeR, by-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(bx-n.BulgeR, by-n.BulgeR, bx+n.BulgeR, by+n.BulgeR) }
|
||||
|
||||
pdf.SetTextColor(120, 120, 120)
|
||||
drawCenteredText(pdf, bx, by, strconv.Itoa(n.Number+1), math.Max(7, n.BulgeR*1.2))
|
||||
}
|
||||
+25
-6
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"toolbox/pkg/base"
|
||||
"toolbox/pkg/learnnumber/data"
|
||||
"toolbox/pkg/learnnumber/logic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -18,14 +19,37 @@ func init() {
|
||||
func (t *learnNumberTool) ID() string { return "learn-number" }
|
||||
func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
|
||||
func (t *learnNumberTool) Description() string { return "包含数图形、基础加减法等趣味数学练习" }
|
||||
func (t *learnNumberTool) Emoji() string { return "🔢" }
|
||||
|
||||
func (t *learnNumberTool) Init() error {
|
||||
fmt.Println("Initializing Learn-Number tool...")
|
||||
return nil
|
||||
// 从内嵌资源加载匿名对象
|
||||
return data.LoadIconsFromEmbed()
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/counting", t.handleCounting)
|
||||
r.POST("/writing", t.handleWriting)
|
||||
r.GET("/categories", t.handleCategories)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleWriting(c *gin.Context) {
|
||||
var req logic.WritingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pdfBytes, err := logic.GenerateWritingPDF(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleCategories(c *gin.Context) {
|
||||
// 返回分类信息供前端预览
|
||||
c.JSON(http.StatusOK, data.IconCategories)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleCounting(c *gin.Context) {
|
||||
@@ -34,15 +58,10 @@ func (t *learnNumberTool) handleCounting(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func init() {
|
||||
func (t *zitieTool) ID() string { return "zitie" }
|
||||
func (t *zitieTool) Name() string { return "汉字字帖生成" }
|
||||
func (t *zitieTool) Description() string { return "提供智能缺字处理和古风排版的专业字帖工具" }
|
||||
func (t *zitieTool) Emoji() string { return "🎨" }
|
||||
|
||||
func (t *zitieTool) Init() error {
|
||||
fmt.Println("Initializing Zitie tool with font check...")
|
||||
|
||||
Reference in New Issue
Block a user