Files
go-cv/pdf.go
Bryan Joshua Pedini 4779e63132 feat: implement PDF generation and update Dockerfile
- Add PDF generation using go-pdf/fpdf (Pure Go library)
- Walk markdown AST and render directly to PDF
- Update Dockerfile to include themes directory and config
- Fix ENTRYPOINT format for serve mode

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-05 02:33:37 +01:00

157 lines
3.7 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/go-pdf/fpdf"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
// generatePDF generates a PDF file from markdown content
func generatePDF(markdownContent, name string) error {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetAutoPageBreak(true, 10)
pdf.SetMargins(15, 15, 15)
// Parse markdown to AST
reader := text.NewReader([]byte(markdownContent))
doc := goldmark.DefaultParser().Parse(reader)
// Walk the AST and render to PDF
err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Heading:
renderHeading(pdf, node, reader)
case *ast.Paragraph:
renderParagraph(pdf, node, reader)
case *ast.List:
// Lists are handled by their items
return ast.WalkContinue, nil
case *ast.ListItem:
renderListItem(pdf, node, reader)
case *ast.CodeBlock:
renderCodeBlock(pdf, node, reader)
case *ast.CodeSpan:
renderCodeSpan(pdf, node, reader)
}
return ast.WalkContinue, nil
})
if err != nil {
return fmt.Errorf("failed to render PDF: %w", err)
}
// Ensure output directory exists
if err := os.MkdirAll(outputPath, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Write PDF to file
outputFile := filepath.Join(outputPath, name+".pdf")
if err := pdf.OutputFileAndClose(outputFile); err != nil {
return fmt.Errorf("failed to write PDF: %w", err)
}
fmt.Printf(" -> Written: %s\n", outputFile)
return nil
}
// getNodeText extracts text content from a node
func getNodeText(n ast.Node, reader text.Reader) string {
var sb strings.Builder
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if textNode, ok := c.(*ast.Text); ok {
segment := textNode.Segment
sb.Write(reader.Value(segment))
} else {
sb.WriteString(getNodeText(c, reader))
}
}
return sb.String()
}
// renderHeading renders a heading to the PDF
func renderHeading(pdf *fpdf.Fpdf, node *ast.Heading, reader text.Reader) {
txt := getNodeText(node, reader)
switch node.Level {
case 1:
pdf.SetFont("Helvetica", "B", 24)
pdf.Ln(5)
case 2:
pdf.SetFont("Helvetica", "B", 18)
pdf.Ln(3)
case 3:
pdf.SetFont("Helvetica", "B", 14)
pdf.Ln(2)
default:
pdf.SetFont("Helvetica", "B", 12)
pdf.Ln(1)
}
pdf.MultiCell(0, 8, txt, "", "", false)
pdf.Ln(2)
}
// renderParagraph renders a paragraph to the PDF
func renderParagraph(pdf *fpdf.Fpdf, node *ast.Paragraph, reader text.Reader) {
txt := getNodeText(node, reader)
if txt == "" {
return
}
pdf.SetFont("Helvetica", "", 12)
pdf.MultiCell(0, 6, txt, "", "", false)
pdf.Ln(2)
}
// renderListItem renders a list item to the PDF
func renderListItem(pdf *fpdf.Fpdf, node *ast.ListItem, reader text.Reader) {
txt := getNodeText(node, reader)
if txt == "" {
return
}
pdf.SetFont("Helvetica", "", 12)
pdf.Cell(5, 6, "- ")
pdf.MultiCell(0, 6, txt, "", "", false)
}
// renderCodeBlock renders a code block to the PDF
func renderCodeBlock(pdf *fpdf.Fpdf, node *ast.CodeBlock, reader text.Reader) {
segments := node.Lines()
if segments.Len() == 0 {
return
}
pdf.SetFont("Courier", "", 10)
pdf.SetFillColor(240, 240, 240)
pdf.Ln(2)
for i := 0; i < segments.Len(); i++ {
segment := segments.At(i)
line := string(reader.Value(segment))
line = strings.TrimRight(line, "\n\r")
pdf.Cell(0, 5, " "+line)
pdf.Ln(-1)
}
pdf.Ln(2)
pdf.SetFont("Helvetica", "", 12)
}
// renderCodeSpan renders inline code to the PDF
func renderCodeSpan(pdf *fpdf.Fpdf, node *ast.CodeSpan, reader text.Reader) {
txt := getNodeText(node, reader)
pdf.SetFont("Courier", "", 12)
pdf.Cell(0, 6, txt)
pdf.SetFont("Helvetica", "", 12)
}