From 4779e63132fa4d2427af5eb90777366b8e488a82 Mon Sep 17 00:00:00 2001 From: Bryan Joshua Pedini Date: Thu, 5 Mar 2026 02:33:37 +0100 Subject: [PATCH] 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) --- PLAN.md | 13 +++-- content.go | 5 ++ dockerfile | 11 +++- go.mod | 1 + go.sum | 2 + pdf.go | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 pdf.go diff --git a/PLAN.md b/PLAN.md index 0fa5736..2b90c85 100644 --- a/PLAN.md +++ b/PLAN.md @@ -11,19 +11,22 @@ - [x] Project backbone exists (HTTP server, graceful shutdown, config reading). - [x] Markdown parsing logic implemented (using goldmark). - [x] HTML Template engine integrated (Hugo-like theme selection). -- [ ] PDF Generation implemented (Pure Go library selected and integrated). +- [x] PDF Generation implemented (using go-pdf/fpdf - Pure Go library). - [x] CLI Mode (`gocv`) generates static files to `./output` and exits. -- [ ] Serve Mode (`gocv serve`) hosts HTML and serves PDF on demand. -- [ ] File Watcher implemented for live reload in Serve Mode. +- [x] Serve Mode (`gocv serve`) hosts HTML and serves PDF on demand. +- [x] File Watcher implemented for live reload in Serve Mode (using fsnotify). - [ ] Dockerfile created for multi-stage build. ## Active Task - [x] Analyze existing backbone code and integrate Markdown parsing. - [x] Integrate HTML template engine with theme selection. -- [ ] Implement Serve Mode with file watching for live reload. +- [x] Implement Serve Mode with file watching for live reload. +- [ ] Create Dockerfile for multi-stage build. ## Known Issues / Blockers -- [ ] Identify best Pure Go PDF library that supports HTML/CSS (or define CSS subset). +- [x] Identify best Pure Go PDF library that supports HTML/CSS (or define CSS subset). + - Selected go-pdf/fpdf - generates PDFs programmatically from markdown AST. + - Note: Not HTML-to-PDF conversion; walks goldmark AST and renders directly. - [x] Current output is raw HTML fragments without full HTML document structure. ## Completed Log diff --git a/content.go b/content.go index aaf1842..1c7be79 100644 --- a/content.go +++ b/content.go @@ -82,6 +82,11 @@ func generateOutput() error { return fmt.Errorf("failed to write %s: %w", outputFile, err) } fmt.Printf(" -> Written: %s\n", outputFile) + + // Generate PDF + if err := generatePDF(file.Content, file.Name); err != nil { + fmt.Printf(" -> PDF generation failed: %s\n", err) + } } return nil diff --git a/dockerfile b/dockerfile index 853e5e1..6e2f9c3 100644 --- a/dockerfile +++ b/dockerfile @@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=${GO_OS} GOARCH=${GO_ARCH} \ go build \ -installsuffix cgo \ -ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=$(git log HEAD --oneline | awk '{print $1}' | head -n1)'" \ - --o /gocv + -o /gocv # Stage 2 ยท scratch image FROM scratch @@ -28,6 +28,13 @@ FROM scratch COPY --from=build /gocv /gocv # Copy the certificates - in case of fetches COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem +# Copy themes for templates +COPY --from=build $GOPATH/src/${GIT_HOST}/${REPO_ORG}/${REPO_NAME}/themes /themes +# Copy default config +COPY --from=build $GOPATH/src/${GIT_HOST}/${REPO_ORG}/${REPO_NAME}/config.yaml /config.yaml + +# Create content and output directories +# Note: In scratch image, we need to mount these at runtime # Execute the binary -ENTRYPOINT ["/gocv serve"] +ENTRYPOINT ["/gocv", "serve"] diff --git a/go.mod b/go.mod index 53e5430..bf27ab4 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( require ( github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-pdf/fpdf v0.9.0 // indirect github.com/yuin/goldmark v1.7.16 // indirect golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index c1503a3..0249a12 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= diff --git a/pdf.go b/pdf.go new file mode 100644 index 0000000..3298060 --- /dev/null +++ b/pdf.go @@ -0,0 +1,156 @@ +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) +}