244 lines
6.3 KiB
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)
|
|
}
|