Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
run: echo "${{ secrets.PUSH_TOKEN }}" | docker login git.pengzhan.dev -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE_NAME="git.pengzhan.dev/$REPO_LOWER"
|
||||
|
||||
# 获取仓库的完整 URL
|
||||
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||
|
||||
echo "Building image: $IMAGE_NAME with source link: $REPO_URL"
|
||||
|
||||
# 关键点:通过 --label 注入关联信息
|
||||
docker build \
|
||||
--network host \
|
||||
--label "org.opencontainers.image.source=$REPO_URL" \
|
||||
-t $IMAGE_NAME:latest \
|
||||
-t $IMAGE_NAME:${{ github.sha }} .
|
||||
|
||||
docker push $IMAGE_NAME:latest
|
||||
docker push $IMAGE_NAME:${{ github.sha }}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Binaries
|
||||
/toolbox
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
.venv/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
# Data (if you want to exclude original design data, but here we include pkg/jitie/data)
|
||||
# data/
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# --- Build Stage ---
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Download dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and data
|
||||
COPY cmd/ ./cmd/
|
||||
COPY pkg/ ./pkg/
|
||||
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /toolbox cmd/toolbox/main.go
|
||||
|
||||
# --- Final Stage ---
|
||||
FROM alpine:latest
|
||||
|
||||
# Basic security & CA certs
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /toolbox .
|
||||
|
||||
# Default environment
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./toolbox"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Own-Tools | 个人工具箱
|
||||
|
||||
这是一个基于 Go 语言构建的模块化、全内嵌、响应式个人工具箱。
|
||||
|
||||
## 🌟 核心特性
|
||||
|
||||
- **模块化架构**:采用插件化设计(`pkg/base` 接口),后端路由自动注册,前端动态加载工具。
|
||||
- **响应式设计**:全站适配桌面端与移动端,具备 Apple 风格的高级质感。
|
||||
- **模板驱动**:基于 Go `html/template` 实现布局继承,全站视觉高度统一。
|
||||
- **全内嵌部署**:所有前端静态资源、千万级字库数据及字体文件均打包进单个二进制文件。
|
||||
- **生产就绪**:支持 Google Analytics 动态注入、Liveness/Readiness 探测、TLS Ingress 自动化部署。
|
||||
|
||||
## 🛠 已集成工具
|
||||
|
||||
### [汉字字帖生成器](./pkg/zitie/README.md)
|
||||
- **2x3 教学方格**:带圆圈序号、方向箭头、骨架红线。
|
||||
- **步进式分解**:9列紧凑米字格,逐笔展示书写过程。
|
||||
- **古风竖排信纸**:红色乌丝栏,支持 4 款顶级华光书法字体,智能缺字处理。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 本地运行
|
||||
```bash
|
||||
./toolbox -port 8888 -ga G-XXXXXXXXXX
|
||||
```
|
||||
访问 `http://localhost:8888` 即可使用。
|
||||
|
||||
### 从源码编译
|
||||
```bash
|
||||
go build -o toolbox cmd/toolbox/main.go
|
||||
```
|
||||
|
||||
## 🐳 容器化与部署
|
||||
|
||||
### 环境变量
|
||||
- `PORT`: 监听端口(默认 8080)。
|
||||
- `GIN_MODE`: 设置为 `release` 以优化性能。
|
||||
- `GA_ID`: 动态注入 Google Analytics ID。
|
||||
|
||||
### K3s 部署
|
||||
```bash
|
||||
kubectl apply -f deploy/k8s.yaml
|
||||
```
|
||||
|
||||
## 📂 项目结构
|
||||
- `cmd/toolbox/`: 程序入口及 web 模板 (`layout.html`, `index.html`)。
|
||||
- `pkg/base/`: 核心插件接口定义。
|
||||
- `pkg/zitie/`: 汉字字帖功能实现。
|
||||
- `deploy/`: Kubernetes 部署清单。
|
||||
- `.gitea/`: CI/CD 流水线配置。
|
||||
|
||||
## 📝 许可证
|
||||
MIT License.
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"flag"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"toolbox/pkg/base"
|
||||
_ "toolbox/pkg/learnnumber"
|
||||
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
portFlag := flag.Int("port", 0, "端口号 (优先于环境变量 PORT)")
|
||||
gaFlag := flag.String("ga", "", "Google Analytics ID")
|
||||
flag.Parse()
|
||||
|
||||
gaID := *gaFlag
|
||||
if gaID == "" {
|
||||
gaID = os.Getenv("GA_ID")
|
||||
}
|
||||
|
||||
if os.Getenv("GIN_MODE") == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.RedirectTrailingSlash = false
|
||||
r.RedirectFixedPath = false
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
|
||||
// 1. 自动发现并注册工具路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
for id, tool := range base.Registry {
|
||||
if err := tool.Init(); err != nil {
|
||||
log.Fatalf("Failed to initialize tool %s: %v", id, err)
|
||||
}
|
||||
tool.RegisterRoutes(api.Group("/" + id))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 静态资源
|
||||
subFS, _ := fs.Sub(webFS, "web")
|
||||
r.StaticFS("/static", http.FS(subFS))
|
||||
|
||||
// 3. 模板引擎初始化
|
||||
// 递归加载 web 目录下所有的 .html 文件
|
||||
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse templates: %v", err)
|
||||
}
|
||||
|
||||
// 通用页面渲染函数
|
||||
serveIndex := func(c *gin.Context) {
|
||||
data := gin.H{
|
||||
"Title": "Own-Tools",
|
||||
"GA_ID": gaID,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
// 因为已经 Sub 了,模板名就是文件名
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Template error: %v", err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
r.GET("/", serveIndex)
|
||||
r.GET("/api/tools", func(c *gin.Context) {
|
||||
var list []map[string]string
|
||||
for _, t := range base.Registry {
|
||||
list = append(list, map[string]string{
|
||||
"id": t.ID(),
|
||||
"name": t.Name(),
|
||||
"desc": t.Description(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
})
|
||||
|
||||
// 4. 全路径回退 (SPA Routing)
|
||||
r.NoRoute(serveIndex)
|
||||
|
||||
// 5. 启动
|
||||
var finalPort string
|
||||
if *portFlag > 0 {
|
||||
finalPort = strconv.Itoa(*portFlag)
|
||||
} else if envPort := os.Getenv("PORT"); envPort != "" {
|
||||
finalPort = envPort
|
||||
} else {
|
||||
finalPort = "8080"
|
||||
}
|
||||
|
||||
log.Printf("Toolbox server starting on :%s", finalPort)
|
||||
if err := r.Run(":" + finalPort); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "content"}}
|
||||
|
||||
<!-- 注入各个工具面板 -->
|
||||
{{template "zitie" .}}
|
||||
{{template "learn_number" .}}
|
||||
|
||||
<!-- 欢迎面板 -->
|
||||
<div id="panel-welcome" class="tool-panel">
|
||||
<header class="page-header">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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';
|
||||
}
|
||||
|
||||
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 })
|
||||
});
|
||||
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}}
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} | Own-Tools</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖋️</text></svg>">
|
||||
|
||||
{{if .GA_ID}}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{.GA_ID}}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '{{.GA_ID}}');
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
<style>
|
||||
:root { --sidebar-width: 260px; --apple-bg: #f5f5f7; --apple-blue: #0071e3; --sidebar-bg: rgba(255, 255, 255, 0.9); }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: var(--apple-bg); color: #1d1d1f; display: flex; height: 100vh; width: 100vw; overflow: hidden; }
|
||||
|
||||
/* Mobile Menu */
|
||||
#menu-toggle { display: none; }
|
||||
.menu-btn { display: none; position: fixed; top: 20px; left: 20px; z-index: 1000; padding: 10px; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); cursor: pointer; }
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width); min-width: var(--sidebar-width);
|
||||
background: var(--sidebar-bg); backdrop-filter: blur(20px);
|
||||
border-right: 1px solid #d2d2d7; display: flex; flex-direction: column;
|
||||
padding: 24px 0; z-index: 100; transition: transform 0.3s ease;
|
||||
}
|
||||
.sidebar-header { padding: 0 24px 24px; border-bottom: 1px solid #e5e5e7; margin-bottom: 16px; }
|
||||
.sidebar-header h2 { font-size: 22px; font-weight: 700; margin: 0; color: #1d1d1f; letter-spacing: -0.5px; cursor: pointer; }
|
||||
|
||||
.nav-list { flex: 1; overflow-y: auto; }
|
||||
.nav-item { padding: 12px 24px; cursor: pointer; transition: all 0.2s; font-size: 15px; font-weight: 500; color: #424245; display: flex; align-items: center; gap: 12px; }
|
||||
.nav-item:hover { background: rgba(0,0,0,0.04); color: #000; }
|
||||
.nav-item.active { background: var(--apple-blue); color: #ffffff; }
|
||||
|
||||
.main-container { flex: 1; display: flex; flex-direction: column; overflow-y: auto; width: 100%; }
|
||||
.main-content { padding: 40px 60px; width: 100%; }
|
||||
|
||||
/* Unified Page Header */
|
||||
.page-header { margin-bottom: 32px; }
|
||||
.page-header h1 { font-size: 34px; font-weight: 700; margin: 0 0 8px 0; }
|
||||
.page-header p { font-size: 18px; color: #86868b; margin: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { position: fixed; left: -260px; height: 100%; }
|
||||
#menu-toggle:checked ~ .sidebar { transform: translateX(260px); }
|
||||
.menu-btn { display: block; }
|
||||
.main-content { padding: 80px 20px 20px; }
|
||||
}
|
||||
|
||||
.tool-panel { display: none; }
|
||||
.tool-panel.active { display: block; animation: fadeIn 0.3s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* Shared Components */
|
||||
.card { background: white; border-radius: 18px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,0.04); margin-bottom: 30px; border: 1px solid rgba(0,0,0,0.05); }
|
||||
.tabs-container { display: flex; gap: 10px; border-bottom: 1px solid #d2d2d7; margin-bottom: 24px; overflow-x: auto; }
|
||||
.tab-btn { padding: 10px 20px; cursor: pointer; font-size: 16px; font-weight: 600; color: #86868b; border-bottom: 3px solid transparent; transition: all 0.2s; white-space: nowrap; }
|
||||
.tab-btn.active { color: var(--apple-blue); border-bottom-color: var(--apple-blue); }
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background-color: var(--apple-blue); color: white; border: none; padding: 0 30px;
|
||||
border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; height: 48px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
button:hover { background-color: #0077ed; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,113,227,0.2); }
|
||||
button:active { transform: translateY(0); }
|
||||
button:disabled { background-color: #d2d2d7; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
/* Form Controls */
|
||||
textarea, select {
|
||||
padding: 14px 18px; border: 1px solid #d2d2d7; border-radius: 12px; font-size: 17px;
|
||||
background-color: #fbfbfd; width: 100%; box-sizing: border-box; transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea:focus, select:focus {
|
||||
border-color: var(--apple-blue); background-color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(0,113,227,0.1); outline: none;
|
||||
}
|
||||
select { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2386868b' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 18px center; padding-right: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<input type="checkbox" id="menu-toggle">
|
||||
<label for="menu-toggle" class="menu-btn">☰</label>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header" onclick="navigateTo('/')"><h2>Own-Tools</h2></div>
|
||||
<div id="nav-list" class="nav-list">
|
||||
<div id="nav-welcome" class="nav-item" onclick="navigateTo('/'); document.getElementById('menu-toggle').checked = false;"><span>🏠</span> 仪表盘</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="main-content">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadTools() {
|
||||
try {
|
||||
const response = await fetch('/api/tools');
|
||||
const tools = await response.json();
|
||||
const navList = document.getElementById('nav-list');
|
||||
tools.forEach(tool => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'nav-item'; item.id = `nav-${tool.id}`;
|
||||
item.innerHTML = `<span>🛠️</span> ${tool.name}`;
|
||||
item.onclick = () => { navigateTo(`/${tool.id}`); document.getElementById('menu-toggle').checked = false; };
|
||||
navList.appendChild(item);
|
||||
});
|
||||
renderCurrentPath();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function navigateTo(path) {
|
||||
window.history.pushState({}, '', path);
|
||||
renderCurrentPath();
|
||||
}
|
||||
|
||||
function renderCurrentPath() {
|
||||
const path = window.location.pathname.replace('/', '') || 'welcome';
|
||||
|
||||
// 隐藏所有面板
|
||||
document.querySelectorAll('.tool-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
// 显示目标面板
|
||||
const target = document.getElementById(`panel-${path}`);
|
||||
if (target) {
|
||||
target.classList.add('active');
|
||||
} else {
|
||||
// 如果找不到路由,默认回首页
|
||||
document.getElementById('panel-welcome').classList.add('active');
|
||||
}
|
||||
|
||||
// 导航高亮
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
const activeNav = document.getElementById(`nav-${path}`) || document.getElementById('nav-welcome');
|
||||
if (activeNav) activeNav.classList.add('active');
|
||||
}
|
||||
|
||||
window.onpopstate = renderCurrentPath;
|
||||
loadTools();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,91 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: toolbox
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: own-tools
|
||||
namespace: toolbox
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: own-tools
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: own-tools
|
||||
spec:
|
||||
containers:
|
||||
- name: toolbox
|
||||
image: git.pengzhan.dev/haopengzhan/own-tools:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: PORT
|
||||
value: "8080"
|
||||
- name: GIN_MODE
|
||||
value: "release"
|
||||
- name: GA_ID
|
||||
value: "G-Z6ZY535DDJ"
|
||||
resources:
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "256Mi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/tools
|
||||
port: 8080
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/tools
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: own-tools-service
|
||||
namespace: toolbox
|
||||
spec:
|
||||
selector:
|
||||
app: own-tools
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: own-tools-ingress
|
||||
namespace: toolbox
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: toolbox.pengzhan.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: own-tools-service
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- toolbox.pengzhan.dev
|
||||
secretName: own-tools-tls-secret
|
||||
@@ -0,0 +1,56 @@
|
||||
# 个人工具箱 - 汉字字帖生成器详细设计
|
||||
|
||||
## 1. 项目目标
|
||||
构建一个基于 Python 的 Web 工具箱,首期功能为生成两种类型的汉字书写字帖:
|
||||
1. **步进式字帖**:展示汉字书写的每一笔过程。
|
||||
2. **教学式大方格**:带笔顺序号、方向箭头和灰色临摹底样的 2x3 大格。
|
||||
|
||||
## 2. 技术栈选择
|
||||
- **核心语言**: Python 3.10+
|
||||
- **Web 框架**: **Streamlit** (用于快速构建交互式 UI) 或 **FastAPI** (如果后续需要更复杂的前后端分离)。初版建议使用 Streamlit 以减少 JS 代码量。
|
||||
- **绘图引擎**: **Pillow (PIL)** (生成图片) 或 **ReportLab / fpdf2** (直接生成可打印的 PDF)。
|
||||
- **数据源**: [Hanzi Writer Data](https://github.com/chanind/hanzi-writer-data) (提供汉字笔画的 SVG Path 数据和中轴线坐标)。
|
||||
|
||||
## 3. 核心功能设计
|
||||
|
||||
### 3.1 数据层 (Data Provider)
|
||||
- **职责**: 加载和缓存汉字笔画 JSON 数据。
|
||||
- **实现**:
|
||||
- 下载常用汉字的 JSON 文件并存储在本地。
|
||||
- 结构化解析 `strokes` (SVG 路径字符串) 和 `medians` (笔画中轴线坐标序列)。
|
||||
|
||||
### 3.2 渲染引擎 (Character Renderer)
|
||||
- **SVG 解析**: 使用 `svgpath2lines` 或类似库将 SVG Path 转换为坐标点。
|
||||
- **步进逻辑 (Step-by-Step)**:
|
||||
- 输入:汉字,方格大小。
|
||||
- 循环:对于第 $i$ 个笔画,在第 $i$ 个格子中绘制 `strokes[0...i]`。
|
||||
- 样式:当前笔画(第 $i$ 笔)用黑色,之前的笔画用浅灰色或同样黑色。
|
||||
- **标注逻辑 (Annotated Square)**:
|
||||
- **底样**: 绘制所有笔画,颜色设为浅灰 (e.g., `#D3D3D3`)。
|
||||
- **序号**: 在每一笔 `medians[i]` 的第一个坐标点附近标注数字 $i+1$。
|
||||
- **箭头**:
|
||||
- 取 `medians[i]` 的前两个点 $P_1(x_1, y_1)$ 和 $P_2(x_2, y_2)$。
|
||||
- 计算向量 $\vec{V} = P_2 - P_1$。
|
||||
- 在 $P_1$ 处绘制指向 $P_2$ 方向的箭头。
|
||||
|
||||
### 3.3 导出模块 (Exporter)
|
||||
- **PDF 导出**: 使用 `fpdf2` 布局页面(A4 纸张),将生成的图片或绘制指令排列在页面上。
|
||||
- **预览**: 在 Web 界面实时展示生成的 PNG 图片。
|
||||
|
||||
## 4. 界面设计 (Web UI)
|
||||
- **侧边栏**: 选择字帖类型(步进式/教学式)、字体大小、纸张方向。
|
||||
- **输入区**: 文本框输入目标汉字(支持多个字)。
|
||||
- **展示区**: 实时生成字帖预览。
|
||||
- **下载区**: 点击按钮下载 PDF 打印版。
|
||||
|
||||
## 5. 待解决的技术细节
|
||||
- **SVG 坐标对齐**: 汉字数据通常基于 1024x1024 的坐标系,需要缩放到 PDF 或图片的分辨率。
|
||||
- **箭头绘制逻辑**: 处理极短笔画(如“点”)时的箭头方向问题。
|
||||
- **田字格背景**: 需要在渲染汉字前绘制标准的“田”字虚线格。
|
||||
|
||||
## 6. 实施步骤
|
||||
1. **环境准备**: 安装 `streamlit`, `pillow`, `fpdf2`, `requests`。
|
||||
2. **数据爬取/获取**: 编写脚本从 Hanzi Writer CDN 获取指定汉字的 JSON 数据。
|
||||
3. **原型开发**: 先在本地用 Pillow 生成一张带箭头和数字的单个汉字图片。
|
||||
4. **Web 包装**: 使用 Streamlit 搭建界面。
|
||||
5. **PDF 布局优化**: 实现多字排版和打印优化。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 个人工具箱
|
||||
|
||||
一个web based的网站,提供各种工具
|
||||
|
||||
## 中文书写字帖生成
|
||||
|
||||
类似于https://www.nqez.com/,但是这个网站里的字帖种类非常丰富了,我只是想先专注于最简单的两种字帖生成。
|
||||
|
||||
一种是在小田字格一排。分别是字的全貌、字的第一笔,第一笔和第二笔。。。。。如果有空,就留空。
|
||||
|
||||
一种是给定字,在纸上生成2*3 6个大方格,每个方格里是灰色的字,同时有小字序号和箭头表示每一笔从哪里写到哪里。
|
||||
|
||||
你觉得这个需求可以做吗?
|
||||
|
||||
怎么实现,请给我一个方案。
|
||||
@@ -0,0 +1,43 @@
|
||||
module toolbox
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/signintech/gopdf v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/phpdave11/gofpdi v1.0.15 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.15 h1:iJazY1BQ07I9s7N5EWjBO1YbhmKfHGxNligUv/Rw4Lc=
|
||||
github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/signintech/gopdf v0.36.0 h1:/7gPwoLtlNv5tPNpYuo3T3z0mWgo62pTrCvVNAiOo2Q=
|
||||
github.com/signintech/gopdf v0.36.0/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,22 @@
|
||||
package base
|
||||
|
||||
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 路由
|
||||
}
|
||||
|
||||
// Registry 存储所有已注册的工具
|
||||
var Registry = make(map[string]Tool)
|
||||
|
||||
// Register 用于工具在 init() 函数中注册自己
|
||||
func Register(t Tool) {
|
||||
Registry[t.ID()] = t
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# 汉字字帖生成器 (Zitie Tool)
|
||||
|
||||
这是 `Own-Tools` 的核心插件之一,专注于生成高质量的书法练习帖。
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
### 1. 2x3 教学方格
|
||||
* **特性**:每页 6 个字,每个汉字包含笔顺序号、行进箭头和红色骨架线。
|
||||
* **特色**:序号采用“圆圈相切”定位算法,美观且精准。
|
||||
* **用途**:适合硬笔书法初学者学习笔画顺序。
|
||||
|
||||
### 2. 步进式笔顺分解
|
||||
* **特性**:9列无缝米字格排版。首格为黑体全字,后续格子展示逐笔增加的灰色过程。
|
||||
* **特色**:智能换行逻辑(第二行首格留空),保持视觉连贯。
|
||||
* **用途**:详细解析汉字的间架结构。
|
||||
|
||||
### 3. 古风竖排信纸
|
||||
* **特性**:模拟传统“乌丝栏”笺纸,从右向左、从上到下竖向排版。
|
||||
* **特色**:
|
||||
* 内嵌 4 款高品质书法字体(楷体、行草、隶变、书宋)。
|
||||
* **智能缺字降级**:当选择字体缺字时,自动切换至宋体/楷体并以 1/2 大小在右上角标注。
|
||||
* 支持换行符 `
|
||||
` 实现自由分列。
|
||||
* **用途**:生成极具艺术感的诗词临摹帖。
|
||||
|
||||
## 🎨 技术细节
|
||||
* **PDF 生成**:基于 `gopdf` 的纯矢量绘图,打印效果极其锐利。
|
||||
* **字体处理**:采用“启动同步至磁盘”技术,绕过了内存加载大型 CJK 字体时的兼容性 Bug。
|
||||
* **坐标计算**:所有元素均基于格子大小动态缩放,完美适配 A4 和 Letter 纸张。
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,253 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/signintech/gopdf"
|
||||
"golang.org/x/image/font/sfnt"
|
||||
)
|
||||
|
||||
var (
|
||||
embeddedFont []byte
|
||||
fontMap = make(map[string]string)
|
||||
sfntMap = make(map[string]*sfnt.Font)
|
||||
)
|
||||
|
||||
func SetFontData(data []byte) { embeddedFont = data }
|
||||
func RegisterFontPath(id, path string) { fontMap[id] = path }
|
||||
func RegisterFontSFNT(id string, f *sfnt.Font) { sfntMap[id] = f }
|
||||
|
||||
// HasGlyph 检查字体是否包含某个字符
|
||||
func HasGlyph(fontId string, char rune) bool {
|
||||
f, ok := sfntMap[fontId]
|
||||
if !ok { return false }
|
||||
var buffer sfnt.Buffer
|
||||
idx, err := f.GlyphIndex(&buffer, char)
|
||||
return err == nil && idx != 0
|
||||
}
|
||||
|
||||
// GeneratePDFExtended 支持动态字体切换和缺字降级
|
||||
func GeneratePDFExtended(chars []HanziData, mode, paperSize, fontId string) ([]byte, error) {
|
||||
pdf := &gopdf.GoPdf{}
|
||||
rect := gopdf.Rect{W: 595.28, H: 841.89}
|
||||
if paperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
|
||||
pdf.Start(gopdf.Config{PageSize: rect})
|
||||
|
||||
if len(embeddedFont) > 0 { _ = pdf.AddTTFFontData("font", embeddedFont) }
|
||||
|
||||
// 注册所有可用字体
|
||||
for id, path := range fontMap {
|
||||
_ = pdf.AddTTFFont(id, path)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "step":
|
||||
addStepPages(pdf, chars, true, rect.W, rect.H)
|
||||
case "manuscript":
|
||||
addManuscriptPages(pdf, chars, rect.W, rect.H, fontId)
|
||||
default:
|
||||
addTeachingPages(pdf, chars, true, rect.W, rect.H)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := pdf.WriteTo(&buf)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
|
||||
cols := 2; gap, margin := 17.0, 40.0
|
||||
size := math.Min((pW-2*margin-gap)/2, (pH-2*margin-2*gap)/3)
|
||||
totalW, totalH := 2*size+gap, 3*size+2*gap
|
||||
marginX, marginY := (pW-totalW)/2, (pH-totalH)/2
|
||||
for i := 0; i < len(chars); i += 6 {
|
||||
pdf.AddPage()
|
||||
end := i + 6; if end > len(chars) { end = len(chars) }
|
||||
for idx, data := range chars[i:end] {
|
||||
drawCharacter(pdf, data, marginX+float64(idx%cols)*(size+gap), marginY+float64(idx/cols)*(size+gap), size, flipY, len(data.Strokes), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStepPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
|
||||
cols, margin := 9, 30.0; size := (pW - 2*margin) / float64(cols)
|
||||
pdf.AddPage(); currY := margin
|
||||
for _, data := range chars {
|
||||
numS := len(data.Strokes); rowsN := 1; if numS > 8 { rowsN = 1 + (numS - 8 + 7) / 8 }
|
||||
if currY + float64(rowsN)*size > pH - margin { pdf.AddPage(); currY = margin }
|
||||
totalG := rowsN * cols; strokeC := 0
|
||||
for gIdx := 0; gIdx < totalG; gIdx++ {
|
||||
r, c := gIdx/cols, gIdx%cols; x, y := margin+float64(c)*size, currY+float64(r)*size
|
||||
if r == 0 && c == 0 { drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false); strokeC = 1
|
||||
} else if r > 0 && c == 0 { drawMiZiGe(pdf, x, y, size)
|
||||
} else if strokeC <= numS { drawStepBox(pdf, data, x, y, size, flipY, strokeC); strokeC++
|
||||
} else { drawMiZiGe(pdf, x, y, size) }
|
||||
}
|
||||
currY += float64(rowsN) * size
|
||||
}
|
||||
}
|
||||
|
||||
func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, targetFont string) {
|
||||
margin := 40.0; cols, rows := 10, 15
|
||||
colW, rowH := (pW-2*margin)/float64(cols), (pH-2*margin)/float64(rows)
|
||||
fontSize := colW * 0.75
|
||||
|
||||
pdf.AddPage(); drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
|
||||
cIdx, rIdx := 0, 0
|
||||
|
||||
for _, data := range chars {
|
||||
if data.Character == "\n" {
|
||||
cIdx++; rIdx = 0
|
||||
if cIdx >= cols { pdf.AddPage(); cIdx = 0; drawManuscriptGrid(pdf, pW, pH, margin, cols, colW) }
|
||||
continue
|
||||
}
|
||||
if rIdx >= rows {
|
||||
cIdx++; rIdx = 0
|
||||
if cIdx >= cols { pdf.AddPage(); cIdx = 0; drawManuscriptGrid(pdf, pW, pH, margin, cols, colW) }
|
||||
}
|
||||
|
||||
x, y := pW - margin - float64(cIdx+1)*colW, margin + float64(rIdx)*rowH
|
||||
charRune := []rune(data.Character)[0]
|
||||
|
||||
// 智能字体选择
|
||||
renderFont := targetFont
|
||||
isFallback := false
|
||||
|
||||
if !HasGlyph(targetFont, charRune) {
|
||||
// 尝试降级到字库最全的宋体或楷体
|
||||
if HasGlyph("songti", charRune) {
|
||||
renderFont = "songti"; isFallback = true
|
||||
} else if HasGlyph("kaiti", charRune) {
|
||||
renderFont = "kaiti"; isFallback = true
|
||||
} else {
|
||||
// 全部缺失,保留空白
|
||||
rIdx++; continue
|
||||
}
|
||||
}
|
||||
|
||||
pdf.SetFillColor(200, 200, 200)
|
||||
if isFallback {
|
||||
// 降级显示:小字 (1/2 大小),右上角对齐
|
||||
smallSize := fontSize * 0.5
|
||||
_ = pdf.SetFont(renderFont, "", smallSize)
|
||||
// 计算右上角位置:X靠右,Y靠上
|
||||
pdf.SetXY(x + colW - smallSize - 2, y + 2)
|
||||
_ = pdf.Cell(nil, data.Character)
|
||||
} else {
|
||||
// 正常显示
|
||||
_ = pdf.SetFont(renderFont, "", fontSize)
|
||||
pdf.SetXY(x + (colW-fontSize*0.9)/2, y + (rowH-fontSize)/2)
|
||||
_ = pdf.Cell(nil, data.Character)
|
||||
}
|
||||
rIdx++
|
||||
}
|
||||
}
|
||||
|
||||
func drawManuscriptGrid(pdf *gopdf.GoPdf, pW, pH, margin float64, cols int, colW float64) {
|
||||
pdf.SetStrokeColor(200, 0, 0); pdf.SetLineWidth(0.6)
|
||||
for c := 0; c <= cols; c++ { x := pW - margin - float64(c)*colW; pdf.Line(x, margin, x, pH-margin) }
|
||||
pdf.Line(margin, margin, pW-margin, margin); pdf.Line(margin, pH-margin, pW-margin, pH-margin)
|
||||
}
|
||||
|
||||
func drawCharacter(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int, showAnnotations bool) {
|
||||
if showAnnotations { drawGrid(pdf, x, y, size) } else { drawMiZiGe(pdf, x, y, size) }
|
||||
p, drawS := size*0.12, size-(size*0.12*2); scale := drawS/1024.0
|
||||
if strokeLimit == len(data.Strokes) {
|
||||
if showAnnotations { pdf.SetFillColor(240, 240, 240) } else { pdf.SetFillColor(0, 0, 0) }
|
||||
} else { pdf.SetFillColor(180, 180, 180) }
|
||||
for sIdx := 0; sIdx < len(data.Strokes); sIdx++ {
|
||||
if sIdx >= strokeLimit && strokeLimit != len(data.Strokes) { break }
|
||||
pts := parseSVGPath(data.Strokes[sIdx], scale, x+p, y+p, flipY)
|
||||
if len(pts) > 2 { pdf.Polygon(pts, "F") }
|
||||
}
|
||||
if showAnnotations && strokeLimit == len(data.Strokes) {
|
||||
fS := size * 0.035
|
||||
_ = pdf.SetFont("font", "", fS)
|
||||
for idx, median := range data.Medians {
|
||||
mP := transformPoints(median, scale, x+p, y+p, flipY); if len(mP) < 2 { continue }
|
||||
pdf.SetStrokeColor(255, 0, 0); pdf.SetLineWidth(0.6)
|
||||
for j := 0; j < len(mP)-1; j++ { pdf.Line(mP[j].X, mP[j].Y, mP[j+1].X, mP[j+1].Y) }
|
||||
pdf.SetFillColor(255, 0, 0); drawArrow(pdf, mP[len(mP)-1], mP[len(mP)-2], size*0.035)
|
||||
r := size*0.025; dx, dy := mP[1].X-mP[0].X, mP[1].Y-mP[0].Y; dist := math.Sqrt(dx*dx+dy*dy); ux, uy := -1.0, -1.0
|
||||
if dist > 0.001 { ux, uy = dx/dist, dy/dist }; cX, cY := mP[0].X-ux*r, mP[0].Y-uy*r
|
||||
pdf.SetStrokeColor(255, 0, 0); pdf.SetLineWidth(0.5); pdf.Oval(cX-r, cY-r, cX+r, cY+r)
|
||||
numS := strconv.Itoa(idx+1); tw := fS*0.6*float64(len(numS))/2; if len(numS) > 1 { tw = fS*0.5 }
|
||||
pdf.SetFillColor(255, 0, 0); pdf.SetXY(cX-tw/2, cY-fS/2); _ = pdf.Cell(nil, numS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawStepBox(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, limit int) {
|
||||
drawMiZiGe(pdf, x, y, size); p, scale := size*0.12, (size-(size*0.12*2))/1024.0
|
||||
pdf.SetFillColor(180, 180, 180); for i := 0; i < limit; i++ {
|
||||
pts := parseSVGPath(data.Strokes[i], scale, x+p, y+p, flipY); if len(pts) > 2 { pdf.Polygon(pts, "F") }
|
||||
}
|
||||
}
|
||||
|
||||
func drawGrid(pdf *gopdf.GoPdf, x, y, size float64) {
|
||||
pdf.SetStrokeColor(220, 220, 220); pdf.SetLineWidth(0.4); pdf.RectFromUpperLeft(x, y, size, size)
|
||||
mid := size/2; drawDashedLine(pdf, x, y+mid, x+size, y+mid); drawDashedLine(pdf, x+mid, y, x+mid, y+size)
|
||||
}
|
||||
|
||||
func drawMiZiGe(pdf *gopdf.GoPdf, x, y, size float64) {
|
||||
pdf.SetStrokeColor(220, 220, 220); pdf.SetLineWidth(0.4); pdf.RectFromUpperLeft(x, y, size, size)
|
||||
mid := size/2; drawDashedLine(pdf, x, y+mid, x+size, y+mid); drawDashedLine(pdf, x+mid, y, x+mid, y+size); drawDashedLine(pdf, x, y, x+size, y+size); drawDashedLine(pdf, x, y+size, x+size, y)
|
||||
}
|
||||
|
||||
func drawDashedLine(pdf *gopdf.GoPdf, x1, y1, x2, y2 float64) {
|
||||
dash := 2.0; dx, dy := x2-x1, y2-y1; dist := math.Sqrt(dx*dx + dy*dy); if dist < 0.001 { return }
|
||||
ux, uy := dx/dist, dy/dist; for i := 0.0; i < dist; i += dash * 2 { end := i + dash; if end > dist { end = dist }; pdf.Line(x1+ux*i, y1+uy*i, x1+ux*end, y1+uy*end) }
|
||||
}
|
||||
|
||||
func drawArrow(pdf *gopdf.GoPdf, target, prev gopdf.Point, length float64) {
|
||||
angle := math.Atan2(target.Y-prev.Y, target.X-prev.X); arrowA := math.Pi/6
|
||||
p1X := target.X+length*math.Cos(angle+math.Pi+arrowA); p1Y := target.Y+length*math.Sin(angle+math.Pi+arrowA)
|
||||
p2X := target.X+length*math.Cos(angle+math.Pi-arrowA); p2Y := target.Y+length*math.Sin(angle+math.Pi-arrowA)
|
||||
pdf.Polygon([]gopdf.Point{target, {X: p1X, Y: p1Y}, {X: p2X, Y: p2Y}}, "F")
|
||||
}
|
||||
|
||||
func transformPoints(pts []Point, scale, ox, oy float64, flipY bool) []gopdf.Point {
|
||||
res := make([]gopdf.Point, len(pts)); for i, p := range pts { y := p[1]; if flipY { y = 1024-y }; res[i] = gopdf.Point{X: ox+p[0]*scale, Y: oy+y*scale} }
|
||||
return res
|
||||
}
|
||||
|
||||
var reSVG = regexp.MustCompile(`([MLQCZ])|(-?\d+\.?\d*)`)
|
||||
|
||||
func parseSVGPath(path string, scale, ox, oy float64, flipY bool) []gopdf.Point {
|
||||
var pts []gopdf.Point; matches := reSVG.FindAllStringSubmatch(path, -1); var lastX, lastY float64
|
||||
for idx := 0; idx < len(matches); {
|
||||
item := matches[idx][0]
|
||||
if item == "M" || item == "L" {
|
||||
if idx+2 < len(matches) {
|
||||
x, _ := strconv.ParseFloat(matches[idx+1][0], 64); y, _ := strconv.ParseFloat(matches[idx+2][0], 64); lastX, lastY = x, y
|
||||
if flipY { y = 1024 - y }; pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}); idx += 3
|
||||
} else { idx++ }
|
||||
} else if item == "C" {
|
||||
if idx+6 < len(matches) {
|
||||
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64); y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||
x, _ := strconv.ParseFloat(matches[idx+5][0], 64); y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
|
||||
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||
tx := math.Pow(1-t, 3)*lastX + 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)*lastY + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
|
||||
ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
||||
}
|
||||
lastX, lastY = x, y; idx += 7
|
||||
} else { idx++ }
|
||||
} else if item == "Q" {
|
||||
if idx+4 < len(matches) {
|
||||
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||
x, _ := strconv.ParseFloat(matches[idx+3][0], 64); y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||
tx := math.Pow(1-t, 2)*lastX + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
|
||||
ty := math.Pow(1-t, 2)*lastY + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
|
||||
ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
||||
}
|
||||
lastX, lastY = x, y; idx += 5
|
||||
} else { idx++ }
|
||||
} else { idx++ }
|
||||
}
|
||||
return pts
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package logic
|
||||
|
||||
type HanziData struct {
|
||||
Character string `json:"character"`
|
||||
Strokes []string `json:"strokes"`
|
||||
Medians [][]Point `json:"medians"`
|
||||
}
|
||||
|
||||
type Point [2]float64
|
||||
@@ -0,0 +1,153 @@
|
||||
package zitie
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"toolbox/pkg/base"
|
||||
"toolbox/pkg/zitie/logic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/image/font/sfnt"
|
||||
)
|
||||
|
||||
//go:embed data/all.json
|
||||
var allDataContent []byte
|
||||
|
||||
//go:embed data/*.ttf
|
||||
var fontFS embed.FS
|
||||
|
||||
type zitieTool struct {
|
||||
allChars map[string]logic.HanziData
|
||||
}
|
||||
|
||||
func init() {
|
||||
base.Register(&zitieTool{})
|
||||
}
|
||||
|
||||
func (t *zitieTool) ID() string { return "zitie" }
|
||||
func (t *zitieTool) Name() string { return "汉字字帖生成" }
|
||||
func (t *zitieTool) Description() string { return "提供智能缺字处理和古风排版的专业字帖工具" }
|
||||
|
||||
func (t *zitieTool) Init() error {
|
||||
fmt.Println("Initializing Zitie tool with font check...")
|
||||
t.allChars = make(map[string]logic.HanziData)
|
||||
if err := json.Unmarshal(allDataContent, &t.allChars); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal all.json: %v", err)
|
||||
}
|
||||
|
||||
fontBytes, _ := fontFS.ReadFile("data/font.ttf")
|
||||
logic.SetFontData(fontBytes)
|
||||
|
||||
fonts := map[string]string{
|
||||
"kaiti": "data/kaiti.ttf",
|
||||
"lishu": "data/lishu.ttf",
|
||||
"xingshu": "data/xingshu.ttf",
|
||||
"songti": "data/songti.ttf",
|
||||
}
|
||||
|
||||
for id, src := range fonts {
|
||||
bytes, err := fontFS.ReadFile(src)
|
||||
if err != nil { continue }
|
||||
|
||||
// 1. 同步到磁盘用于 gopdf
|
||||
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("own_tools_%s.ttf", id))
|
||||
_ = os.WriteFile(tmpPath, bytes, 0644)
|
||||
logic.RegisterFontPath(id, tmpPath)
|
||||
|
||||
// 2. 解析 Cmap 用于缺字检查
|
||||
f, err := sfnt.Parse(bytes)
|
||||
if err == nil {
|
||||
logic.RegisterFontSFNT(id, f)
|
||||
fmt.Printf("Font [%s] analyzed and registered\n", id)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *zitieTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/teaching", t.handleTeaching)
|
||||
r.POST("/step", t.handleStep)
|
||||
r.POST("/manuscript", t.handleManuscript)
|
||||
}
|
||||
|
||||
type ZitieRequest struct {
|
||||
Chars string `json:"chars" binding:"required"`
|
||||
PaperSize string `json:"paper_size"`
|
||||
FontType string `json:"font_type"`
|
||||
}
|
||||
|
||||
func (t *zitieTool) handleTeaching(c *gin.Context) {
|
||||
var req ZitieRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data := t.filterChars(req.Chars, false)
|
||||
t.generateAndResponse(c, data, "teaching", req.PaperSize, "kaiti")
|
||||
}
|
||||
|
||||
func (t *zitieTool) handleStep(c *gin.Context) {
|
||||
var req ZitieRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data := t.filterChars(req.Chars, false)
|
||||
t.generateAndResponse(c, data, "step", req.PaperSize, "kaiti")
|
||||
}
|
||||
|
||||
func (t *zitieTool) handleManuscript(c *gin.Context) {
|
||||
var req ZitieRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.FontType == "" { req.FontType = "kaiti" }
|
||||
data := t.filterChars(req.Chars, true)
|
||||
t.generateAndResponse(c, data, "manuscript", req.PaperSize, req.FontType)
|
||||
}
|
||||
|
||||
func (t *zitieTool) filterChars(input string, keepNewline bool) []logic.HanziData {
|
||||
var res []logic.HanziData
|
||||
// 扩充标点符号列表
|
||||
puncs := ",。!?;:、“”()《》〈〉…·.?!,:;\"'()<> 「」【】『』〔〕"
|
||||
|
||||
for _, r := range input {
|
||||
charStr := string(r)
|
||||
if r == '\n' {
|
||||
if keepNewline { res = append(res, logic.HanziData{Character: "\n"}) }
|
||||
continue
|
||||
}
|
||||
if r == ' ' || r == '\r' || r == '\t' { continue }
|
||||
|
||||
isPunc := false
|
||||
for _, p := range puncs { if r == p { isPunc = true; break } }
|
||||
if isPunc { continue }
|
||||
|
||||
if hd, ok := t.allChars[charStr]; ok {
|
||||
hd.Character = charStr
|
||||
res = append(res, hd)
|
||||
} else {
|
||||
res = append(res, logic.HanziData{Character: charStr})
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (t *zitieTool) generateAndResponse(c *gin.Context, data []logic.HanziData, mode, paper, font string) {
|
||||
if len(data) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no valid characters found"})
|
||||
return
|
||||
}
|
||||
pdfBytes, err := logic.GeneratePDFExtended(data, mode, paper, font)
|
||||
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