Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Successful in 2m30s
Build and Push Docker Image / build (push) Successful in 2m30s
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,62 @@
|
|||||||
|
# 个人工具箱 (Own-Tools)
|
||||||
|
|
||||||
|
这是一个基于 Go 语言构建的模块化个人工具箱。目前已实现核心功能:**汉字字帖生成器**。
|
||||||
|
|
||||||
|
## 🌟 核心特性
|
||||||
|
|
||||||
|
- **全内嵌部署**:所有前端静态资源、汉字笔顺数据(9574个字)以及字体文件均通过 `go:embed` 打包进单个二进制文件。
|
||||||
|
- **高性能渲染**:原生 Go 实现的矢量 PDF 绘图,支持贝塞尔曲线平滑采样,生成高质量、不失真的字帖。
|
||||||
|
- **模块化架构**:采用插件化设计(`pkg/base` 接口),可轻松扩展新工具。
|
||||||
|
- **云原生就绪**:提供多阶段构建的 Dockerfile,并集成了 Gitea Actions CI/CD。
|
||||||
|
|
||||||
|
## 🛠 已集成工具
|
||||||
|
|
||||||
|
### 汉字字帖生成器 (`zitie`)
|
||||||
|
- **教学字帖 (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/zitie/`: 汉字字帖工具实现。
|
||||||
|
- `data/`: 内嵌的 30MB 完整字库及字体。
|
||||||
|
- `logic/`: PDF 渲染、SVG 解析、避让算法逻辑。
|
||||||
|
- `.gitea/workflows/`: CI/CD 自动化流水线配置。
|
||||||
|
- `design/`: 项目设计文档。
|
||||||
|
|
||||||
|
## 📝 许可证
|
||||||
|
自用工具,遵循 MIT 协议。
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"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 (例如: G-XXXXXXXXXX)")
|
||||||
|
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))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态注入 Google Analytics
|
||||||
|
if gaID != "" {
|
||||||
|
gaScript := `
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=` + gaID + `"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.onLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '` + gaID + `');
|
||||||
|
</script>`
|
||||||
|
content = bytes.Replace(content, []byte("</head>"), []byte(gaScript+"</head>"), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||||
|
})
|
||||||
|
// 改为 /static 避免与 /web 混淆
|
||||||
|
|
||||||
|
// 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. 全路径回退 (SPA Routing)
|
||||||
|
// 任何不匹配 API 或静态资源的请求,都返回 index.html
|
||||||
|
r.NoRoute(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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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,167 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Own-Tools | 个人工具箱</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖋️</text></svg>">
|
||||||
|
<style>
|
||||||
|
:root { --sidebar-width: 260px; --apple-bg: #f5f5f7; --apple-blue: #0071e3; --sidebar-bg: rgba(255, 255, 255, 0.9); }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: var(--apple-bg); color: #1d1d1f; display: flex; height: 100vh; width: 100vw; overflow: hidden; }
|
||||||
|
.sidebar { width: var(--sidebar-width); min-width: var(--sidebar-width); background: var(--sidebar-bg); backdrop-filter: blur(20px); border-right: 1px solid #d2d2d7; display: flex; flex-direction: column; padding: 24px 0; z-index: 100; }
|
||||||
|
.sidebar-header { padding: 0 24px 24px; border-bottom: 1px solid #e5e5e7; margin-bottom: 16px; }
|
||||||
|
.sidebar-header h2 { font-size: 22px; font-weight: 700; margin: 0; color: #1d1d1f; letter-spacing: -0.5px; cursor: pointer; }
|
||||||
|
.nav-list { flex: 1; overflow-y: auto; }
|
||||||
|
.nav-item { padding: 12px 24px; cursor: pointer; transition: all 0.2s; font-size: 15px; font-weight: 500; color: #424245; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.nav-item:hover { background: rgba(0,0,0,0.04); color: #000; }
|
||||||
|
.nav-item.active { background: var(--apple-blue); color: #ffffff; }
|
||||||
|
.main-content { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 40px 60px; }
|
||||||
|
.tool-panel { display: none; animation: fadeIn 0.3s ease-out; }
|
||||||
|
.tool-panel.active { display: block; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.tabs-container { display: flex; gap: 10px; border-bottom: 1px solid #d2d2d7; margin-bottom: 24px; overflow-x: auto; }
|
||||||
|
.tab-btn { padding: 10px 20px; cursor: pointer; font-size: 16px; font-weight: 600; color: #86868b; border-bottom: 3px solid transparent; transition: all 0.2s; }
|
||||||
|
.tab-btn.active { color: var(--apple-blue); border-bottom-color: var(--apple-blue); }
|
||||||
|
.card { background: white; border-radius: 18px; padding: 32px; box-shadow: 0 4px 24px rgba(0,0,0,0.04); margin-bottom: 30px; border: 1px solid rgba(0,0,0,0.05); }
|
||||||
|
textarea { padding: 14px 18px; border: 1px solid #d2d2d7; border-radius: 12px; font-size: 17px; background-color: #fbfbfd; width: 100%; box-sizing: border-box; min-height: 150px; font-family: inherit; resize: vertical; }
|
||||||
|
.input-row-bottom { display: flex; gap: 20px; align-items: flex-end; margin-top: 20px; }
|
||||||
|
.input-group { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
select { padding: 13px 18px; border: 1px solid #d2d2d7; border-radius: 12px; font-size: 16px; background-color: #fbfbfd; cursor: pointer; }
|
||||||
|
button { background-color: var(--apple-blue); color: white; border: none; padding: 0 30px; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; height: 48px; transition: all 0.2s; }
|
||||||
|
#pdf-preview-container { 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 { width: 100%; height: 100%; border: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header" onclick="navigateTo('/')"><h2>Own-Tools</h2></div>
|
||||||
|
<div id="nav-list" class="nav-list">
|
||||||
|
<div id="nav-welcome" class="nav-item active" onclick="navigateTo('/')"><span>🏠</span> 仪表盘</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div id="panel-zitie" class="tool-panel">
|
||||||
|
<h1 style="font-size: 34px; font-weight: 700; margin-bottom: 20px;">汉字字帖生成器</h1>
|
||||||
|
<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 class="form-layout">
|
||||||
|
<label id="input-label">输入汉字内容 (标点符号将被自动过滤)</label>
|
||||||
|
<textarea id="chars" placeholder="输入内容...">永和九年,岁在癸丑。
|
||||||
|
暮春之初,会于会稽山阴之兰亭,修禊事也。</textarea>
|
||||||
|
<div class="input-row-bottom">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>纸张大小</label>
|
||||||
|
<select id="paper_size" style="width: 180px;">
|
||||||
|
<option value="A4">A4 (210x297mm)</option>
|
||||||
|
<option value="Letter">Letter (8.5x11in)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="font-select-group" class="input-group" style="display: none;">
|
||||||
|
<label>书法字体</label>
|
||||||
|
<select id="font_type" style="width: 180px;">
|
||||||
|
<option value="kaiti">华光楷体</option>
|
||||||
|
<option value="xingshu">华光行草</option>
|
||||||
|
<option value="lishu">华光隶变</option>
|
||||||
|
<option value="songti">华光书宋</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;"></div>
|
||||||
|
<button onclick="generatePDF()">生成高清预览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pdf-preview-container" style="display:none;"><iframe id="pdf-frame"></iframe></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-welcome" class="tool-panel active">
|
||||||
|
<h1 style="font-size: 40px; font-weight: 700;">欢迎使用</h1>
|
||||||
|
<p style="font-size: 20px; color: #86868b;">请选择工具开始工作。</p>
|
||||||
|
<div style="margin-top: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||||
|
<div class="card" style="cursor:pointer;" onclick="navigateTo('/zitie')">
|
||||||
|
<h3>🖋️ 汉字字帖</h3>
|
||||||
|
<p>生成高颜值的硬笔/毛笔书法练习帖。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentMode = 'teaching';
|
||||||
|
|
||||||
|
async function loadTools() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tools');
|
||||||
|
const tools = await response.json();
|
||||||
|
const navList = document.getElementById('nav-list');
|
||||||
|
tools.forEach(tool => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'nav-item'; item.id = `nav-${tool.id}`;
|
||||||
|
item.innerHTML = `<span>🛠️</span> ${tool.name}`;
|
||||||
|
item.onclick = () => navigateTo(`/${tool.id}`);
|
||||||
|
navList.appendChild(item);
|
||||||
|
});
|
||||||
|
renderCurrentPath();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path) {
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
renderCurrentPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentPath() {
|
||||||
|
const path = window.location.pathname.replace('/', '') || 'welcome';
|
||||||
|
document.querySelectorAll('.tool-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
const target = document.getElementById(`panel-${path}`);
|
||||||
|
if (target) target.classList.add('active');
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
const activeNav = document.getElementById(`nav-${path}`);
|
||||||
|
if (activeNav) activeNav.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onpopstate = renderCurrentPath;
|
||||||
|
|
||||||
|
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') ? 'flex' : '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');
|
||||||
|
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);
|
||||||
|
} else { alert('生成失败'); }
|
||||||
|
} catch (e) { alert('网络错误'); } finally { btn.innerText = '生成高清预览'; btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTools();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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"
|
||||||
|
- name: GIN_MODE
|
||||||
|
value: "release"
|
||||||
|
- name: GA_ID
|
||||||
|
value: "" # 填入你的 G-XXXXXXXXXX
|
||||||
|
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,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
|
||||||
|
}
|
||||||
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