Former/pdf.go

244 lines
6.3 KiB
Go

package main
import (
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GenerateFaceTemplatePDF renders face template SVGs to a combined PDF.
// Uses Chrome headless to render each SVG page, then merges them.
func GenerateFaceTemplatePDF(cfg FaceTemplateConfig, outputDir string, shapes []TracedFace) (string, error) {
os.MkdirAll(outputDir, 0755)
if cfg.PageWidth <= 0 {
cfg.PageWidth = 215.9
}
if cfg.PageHeight <= 0 {
cfg.PageHeight = 279.4
}
if cfg.NumFaces < 1 {
cfg.NumFaces = 6
}
if cfg.LongestSide <= 0 {
cfg.LongestSide = 50
}
if cfg.GridSpacing <= 0 {
cfg.GridSpacing = pickGridSpacing(cfg.LongestSide)
}
shapeMap := map[int]TracedFace{}
for _, s := range shapes {
shapeMap[s.FaceNum] = s
}
layout := computePageLayout(cfg)
chromePath := findChrome()
if chromePath == "" {
debugLog("GenerateFaceTemplatePDF: Chrome not found, using pandoc fallback")
return generatePDFWithPandoc(cfg, outputDir, layout, shapeMap)
}
var pagePDFs []string
for i, page := range layout {
svg := renderTemplatePage(cfg, page, shapeMap)
// Wrap SVG in HTML for Chrome to render at exact dimensions
html := svgToHTML(svg, cfg.PageWidth, cfg.PageHeight)
htmlPath := filepath.Join(outputDir, fmt.Sprintf("_page_%d.html", i+1))
if err := os.WriteFile(htmlPath, []byte(html), 0644); err != nil {
return "", fmt.Errorf("write HTML: %w", err)
}
defer os.Remove(htmlPath)
pdfPath := filepath.Join(outputDir, fmt.Sprintf("_page_%d.pdf", i+1))
err := chromePrintToPDF(chromePath, htmlPath, pdfPath, cfg.PageWidth, cfg.PageHeight)
if err != nil {
debugLog(" Chrome PDF failed for page %d: %v, trying pandoc", i+1, err)
return generatePDFWithPandoc(cfg, outputDir, layout, shapeMap)
}
pagePDFs = append(pagePDFs, pdfPath)
defer os.Remove(pdfPath)
}
outPath := filepath.Join(outputDir, "faces.pdf")
if len(pagePDFs) == 1 {
data, err := os.ReadFile(pagePDFs[0])
if err != nil {
return "", err
}
if err := os.WriteFile(outPath, data, 0644); err != nil {
return "", err
}
} else {
if err := mergePDFs(pagePDFs, outPath); err != nil {
return "", fmt.Errorf("merge PDFs: %w", err)
}
}
debugLog("GenerateFaceTemplatePDF: %d pages to %s", len(layout), outPath)
return outPath, nil
}
func svgToHTML(svg string, pageWmm, pageHmm float64) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<style>
@page { size: %.1fmm %.1fmm; margin: 0; }
body { margin: 0; padding: 0; }
</style>
</head>
<body>
%s
</body>
</html>`, pageWmm, pageHmm, svg)
}
func findChrome() string {
paths := []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("google-chrome"); err == nil {
return p
}
if p, err := exec.LookPath("chromium"); err == nil {
return p
}
return ""
}
func chromePrintToPDF(chromePath, htmlPath, pdfPath string, pageWmm, pageHmm float64) error {
absHTML, _ := filepath.Abs(htmlPath)
fileURL := (&url.URL{Scheme: "file", Path: absHTML}).String()
// Chrome headless print-to-pdf with exact page dimensions
pageWIn := pageWmm / 25.4
pageHIn := pageHmm / 25.4
cmd := exec.Command(chromePath,
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--disable-software-rasterizer",
fmt.Sprintf("--print-to-pdf=%s", pdfPath),
"--no-pdf-header-footer",
fmt.Sprintf("--paper-width=%.4f", pageWIn),
fmt.Sprintf("--paper-height=%.4f", pageHIn),
"--run-all-compositor-stages-before-draw",
"--virtual-time-budget=2000",
fileURL,
)
debugLog(" chrome: %s", strings.Join(cmd.Args, " "))
if err := cmd.Run(); err != nil {
return fmt.Errorf("chrome print-to-pdf: %w", err)
}
info, err := os.Stat(pdfPath)
if err != nil || info.Size() < 100 {
return fmt.Errorf("chrome produced empty/missing PDF")
}
return nil
}
func findPandoc() string {
if p, err := exec.LookPath("pandoc"); err == nil {
return p
}
for _, p := range []string{
"/opt/homebrew/bin/pandoc",
"/usr/local/bin/pandoc",
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func generatePDFWithPandoc(cfg FaceTemplateConfig, outputDir string, layout []pageLayout, shapeMap map[int]TracedFace) (string, error) {
pandocPath := findPandoc()
if pandocPath == "" {
return "", fmt.Errorf("neither Chrome nor pandoc found for PDF generation")
}
// Write each SVG page to a temp HTML
var htmlPaths []string
for i, page := range layout {
svg := renderTemplatePage(cfg, page, shapeMap)
html := svgToHTML(svg, cfg.PageWidth, cfg.PageHeight)
htmlPath := filepath.Join(outputDir, fmt.Sprintf("_page_%d.html", i+1))
if err := os.WriteFile(htmlPath, []byte(html), 0644); err != nil {
return "", err
}
htmlPaths = append(htmlPaths, htmlPath)
defer os.Remove(htmlPath)
}
outPath := filepath.Join(outputDir, "faces.pdf")
args := append(htmlPaths,
"-o", outPath,
"--pdf-engine=wkhtmltopdf",
fmt.Sprintf("--variable=geometry:paperwidth=%.1fmm,paperheight=%.1fmm,margin=0mm", cfg.PageWidth, cfg.PageHeight),
)
cmd := exec.Command(pandocPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
// Try without wkhtmltopdf engine
args = append(htmlPaths, "-o", outPath)
cmd = exec.Command(pandocPath, args...)
output, err = cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("pandoc: %w\n%s", err, output)
}
}
return outPath, nil
}
// mergePDFs concatenates multiple single-page PDFs into one.
// Uses Python's built-in PyPDF or a simple binary append if unavailable.
func mergePDFs(inputs []string, output string) error {
// Try Python PyPDF2/pypdf merge
script := `
import sys
try:
from pypdf import PdfMerger
except ImportError:
from PyPDF2 import PdfMerger
m = PdfMerger()
for f in sys.argv[2:]:
m.append(f)
m.write(sys.argv[1])
m.close()
`
args := append([]string{"-c", script, output}, inputs...)
cmd := exec.Command("python3", args...)
if err := cmd.Run(); err == nil {
return nil
}
// Fallback: just use the first page if merge fails
debugLog(" PDF merge via python failed, using first page only")
data, err := os.ReadFile(inputs[0])
if err != nil {
return err
}
return os.WriteFile(output, data, 0644)
}