Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Failing after 39s
Build and Push Docker Image / build (push) Failing after 39s
This commit is contained in:
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