Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Has been cancelled

This commit is contained in:
2026-02-23 02:04:11 -08:00
commit f474b5a51e
28 changed files with 1601 additions and 0 deletions
+22
View File
@@ -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
}
+41
View File
@@ -0,0 +1,41 @@
package data
type Icon struct {
Name string
Paths []string
}
// 升级版:更卡通、更饱满的简笔画
var CountingIcons = []Icon{
{Name: "Bear", Paths: []string{
"M 512 800 C 300 800 200 700 200 500 C 200 300 350 200 512 200 C 674 200 824 300 824 500 C 824 700 724 800 512 800 Z", // Body
"M 300 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear L
"M 724 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear R
"M 400 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye L
"M 624 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye R
"M 512 550 Q 512 650 400 650 M 512 550 Q 512 650 624 650", // Mouth
}},
{Name: "Cat", Paths: []string{
"M 200 800 L 300 400 L 400 200 L 512 350 L 624 200 L 724 400 L 824 800 Z", // Head
"M 400 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye L
"M 624 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye R
"M 512 650 L 450 700 M 512 650 L 574 700", // Nose
}},
{Name: "Car", Paths: []string{
"M 100 700 L 100 500 Q 100 400 300 400 L 700 400 Q 900 400 900 500 L 900 700 Z", // Body
"M 250 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel L
"M 750 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel R
"M 300 400 L 400 250 L 624 250 L 724 400", // Roof
}},
{Name: "Bird", Paths: []string{
"M 512 512 m -300 0 a 300 300 0 1 0 600 0 a 300 300 0 1 0 -600 0", // Body
"M 812 512 L 950 450 L 812 400 Z", // Beak
"M 400 400 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye
"M 212 512 Q 100 400 212 300", // Wing
}},
{Name: "Rocket", Paths: []string{
"M 512 100 Q 700 400 700 800 L 324 800 Q 324 400 512 100 Z", // Body
"M 512 400 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Window
"M 324 800 L 200 950 L 324 900 M 700 800 L 824 950 L 700 900", // Fins
}},
}
+168
View File
@@ -0,0 +1,168 @@
package logic
import (
"bytes"
"math"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"toolbox/pkg/learnnumber/data"
"github.com/signintech/gopdf"
)
type CountingRequest struct {
TotalCount int `json:"total_count"`
IconTypes int `json:"icon_types"`
PaperSize string `json:"paper_size"`
}
var reSVG = regexp.MustCompile(`([MLQCZmlqcz])|(-?\d+\.?\d*)`)
func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
rand.Seed(time.Now().UnixNano())
pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89}
if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
pdf.Start(gopdf.Config{PageSize: rect})
pdf.AddPage()
drawProblem(pdf, req, 0, rect.W, rect.H/2)
drawProblem(pdf, req, rect.H/2, rect.W, rect.H/2)
var buf bytes.Buffer
_, err := pdf.WriteTo(&buf)
return buf.Bytes(), err
}
func drawProblem(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) {
margin := 40.0
boxW := (pW - 2*margin) * 0.7
boxH := pH - 60.0
x, y := margin, startY + 30.0
// 1. 绘制方框
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.0)
pdf.RectFromUpperLeft(x, y, boxW, boxH)
// 2. 分配图标数量
numTypes := req.IconTypes; if numTypes < 1 { numTypes = 1 }
if numTypes > len(data.CountingIcons) { numTypes = len(data.CountingIcons) }
total := req.TotalCount; if total < numTypes { total = numTypes }
if total > 30 { total = 30 }
// 随机选出图标种类
allIcons := rand.Perm(len(data.CountingIcons))
selectedIcons := []data.Icon{}
counts := []int{}
// 先每种分配1个
rem := total - numTypes
for i := 0; i < numTypes; i++ {
selectedIcons = append(selectedIcons, data.CountingIcons[allIcons[i]])
counts = append(counts, 1)
}
// 随机分配剩下的
for i := 0; i < rem; i++ {
counts[rand.Intn(numTypes)]++
}
// 3. 布局计划 (使用网格,增加 Padding 避开边框)
rows, cols := 5, 6
cellW, cellH := boxW/float64(cols), boxH/float64(rows)
indices := rand.Perm(rows * cols)
// 严格图标大小:单元格的 70%,确保不溢出单元格边界
iconSize := math.Min(cellW, cellH) * 0.7
paddingX, paddingY := (cellW - iconSize)/2, (cellH - iconSize)/2
iconTypeIdx := 0
countInCurrentType := 0
for i := 0; i < total; i++ {
cellIdx := indices[i]
rIdx, cIdx := cellIdx/cols, cellIdx%cols
// 基础位置
baseX := x + float64(cIdx)*cellW + paddingX
baseY := y + float64(rIdx)*cellH + paddingY
// 渲染图标
drawIcon(pdf, selectedIcons[iconTypeIdx], baseX + iconSize/2, baseY + iconSize/2, iconSize)
countInCurrentType++
if countInCurrentType >= counts[iconTypeIdx] {
iconTypeIdx++
countInCurrentType = 0
}
}
// 4. 绘制右侧对照表
legendX := x + boxW + 20.0
legendStepY := boxH / float64(numTypes + 1)
for i := 0; i < numTypes; i++ {
lY := y + float64(i+1)*legendStepY
drawIcon(pdf, selectedIcons[i], legendX + 20, lY, 30)
pdf.SetStrokeColor(150, 150, 150); pdf.SetLineWidth(0.8)
pdf.RectFromUpperLeft(legendX + 50, lY - 15, 30, 30)
}
}
func drawIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
scale := size / 1024.0
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.5) // 加粗线条
ox, oy := cX - size/2, cY - size/2
for _, pStr := range icon.Paths {
pts := parseSmoothPath(pStr, scale, ox, oy)
if len(pts) > 1 {
// 如果是闭合路径
if strings.Contains(strings.ToUpper(pStr), "Z") {
pdf.Polygon(pts, "D")
} else {
for i := 0; i < len(pts)-1; i++ {
pdf.Line(pts[i].X, pts[i].Y, pts[i+1].X, pts[i+1].Y)
}
}
}
}
}
// 支持贝塞尔曲线采样,确保图标圆润
func parseSmoothPath(path string, scale, ox, oy float64) []gopdf.Point {
var pts []gopdf.Point
matches := reSVG.FindAllStringSubmatch(path, -1)
var lx, ly float64
for i := 0; i < len(matches); {
cmd := matches[i][0]
if cmd == "M" || cmd == "L" {
x, _ := strconv.ParseFloat(matches[i+1][0], 64); y, _ := strconv.ParseFloat(matches[i+2][0], 64)
lx, ly = x, y
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
i += 3
} else if cmd == "C" {
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
x2, _ := strconv.ParseFloat(matches[i+3][0], 64); y2, _ := strconv.ParseFloat(matches[i+4][0], 64)
x, _ := strconv.ParseFloat(matches[i+5][0], 64); y, _ := strconv.ParseFloat(matches[i+6][0], 64)
for t := 0.25; t <= 1.0; t += 0.25 {
tx := math.Pow(1-t, 3)*lx + 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)*ly + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y; i += 7
} else if cmd == "Q" {
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
x, _ := strconv.ParseFloat(matches[i+3][0], 64); y, _ := strconv.ParseFloat(matches[i+4][0], 64)
for t := 0.25; t <= 1.0; t += 0.25 {
tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y; i += 5
} else { i++ }
}
return pts
}
+48
View File
@@ -0,0 +1,48 @@
package learnnumber
import (
"fmt"
"net/http"
"toolbox/pkg/base"
"toolbox/pkg/learnnumber/logic"
"github.com/gin-gonic/gin"
)
type learnNumberTool struct{}
func init() {
base.Register(&learnNumberTool{})
}
func (t *learnNumberTool) ID() string { return "learn-number" }
func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
func (t *learnNumberTool) Description() string { return "包含数图形、基础加减法等趣味数学练习" }
func (t *learnNumberTool) Init() error {
fmt.Println("Initializing Learn-Number tool...")
return nil
}
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
r.POST("/counting", t.handleCounting)
}
func (t *learnNumberTool) handleCounting(c *gin.Context) {
var req logic.CountingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.TotalCount <= 0 { req.TotalCount = 20 }
if req.TotalCount > 30 { req.TotalCount = 30 }
pdfBytes, err := logic.GenerateCountingPDF(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}
+29
View File
@@ -0,0 +1,29 @@
# 汉字字帖生成器 (Zitie Tool)
这是 `Own-Tools` 的核心插件之一,专注于生成高质量的书法练习帖。
## ✨ 主要功能
### 1. 2x3 教学方格
* **特性**:每页 6 个字,每个汉字包含笔顺序号、行进箭头和红色骨架线。
* **特色**:序号采用“圆圈相切”定位算法,美观且精准。
* **用途**:适合硬笔书法初学者学习笔画顺序。
### 2. 步进式笔顺分解
* **特性**:9列无缝米字格排版。首格为黑体全字,后续格子展示逐笔增加的灰色过程。
* **特色**:智能换行逻辑(第二行首格留空),保持视觉连贯。
* **用途**:详细解析汉字的间架结构。
### 3. 古风竖排信纸
* **特性**:模拟传统“乌丝栏”笺纸,从右向左、从上到下竖向排版。
* **特色**
* 内嵌 4 款高品质书法字体(楷体、行草、隶变、书宋)。
* **智能缺字降级**:当选择字体缺字时,自动切换至宋体/楷体并以 1/2 大小在右上角标注。
* 支持换行符 `
` 实现自由分列。
* **用途**:生成极具艺术感的诗词临摹帖。
## 🎨 技术细节
* **PDF 生成**:基于 `gopdf` 的纯矢量绘图,打印效果极其锐利。
* **字体处理**:采用“启动同步至磁盘”技术,绕过了内存加载大型 CJK 字体时的兼容性 Bug。
* **坐标计算**:所有元素均基于格子大小动态缩放,完美适配 A4 和 Letter 纸张。
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+253
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
package logic
type HanziData struct {
Character string `json:"character"`
Strokes []string `json:"strokes"`
Medians [][]Point `json:"medians"`
}
type Point [2]float64
+153
View File
@@ -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)
}