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 }