cmc-sales/go/internal/cmc/pdf/html_generator.go

390 lines
13 KiB
Go
Raw Normal View History

2026-01-16 04:26:56 -08:00
package pdf
import (
"bytes"
"context"
"database/sql"
"fmt"
"html/template"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
// HTMLInvoiceGenerator generates PDF invoices from HTML templates using chromedp
type HTMLInvoiceGenerator struct {
outputDir string
}
// NewHTMLInvoiceGenerator creates a new HTML-based invoice generator
func NewHTMLInvoiceGenerator(outputDir string) *HTMLInvoiceGenerator {
return &HTMLInvoiceGenerator{
outputDir: outputDir,
}
}
// GenerateInvoicePDF creates a PDF invoice from HTML template
func (g *HTMLInvoiceGenerator) 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 with correct page count
fmt.Printf("=== HTML Generator: Second pass - regenerating with page count %d ===\n", totalPageCount)
html = g.buildInvoiceHTML(data, totalPageCount, 1)
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)
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, nil
}
// htmlToPDF converts an HTML file to PDF using chromedp
func (g *HTMLInvoiceGenerator) 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 *HTMLInvoiceGenerator) 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
}
// loadLogoAsBase64 loads the logo image and returns it as a relative path
func (g *HTMLInvoiceGenerator) loadLogoAsBase64() 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, "invoice_logo.png")
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 "invoice_logo.png"
}
// buildInvoiceHTML generates the complete HTML for an invoice using templates
func (g *HTMLInvoiceGenerator) buildInvoiceHTML(data *InvoicePDFData, totalPages int, currentPage int) string {
// Get invoice number, fall back to invoice title so the template always shows something meaningful
invoiceNum := ""
if data.Document != nil {
invoiceNum = data.Document.CmcReference
}
if invoiceNum == "" {
invoiceNum = data.Invoice.Title
}
fmt.Printf("=== buildInvoiceHTML: Invoice number: %s ===\n", invoiceNum)
// Prepare template data
templateData := struct {
InvoiceNumber string
IssueDateString string
BillTo template.HTML
ShipTo template.HTML
CustomerOrderNumber string
JobTitle string
FOB string
PaymentTerms string
CustomerABN string
CurrencyCode string
CurrencySymbol string
LineItems []LineItemTemplateData
Subtotal interface{}
GSTAmount interface{}
Total interface{}
ShowGST bool
PageCount int
CurrentPage int
LogoDataURI string
}{
InvoiceNumber: invoiceNum,
IssueDateString: data.IssueDateString,
BillTo: template.HTML(data.BillTo),
ShipTo: template.HTML(data.ShipTo),
CustomerOrderNumber: data.CustomerOrderNumber,
JobTitle: data.JobTitle,
FOB: data.FOB,
PaymentTerms: data.PaymentTerms,
CustomerABN: data.CustomerABN,
CurrencyCode: data.CurrencyCode,
CurrencySymbol: data.CurrencySymbol,
Subtotal: data.Subtotal,
GSTAmount: data.GSTAmount,
Total: data.Total,
ShowGST: data.ShowGST,
PageCount: totalPages,
CurrentPage: currentPage,
LogoDataURI: g.loadLogoAsBase64(),
}
// Convert line items to template format
for _, item := range data.LineItems {
templateData.LineItems = append(templateData.LineItems, LineItemTemplateData{
Title: item.Title,
Description: template.HTML(item.Description), // Allow HTML in description
Quantity: item.Quantity,
GrossUnitPrice: item.GrossUnitPrice,
DiscountAmountTotal: item.DiscountAmountTotal,
GrossPrice: item.GrossPrice,
})
}
// Define template functions
funcMap := template.FuncMap{
"formatPrice": func(price sql.NullString) template.HTML {
if !price.Valid || price.String == "" {
return ""
}
formatted := formatPriceWithCommas(price.String)
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
},
"formatDiscount": func(discount sql.NullString) template.HTML {
if !discount.Valid || discount.String == "" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
if discount.String == "0" || discount.String == "0.00" {
return template.HTML(fmt.Sprintf("%s0.00", data.CurrencySymbol))
}
formatted := formatPriceWithCommas(discount.String)
return template.HTML(fmt.Sprintf("-%s%s", data.CurrencySymbol, formatted))
},
"formatTotal": func(amount interface{}) template.HTML {
if amount == nil {
return ""
}
if val, ok := amount.(float64); ok {
formatted := formatPriceWithCommas(fmt.Sprintf("%.2f", val))
return template.HTML(fmt.Sprintf("%s%s", data.CurrencySymbol, formatted))
}
return template.HTML(fmt.Sprintf("%v", amount))
},
}
// Parse and execute template
tmplPath := filepath.Join("internal", "cmc", "pdf", "templates", "invoice.html")
tmpl, err := template.New("invoice.html").Funcs(funcMap).ParseFiles(tmplPath)
if err != nil {
fmt.Printf("Error parsing template: %v\n", err)
return ""
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
fmt.Printf("Error executing template: %v\n", err)
return ""
}
return buf.String()
}
// LineItemTemplateData represents a line item for template rendering
type LineItemTemplateData struct {
Title string
Description template.HTML
Quantity string
GrossUnitPrice sql.NullString
DiscountAmountTotal sql.NullString
GrossPrice sql.NullString
}
// formatPriceWithCommas formats a price string with comma separators
func formatPriceWithCommas(priceStr string) string {
if priceStr == "" {
return ""
}
// Try to parse as float
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
// Return as-is if not parseable
return priceStr
}
// Format with 2 decimal places and commas
amountStr := strconv.FormatFloat(price, 'f', 2, 64)
parts := strings.Split(amountStr, ".")
intPart := parts[0]
decPart := "." + parts[1]
// Add comma separators
var result strings.Builder
for i, ch := range intPart {
if i > 0 && (len(intPart)-i)%3 == 0 {
result.WriteString(",")
}
result.WriteRune(ch)
}
return result.String() + decPart
}