Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Failing after 2m11s
Build and Push Docker Image / build (push) Failing after 2m11s
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# 指向 K8s Runner 中共享挂载的 Socket 路径
|
||||||
|
DOCKER_HOST: unix:///run/docker.sock
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
run: echo "${{ secrets.PUSH_TOKEN }}" | docker login git.pengzhan.dev -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
run: |
|
||||||
|
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
IMAGE_NAME="git.pengzhan.dev/$REPO_LOWER"
|
||||||
|
docker build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${{ github.sha }} .
|
||||||
|
docker push $IMAGE_NAME:latest
|
||||||
|
docker push $IMAGE_NAME:${{ github.sha }}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Binaries
|
||||||
|
toolbox
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Data (if you want to exclude original design data, but here we include pkg/jitie/data)
|
||||||
|
# data/
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# --- Build Stage ---
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source and data
|
||||||
|
COPY cmd/ ./cmd/
|
||||||
|
COPY pkg/ ./pkg/
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /toolbox cmd/toolbox/main.go
|
||||||
|
|
||||||
|
# --- Final Stage ---
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Basic security & CA certs
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /toolbox .
|
||||||
|
|
||||||
|
# Default environment
|
||||||
|
ENV PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["./toolbox"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 个人工具箱 (Own-Tools)
|
||||||
|
|
||||||
|
这是一个基于 Go 语言构建的模块化个人工具箱。目前已实现核心功能:**汉字字帖生成器**。
|
||||||
|
|
||||||
|
## 🌟 核心特性
|
||||||
|
|
||||||
|
- **全内嵌部署**:所有前端静态资源、汉字笔顺数据(9574个字)以及字体文件均通过 `go:embed` 打包进单个二进制文件。
|
||||||
|
- **高性能渲染**:原生 Go 实现的矢量 PDF 绘图,支持贝塞尔曲线平滑采样,生成高质量、不失真的字帖。
|
||||||
|
- **模块化架构**:采用插件化设计(`pkg/base` 接口),可轻松扩展新工具。
|
||||||
|
- **云原生就绪**:提供多阶段构建的 Dockerfile,并集成了 Gitea Actions CI/CD。
|
||||||
|
|
||||||
|
## 🛠 已集成工具
|
||||||
|
|
||||||
|
### 汉字字帖生成器 (`jitie`)
|
||||||
|
- **教学字帖 (2x3)**:带红色圆圈序号、方向箭头和骨架红线的专业临摹贴。
|
||||||
|
- **步进分解 (9列)**:米字格背景,逐笔展示汉字书写过程,支持多字连续排版。
|
||||||
|
- **智能适配**:自动适配 A4 和 Letter 纸张尺寸。
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 本地运行 (已有编译好的二进制文件)
|
||||||
|
```bash
|
||||||
|
./toolbox -port 8888
|
||||||
|
```
|
||||||
|
访问 `http://localhost:8888` 即可开始使用。
|
||||||
|
|
||||||
|
### 从源码编译
|
||||||
|
确保已安装 Go 1.23+。
|
||||||
|
```bash
|
||||||
|
go build -o toolbox cmd/toolbox/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令行参数
|
||||||
|
- `-port`: 指定 Web 服务端口(默认 8080,优先级高于环境变量)。
|
||||||
|
|
||||||
|
## 🐳 容器化部署
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
- `PORT`: 容器内监听端口(默认 8080)。
|
||||||
|
|
||||||
|
### 构建镜像
|
||||||
|
```bash
|
||||||
|
docker build -t toolbox:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行镜像
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:8080 toolbox:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 项目结构
|
||||||
|
|
||||||
|
- `cmd/toolbox/`: 程序入口及内嵌前端网页。
|
||||||
|
- `pkg/base/`: 工具标准接口定义及自动注册中心。
|
||||||
|
- `pkg/jitie/`: 汉字字帖工具实现。
|
||||||
|
- `data/`: 内嵌的 30MB 完整字库及字体。
|
||||||
|
- `logic/`: PDF 渲染、SVG 解析、避让算法逻辑。
|
||||||
|
- `.gitea/workflows/`: CI/CD 自动化流水线配置。
|
||||||
|
- `design/`: 项目设计文档。
|
||||||
|
|
||||||
|
## 📝 许可证
|
||||||
|
自用工具,遵循 MIT 协议。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"toolbox/pkg/base"
|
||||||
|
_ "toolbox/pkg/jitie" // 匿名导入以触发 init()
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/*
|
||||||
|
var webFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 定义端口 Flag
|
||||||
|
portFlag := flag.Int("port", 0, "端口号 (优先于环境变量 PORT)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 禁用 Gin 的默认重定向逻辑,防止与静态资源冲突
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
|
r.RedirectTrailingSlash = false
|
||||||
|
r.RedirectFixedPath = false
|
||||||
|
|
||||||
|
// 1. 自动发现并注册工具路由
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
for id, tool := range base.Registry {
|
||||||
|
log.Printf("Loading tool: %s (%s)", tool.Name(), id)
|
||||||
|
if err := tool.Init(); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize tool %s: %v", id, err)
|
||||||
|
}
|
||||||
|
group := api.Group("/" + id)
|
||||||
|
tool.RegisterRoutes(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 静态资源路由 (内嵌前端)
|
||||||
|
// 根路径直接返回 index.html 内容,不使用文件服务器转发
|
||||||
|
r.GET("/", func(c *gin.Context) {
|
||||||
|
content, err := webFS.ReadFile("web/index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "index.html not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果将来有 css/js,可以挂载到 /web 路径
|
||||||
|
subFS, _ := fs.Sub(webFS, "web")
|
||||||
|
r.StaticFS("/web", http.FS(subFS))
|
||||||
|
|
||||||
|
// 3. 工具列表发现接口
|
||||||
|
r.GET("/api/tools", func(c *gin.Context) {
|
||||||
|
var list []map[string]string
|
||||||
|
for _, t := range base.Registry {
|
||||||
|
list = append(list, map[string]string{
|
||||||
|
"id": t.ID(),
|
||||||
|
"name": t.Name(),
|
||||||
|
"desc": t.Description(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 确定最终使用的端口
|
||||||
|
var finalPort string
|
||||||
|
if *portFlag > 0 {
|
||||||
|
finalPort = strconv.Itoa(*portFlag)
|
||||||
|
} else if envPort := os.Getenv("PORT"); envPort != "" {
|
||||||
|
finalPort = envPort
|
||||||
|
} else {
|
||||||
|
finalPort = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Toolbox server starting on :%s", finalPort)
|
||||||
|
if err := r.Run(":" + finalPort); err != nil {
|
||||||
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>个人工具箱 - 汉字字帖生成器</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f5f5f7;
|
||||||
|
color: #1d1d1f;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 32px; margin-bottom: 8px; }
|
||||||
|
header p { color: #86868b; margin: 0; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row-top {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row-bottom {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.input-group.flex-main { flex: 2; }
|
||||||
|
.input-group.flex-side { flex: 1; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d1d1f;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #d2d2d7;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
background-color: #fbfbfd;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: #0071e3;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(0,113,227,0.1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #d2d2d7;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
background-color: #fbfbfd;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #0071e3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
button:hover { background-color: #0077ed; transform: translateY(-1px); }
|
||||||
|
button:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
#preview-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 850px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
iframe { width: 100%; height: 100%; border: none; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
border: 2px dashed #d2d2d7;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #86868b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>汉字字帖工具箱</h1>
|
||||||
|
<p>生成标准笔顺教学字帖,支持 A4/Letter 打印</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-layout">
|
||||||
|
<div class="input-row-top">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="chars">输入想要生成的汉字</label>
|
||||||
|
<input type="text" id="chars" placeholder="例如:永和九年,岁在癸丑..." value="永">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-row-bottom">
|
||||||
|
<div class="input-group flex-main">
|
||||||
|
<label for="mode">布局模式</label>
|
||||||
|
<select id="mode">
|
||||||
|
<option value="teaching">2x3 教学方格 (带笔顺、箭头、序号)</option>
|
||||||
|
<option value="step">步进式分解 (逐笔展示过程)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-group flex-side">
|
||||||
|
<label for="paper_size">纸张大小</label>
|
||||||
|
<select id="paper_size">
|
||||||
|
<option value="A4">A4 (210x297mm)</option>
|
||||||
|
<option value="Letter">Letter (8.5x11in)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="generatePDF()">生成预览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-container">
|
||||||
|
<iframe id="pdf-frame"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="empty-state" class="empty-state">
|
||||||
|
<p>在上方输入汉字并点击按钮,即可生成高清矢量字帖预览</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function generatePDF() {
|
||||||
|
const chars = document.getElementById('chars').value;
|
||||||
|
const mode = document.getElementById('mode').value;
|
||||||
|
const paper_size = document.getElementById('paper_size').value;
|
||||||
|
|
||||||
|
if (!chars.trim()) {
|
||||||
|
alert('请输入汉字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.querySelector('button');
|
||||||
|
const originalText = btn.innerText;
|
||||||
|
btn.innerText = '正在绘图中...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/jitie/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chars: chars,
|
||||||
|
mode: mode,
|
||||||
|
flip_y: true,
|
||||||
|
paper_size: paper_size
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
const container = document.getElementById('preview-container');
|
||||||
|
container.style.display = 'block';
|
||||||
|
document.getElementById('pdf-frame').src = url;
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('生成失败: ' + (err.error || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('网络请求失败,请检查后端服务是否运行');
|
||||||
|
} finally {
|
||||||
|
btn.innerText = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: own-tools
|
||||||
|
labels:
|
||||||
|
app: own-tools
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: own-tools
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: own-tools
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: toolbox
|
||||||
|
# TODO: 确认你的 Gitea 镜像完整路径
|
||||||
|
image: git.pengzhan.dev/haopengzhan/own-tools:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
env:
|
||||||
|
- name: PORT
|
||||||
|
value: "8080"
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "256Mi"
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "128Mi"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: own-tools-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: own-tools
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: own-tools-ingress
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: traefik
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: toolbox.pengzhan.dev # 请修改为你的实际域名
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: own-tools-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 个人工具箱 - 汉字字帖生成器详细设计
|
||||||
|
|
||||||
|
## 1. 项目目标
|
||||||
|
构建一个基于 Python 的 Web 工具箱,首期功能为生成两种类型的汉字书写字帖:
|
||||||
|
1. **步进式字帖**:展示汉字书写的每一笔过程。
|
||||||
|
2. **教学式大方格**:带笔顺序号、方向箭头和灰色临摹底样的 2x3 大格。
|
||||||
|
|
||||||
|
## 2. 技术栈选择
|
||||||
|
- **核心语言**: Python 3.10+
|
||||||
|
- **Web 框架**: **Streamlit** (用于快速构建交互式 UI) 或 **FastAPI** (如果后续需要更复杂的前后端分离)。初版建议使用 Streamlit 以减少 JS 代码量。
|
||||||
|
- **绘图引擎**: **Pillow (PIL)** (生成图片) 或 **ReportLab / fpdf2** (直接生成可打印的 PDF)。
|
||||||
|
- **数据源**: [Hanzi Writer Data](https://github.com/chanind/hanzi-writer-data) (提供汉字笔画的 SVG Path 数据和中轴线坐标)。
|
||||||
|
|
||||||
|
## 3. 核心功能设计
|
||||||
|
|
||||||
|
### 3.1 数据层 (Data Provider)
|
||||||
|
- **职责**: 加载和缓存汉字笔画 JSON 数据。
|
||||||
|
- **实现**:
|
||||||
|
- 下载常用汉字的 JSON 文件并存储在本地。
|
||||||
|
- 结构化解析 `strokes` (SVG 路径字符串) 和 `medians` (笔画中轴线坐标序列)。
|
||||||
|
|
||||||
|
### 3.2 渲染引擎 (Character Renderer)
|
||||||
|
- **SVG 解析**: 使用 `svgpath2lines` 或类似库将 SVG Path 转换为坐标点。
|
||||||
|
- **步进逻辑 (Step-by-Step)**:
|
||||||
|
- 输入:汉字,方格大小。
|
||||||
|
- 循环:对于第 $i$ 个笔画,在第 $i$ 个格子中绘制 `strokes[0...i]`。
|
||||||
|
- 样式:当前笔画(第 $i$ 笔)用黑色,之前的笔画用浅灰色或同样黑色。
|
||||||
|
- **标注逻辑 (Annotated Square)**:
|
||||||
|
- **底样**: 绘制所有笔画,颜色设为浅灰 (e.g., `#D3D3D3`)。
|
||||||
|
- **序号**: 在每一笔 `medians[i]` 的第一个坐标点附近标注数字 $i+1$。
|
||||||
|
- **箭头**:
|
||||||
|
- 取 `medians[i]` 的前两个点 $P_1(x_1, y_1)$ 和 $P_2(x_2, y_2)$。
|
||||||
|
- 计算向量 $\vec{V} = P_2 - P_1$。
|
||||||
|
- 在 $P_1$ 处绘制指向 $P_2$ 方向的箭头。
|
||||||
|
|
||||||
|
### 3.3 导出模块 (Exporter)
|
||||||
|
- **PDF 导出**: 使用 `fpdf2` 布局页面(A4 纸张),将生成的图片或绘制指令排列在页面上。
|
||||||
|
- **预览**: 在 Web 界面实时展示生成的 PNG 图片。
|
||||||
|
|
||||||
|
## 4. 界面设计 (Web UI)
|
||||||
|
- **侧边栏**: 选择字帖类型(步进式/教学式)、字体大小、纸张方向。
|
||||||
|
- **输入区**: 文本框输入目标汉字(支持多个字)。
|
||||||
|
- **展示区**: 实时生成字帖预览。
|
||||||
|
- **下载区**: 点击按钮下载 PDF 打印版。
|
||||||
|
|
||||||
|
## 5. 待解决的技术细节
|
||||||
|
- **SVG 坐标对齐**: 汉字数据通常基于 1024x1024 的坐标系,需要缩放到 PDF 或图片的分辨率。
|
||||||
|
- **箭头绘制逻辑**: 处理极短笔画(如“点”)时的箭头方向问题。
|
||||||
|
- **田字格背景**: 需要在渲染汉字前绘制标准的“田”字虚线格。
|
||||||
|
|
||||||
|
## 6. 实施步骤
|
||||||
|
1. **环境准备**: 安装 `streamlit`, `pillow`, `fpdf2`, `requests`。
|
||||||
|
2. **数据爬取/获取**: 编写脚本从 Hanzi Writer CDN 获取指定汉字的 JSON 数据。
|
||||||
|
3. **原型开发**: 先在本地用 Pillow 生成一张带箭头和数字的单个汉字图片。
|
||||||
|
4. **Web 包装**: 使用 Streamlit 搭建界面。
|
||||||
|
5. **PDF 布局优化**: 实现多字排版和打印优化。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 个人工具箱
|
||||||
|
|
||||||
|
一个web based的网站,提供各种工具
|
||||||
|
|
||||||
|
## 中文书写字帖生成
|
||||||
|
|
||||||
|
类似于https://www.nqez.com/,但是这个网站里的字帖种类非常丰富了,我只是想先专注于最简单的两种字帖生成。
|
||||||
|
|
||||||
|
一种是在小田字格一排。分别是字的全貌、字的第一笔,第一笔和第二笔。。。。。如果有空,就留空。
|
||||||
|
|
||||||
|
一种是给定字,在纸上生成2*3 6个大方格,每个方格里是灰色的字,同时有小字序号和箭头表示每一笔从哪里写到哪里。
|
||||||
|
|
||||||
|
你觉得这个需求可以做吗?
|
||||||
|
|
||||||
|
怎么实现,请给我一个方案。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
module toolbox
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/signintech/gopdf v0.36.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/phpdave11/gofpdi v1.0.15 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.15 h1:iJazY1BQ07I9s7N5EWjBO1YbhmKfHGxNligUv/Rw4Lc=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/signintech/gopdf v0.36.0 h1:/7gPwoLtlNv5tPNpYuo3T3z0mWgo62pTrCvVNAiOo2Q=
|
||||||
|
github.com/signintech/gopdf v0.36.0/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||||
|
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package base
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tool 定义了工具箱中每个子工具必须实现的接口
|
||||||
|
type Tool interface {
|
||||||
|
ID() string // 工具的唯一标识,用于路由前缀,如 "jitie"
|
||||||
|
Name() string // 工具的显示名称
|
||||||
|
Description() string // 工具的描述
|
||||||
|
Init() error // 初始化逻辑,如加载 embed 的数据
|
||||||
|
RegisterRoutes(r *gin.RouterGroup) // 注册该工具的 API 路由
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry 存储所有已注册的工具
|
||||||
|
var Registry = make(map[string]Tool)
|
||||||
|
|
||||||
|
// Register 用于工具在 init() 函数中注册自己
|
||||||
|
func Register(t Tool) {
|
||||||
|
Registry[t.ID()] = t
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,330 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/signintech/gopdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
var embeddedFont []byte
|
||||||
|
|
||||||
|
func SetFontData(data []byte) {
|
||||||
|
embeddedFont = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePDF 生成 PDF 字节流
|
||||||
|
func GeneratePDF(chars []HanziData, mode, paperSize string, flipY bool) ([]byte, error) {
|
||||||
|
pdf := &gopdf.GoPdf{}
|
||||||
|
|
||||||
|
rect := gopdf.Rect{W: 595.28, H: 841.89} // A4 default
|
||||||
|
if paperSize == "Letter" {
|
||||||
|
rect = gopdf.Rect{W: 612, H: 792}
|
||||||
|
}
|
||||||
|
pdf.Start(gopdf.Config{PageSize: rect})
|
||||||
|
|
||||||
|
if len(embeddedFont) > 0 {
|
||||||
|
_ = pdf.AddTTFFontData("font", embeddedFont)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "step" {
|
||||||
|
addStepPages(pdf, chars, flipY, rect.W, rect.H)
|
||||||
|
} else {
|
||||||
|
addTeachingPages(pdf, chars, flipY, rect.W, rect.H)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err := pdf.WriteTo(&buf)
|
||||||
|
return buf.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
|
||||||
|
cols, rows := 2, 3
|
||||||
|
gap := 17.0
|
||||||
|
margin := 40.0
|
||||||
|
|
||||||
|
cellW := (pW - 2*margin - float64(cols-1)*gap) / float64(cols)
|
||||||
|
cellH := (pH - 2*margin - float64(rows-1)*gap) / float64(rows)
|
||||||
|
size := math.Min(cellW, cellH)
|
||||||
|
|
||||||
|
totalW := float64(cols)*size + float64(cols-1)*gap
|
||||||
|
totalH := float64(rows)*size + float64(rows-1)*gap
|
||||||
|
marginX := (pW - totalW) / 2
|
||||||
|
marginY := (pH - totalH) / 2
|
||||||
|
|
||||||
|
for i := 0; i < len(chars); i += 6 {
|
||||||
|
pdf.AddPage()
|
||||||
|
end := i + 6
|
||||||
|
if end > len(chars) {
|
||||||
|
end = len(chars)
|
||||||
|
}
|
||||||
|
batch := chars[i:end]
|
||||||
|
|
||||||
|
for idx, data := range batch {
|
||||||
|
c, r := idx%cols, idx/cols
|
||||||
|
x := marginX + float64(c)*(size+gap)
|
||||||
|
y := marginY + float64(r)*(size+gap)
|
||||||
|
drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addStepPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
|
||||||
|
cols := 9
|
||||||
|
margin := 30.0
|
||||||
|
size := (pW - 2*margin) / float64(cols)
|
||||||
|
|
||||||
|
pdf.AddPage()
|
||||||
|
currY := margin
|
||||||
|
|
||||||
|
for _, data := range chars {
|
||||||
|
numStrokes := len(data.Strokes)
|
||||||
|
rowsNeeded := 1
|
||||||
|
if numStrokes > 8 {
|
||||||
|
rowsNeeded = 1 + (numStrokes - 8 + 7) / 8
|
||||||
|
}
|
||||||
|
|
||||||
|
if currY + float64(rowsNeeded)*size > pH - margin {
|
||||||
|
pdf.AddPage()
|
||||||
|
currY = margin
|
||||||
|
}
|
||||||
|
|
||||||
|
totalGridsInRows := rowsNeeded * cols
|
||||||
|
strokeCounter := 0
|
||||||
|
|
||||||
|
for gridIdx := 0; gridIdx < totalGridsInRows; gridIdx++ {
|
||||||
|
rowInChar := gridIdx / cols
|
||||||
|
colInChar := gridIdx % cols
|
||||||
|
x := margin + float64(colInChar)*size
|
||||||
|
y := currY + float64(rowInChar)*size
|
||||||
|
|
||||||
|
if rowInChar == 0 && colInChar == 0 {
|
||||||
|
drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false)
|
||||||
|
strokeCounter = 1
|
||||||
|
} else if rowInChar > 0 && colInChar == 0 {
|
||||||
|
drawMiZiGe(pdf, x, y, size)
|
||||||
|
} else if strokeCounter <= numStrokes {
|
||||||
|
drawStepBox(pdf, data, x, y, size, flipY, strokeCounter)
|
||||||
|
strokeCounter++
|
||||||
|
} else {
|
||||||
|
drawMiZiGe(pdf, x, y, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currY += float64(rowsNeeded) * size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawStepBox(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int) {
|
||||||
|
drawMiZiGe(pdf, x, y, size)
|
||||||
|
padding := size * 0.12
|
||||||
|
drawSize := size - 2*padding
|
||||||
|
scale := drawSize / 1024.0
|
||||||
|
offsetX := x + padding
|
||||||
|
offsetY := y + padding
|
||||||
|
|
||||||
|
pdf.SetFillColor(180, 180, 180)
|
||||||
|
for i := 0; i < strokeLimit; i++ {
|
||||||
|
points := parseSVGPath(data.Strokes[i], scale, offsetX, offsetY, flipY)
|
||||||
|
if len(points) > 2 {
|
||||||
|
pdf.Polygon(points, "F")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawCharacter(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int, showAnnotations bool) {
|
||||||
|
if showAnnotations {
|
||||||
|
drawGrid(pdf, x, y, size)
|
||||||
|
} else {
|
||||||
|
drawMiZiGe(pdf, x, y, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
padding := size * 0.12
|
||||||
|
drawSize := size - 2*padding
|
||||||
|
scale := drawSize / 1024.0
|
||||||
|
offsetX := x + padding
|
||||||
|
offsetY := y + padding
|
||||||
|
|
||||||
|
isFull := strokeLimit == len(data.Strokes)
|
||||||
|
if isFull {
|
||||||
|
if showAnnotations {
|
||||||
|
pdf.SetFillColor(240, 240, 240)
|
||||||
|
} else {
|
||||||
|
pdf.SetFillColor(0, 0, 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pdf.SetFillColor(180, 180, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
for sIdx := 0; sIdx < len(data.Strokes); sIdx++ {
|
||||||
|
if sIdx >= strokeLimit && !isFull {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
points := parseSVGPath(data.Strokes[sIdx], scale, offsetX, offsetY, flipY)
|
||||||
|
if len(points) > 2 {
|
||||||
|
pdf.Polygon(points, "F")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showAnnotations && isFull {
|
||||||
|
fontSize := size * 0.035
|
||||||
|
if len(embeddedFont) > 0 {
|
||||||
|
_ = pdf.SetFont("font", "", fontSize)
|
||||||
|
}
|
||||||
|
for idx, median := range data.Medians {
|
||||||
|
mPoints := transformPoints(median, scale, offsetX, offsetY, flipY)
|
||||||
|
if len(mPoints) < 2 { continue }
|
||||||
|
|
||||||
|
pdf.SetStrokeColor(255, 0, 0)
|
||||||
|
pdf.SetLineWidth(0.6)
|
||||||
|
for j := 0; j < len(mPoints)-1; j++ {
|
||||||
|
pdf.Line(mPoints[j].X, mPoints[j].Y, mPoints[j+1].X, mPoints[j+1].Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFillColor(255, 0, 0)
|
||||||
|
drawArrow(pdf, mPoints[len(mPoints)-1], mPoints[len(mPoints)-2], size * 0.035)
|
||||||
|
|
||||||
|
startP, nextP := mPoints[0], mPoints[1]
|
||||||
|
r := size * 0.025
|
||||||
|
dx, dy := nextP.X-startP.X, nextP.Y-startP.Y
|
||||||
|
dist := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
ux, uy := -1.0, -1.0
|
||||||
|
if dist > 0.001 { ux, uy = dx/dist, dy/dist }
|
||||||
|
centerX, centerY := startP.X - ux*r, startP.Y - uy*r
|
||||||
|
|
||||||
|
pdf.SetStrokeColor(255, 0, 0)
|
||||||
|
pdf.SetLineWidth(0.5)
|
||||||
|
pdf.Oval(centerX - r, centerY - r, centerX + r, centerY + r)
|
||||||
|
|
||||||
|
if len(embeddedFont) > 0 {
|
||||||
|
numStr := strconv.Itoa(idx+1)
|
||||||
|
tw := fontSize * 0.6 * float64(len(numStr)) / 2
|
||||||
|
if len(numStr) > 1 { tw = fontSize * 0.5 }
|
||||||
|
pdf.SetFillColor(255, 0, 0)
|
||||||
|
pdf.SetXY(centerX - tw/2, centerY - fontSize/2)
|
||||||
|
_ = pdf.Cell(nil, numStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawGrid(pdf *gopdf.GoPdf, x, y, size float64) {
|
||||||
|
pdf.SetStrokeColor(220, 220, 220)
|
||||||
|
pdf.SetLineWidth(0.4)
|
||||||
|
pdf.RectFromUpperLeft(x, y, size, size)
|
||||||
|
mid := size / 2
|
||||||
|
drawDashedLine(pdf, x, y+mid, x+size, y+mid)
|
||||||
|
drawDashedLine(pdf, x+mid, y, x+mid, y+size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawMiZiGe(pdf *gopdf.GoPdf, x, y, size float64) {
|
||||||
|
pdf.SetStrokeColor(220, 220, 220)
|
||||||
|
pdf.SetLineWidth(0.4)
|
||||||
|
pdf.RectFromUpperLeft(x, y, size, size)
|
||||||
|
mid := size / 2
|
||||||
|
drawDashedLine(pdf, x, y+mid, x+size, y+mid)
|
||||||
|
drawDashedLine(pdf, x+mid, y, x+mid, y+size)
|
||||||
|
drawDashedLine(pdf, x, y, x+size, y+size)
|
||||||
|
drawDashedLine(pdf, x, y+size, x+size, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawDashedLine(pdf *gopdf.GoPdf, x1, y1, x2, y2 float64) {
|
||||||
|
dashLen := 2.0
|
||||||
|
dx, dy := x2-x1, y2-y1
|
||||||
|
dist := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
if dist < 0.001 { return }
|
||||||
|
ux, uy := dx/dist, dy/dist
|
||||||
|
for i := 0.0; i < dist; i += dashLen * 2 {
|
||||||
|
end := i + dashLen
|
||||||
|
if end > dist { end = dist }
|
||||||
|
pdf.Line(x1+ux*i, y1+uy*i, x1+ux*end, y1+uy*end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawArrow(pdf *gopdf.GoPdf, target, prev gopdf.Point, length float64) {
|
||||||
|
angle := math.Atan2(target.Y-prev.Y, target.X-prev.X)
|
||||||
|
arrowAngle := math.Pi / 6
|
||||||
|
p1X := target.X + length*math.Cos(angle+math.Pi+arrowAngle)
|
||||||
|
p1Y := target.Y + length*math.Sin(angle+math.Pi+arrowAngle)
|
||||||
|
p2X := target.X + length*math.Cos(angle+math.Pi-arrowAngle)
|
||||||
|
p2Y := target.Y + length*math.Sin(angle+math.Pi-arrowAngle)
|
||||||
|
pdf.Polygon([]gopdf.Point{target, {X: p1X, Y: p1Y}, {X: p2X, Y: p2Y}}, "F")
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformPoints(pts []Point, scale, ox, oy float64, flipY bool) []gopdf.Point {
|
||||||
|
res := make([]gopdf.Point, len(pts))
|
||||||
|
for i, p := range pts {
|
||||||
|
y := p[1]
|
||||||
|
if flipY { y = 1024 - y }
|
||||||
|
res[i] = gopdf.Point{X: ox + p[0]*scale, Y: oy + y*scale}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
var reSVG = regexp.MustCompile(`([MLQCZ])|(-?\d+\.?\d*)`)
|
||||||
|
|
||||||
|
func parseSVGPath(path string, scale, ox, oy float64, flipY bool) []gopdf.Point {
|
||||||
|
var pts []gopdf.Point
|
||||||
|
matches := reSVG.FindAllStringSubmatch(path, -1)
|
||||||
|
|
||||||
|
var lastX, lastY float64
|
||||||
|
|
||||||
|
for idx := 0; idx < len(matches); {
|
||||||
|
item := matches[idx][0]
|
||||||
|
if item == "M" || item == "L" {
|
||||||
|
if idx+2 < len(matches) {
|
||||||
|
x, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
||||||
|
y, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
|
lastX, lastY = x, y
|
||||||
|
if flipY { y = 1024 - y }
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
||||||
|
idx += 3
|
||||||
|
} else { idx++ }
|
||||||
|
} else if item == "C" {
|
||||||
|
// Cubic Bezier: C x1 y1 x2 y2 x y
|
||||||
|
if idx+6 < len(matches) {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
||||||
|
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
|
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64)
|
||||||
|
y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||||
|
x, _ := strconv.ParseFloat(matches[idx+5][0], 64)
|
||||||
|
y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
|
||||||
|
|
||||||
|
// Sample points along the curve
|
||||||
|
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||||
|
tx := math.Pow(1-t, 3)*lastX + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
|
||||||
|
ty := math.Pow(1-t, 3)*lastY + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
|
||||||
|
ty_final := ty
|
||||||
|
if flipY { ty_final = 1024 - ty }
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_final*scale})
|
||||||
|
}
|
||||||
|
lastX, lastY = x, y
|
||||||
|
idx += 7
|
||||||
|
} else { idx++ }
|
||||||
|
} else if item == "Q" {
|
||||||
|
// Quadratic Bezier: Q x1 y1 x y
|
||||||
|
if idx+4 < len(matches) {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
||||||
|
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
|
x, _ := strconv.ParseFloat(matches[idx+3][0], 64)
|
||||||
|
y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||||
|
|
||||||
|
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||||
|
tx := math.Pow(1-t, 2)*lastX + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
|
||||||
|
ty := math.Pow(1-t, 2)*lastY + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
|
||||||
|
ty_final := ty
|
||||||
|
if flipY { ty_final = 1024 - ty }
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_final*scale})
|
||||||
|
}
|
||||||
|
lastX, lastY = x, y
|
||||||
|
idx += 5
|
||||||
|
} else { idx++ }
|
||||||
|
} else if item == "Z" {
|
||||||
|
idx++
|
||||||
|
} else {
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
type HanziData struct {
|
||||||
|
Character string `json:"character"`
|
||||||
|
Strokes []string `json:"strokes"`
|
||||||
|
Medians [][]Point `json:"medians"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Point [2]float64
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package jitie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"toolbox/pkg/base"
|
||||||
|
"toolbox/pkg/jitie/logic"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed data/all.json
|
||||||
|
var allDataContent []byte
|
||||||
|
|
||||||
|
//go:embed data/*.ttf
|
||||||
|
var fontFS embed.FS
|
||||||
|
|
||||||
|
type jitieTool struct {
|
||||||
|
allChars map[string]logic.HanziData
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
base.Register(&jitieTool{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *jitieTool) ID() string { return "jitie" }
|
||||||
|
func (t *jitieTool) Name() string { return "汉字字帖生成" }
|
||||||
|
func (t *jitieTool) Description() string { return "基于本地 30MB 数据集生成的教学/步进式字帖" }
|
||||||
|
|
||||||
|
func (t *jitieTool) Init() error {
|
||||||
|
fmt.Println("Loading 30MB character dataset...")
|
||||||
|
t.allChars = make(map[string]logic.HanziData)
|
||||||
|
if err := json.Unmarshal(allDataContent, &t.allChars); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal all.json: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Loaded %d characters into memory\n", len(t.allChars))
|
||||||
|
|
||||||
|
// Load font for logic layer
|
||||||
|
fontBytes, err := fontFS.ReadFile("data/font.ttf")
|
||||||
|
if err == nil && len(fontBytes) > 500 { // Check if it's a real font
|
||||||
|
logic.SetFontData(fontBytes)
|
||||||
|
fmt.Println("Font loaded for numbering")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Warning: Could not load real font for numbering")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *jitieTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||||
|
r.POST("/generate", t.handleGenerate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateRequest struct {
|
||||||
|
Chars string `json:"chars" binding:"required"`
|
||||||
|
Mode string `json:"mode"` // "teaching" or "step"
|
||||||
|
FlipY bool `json:"flip_y"`
|
||||||
|
PaperSize string `json:"paper_size"` // "A4" or "Letter"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *jitieTool) handleGenerate(c *gin.Context) {
|
||||||
|
var req GenerateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var charsData []logic.HanziData
|
||||||
|
for _, char := range req.Chars {
|
||||||
|
charStr := string(char)
|
||||||
|
if data, ok := t.allChars[charStr]; ok {
|
||||||
|
data.Character = charStr // Ensure character field is set
|
||||||
|
charsData = append(charsData, data)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: character %s not found in local dataset\n", charStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(charsData) == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no valid character data found in local database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := logic.GeneratePDF(charsData, req.Mode, req.PaperSize, req.FlipY)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user