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>
This commit is contained in:
13
PLAN.md
13
PLAN.md
@@ -11,19 +11,22 @@
|
|||||||
- [x] Project backbone exists (HTTP server, graceful shutdown, config reading).
|
- [x] Project backbone exists (HTTP server, graceful shutdown, config reading).
|
||||||
- [x] Markdown parsing logic implemented (using goldmark).
|
- [x] Markdown parsing logic implemented (using goldmark).
|
||||||
- [x] HTML Template engine integrated (Hugo-like theme selection).
|
- [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.
|
- [x] CLI Mode (`gocv`) generates static files to `./output` and exits.
|
||||||
- [ ] Serve Mode (`gocv serve`) hosts HTML and serves PDF on demand.
|
- [x] Serve Mode (`gocv serve`) hosts HTML and serves PDF on demand.
|
||||||
- [ ] File Watcher implemented for live reload in Serve Mode.
|
- [x] File Watcher implemented for live reload in Serve Mode (using fsnotify).
|
||||||
- [ ] Dockerfile created for multi-stage build.
|
- [ ] Dockerfile created for multi-stage build.
|
||||||
|
|
||||||
## Active Task
|
## Active Task
|
||||||
- [x] Analyze existing backbone code and integrate Markdown parsing.
|
- [x] Analyze existing backbone code and integrate Markdown parsing.
|
||||||
- [x] Integrate HTML template engine with theme selection.
|
- [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
|
## 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.
|
- [x] Current output is raw HTML fragments without full HTML document structure.
|
||||||
|
|
||||||
## Completed Log
|
## Completed Log
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ func generateOutput() error {
|
|||||||
return fmt.Errorf("failed to write %s: %w", outputFile, err)
|
return fmt.Errorf("failed to write %s: %w", outputFile, err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" -> Written: %s\n", outputFile)
|
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
|
return nil
|
||||||
|
|||||||
11
dockerfile
11
dockerfile
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=${GO_OS} GOARCH=${GO_ARCH} \
|
|||||||
go build \
|
go build \
|
||||||
-installsuffix cgo \
|
-installsuffix cgo \
|
||||||
-ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=$(git log HEAD --oneline | awk '{print $1}' | head -n1)'" \
|
-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
|
# Stage 2 · scratch image
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -28,6 +28,13 @@ FROM scratch
|
|||||||
COPY --from=build /gocv /gocv
|
COPY --from=build /gocv /gocv
|
||||||
# Copy the certificates - in case of fetches
|
# Copy the certificates - in case of fetches
|
||||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem
|
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
|
# Execute the binary
|
||||||
ENTRYPOINT ["/gocv serve"]
|
ENTRYPOINT ["/gocv", "serve"]
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -9,6 +9,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
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
|
github.com/yuin/goldmark v1.7.16 // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,5 +1,7 @@
|
|||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
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/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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
|
|||||||
156
pdf.go
Normal file
156
pdf.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user