package pdf import ( "context" "fmt" "io/ioutil" "log" "os" "path/filepath" "time" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "github.com/pdfcpu/pdfcpu/pkg/api" ) // HTMLDocumentGenerator generates PDF documents from HTML templates using chromedp type HTMLDocumentGenerator struct { outputDir string } // NewHTMLDocumentGenerator creates a new HTML-based document generator func NewHTMLDocumentGenerator(outputDir string) *HTMLDocumentGenerator { return &HTMLDocumentGenerator{ outputDir: outputDir, } } // GenerateInvoicePDF creates a PDF invoice from HTML template // Returns (filename, error) func (g *HTMLDocumentGenerator) GenerateInvoicePDF(data *InvoicePDFData) (string, error) { fmt.Println("=== HTML Generator: Starting invoice generation (two-pass with T&C page count) ===") // FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C) fmt.Println("=== HTML Generator: First pass - generating without page count ===") html := g.BuildInvoiceHTML(data, 0, 0) fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html)) tempHTML := filepath.Join(g.outputDir, "temp_invoice.html") if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil { return "", fmt.Errorf("failed to write temp HTML: %w", err) } defer os.Remove(tempHTML) defer os.Remove(filepath.Join(g.outputDir, "invoice_logo.png")) // Clean up temp logo defer os.Remove(filepath.Join(g.outputDir, "temp_logo.png")) // Clean up temp logo // Generate temp PDF tempPDFPath := filepath.Join(g.outputDir, "temp_invoice_first_pass.pdf") if err := g.htmlToPDF(tempHTML, tempPDFPath); err != nil { return "", fmt.Errorf("failed to convert HTML to PDF (first pass): %w", err) } // Get initial page count from invoice invoicePageCount, err := g.getPageCount(tempPDFPath) if err != nil { fmt.Printf("Warning: Could not extract invoice page count: %v\n", err) invoicePageCount = 1 } // Check if T&C exists and merge to get total page count totalPageCount := invoicePageCount termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf") tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_first_pass.pdf", data.Invoice.Title)) if _, err := os.Stat(termsPath); err == nil { fmt.Println("=== HTML Generator: T&C found, merging to determine total pages ===") if err := MergePDFs(tempPDFPath, termsPath, tempMergedPath); err == nil { // Get total page count from merged PDF totalPageCount, err = g.getPageCount(tempMergedPath) if err != nil { fmt.Printf("Warning: Could not extract merged page count, using invoice count: %v\n", err) totalPageCount = invoicePageCount } else { fmt.Printf("=== HTML Generator: Total pages (invoice + T&C): %d ===\n", totalPageCount) } } else { fmt.Printf("Warning: Could not merge T&C for counting: %v\n", err) } } fmt.Printf("=== HTML Generator: First pass complete, detected %d total pages ===\n", totalPageCount) os.Remove(tempPDFPath) os.Remove(tempMergedPath) // SECOND PASS: Generate final PDF without page count in HTML (will be added via pdfcpu after merge) fmt.Println("=== HTML Generator: Second pass - regenerating final PDF ===") html = g.BuildInvoiceHTML(data, 0, 0) if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil { return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err) } // Generate final PDF filename using invoice number; fallback to a stable default invoiceNumber := "" if data.Document != nil { invoiceNumber = data.Document.CmcReference } filenameBase := invoiceNumber if filenameBase == "" { filenameBase = "CMC Invoice" } filename := fmt.Sprintf("%s.pdf", filenameBase) pdfPath := filepath.Join(g.outputDir, filename) fmt.Printf("=== HTML Generator: Invoice filename generation - invoiceNumber='%s', filenameBase='%s', final filename='%s' ===\n", invoiceNumber, filenameBase, filename) if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { return "", fmt.Errorf("failed to convert HTML to PDF (second pass): %w", err) } fmt.Println("=== HTML Generator: PDF generation complete ===") // Merge with T&C PDF if it exists if _, err := os.Stat(termsPath); err == nil { fmt.Println("=== HTML Generator: Found T&C PDF, merging ===") tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_temp.pdf", filenameBase)) if err := MergePDFs(pdfPath, termsPath, tempMergedPath); err != nil { fmt.Printf("=== HTML Generator: Warning - could not merge T&C PDF: %v. Returning invoice without T&C.\n", err) return filename, nil } // Replace original with merged version if err := os.Rename(tempMergedPath, pdfPath); err != nil { fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err) return filename, err } } // Add page numbers to final PDF // TODO: Re-enable after fixing pdfcpu watermark API usage // fmt.Println("=== HTML Generator: Adding page numbers to final PDF ===") // if err := AddPageNumbers(pdfPath); err != nil { // fmt.Printf("=== HTML Generator: Warning - could not add page numbers: %v\n", err) // } return filename, nil } // GenerateQuotePDF creates a PDF quote from HTML template // Returns (filename, error) func (g *HTMLDocumentGenerator) GenerateQuotePDF(data *QuotePDFData) (string, error) { fmt.Println("=== HTML Generator: Starting quote generation (two-pass with T&C page count) ===") // FIRST PASS: Generate PDF without page numbers to determine total pages (including T&C) fmt.Println("=== HTML Generator: First pass - generating without page count ===") html := g.BuildQuoteHTML(data, 0, 0) fmt.Printf("=== HTML Generator: Generated %d bytes of HTML ===\n", len(html)) tempHTML := filepath.Join(g.outputDir, "temp_quote.html") if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil { return "", fmt.Errorf("failed to write temp HTML: %w", err) } defer os.Remove(tempHTML) defer os.Remove(filepath.Join(g.outputDir, "quote_logo.png")) // Clean up temp logo // Generate temp PDF tempPDFPath := filepath.Join(g.outputDir, "temp_quote_first_pass.pdf") if err := g.htmlToPDF(tempHTML, tempPDFPath); err != nil { return "", fmt.Errorf("failed to convert HTML to PDF (first pass): %w", err) } // Get initial page count from quote quotePageCount, err := g.getPageCount(tempPDFPath) if err != nil { fmt.Printf("Warning: Could not extract quote page count: %v\n", err) quotePageCount = 1 } // Check if T&C exists and merge to get total page count totalPageCount := quotePageCount termsPath := filepath.Join(g.outputDir, "CMC_terms_and_conditions2006_A4.pdf") tempMergedPath := filepath.Join(g.outputDir, "temp_quote_merged_first_pass.pdf") if _, err := os.Stat(termsPath); err == nil { fmt.Println("=== HTML Generator: T&C found, merging to determine total pages ===") if err := MergePDFs(tempPDFPath, termsPath, tempMergedPath); err == nil { // Get total page count from merged PDF totalPageCount, err = g.getPageCount(tempMergedPath) if err != nil { fmt.Printf("Warning: Could not extract merged page count, using quote count: %v\n", err) totalPageCount = quotePageCount } else { fmt.Printf("=== HTML Generator: Total pages (quote + T&C): %d ===\n", totalPageCount) } } else { fmt.Printf("Warning: Could not merge T&C for counting: %v\n", err) } } fmt.Printf("=== HTML Generator: First pass complete, detected %d total pages ===\n", totalPageCount) os.Remove(tempPDFPath) os.Remove(tempMergedPath) // SECOND PASS: Generate final PDF without page count in HTML (will be added via pdfcpu after merge) fmt.Println("=== HTML Generator: Second pass - regenerating final PDF ===") html = g.BuildQuoteHTML(data, 0, 0) if err := ioutil.WriteFile(tempHTML, []byte(html), 0644); err != nil { return "", fmt.Errorf("failed to write temp HTML (second pass): %w", err) } // Generate final PDF filename using quote number quoteNumber := "" if data.Document != nil { log.Printf("=== HTML Generator: Document not nil, CmcReference='%s'", data.Document.CmcReference) quoteNumber = data.Document.CmcReference if quoteNumber == "" { // Strong fallback so we never emit a leading underscore filename quoteNumber = fmt.Sprintf("Quote-%d", data.Document.ID) } if data.Document.Revision > 0 { quoteNumber = fmt.Sprintf("%s_%d", quoteNumber, data.Document.Revision) } } else { log.Printf("=== HTML Generator: Document is nil!") } log.Printf("=== HTML Generator: Quote number before fallback: '%s', Document ID: %d, Revision: %d", quoteNumber, data.Document.ID, data.Document.Revision) filenameBase := quoteNumber if filenameBase == "" { log.Printf("=== HTML Generator: Using fallback filename") filenameBase = "CMC Quote" } log.Printf("=== HTML Generator: Final filename base: '%s'", filenameBase) filename := fmt.Sprintf("%s.pdf", filenameBase) pdfPath := filepath.Join(g.outputDir, filename) if err := g.htmlToPDF(tempHTML, pdfPath); err != nil { return "", fmt.Errorf("failed to convert HTML to PDF (second pass): %w", err) } fmt.Println("=== HTML Generator: PDF generation complete ===") // Merge with T&C PDF if it exists if _, err := os.Stat(termsPath); err == nil { fmt.Println("=== HTML Generator: Found T&C PDF, merging ===") tempMergedPath := filepath.Join(g.outputDir, fmt.Sprintf("%s_merged_temp.pdf", filenameBase)) if err := MergePDFs(pdfPath, termsPath, tempMergedPath); err != nil { fmt.Printf("=== HTML Generator: Warning - could not merge T&C PDF: %v. Returning quote without T&C.\n", err) fmt.Printf("=== HTML Generator: Checking if pdfPath exists: %s\n", pdfPath) if stat, err := os.Stat(pdfPath); err == nil { fmt.Printf("=== HTML Generator: pdfPath exists, size=%d bytes\n", stat.Size()) } return filename, nil } fmt.Printf("=== HTML Generator: Merge succeeded, replacing original PDF\n") // Replace original with merged version if err := os.Rename(tempMergedPath, pdfPath); err != nil { fmt.Printf("=== HTML Generator: Warning - could not replace PDF: %v\n", err) fmt.Printf("=== HTML Generator: tempMergedPath: %s\n", tempMergedPath) fmt.Printf("=== HTML Generator: pdfPath: %s\n", pdfPath) return filename, err } fmt.Printf("=== HTML Generator: Replaced PDF successfully\n") // Verify the final file exists if stat, err := os.Stat(pdfPath); err == nil { fmt.Printf("=== HTML Generator: Final PDF verified, size=%d bytes\n", stat.Size()) } else { fmt.Printf("=== HTML Generator: ERROR - Final PDF does not exist after merge: %v\n", err) return filename, fmt.Errorf("final PDF does not exist: %w", err) } } // Add page numbers to final PDF // TODO: Re-enable after fixing pdfcpu watermark API usage // fmt.Println("=== HTML Generator: Adding page numbers to final PDF ===") // if err := AddPageNumbers(pdfPath); err != nil { // fmt.Printf("=== HTML Generator: Warning - could not add page numbers: %v\n", err) // } return filename, nil } // htmlToPDF converts an HTML file to PDF using chromedp func (g *HTMLDocumentGenerator) htmlToPDF(htmlPath, pdfPath string) error { // Read the HTML file to get its content htmlContent, err := ioutil.ReadFile(htmlPath) if err != nil { return fmt.Errorf("failed to read HTML file: %w", err) } fmt.Printf("=== htmlToPDF: Read HTML file, size=%d bytes ===\n", len(htmlContent)) // Create chromedp context opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("no-sandbox", true), chromedp.Flag("disable-dev-shm-usage", true), ) allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) defer cancel() ctx, cancel := chromedp.NewContext(allocCtx) defer cancel() // Set timeout ctx, cancel = context.WithTimeout(ctx, 30*time.Second) defer cancel() // Navigate to file URL and print to PDF var pdfBuf []byte fileURL := "file://" + htmlPath fmt.Printf("=== htmlToPDF: Using file URL: %s ===\n", fileURL) fmt.Println("=== htmlToPDF: Starting chromedp navigation ===") if err := chromedp.Run(ctx, chromedp.Navigate(fileURL), chromedp.ActionFunc(func(ctx context.Context) error { fmt.Println("=== htmlToPDF: Executing PrintToPDF ===") var err error pdfBuf, _, err = page.PrintToPDF(). WithPrintBackground(true). WithMarginTop(0). WithMarginBottom(0). WithMarginLeft(0). WithMarginRight(0). WithPaperWidth(8.27). // A4 width in inches WithPaperHeight(11.69). // A4 height in inches Do(ctx) if err != nil { fmt.Printf("=== htmlToPDF: PrintToPDF error: %v ===\n", err) } else { fmt.Printf("=== htmlToPDF: PrintToPDF succeeded, PDF size=%d bytes ===\n", len(pdfBuf)) } return err }), ); err != nil { return fmt.Errorf("chromedp failed: %w", err) } // Write PDF to file if err := ioutil.WriteFile(pdfPath, pdfBuf, 0644); err != nil { return fmt.Errorf("failed to write PDF: %w", err) } return nil } // getPageCount extracts the page count from a PDF file func (g *HTMLDocumentGenerator) getPageCount(pdfPath string) (int, error) { pageCount, err := api.PageCountFile(pdfPath) if err != nil { return 0, fmt.Errorf("failed to get page count: %w", err) } return pageCount, nil } // loadLogo loads the logo image and returns it as a relative path func (g *HTMLDocumentGenerator) loadLogo(logoFileName string) string { // Use canonical path: /app/static/images in Docker, go/static/images locally logoPath := "/app/static/images/CMC-Mobile-Logo.png" if _, err := os.Stat(logoPath); err != nil { // Local development path logoPath = filepath.Join("go", "static", "images", "CMC-Mobile-Logo.png") } logoData, err := ioutil.ReadFile(logoPath) if err != nil { fmt.Printf("Warning: Could not read logo at %s: %v\n", logoPath, err) return "" } // Copy logo to output directory for chromedp to access destPath := filepath.Join(g.outputDir, logoFileName) if err := ioutil.WriteFile(destPath, logoData, 0644); err != nil { fmt.Printf("Warning: Could not write logo to output dir: %v\n", err) return "" } fmt.Printf("=== Copied logo from %s to %s ===\n", logoPath, destPath) // Return relative path (same directory as HTML file) return logoFileName }