Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Failing after 3h14m47s
Build and Push Docker Image / build (push) Failing after 3h14m47s
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# 指向 K8s Runner 中共享挂载的 Socket 路径
|
||||
DOCKER_HOST: unix:///run/docker.sock
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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"
|
||||
docker build -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,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 协议。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user