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(`
%s `, 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) }