Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Successful in 2m48s

This commit is contained in:
2026-02-23 02:04:11 -08:00
commit 55ba1c4be9
17 changed files with 1191 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Build and Push Docker Image
on:
push:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
env:
# 明确指向 K8s 挂载的绝对路径
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"
# 增加构建时的内存优化建议(如果是 Go 编译卡住)
echo "Starting Docker build..."
docker build --network host -t $IMAGE_NAME:latest -t $IMAGE_NAME:${{ github.sha }} .
echo "Pushing image..."
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:${{ github.sha }}
+23
View File
@@ -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
View File
@@ -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"]
+62
View File
@@ -0,0 +1,62 @@
# 个人工具箱 (Own-Tools)
这是一个基于 Go 语言构建的模块化个人工具箱。目前已实现核心功能:**汉字字帖生成器**。
## 🌟 核心特性
- **全内嵌部署**:所有前端静态资源、汉字笔顺数据(9574个字)以及字体文件均通过 `go:embed` 打包进单个二进制文件。
- **高性能渲染**:原生 Go 实现的矢量 PDF 绘图,支持贝塞尔曲线平滑采样,生成高质量、不失真的字帖。
- **模块化架构**:采用插件化设计(`pkg/base` 接口),可轻松扩展新工具。
- **云原生就绪**:提供多阶段构建的 Dockerfile,并集成了 Gitea Actions CI/CD。
## 🛠 已集成工具
### 汉字字帖生成器 (`jitie`)
- **教学字帖 (2x3)**:带红色圆圈序号、方向箭头和骨架红线的专业临摹贴。
- **步进分解 (9列)**:米字格背景,逐笔展示汉字书写过程,支持多字连续排版。
- **智能适配**:自动适配 A4 和 Letter 纸张尺寸。
## 🚀 快速开始
### 本地运行 (已有编译好的二进制文件)
```bash
./toolbox -port 8888
```
访问 `http://localhost:8888` 即可开始使用。
### 从源码编译
确保已安装 Go 1.23+。
```bash
go build -o toolbox cmd/toolbox/main.go
```
### 命令行参数
- `-port`: 指定 Web 服务端口(默认 8080,优先级高于环境变量)。
## 🐳 容器化部署
### 环境变量
- `PORT`: 容器内监听端口(默认 8080)。
### 构建镜像
```bash
docker build -t toolbox:latest .
```
### 运行镜像
```bash
docker run -p 8080:8080 toolbox:latest
```
## 📂 项目结构
- `cmd/toolbox/`: 程序入口及内嵌前端网页。
- `pkg/base/`: 工具标准接口定义及自动注册中心。
- `pkg/jitie/`: 汉字字帖工具实现。
- `data/`: 内嵌的 30MB 完整字库及字体。
- `logic/`: PDF 渲染、SVG 解析、避让算法逻辑。
- `.gitea/workflows/`: CI/CD 自动化流水线配置。
- `design/`: 项目设计文档。
## 📝 许可证
自用工具,遵循 MIT 协议。
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"embed"
"flag"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"toolbox/pkg/base"
_ "toolbox/pkg/jitie" // 匿名导入以触发 init()
"github.com/gin-gonic/gin"
)
//go:embed web/*
var webFS embed.FS
func main() {
// 定义端口 Flag
portFlag := flag.Int("port", 0, "端口号 (优先于环境变量 PORT)")
flag.Parse()
// 禁用 Gin 的默认重定向逻辑,防止与静态资源冲突
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.RedirectTrailingSlash = false
r.RedirectFixedPath = false
// 1. 自动发现并注册工具路由
api := r.Group("/api")
{
for id, tool := range base.Registry {
log.Printf("Loading tool: %s (%s)", tool.Name(), id)
if err := tool.Init(); err != nil {
log.Fatalf("Failed to initialize tool %s: %v", id, err)
}
group := api.Group("/" + id)
tool.RegisterRoutes(group)
}
}
// 2. 静态资源路由 (内嵌前端)
// 根路径直接返回 index.html 内容,不使用文件服务器转发
r.GET("/", func(c *gin.Context) {
content, err := webFS.ReadFile("web/index.html")
if err != nil {
c.String(http.StatusNotFound, "index.html not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
})
// 如果将来有 css/js,可以挂载到 /web 路径
subFS, _ := fs.Sub(webFS, "web")
r.StaticFS("/web", http.FS(subFS))
// 3. 工具列表发现接口
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. 确定最终使用的端口
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)
}
}
+224
View File
@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人工具箱 - 汉字字帖生成器</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f7;
color: #1d1d1f;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
display: flex;
flex-direction: column;
gap: 24px;
}
header {
text-align: center;
margin-bottom: 10px;
}
header h1 { font-size: 32px; margin-bottom: 8px; }
header p { color: #86868b; margin: 0; }
.card {
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 8px 30px rgba(0,0,0,0.04);
}
.form-layout {
display: flex;
flex-direction: column;
gap: 20px;
}
.input-row-top {
width: 100%;
}
.input-row-bottom {
display: flex;
gap: 16px;
align-items: flex-end;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-group.flex-main { flex: 2; }
.input-group.flex-side { flex: 1; }
label {
font-size: 13px;
font-weight: 600;
color: #1d1d1f;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input[type="text"] {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
border: 1px solid #d2d2d7;
border-radius: 10px;
font-size: 18px;
background-color: #fbfbfd;
transition: all 0.2s;
}
input[type="text"]:focus {
border-color: #0071e3;
background-color: #fff;
box-shadow: 0 0 0 4px rgba(0,113,227,0.1);
outline: none;
}
select {
appearance: none;
padding: 12px 16px;
border: 1px solid #d2d2d7;
border-radius: 10px;
font-size: 15px;
background-color: #fbfbfd;
cursor: pointer;
}
button {
background-color: #0071e3;
color: white;
border: none;
padding: 14px 32px;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
height: 48px;
}
button:hover { background-color: #0077ed; transform: translateY(-1px); }
button:active { transform: translateY(0); }
#preview-container {
width: 100%;
height: 850px;
border-radius: 16px;
overflow: hidden;
background: #fff;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
display: none;
}
iframe { width: 100%; height: 100%; border: none; }
.empty-state {
text-align: center;
padding: 80px 0;
border: 2px dashed #d2d2d7;
border-radius: 16px;
color: #86868b;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>汉字字帖工具箱</h1>
<p>生成标准笔顺教学字帖,支持 A4/Letter 打印</p>
</header>
<div class="card">
<div class="form-layout">
<div class="input-row-top">
<div class="input-group">
<label for="chars">输入想要生成的汉字</label>
<input type="text" id="chars" placeholder="例如:永和九年,岁在癸丑..." value="永">
</div>
</div>
<div class="input-row-bottom">
<div class="input-group flex-main">
<label for="mode">布局模式</label>
<select id="mode">
<option value="teaching">2x3 教学方格 (带笔顺、箭头、序号)</option>
<option value="step">步进式分解 (逐笔展示过程)</option>
</select>
</div>
<div class="input-group flex-side">
<label for="paper_size">纸张大小</label>
<select id="paper_size">
<option value="A4">A4 (210x297mm)</option>
<option value="Letter">Letter (8.5x11in)</option>
</select>
</div>
<button onclick="generatePDF()">生成预览</button>
</div>
</div>
</div>
<div id="preview-container">
<iframe id="pdf-frame"></iframe>
</div>
<div id="empty-state" class="empty-state">
<p>在上方输入汉字并点击按钮,即可生成高清矢量字帖预览</p>
</div>
</div>
<script>
async function generatePDF() {
const chars = document.getElementById('chars').value;
const mode = document.getElementById('mode').value;
const paper_size = document.getElementById('paper_size').value;
if (!chars.trim()) {
alert('请输入汉字');
return;
}
const btn = document.querySelector('button');
const originalText = btn.innerText;
btn.innerText = '正在绘图中...';
btn.disabled = true;
try {
const response = await fetch('/api/jitie/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chars: chars,
mode: mode,
flip_y: true,
paper_size: paper_size
})
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
document.getElementById('empty-state').style.display = 'none';
const container = document.getElementById('preview-container');
container.style.display = 'block';
document.getElementById('pdf-frame').src = url;
} else {
const err = await response.json();
alert('生成失败: ' + (err.error || '未知错误'));
}
} catch (e) {
alert('网络请求失败,请检查后端服务是否运行');
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: own-tools
labels:
app: own-tools
spec:
replicas: 1
selector:
matchLabels:
app: own-tools
template:
metadata:
labels:
app: own-tools
spec:
containers:
- name: toolbox
# TODO: 确认你的 Gitea 镜像完整路径
image: git.pengzhan.dev/haopengzhan/own-tools:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
env:
- name: PORT
value: "8080"
resources:
limits:
cpu: "500m"
memory: "256Mi"
requests:
cpu: "100m"
memory: "128Mi"
---
apiVersion: v1
kind: Service
metadata:
name: own-tools-service
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
annotations:
kubernetes.io/ingress.class: traefik
spec:
rules:
- host: toolbox.pengzhan.dev # 请修改为你的实际域名
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: own-tools-service
port:
number: 80
+56
View File
@@ -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 布局优化**: 实现多字排版和打印优化。
+15
View File
@@ -0,0 +1,15 @@
# 个人工具箱
一个web based的网站,提供各种工具
## 中文书写字帖生成
类似于https://www.nqez.com/,但是这个网站里的字帖种类非常丰富了,我只是想先专注于最简单的两种字帖生成。
一种是在小田字格一排。分别是字的全貌、字的第一笔,第一笔和第二笔。。。。。如果有空,就留空。
一种是给定字,在纸上生成2*3 6个大方格,每个方格里是灰色的字,同时有小字序号和箭头表示每一笔从哪里写到哪里。
你觉得这个需求可以做吗?
怎么实现,请给我一个方案。
+42
View File
@@ -0,0 +1,42 @@
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/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
)
+95
View File
@@ -0,0 +1,95 @@
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/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=
+22
View File
@@ -0,0 +1,22 @@
package base
import (
"github.com/gin-gonic/gin"
)
// Tool 定义了工具箱中每个子工具必须实现的接口
type Tool interface {
ID() string // 工具的唯一标识,用于路由前缀,如 "jitie"
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
}
File diff suppressed because one or more lines are too long
Binary file not shown.
+330
View File
@@ -0,0 +1,330 @@
package logic
import (
"bytes"
"math"
"regexp"
"strconv"
"github.com/signintech/gopdf"
)
var embeddedFont []byte
func SetFontData(data []byte) {
embeddedFont = data
}
// GeneratePDF 生成 PDF 字节流
func GeneratePDF(chars []HanziData, mode, paperSize string, flipY bool) ([]byte, error) {
pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89} // A4 default
if paperSize == "Letter" {
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect})
if len(embeddedFont) > 0 {
_ = pdf.AddTTFFontData("font", embeddedFont)
}
if mode == "step" {
addStepPages(pdf, chars, flipY, rect.W, rect.H)
} else {
addTeachingPages(pdf, chars, flipY, 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, rows := 2, 3
gap := 17.0
margin := 40.0
cellW := (pW - 2*margin - float64(cols-1)*gap) / float64(cols)
cellH := (pH - 2*margin - float64(rows-1)*gap) / float64(rows)
size := math.Min(cellW, cellH)
totalW := float64(cols)*size + float64(cols-1)*gap
totalH := float64(rows)*size + float64(rows-1)*gap
marginX := (pW - totalW) / 2
marginY := (pH - totalH) / 2
for i := 0; i < len(chars); i += 6 {
pdf.AddPage()
end := i + 6
if end > len(chars) {
end = len(chars)
}
batch := chars[i:end]
for idx, data := range batch {
c, r := idx%cols, idx/cols
x := marginX + float64(c)*(size+gap)
y := marginY + float64(r)*(size+gap)
drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), true)
}
}
}
func addStepPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
cols := 9
margin := 30.0
size := (pW - 2*margin) / float64(cols)
pdf.AddPage()
currY := margin
for _, data := range chars {
numStrokes := len(data.Strokes)
rowsNeeded := 1
if numStrokes > 8 {
rowsNeeded = 1 + (numStrokes - 8 + 7) / 8
}
if currY + float64(rowsNeeded)*size > pH - margin {
pdf.AddPage()
currY = margin
}
totalGridsInRows := rowsNeeded * cols
strokeCounter := 0
for gridIdx := 0; gridIdx < totalGridsInRows; gridIdx++ {
rowInChar := gridIdx / cols
colInChar := gridIdx % cols
x := margin + float64(colInChar)*size
y := currY + float64(rowInChar)*size
if rowInChar == 0 && colInChar == 0 {
drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false)
strokeCounter = 1
} else if rowInChar > 0 && colInChar == 0 {
drawMiZiGe(pdf, x, y, size)
} else if strokeCounter <= numStrokes {
drawStepBox(pdf, data, x, y, size, flipY, strokeCounter)
strokeCounter++
} else {
drawMiZiGe(pdf, x, y, size)
}
}
currY += float64(rowsNeeded) * size
}
}
func drawStepBox(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int) {
drawMiZiGe(pdf, x, y, size)
padding := size * 0.12
drawSize := size - 2*padding
scale := drawSize / 1024.0
offsetX := x + padding
offsetY := y + padding
pdf.SetFillColor(180, 180, 180)
for i := 0; i < strokeLimit; i++ {
points := parseSVGPath(data.Strokes[i], scale, offsetX, offsetY, flipY)
if len(points) > 2 {
pdf.Polygon(points, "F")
}
}
}
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)
}
padding := size * 0.12
drawSize := size - 2*padding
scale := drawSize / 1024.0
offsetX := x + padding
offsetY := y + padding
isFull := strokeLimit == len(data.Strokes)
if isFull {
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 && !isFull {
break
}
points := parseSVGPath(data.Strokes[sIdx], scale, offsetX, offsetY, flipY)
if len(points) > 2 {
pdf.Polygon(points, "F")
}
}
if showAnnotations && isFull {
fontSize := size * 0.035
if len(embeddedFont) > 0 {
_ = pdf.SetFont("font", "", fontSize)
}
for idx, median := range data.Medians {
mPoints := transformPoints(median, scale, offsetX, offsetY, flipY)
if len(mPoints) < 2 { continue }
pdf.SetStrokeColor(255, 0, 0)
pdf.SetLineWidth(0.6)
for j := 0; j < len(mPoints)-1; j++ {
pdf.Line(mPoints[j].X, mPoints[j].Y, mPoints[j+1].X, mPoints[j+1].Y)
}
pdf.SetFillColor(255, 0, 0)
drawArrow(pdf, mPoints[len(mPoints)-1], mPoints[len(mPoints)-2], size * 0.035)
startP, nextP := mPoints[0], mPoints[1]
r := size * 0.025
dx, dy := nextP.X-startP.X, nextP.Y-startP.Y
dist := math.Sqrt(dx*dx + dy*dy)
ux, uy := -1.0, -1.0
if dist > 0.001 { ux, uy = dx/dist, dy/dist }
centerX, centerY := startP.X - ux*r, startP.Y - uy*r
pdf.SetStrokeColor(255, 0, 0)
pdf.SetLineWidth(0.5)
pdf.Oval(centerX - r, centerY - r, centerX + r, centerY + r)
if len(embeddedFont) > 0 {
numStr := strconv.Itoa(idx+1)
tw := fontSize * 0.6 * float64(len(numStr)) / 2
if len(numStr) > 1 { tw = fontSize * 0.5 }
pdf.SetFillColor(255, 0, 0)
pdf.SetXY(centerX - tw/2, centerY - fontSize/2)
_ = pdf.Cell(nil, numStr)
}
}
}
}
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) {
dashLen := 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 += dashLen * 2 {
end := i + dashLen
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)
arrowAngle := math.Pi / 6
p1X := target.X + length*math.Cos(angle+math.Pi+arrowAngle)
p1Y := target.Y + length*math.Sin(angle+math.Pi+arrowAngle)
p2X := target.X + length*math.Cos(angle+math.Pi-arrowAngle)
p2Y := target.Y + length*math.Sin(angle+math.Pi-arrowAngle)
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" {
// Cubic Bezier: C x1 y1 x2 y2 x y
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)
// Sample points along the curve
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_final := ty
if flipY { ty_final = 1024 - ty }
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_final*scale})
}
lastX, lastY = x, y
idx += 7
} else { idx++ }
} else if item == "Q" {
// Quadratic Bezier: Q x1 y1 x y
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_final := ty
if flipY { ty_final = 1024 - ty }
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_final*scale})
}
lastX, lastY = x, y
idx += 5
} else { idx++ }
} else if item == "Z" {
idx++
} else {
idx++
}
}
return pts
}
+9
View File
@@ -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
+93
View File
@@ -0,0 +1,93 @@
package jitie
import (
"embed"
"encoding/json"
"fmt"
"net/http"
"toolbox/pkg/base"
"toolbox/pkg/jitie/logic"
"github.com/gin-gonic/gin"
)
//go:embed data/all.json
var allDataContent []byte
//go:embed data/*.ttf
var fontFS embed.FS
type jitieTool struct {
allChars map[string]logic.HanziData
}
func init() {
base.Register(&jitieTool{})
}
func (t *jitieTool) ID() string { return "jitie" }
func (t *jitieTool) Name() string { return "汉字字帖生成" }
func (t *jitieTool) Description() string { return "基于本地 30MB 数据集生成的教学/步进式字帖" }
func (t *jitieTool) Init() error {
fmt.Println("Loading 30MB character dataset...")
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)
}
fmt.Printf("Loaded %d characters into memory\n", len(t.allChars))
// Load font for logic layer
fontBytes, err := fontFS.ReadFile("data/font.ttf")
if err == nil && len(fontBytes) > 500 { // Check if it's a real font
logic.SetFontData(fontBytes)
fmt.Println("Font loaded for numbering")
} else {
fmt.Println("Warning: Could not load real font for numbering")
}
return nil
}
func (t *jitieTool) RegisterRoutes(r *gin.RouterGroup) {
r.POST("/generate", t.handleGenerate)
}
type GenerateRequest struct {
Chars string `json:"chars" binding:"required"`
Mode string `json:"mode"` // "teaching" or "step"
FlipY bool `json:"flip_y"`
PaperSize string `json:"paper_size"` // "A4" or "Letter"
}
func (t *jitieTool) handleGenerate(c *gin.Context) {
var req GenerateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var charsData []logic.HanziData
for _, char := range req.Chars {
charStr := string(char)
if data, ok := t.allChars[charStr]; ok {
data.Character = charStr // Ensure character field is set
charsData = append(charsData, data)
} else {
fmt.Printf("Warning: character %s not found in local dataset\n", charStr)
}
}
if len(charsData) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no valid character data found in local database"})
return
}
pdfBytes, err := logic.GeneratePDF(charsData, req.Mode, req.PaperSize, req.FlipY)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}