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 }