Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Successful in 2m45s
Build and Push Docker Image / build (push) Successful in 2m45s
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,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"toolbox/pkg/base"
|
||||||
|
_ "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. 模板引擎初始化
|
||||||
|
tmpl, err := template.ParseFS(webFS, "web/layout.html", "web/index.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
|
||||||
|
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,107 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<!-- 汉字字帖面板 -->
|
||||||
|
<div id="panel-zitie" class="tool-panel">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>汉字字帖生成器</h1>
|
||||||
|
<p>生成高颜值的硬笔/毛笔书法练习帖,支持 A4/Letter 高清矢量打印。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs-container">
|
||||||
|
<div id="tab-teaching" class="tab-btn active" onclick="switchZitieTab('teaching')">2x3 教学方格</div>
|
||||||
|
<div id="tab-step" class="tab-btn" onclick="switchZitieTab('step')">步进式分解</div>
|
||||||
|
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieTab('manuscript')">古风竖排</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||||
|
<div class="input-group">
|
||||||
|
<label id="input-label">输入汉字内容 (2x3 教学方格模式下标点将被自动过滤)</label>
|
||||||
|
<textarea id="chars" placeholder="支持多行输入...">永和九年,岁在癸丑。</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;">
|
||||||
|
<div style="flex: 1; min-width: 200px;">
|
||||||
|
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 8px;">纸张大小</label>
|
||||||
|
<select id="paper_size">
|
||||||
|
<option value="A4">A4 (210x297mm)</option>
|
||||||
|
<option value="Letter">Letter (8.5x11in)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="font-select-group" style="flex: 1; min-width: 200px; display: none;">
|
||||||
|
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 8px;">书法字体</label>
|
||||||
|
<select id="font_type">
|
||||||
|
<option value="kaiti">华光楷体</option>
|
||||||
|
<option value="xingshu">华光行草</option>
|
||||||
|
<option value="lishu">华光隶变</option>
|
||||||
|
<option value="songti">华光书宋</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="generatePDF()">生成高清预览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pdf-preview-container" style="display:none; width: 100%; height: 850px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
|
||||||
|
<iframe id="pdf-frame" style="width: 100%; height: 100%; border: none;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 默认欢迎面板 -->
|
||||||
|
<div id="panel-welcome" class="tool-panel">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>欢迎使用个人工具箱</h1>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentMode = 'teaching';
|
||||||
|
|
||||||
|
function switchZitieTab(mode) {
|
||||||
|
currentMode = mode;
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
document.getElementById(`tab-${mode}`).classList.add('active');
|
||||||
|
document.getElementById('font-select-group').style.display = (mode === 'manuscript') ? 'block' : 'none';
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
'teaching': '输入汉字内容 (2x3 教学方格模式下标点将被自动过滤)',
|
||||||
|
'step': '输入汉字内容 (步进式分解模式下标点将被自动过滤)',
|
||||||
|
'manuscript': '输入汉字内容 (古风竖排支持换行,标点将被过滤)'
|
||||||
|
};
|
||||||
|
document.getElementById('input-label').innerText = labels[mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePDF() {
|
||||||
|
const chars = document.getElementById('chars').value;
|
||||||
|
const paper_size = document.getElementById('paper_size').value;
|
||||||
|
const font_type = document.getElementById('font_type').value;
|
||||||
|
const btn = document.querySelector('button');
|
||||||
|
const originalText = btn.innerText;
|
||||||
|
btn.innerText = '正在绘图中...'; btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/zitie/${currentMode}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ chars, paper_size, font_type })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
document.getElementById('pdf-preview-container').style.display = 'block';
|
||||||
|
document.getElementById('pdf-frame').src = URL.createObjectURL(blob);
|
||||||
|
if(window.innerWidth < 768) document.getElementById('pdf-preview-container').scrollIntoView({behavior: 'smooth'});
|
||||||
|
} else { alert('生成失败'); }
|
||||||
|
} catch (e) { alert('网络错误'); } finally {
|
||||||
|
btn.innerText = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: own-tools
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: own-tools
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: own-tools
|
||||||
|
spec:
|
||||||
|
# 如果你的 Gitea 仓库是私有的,请取消下面注释并创建对应的 Secret
|
||||||
|
# imagePullSecrets:
|
||||||
|
# - name: gitea-registry-secret
|
||||||
|
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
|
||||||
|
namespace: default
|
||||||
|
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: default
|
||||||
|
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
|
||||||
|
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,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