package pdf import ( "context" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/chromedp/chromedp" "github.com/jung-kurt/gofpdf" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // TextSegment represents a text segment with formatting information type TextSegment struct { Text string Bold bool Italic bool } // formatCurrency formats a float as currency with thousand separators func formatCurrency(symbol string, amount float64) string { // Format with 2 decimal places amountStr := strconv.FormatFloat(amount, 'f', 2, 64) // Split into integer and decimal parts parts := strings.Split(amountStr, ".") intPart := parts[0] decPart := "." + parts[1] // Add comma separators to integer part var result strings.Builder for i, ch := range intPart { if i > 0 && (len(intPart)-i)%3 == 0 { result.WriteString(",") } result.WriteRune(ch) } return symbol + result.String() + decPart } // renderHTMLToImage renders an HTML fragment to a PNG image using chromedp func renderHTMLToImage(htmlContent string, width int) (string, float64, error) { // Wrap HTML fragment in a document fullHTML := fmt.Sprintf(` %s `, width, htmlContent) // Create temp file for screenshot tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("pdf_desc_%d.png", time.Now().UnixNano())) // Create chromedp allocator with headless options 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), chromedp.Flag("disable-software-rasterizer", true), ) allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) defer cancel() // Create chromedp context ctx, cancel := chromedp.NewContext(allocCtx) defer cancel() // Set timeout ctx, cancel = context.WithTimeout(ctx, 10*time.Second) defer cancel() var height int64 var buf []byte // Run chromedp tasks err := chromedp.Run(ctx, chromedp.Navigate("about:blank"), chromedp.ActionFunc(func(ctx context.Context) error { return chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`document.write(%q)`, fullHTML), nil), ) }), chromedp.Sleep(100*time.Millisecond), // Wait for render chromedp.Evaluate(`document.body.scrollHeight`, &height), chromedp.CaptureScreenshot(&buf), ) if err != nil { return "", 0, fmt.Errorf("chromedp error: %w", err) } // Write screenshot to file if err := os.WriteFile(tmpFile, buf, 0644); err != nil { return "", 0, err } // Convert height from pixels to mm (assuming 96 DPI) heightMM := float64(height) * 25.4 / 96.0 return tmpFile, heightMM, nil } // parseHTMLToSegments parses HTML using the DOM and extracts text segments with formatting func parseHTMLToSegments(htmlStr string) []TextSegment { segments := []TextSegment{} // Parse HTML into a DOM tree doc, err := html.Parse(strings.NewReader(htmlStr)) if err != nil { // Fallback: treat as plain text if parsing fails return []TextSegment{{Text: htmlStr, Bold: false, Italic: false}} } // Traverse the DOM tree and extract segments var traverse func(*html.Node, bool, bool) traverse = func(n *html.Node, bold, italic bool) { if n == nil { return } switch n.Type { case html.TextNode: text := strings.TrimSpace(n.Data) if text != "" { // Decode HTML entities text = decodeEntities(text) segments = append(segments, TextSegment{ Text: text, Bold: bold, Italic: italic, }) } case html.ElementNode: // Determine if this element adds bold or italic newBold := bold newItalic := italic addLineBreak := false switch n.DataAtom { case atom.Strong, atom.B: newBold = true case atom.Em, atom.I: newItalic = true case atom.Br: segments = append(segments, TextSegment{Text: "\n", Bold: bold, Italic: italic}) return case atom.P, atom.Div: // Add line break after paragraph/div addLineBreak = true case atom.Li: // Add bullet point for list items segments = append(segments, TextSegment{Text: "• ", Bold: false, Italic: false}) } // Recursively traverse children for c := n.FirstChild; c != nil; c = c.NextSibling { traverse(c, newBold, newItalic) } // Add line break after block elements if addLineBreak { segments = append(segments, TextSegment{Text: "\n", Bold: false, Italic: false}) } } } traverse(doc, false, false) // Clean up segments cleanedSegments := []TextSegment{} for _, seg := range segments { // Collapse multiple newlines text := seg.Text for strings.Contains(text, "\n\n\n") { text = strings.ReplaceAll(text, "\n\n\n", "\n\n") } // Trim leading/trailing whitespace (but preserve newlines) text = strings.TrimRight(text, " \t") text = strings.TrimLeft(text, " \t") if text != "" { cleanedSegments = append(cleanedSegments, TextSegment{ Text: text, Bold: seg.Bold, Italic: seg.Italic, }) } } return cleanedSegments } // cleanHTMLLineBreaks converts HTML line breaks to newlines func cleanHTMLLineBreaks(text string) string { text = strings.ReplaceAll(text, "
", "\n") text = strings.ReplaceAll(text, "
", "\n") text = strings.ReplaceAll(text, "
", "\n") return text } // decodeEntities decodes HTML entities to their character equivalents func decodeEntities(text string) string { // Handle common HTML entities text = strings.ReplaceAll(text, " ", " ") text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") text = strings.ReplaceAll(text, """, "\"") text = strings.ReplaceAll(text, "'", "'") text = strings.ReplaceAll(text, "'", "'") text = strings.ReplaceAll(text, "®", "®") text = strings.ReplaceAll(text, "®", "®") text = strings.ReplaceAll(text, "™", "™") text = strings.ReplaceAll(text, "™", "™") text = strings.ReplaceAll(text, "©", "©") text = strings.ReplaceAll(text, "©", "©") text = strings.ReplaceAll(text, "°", "°") text = strings.ReplaceAll(text, "°", "°") text = strings.ReplaceAll(text, "Ëš", "°") text = strings.ReplaceAll(text, "°", "°") text = strings.ReplaceAll(text, "Â", "") text = strings.ReplaceAll(text, "•", "•") text = strings.ReplaceAll(text, "’", "'") text = strings.ReplaceAll(text, "“", "\"") text = strings.ReplaceAll(text, "â€", "\"") return text } // estimateHTMLHeight estimates the height needed for HTML content with formatting func estimateHTMLHeight(pdf *gofpdf.Fpdf, html string, width float64, lineHeight float64) float64 { // Parse to plain text to estimate line count segments := parseHTMLToSegments(html) currentWidth := 0.0 lineCount := 1.0 lineSpacing := 3.1 currentStyle := "" for _, segment := range segments { style := "" if segment.Bold { style += "B" } if segment.Italic { style += "I" } if style != currentStyle { pdf.SetFont("Helvetica", style, 9) currentStyle = style } // Handle newlines if strings.Contains(segment.Text, "\n") { parts := strings.Split(segment.Text, "\n") for i, part := range parts { if i > 0 { lineCount++ currentWidth = 0 } if part != "" { words := strings.Fields(part) for _, word := range words { wordWidth := pdf.GetStringWidth(word + " ") if currentWidth+wordWidth > width && currentWidth > 0 { lineCount++ currentWidth = wordWidth } else { currentWidth += wordWidth } } } } } else { words := strings.Fields(segment.Text) for _, word := range words { wordWidth := pdf.GetStringWidth(word + " ") if currentWidth+wordWidth > width && currentWidth > 0 { lineCount++ currentWidth = wordWidth } else { currentWidth += wordWidth } } } } return lineCount * lineSpacing } // renderHTMLWithFormatting renders HTML content with rich formatting in a MultiCell // Returns the height actually used by the content func renderHTMLWithFormatting(pdf *gofpdf.Fpdf, html string, width float64, lineHeight float64, align string) float64 { segments := parseHTMLToSegments(html) startX := pdf.GetX() currentX := startX startY := pdf.GetY() currentY := startY maxWidth := width lineSpacing := 3.1 // Proper line spacing to prevent overlapping for _, segment := range segments { // Handle newlines if strings.Contains(segment.Text, "\n") { parts := strings.Split(segment.Text, "\n") for i, part := range parts { if i > 0 { currentY += lineSpacing currentX = startX pdf.SetXY(currentX, currentY) } if part != "" { // Set font style style := "" if segment.Bold { style += "B" } if segment.Italic { style += "I" } pdf.SetFont("Helvetica", style, 9) // Render text character by character for proper wrapping for _, ch := range part { charStr := string(ch) charWidth := pdf.GetStringWidth(charStr) if currentX+charWidth > startX+maxWidth { currentY += lineSpacing currentX = startX pdf.SetXY(currentX, currentY) } pdf.CellFormat(charWidth, lineSpacing, charStr, "", 0, "L", false, 0, "") currentX += charWidth } } } } else { // Set font style style := "" if segment.Bold { style += "B" } if segment.Italic { style += "I" } pdf.SetFont("Helvetica", style, 9) // Handle word wrapping words := strings.Fields(segment.Text) for wi, word := range words { // Add space after word except for last word if wi > 0 { word = " " + word } if wi < len(words)-1 { word = word + " " } else if wi == len(words)-1 && wi > 0 { word = word + " " } wordWidth := pdf.GetStringWidth(word) if currentX+wordWidth > startX+maxWidth && currentX > startX { currentY += lineSpacing currentX = startX pdf.SetXY(currentX, currentY) // Remove leading space if we wrapped word = strings.TrimLeft(word, " ") wordWidth = pdf.GetStringWidth(word) } pdf.CellFormat(wordWidth, lineSpacing, word, "", 0, "L", false, 0, "") currentX += wordWidth } } } // Set position to end of rendered content and return height used pdf.SetXY(startX, currentY) return currentY - startY + lineSpacing // Add one line height for proper spacing } // renderHTMLContent renders HTML content with proper formatting support // Returns a string suitable for MultiCell, with formatting preserved through segments func renderHTMLContent(pdf *gofpdf.Fpdf, htmlStr string, width float64) string { segments := parseHTMLToSegments(htmlStr) // Build formatted text by applying font changes inline // This is a simplified approach - we'll just return plain text with newlines preserved // The actual formatting (bold/italic) will be handled at render time var result strings.Builder for _, seg := range segments { result.WriteString(seg.Text) // Add space after non-newline segments for proper word spacing if !strings.HasSuffix(seg.Text, "\n") && !strings.HasSuffix(seg.Text, " ") { result.WriteString(" ") } } text := strings.TrimSpace(result.String()) // Clean up multiple spaces and newlines text = regexp.MustCompile(` +`).ReplaceAllString(text, " ") text = regexp.MustCompile(`\n\n+`).ReplaceAllString(text, "\n") text = regexp.MustCompile(`\n `).ReplaceAllString(text, "\n") return text } // renderFormattedDescription renders HTML segments with proper formatting (bold/italic) within a cell func (g *Generator) renderFormattedDescription(title string, htmlContent string, x, y, cellWidth, lineHeight float64) float64 { fmt.Printf("=== renderFormattedDescription called: title=%s, html=%s ===\n", title, htmlContent) // Build full HTML with title fullHTML := fmt.Sprintf("%s
%s", title, htmlContent) // Convert cell width from mm to pixels (96 DPI) widthPx := int(cellWidth * 96.0 / 25.4) fmt.Printf("=== Calling renderHTMLToImage with width=%d ===\n", widthPx) // Render HTML to image imgPath, heightMM, err := renderHTMLToImage(fullHTML, widthPx) if err != nil { // No fallback - panic so we see the error panic(fmt.Sprintf("chromedp render failed: %v", err)) } defer os.Remove(imgPath) // Clean up temp file fmt.Printf("=== Image rendered: path=%s, height=%.2fmm ===\n", imgPath, heightMM) // Embed image in PDF g.pdf.Image(imgPath, x, y, cellWidth, heightMM, false, "", 0, "") return y + heightMM } // Generator handles PDF generation for documents type Generator struct { pdf *gofpdf.Fpdf outputDir string headerText string footerText string docRef string currentPage int } // NewGenerator creates a new PDF generator func NewGenerator(outputDir string) *Generator { pdf := gofpdf.New("P", "mm", "A4", "") return &Generator{ pdf: pdf, outputDir: outputDir, headerText: "CMC TECHNOLOGIES", footerText: "Copyright © %d CMC Technologies. All rights reserved.", } } // AddPage adds a new page to the PDF func (g *Generator) AddPage() { g.pdf.AddPage() g.currentPage++ } // Page1Header adds the standard header for page 1 func (g *Generator) Page1Header() { // Add logo at top left (above the line) logoPath := filepath.Join("static", "images", "CMC-Mobile-Logo.png") g.pdf.ImageOptions(logoPath, 10, 10, 0, 28, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "http://www.cmctechnologies.com.au") // Company name centered g.pdf.SetTextColor(0, 0, 152) g.pdf.SetFont("Helvetica", "B", 30) g.pdf.SetXY(10, 15) g.pdf.CellFormat(190, 10, g.headerText, "", 1, "C", false, 0, "") // Company details centered with more spacing g.pdf.SetFont("Helvetica", "", 10) g.pdf.SetXY(10, 26) g.pdf.CellFormat(190, 5, "PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224", "", 1, "C", false, 0, "") // Add padding before the line lineY := 34.0 // Draw horizontal line g.pdf.SetDrawColor(0, 0, 0) g.pdf.Line(10, lineY, 200, lineY) // Engineering text on LEFT below the line, beneath the logo g.pdf.SetTextColor(0, 0, 152) g.pdf.SetFont("Helvetica", "B", 9) startY := lineY + 2 lineHeight := 4.0 g.pdf.SetXY(10, startY) g.pdf.Cell(50, lineHeight, "Engineering &") g.pdf.SetXY(10, startY+lineHeight) g.pdf.Cell(50, lineHeight, "Industrial") g.pdf.SetXY(10, startY+lineHeight*2) g.pdf.Cell(50, lineHeight, "Instrumentation") // Contact details on RIGHT side g.pdf.SetTextColor(0, 0, 0) g.pdf.SetFont("Helvetica", "", 9) contactX := 120.0 // Phone g.pdf.SetXY(contactX, startY) g.pdf.Cell(15, lineHeight, "Phone:") g.pdf.SetXY(contactX+15, startY) g.pdf.Cell(50, lineHeight, "+61 2 9669 4000") // Fax g.pdf.SetXY(contactX, startY+lineHeight) g.pdf.Cell(15, lineHeight, "Fax:") g.pdf.SetXY(contactX+15, startY+lineHeight) g.pdf.Cell(50, lineHeight, "+61 2 9669 4111") // Email g.pdf.SetXY(contactX, startY+lineHeight*2) g.pdf.Cell(15, lineHeight, "Email:") g.pdf.SetXY(contactX+15, startY+lineHeight*2) g.pdf.Cell(50, lineHeight, "sales@cmctechnologies.com.au") // Web Site g.pdf.SetXY(contactX, startY+lineHeight*3) g.pdf.Cell(15, lineHeight, "Web Site:") g.pdf.SetXY(contactX+15, startY+lineHeight*3) g.pdf.Cell(50, lineHeight, "www.cmctechnologies.net.au") // Address on far RIGHT g.pdf.SetXY(contactX, startY+lineHeight*5) g.pdf.Cell(50, lineHeight, "Unit 19, 77 Bourke Rd") g.pdf.SetXY(contactX, startY+lineHeight*6) g.pdf.Cell(50, lineHeight, "Alexandria NSW 2015") g.pdf.SetXY(contactX, startY+lineHeight*7) g.pdf.Cell(50, lineHeight, "AUSTRALIA") // Reset text color and set position for content below g.pdf.SetTextColor(0, 0, 0) g.pdf.SetY(68) } // Page1Footer adds the standard footer func (g *Generator) Page1Footer() { g.pdf.SetY(-20) // Footer line g.pdf.SetDrawColor(0, 0, 0) g.pdf.Line(10, g.pdf.GetY(), 200, g.pdf.GetY()) // Footer text g.pdf.SetFont("Helvetica", "", 9) g.pdf.SetY(-18) g.pdf.CellFormat(0, 5, "CMC TECHNOLOGIES Provides Solutions in the Following Fields", "", 1, "C", false, 0, "") // Color-coded services g.pdf.SetY(-13) g.pdf.SetX(10) // First line of services services := []struct { text string r, g, b int }{ {"EXPLOSION PREVENTION AND PROTECTION", 153, 0, 10}, {"—", 0, 0, 0}, {"FIRE PROTECTION", 255, 153, 0}, {"—", 0, 0, 0}, {"PRESSURE RELIEF", 255, 0, 25}, {"—", 0, 0, 0}, {"VISION IN THE PROCESS", 0, 128, 30}, } x := 15.0 for _, service := range services { g.pdf.SetTextColor(service.r, service.g, service.b) width := g.pdf.GetStringWidth(service.text) g.pdf.SetX(x) g.pdf.CellFormat(width, 5, service.text, "", 0, "L", false, 0, "") x += width + 1 } // Second line of services g.pdf.SetY(-8) g.pdf.SetX(60) g.pdf.SetTextColor(47, 75, 224) g.pdf.CellFormat(0, 5, "FLOW MEASUREMENT", "", 0, "L", false, 0, "") g.pdf.SetX(110) g.pdf.SetTextColor(171, 49, 248) g.pdf.CellFormat(0, 5, "—PROCESS INSTRUMENTATION", "", 0, "L", false, 0, "") } // DetailsBox adds the document details box (quote/invoice details) func (g *Generator) DetailsBox(docType, companyName, emailTo, attention, fromName, fromEmail, refNumber, yourRef, issueDate string) { g.pdf.SetY(60) g.pdf.SetFont("Helvetica", "", 10) g.pdf.SetTextColor(0, 0, 0) // Create details table lineHeight := 5.0 col1Width := 40.0 col2Width := 60.0 col3Width := 40.0 col4Width := 50.0 // Row 1 g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col1Width, lineHeight, "COMPANY NAME:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col2Width, lineHeight, companyName, "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col3Width, lineHeight, docType+" NO.:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col4Width, lineHeight, refNumber, "1", 1, "L", false, 0, "") // Row 2 g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col1Width, lineHeight, "EMAIL TO:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col2Width, lineHeight, emailTo, "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col3Width, lineHeight, "YOUR REFERENCE:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col4Width, lineHeight, yourRef, "1", 1, "L", false, 0, "") // Row 3 g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col1Width, lineHeight, "ATTENTION:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col2Width, lineHeight, attention, "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col3Width, lineHeight, "ISSUE DATE:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col4Width, lineHeight, issueDate, "1", 1, "L", false, 0, "") // Row 4 g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col1Width, lineHeight, "FROM:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col2Width, lineHeight, fromName, "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col3Width, lineHeight, "PAGES:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) pageText := fmt.Sprintf("%d of {nb}", g.pdf.PageNo()) g.pdf.CellFormat(col4Width, lineHeight, pageText, "1", 1, "L", false, 0, "") // Row 5 g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(col1Width, lineHeight, "EMAIL:", "1", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 10) g.pdf.CellFormat(col2Width, lineHeight, fromEmail, "1", 0, "L", false, 0, "") g.pdf.CellFormat(col3Width+col4Width, lineHeight, "", "1", 1, "L", false, 0, "") } // AddContent adds HTML content to the PDF func (g *Generator) AddContent(content string) { g.pdf.SetFont("Helvetica", "", 10) g.pdf.SetTextColor(0, 0, 0) g.pdf.SetY(g.pdf.GetY() + 10) // Basic HTML to PDF conversion // Note: gofpdf has limited HTML support, so we'll need to parse and convert manually // For now, we'll just add the content as plain text g.pdf.MultiCell(0, 5, content, "", "L", false) } // AddLineItemsTable adds a table of line items func (g *Generator) AddLineItemsTable(items []LineItem, currencySymbol string, showGST bool) { g.pdf.SetFont("Helvetica", "B", 10) g.pdf.SetTextColor(0, 0, 0) // Column widths itemWidth := 15.0 qtyWidth := 15.0 descWidth := 100.0 unitWidth := 30.0 totalWidth := 30.0 // Table header g.pdf.CellFormat(itemWidth, 7, "ITEM", "1", 0, "C", false, 0, "") g.pdf.CellFormat(qtyWidth, 7, "QTY", "1", 0, "C", false, 0, "") g.pdf.CellFormat(descWidth, 7, "DESCRIPTION", "1", 0, "C", false, 0, "") g.pdf.CellFormat(unitWidth, 7, "UNIT PRICE", "1", 0, "C", false, 0, "") g.pdf.CellFormat(totalWidth, 7, "TOTAL", "1", 1, "C", false, 0, "") // Table rows g.pdf.SetFont("Helvetica", "", 9) subtotal := 0.0 for _, item := range items { // Check if we need a new page if g.pdf.GetY() > 250 { g.AddPage() g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(itemWidth, 7, "ITEM", "1", 0, "C", false, 0, "") g.pdf.CellFormat(qtyWidth, 7, "QTY", "1", 0, "C", false, 0, "") g.pdf.CellFormat(descWidth, 7, "DESCRIPTION", "1", 0, "C", false, 0, "") g.pdf.CellFormat(unitWidth, 7, "UNIT PRICE", "1", 0, "C", false, 0, "") g.pdf.CellFormat(totalWidth, 7, "TOTAL", "1", 1, "C", false, 0, "") g.pdf.SetFont("Helvetica", "", 9) } currentY := g.pdf.GetY() // Render the description with formatting using chromedp descEndY := g.renderFormattedDescription(item.Title, item.Description, 40, currentY, descWidth, 5) rowHeight := descEndY - currentY if rowHeight < 6.0 { rowHeight = 6.0 } // Item number at X=10, width=15 - stretch to match description height g.pdf.SetXY(10, currentY) g.pdf.CellFormat(itemWidth, rowHeight, item.ItemNumber, "1", 0, "C", false, 0, "") // Quantity at X=25 (10+15), width=15 - stretch to match description height g.pdf.SetXY(25, currentY) g.pdf.CellFormat(qtyWidth, rowHeight, item.Quantity, "1", 0, "C", false, 0, "") // Description cell with border (drawing border only since we render content manually) g.pdf.SetXY(40, currentY) g.pdf.Rect(40, currentY, descWidth, rowHeight, "") // Unit Price at X=140 (10+15+15+100), width=30 - stretch to match description height g.pdf.SetXY(140, currentY) g.pdf.CellFormat(unitWidth, rowHeight, fmt.Sprintf("%s%.2f", currencySymbol, item.UnitPrice), "1", 0, "R", false, 0, "") // Total Price at X=170 (10+15+15+100+30), width=30 - stretch to match description height g.pdf.SetXY(170, currentY) g.pdf.CellFormat(totalWidth, rowHeight, fmt.Sprintf("%s%.2f", currencySymbol, item.TotalPrice), "1", 0, "R", false, 0, "") // Move Y position for next row g.pdf.SetY(currentY + rowHeight) subtotal += item.TotalPrice } // Totals g.pdf.SetFont("Helvetica", "B", 10) // Subtotal g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth) g.pdf.CellFormat(unitWidth, 6, "SUBTOTAL:", "1", 0, "R", false, 0, "") g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, subtotal), "1", 1, "R", false, 0, "") // GST if applicable if showGST { gst := subtotal * 0.10 g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth) g.pdf.CellFormat(unitWidth, 6, "GST (10%):", "1", 0, "R", false, 0, "") g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, gst), "1", 1, "R", false, 0, "") // Total total := subtotal + gst g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth) g.pdf.CellFormat(unitWidth, 6, "TOTAL DUE:", "1", 0, "R", false, 0, "") g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, total), "1", 1, "R", false, 0, "") } else { // Total without GST g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth) g.pdf.CellFormat(unitWidth, 6, "TOTAL:", "1", 0, "R", false, 0, "") g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, subtotal), "1", 1, "R", false, 0, "") } } // AddTermsAndConditions adds a T&C page at the end of the PDF // This renders a simple text page indicating T&Cs apply func (g *Generator) AddTermsAndConditions() { g.AddPage() g.pdf.SetFont("Helvetica", "B", 14) g.pdf.SetTextColor(171, 49, 248) // Purple/magenta color matching old format g.pdf.SetY(20) g.pdf.CellFormat(0, 10, "TERMS AND CONDITIONS", "", 1, "C", false, 0, "") g.pdf.Ln(5) g.pdf.SetFont("Helvetica", "", 9) g.pdf.SetTextColor(171, 49, 248) // Keep purple for body text g.pdf.SetLeftMargin(15) g.pdf.SetRightMargin(15) // Add disclaimer text disclaimerText := `These Terms and Conditions apply to all quotations, invoices, purchase orders, and other documents issued by CMC TECHNOLOGIES. By accepting this document, you agree to be bound by these terms. Payment Terms: Invoices are due within 30 days of issue unless otherwise agreed in writing. Delivery: Delivery dates are estimates only and not guaranteed. CMC TECHNOLOGIES is not liable for delays in delivery. Intellectual Property: All designs, drawings, and specifications remain the property of CMC TECHNOLOGIES unless otherwise agreed. Limitation of Liability: CMC TECHNOLOGIES shall not be liable for any indirect, incidental, or consequential damages arising out of or related to this document or transaction. Entire Agreement: This document and any attached terms constitute the entire agreement between the parties. For full terms and conditions, please refer to our website or contact CMC TECHNOLOGIES directly.` g.pdf.MultiCell(0, 4, disclaimerText, "", "L", false) g.pdf.SetTextColor(0, 0, 0) // Reset to black } // AddQuoteDetailsTable adds quote commercial details table (delivery time, payment terms, etc.) func (g *Generator) AddQuoteDetailsTable(data *QuotePDFData) { g.pdf.Ln(8) g.pdf.SetFont("Helvetica", "B", 10) g.pdf.CellFormat(0, 5, "QUOTE DETAILS", "", 1, "L", false, 0, "") g.pdf.Ln(2) // Create a simple table for quote details g.pdf.SetFont("Helvetica", "", 9) g.pdf.SetLeftMargin(15) // Set column widths for a 3-column table colWidth := 50.0 // Row 1: Delivery Time | Payment Terms | Days Valid g.pdf.SetX(15) g.pdf.SetLeftMargin(15) if data.DeliveryTime != "" { g.pdf.SetFont("Helvetica", "B", 8) g.pdf.CellFormat(colWidth, 4, "Delivery Time:", "L", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 8) g.pdf.CellFormat(colWidth, 4, data.DeliveryTime, "", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "B", 8) g.pdf.CellFormat(colWidth, 4, "Payment Terms:", "", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 8) g.pdf.MultiCell(0, 4, cleanHTMLLineBreaks(data.PaymentTerms), "R", "L", false) } // Row 2: Delivery Point | Days Valid if data.DeliveryPoint != "" || data.DaysValid > 0 { g.pdf.SetFont("Helvetica", "B", 8) g.pdf.CellFormat(colWidth, 4, "Delivery Point:", "L", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 8) g.pdf.CellFormat(colWidth, 4, data.DeliveryPoint, "", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "B", 8) daysStr := "" if data.DaysValid > 0 { daysStr = fmt.Sprintf("%d days", data.DaysValid) } g.pdf.CellFormat(colWidth, 4, "Valid For:", "", 0, "L", false, 0, "") g.pdf.SetFont("Helvetica", "", 8) g.pdf.CellFormat(0, 4, daysStr, "R", 1, "L", false, 0, "") } g.pdf.SetLeftMargin(10) } // AddInvoiceAddressBoxes adds the Sold To / Delivery Address boxes for invoices func (g *Generator) AddInvoiceAddressBoxes(data *InvoicePDFData) { g.pdf.SetFont("Helvetica", "", 9) // Start position x := 10.0 y := g.pdf.GetY() boxHeight := 30.0 // Left box: Sold To / Invoice Address header g.pdf.SetXY(x, y) g.pdf.SetFillColor(242, 242, 242) g.pdf.SetFont("Helvetica", "B", 9) g.pdf.CellFormat(58, 5, "Sold To / Invoice Address:", "1", 0, "L", true, 0, "") // Middle box: Delivery Address header g.pdf.CellFormat(58, 5, "Delivery Address:", "1", 0, "L", true, 0, "") // Right side: Invoice number (only on first line) g.pdf.SetXY(x+122, y) g.pdf.SetFont("Helvetica", "U", 11) g.pdf.CellFormat(0, 5, fmt.Sprintf("CMC INVOICE#: %s", data.Invoice.Title), "", 1, "L", false, 0, "") // Second row - address content y += 5 g.pdf.SetXY(x, y) g.pdf.SetFont("Helvetica", "", 8) // Sold To content with borders g.pdf.MultiCell(58, 4, cleanHTMLLineBreaks(data.BillTo), "1", "L", false) soldToEndY := g.pdf.GetY() // Ship To content - positioned next to Sold To g.pdf.SetXY(x+58, y) g.pdf.MultiCell(58, 4, cleanHTMLLineBreaks(data.ShipTo), "1", "L", false) shipToEndY := g.pdf.GetY() // Calculate max end Y for right column positioning maxEndY := soldToEndY if shipToEndY > maxEndY { maxEndY = shipToEndY } if maxEndY < y+boxHeight { maxEndY = y + boxHeight } // Right column: Date and Page on first two lines g.pdf.SetXY(x+122, y) g.pdf.SetFont("Helvetica", "", 8) g.pdf.Cell(0, 4, fmt.Sprintf("Date: %s", data.IssueDateString)) g.pdf.SetXY(x+122, y+4) g.pdf.Cell(0, 4, "Page: 1 of {nb}") // Right column: MAKE PAYMENT TO section g.pdf.SetXY(x+122, y+10) g.pdf.SetFont("Helvetica", "B", 8) g.pdf.Cell(0, 4, "MAKE PAYMENT TO:") // Bank details g.pdf.SetFont("Helvetica", "", 7) bankY := y + 14 bankLineHeight := 3.0 switch data.CurrencyCode { case "EUR": g.pdf.SetXY(x+122, bankY) g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nAccount Number/IBAN: 06200015682004\nBranch code: 06200\nSWIFT Code/BIC: CTBAAU2S", "", "L", false) case "GBP": g.pdf.SetXY(x+122, bankY) g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nAccount Number/IBAN: 06200015642694\nBranch code: 06200\nSWIFT Code/BIC: CTBAAU2S", "", "L", false) case "USD": g.pdf.SetXY(x+122, bankY) g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nAccount Number/IBAN: 06200015681984\nBranch code: 06200\nSWIFT Code/BIC: CTBAAU2S", "", "L", false) default: // AUD and others g.pdf.SetXY(x+122, bankY) g.pdf.MultiCell(80, bankLineHeight, "Account Name: CMC Technologies Pty Ltd\nBSB: 062-458\nAccount Number: 10067982\nSWIFT Code: CTBAAU2S\nIBAN: 06245810067982", "", "L", false) } // Position for next section g.pdf.SetY(maxEndY + 2) } // AddInvoiceDetailsTable adds the order number, job, FOB, payment terms, ABN table func (g *Generator) AddInvoiceDetailsTable(data *InvoicePDFData) { g.pdf.SetFont("Helvetica", "B", 9) g.pdf.SetFillColor(242, 242, 242) // Header row colWidth := 38.0 g.pdf.CellFormat(colWidth, 5, "CUSTOMER ORDER NO", "1", 0, "C", true, 0, "") g.pdf.CellFormat(colWidth, 5, "CMC JOB #", "1", 0, "C", true, 0, "") g.pdf.CellFormat(colWidth, 5, "INCOTERMS 2010", "1", 0, "C", true, 0, "") g.pdf.CellFormat(colWidth, 5, "PAYMENT TERMS", "1", 0, "C", true, 0, "") g.pdf.CellFormat(colWidth, 5, "CUSTOMER ABN", "1", 1, "C", true, 0, "") // Data row g.pdf.SetFont("Helvetica", "", 9) // Record starting Y for the row rowStartY := g.pdf.GetY() // Calculate payment terms height first paymentTermsLines := g.pdf.SplitLines([]byte(data.PaymentTerms), colWidth-2) paymentTermsHeight := float64(len(paymentTermsLines))*3.0 + 2.0 // Add padding if paymentTermsHeight < 5 { paymentTermsHeight = 5 } // Draw all cells with the calculated height g.pdf.CellFormat(colWidth, paymentTermsHeight, data.CustomerOrderNumber, "1", 0, "C", false, 0, "") g.pdf.CellFormat(colWidth, paymentTermsHeight, data.JobTitle, "1", 0, "C", false, 0, "") g.pdf.CellFormat(colWidth, paymentTermsHeight, data.FOB, "1", 0, "C", false, 0, "") // Payment terms with wrapping paymentTermsX := g.pdf.GetX() g.pdf.CellFormat(colWidth, paymentTermsHeight, "", "1", 0, "C", false, 0, "") g.pdf.SetXY(paymentTermsX+1, rowStartY+0.5) g.pdf.MultiCell(colWidth-2, 3, data.PaymentTerms, "", "C", false) g.pdf.SetXY(paymentTermsX+colWidth, rowStartY) g.pdf.CellFormat(colWidth, paymentTermsHeight, data.CustomerABN, "1", 1, "C", false, 0, "") } // AddInvoiceLineItemsHeader adds just the header row for line items table func (g *Generator) AddInvoiceLineItemsHeader(currencyCode string) { g.pdf.SetFont("Helvetica", "B", 9) g.pdf.SetFillColor(242, 242, 242) g.pdf.CellFormat(15, 5, "ITEM NO.", "1", 0, "C", true, 0, "") g.pdf.CellFormat(15, 5, "QTY", "1", 0, "C", true, 0, "") g.pdf.CellFormat(100, 5, "DESCRIPTION", "1", 0, "C", true, 0, "") g.pdf.CellFormat(30, 5, "UNIT PRICE", "1", 0, "C", true, 0, "") g.pdf.CellFormat(30, 5, "TOTAL PRICE", "1", 1, "C", true, 0, "") // Currency row g.pdf.SetFont("Helvetica", "", 8) g.pdf.CellFormat(15, 4, "", "1", 0, "C", false, 0, "") g.pdf.CellFormat(15, 4, "", "1", 0, "C", false, 0, "") g.pdf.CellFormat(100, 4, "", "1", 0, "C", false, 0, "") g.pdf.CellFormat(30, 4, currencyCode, "1", 0, "C", false, 0, "") g.pdf.CellFormat(30, 4, currencyCode, "1", 1, "C", false, 0, "") } // AddInvoiceLineItemsContent adds line items and totals for invoices func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) { g.pdf.SetFont("Helvetica", "", 9) g.pdf.SetTextColor(0, 0, 0) firstPageBreak := false // Line items for i, item := range data.LineItems { // Build description HTML with title and description descriptionHTML := "" + item.Title + "" if item.Description != "" { // Use raw description - PHP should format it before sending descriptionHTML += "
" + item.Description } // Check if row will fit on current page estimatedHeight := estimateHTMLHeight(g.pdf, descriptionHTML, 98, 4) if g.pdf.GetY()+estimatedHeight > 240 && i > 0 { g.AddPage() if !firstPageBreak { g.pdf.SetFont("Helvetica", "B", 12) g.pdf.CellFormat(0, 6, "CONTINUED: "+data.Invoice.Title, "", 1, "L", false, 0, "") g.pdf.Ln(4) firstPageBreak = true } // Re-draw header on new page g.pdf.SetFont("Helvetica", "B", 9) g.pdf.SetFillColor(242, 242, 242) g.pdf.CellFormat(15, 5, "ITEM NO.", "1", 0, "C", true, 0, "") g.pdf.CellFormat(15, 5, "QTY", "1", 0, "C", true, 0, "") g.pdf.CellFormat(100, 5, "DESCRIPTION", "1", 0, "C", true, 0, "") g.pdf.CellFormat(30, 5, "UNIT PRICE", "1", 0, "C", true, 0, "") g.pdf.CellFormat(30, 5, "TOTAL PRICE", "1", 1, "C", true, 0, "") g.pdf.SetFont("Helvetica", "", 9) } unitPrice := 0.0 totalPrice := 0.0 netUnitPrice := 0.0 netTotalPrice := 0.0 discountPercent := 0.0 discountAmountUnit := 0.0 discountAmountTotal := 0.0 unitPriceStr := "" totalPriceStr := "" if item.GrossUnitPrice.Valid && item.GrossUnitPrice.String != "" { fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice) } if item.GrossPrice.Valid && item.GrossPrice.String != "" { fmt.Sscanf(item.GrossPrice.String, "%f", &totalPrice) } if item.NetUnitPrice.Valid && item.NetUnitPrice.String != "" { fmt.Sscanf(item.NetUnitPrice.String, "%f", &netUnitPrice) } if item.NetPrice.Valid && item.NetPrice.String != "" { fmt.Sscanf(item.NetPrice.String, "%f", &netTotalPrice) } if item.DiscountPercent.Valid && item.DiscountPercent.String != "" { fmt.Sscanf(item.DiscountPercent.String, "%f", &discountPercent) } if item.DiscountAmountUnit.Valid && item.DiscountAmountUnit.String != "" { fmt.Sscanf(item.DiscountAmountUnit.String, "%f", &discountAmountUnit) } if item.DiscountAmountTotal.Valid && item.DiscountAmountTotal.String != "" { fmt.Sscanf(item.DiscountAmountTotal.String, "%f", &discountAmountTotal) } // Calculate net prices if not provided if netUnitPrice == 0 && unitPrice > 0 { netUnitPrice = unitPrice - discountAmountUnit } if netTotalPrice == 0 && totalPrice > 0 { netTotalPrice = totalPrice - discountAmountTotal } // Build unit price string with discount formatting if discountPercent > 0 || discountAmountUnit > 0 { unitPriceStr = fmt.Sprintf("%s\nless\n%.2f%%\ndiscount*\n(-%s)\n=\n%s", formatCurrency(data.CurrencySymbol, unitPrice), discountPercent, formatCurrency("", discountAmountUnit), formatCurrency(data.CurrencySymbol, netUnitPrice)) } else { unitPriceStr = formatCurrency(data.CurrencySymbol, unitPrice) } // Build total price string with discount formatting if discountPercent > 0 || discountAmountTotal > 0 { totalPriceStr = fmt.Sprintf("%s\nless\n%.2f%%\ndiscount*\n(-%s)\n=\n%s", formatCurrency(data.CurrencySymbol, totalPrice), discountPercent, formatCurrency("", discountAmountTotal), formatCurrency(data.CurrencySymbol, netTotalPrice)) } else { totalPriceStr = formatCurrency(data.CurrencySymbol, totalPrice) } // Record starting Y position startY := g.pdf.GetY() // Calculate heights for all columns WITHOUT rendering // Description height - count lines in HTML plainDesc := descriptionHTML re := regexp.MustCompile(`<[^>]*>`) plainDesc = re.ReplaceAllString(plainDesc, "") descLines := strings.Count(plainDesc, "\n") + 1 if descLines < 1 { descLines = 1 } descriptionHeight := float64(descLines) * 3.1 // Price columns heights unitPriceLines := g.pdf.SplitLines([]byte(unitPriceStr), 28) unitPriceTextHeight := float64(len(unitPriceLines))*3.1 + 0.5 totalPriceLines := g.pdf.SplitLines([]byte(totalPriceStr), 28) totalPriceTextHeight := float64(len(totalPriceLines))*3.1 + 0.5 // Use the maximum height from all columns rowHeight := descriptionHeight + 1.5 // Add minimal padding to description if unitPriceTextHeight > rowHeight { rowHeight = unitPriceTextHeight } if totalPriceTextHeight > rowHeight { rowHeight = totalPriceTextHeight } if rowHeight < 5 { rowHeight = 5 } // Now draw all cells with borders at the correct row height // Draw item number at X=10, width=15 g.pdf.SetXY(10, startY) g.pdf.CellFormat(15, rowHeight, item.ItemNumber, "1", 0, "C", false, 0, "") // Draw quantity at X=25 (10+15), width=15 g.pdf.SetXY(25, startY) g.pdf.CellFormat(15, rowHeight, item.Quantity, "1", 0, "C", false, 0, "") // Draw description border at X=40, width=100 g.pdf.SetXY(40, startY) g.pdf.CellFormat(100, rowHeight, "", "1", 0, "L", false, 0, "") // Render description using chromedp g.pdf.SetXY(41, startY+1) _ = g.renderFormattedDescription(item.Title, item.Description, 41, startY+1, 98, 3.1) // Reset to normal font g.pdf.SetFont("Helvetica", "", 9) // Recalculate vertical centering offset for price text with new row height unitPriceTextHeight = float64(len(unitPriceLines)) * 3.1 unitPriceOffset := (rowHeight - unitPriceTextHeight) / 2 if unitPriceOffset < 0.5 { unitPriceOffset = 0.5 } totalPriceTextHeight = float64(len(totalPriceLines)) * 3.1 totalPriceOffset := (rowHeight - totalPriceTextHeight) / 2 if totalPriceOffset < 0.5 { totalPriceOffset = 0.5 } // Draw unit price at X=140 (10+15+15+100), width=30 g.pdf.SetXY(140, startY) g.pdf.CellFormat(30, rowHeight, "", "1", 0, "C", false, 0, "") g.pdf.SetXY(141, startY+unitPriceOffset) g.pdf.SetFont("Helvetica", "", 9) g.pdf.MultiCell(28, 3.1, unitPriceStr, "", "C", false) // Draw total price at X=170 (10+15+15+100+30), width=30 g.pdf.SetXY(170, startY) g.pdf.CellFormat(30, rowHeight, "", "1", 0, "C", false, 0, "") g.pdf.SetXY(171, startY+totalPriceOffset) g.pdf.SetFont("Helvetica", "", 9) g.pdf.MultiCell(28, 3.1, totalPriceStr, "", "C", false) // Position cursor for next row - CRITICAL to avoid overlapping cells g.pdf.SetXY(10, startY+rowHeight) } // Freight details and totals g.pdf.SetFillColor(242, 242, 242) g.pdf.SetFont("Helvetica", "B", 9) // Record starting Y for freight section y := g.pdf.GetY() // Freight details header (left side) g.pdf.CellFormat(130, 5, "FREIGHT DETAILS:", "1", 0, "L", true, 0, "") // Subtotal g.pdf.CellFormat(30, 5, "SUBTOTAL", "1", 0, "L", true, 0, "") g.pdf.SetFont("Helvetica", "", 9) subtotalStr := fmt.Sprintf("%v", data.Subtotal) if val, ok := data.Subtotal.(float64); ok { subtotalStr = fmt.Sprintf("%s%.2f", data.CurrencySymbol, val) } g.pdf.CellFormat(30, 5, subtotalStr, "1", 1, "C", false, 0, "") // GST row (right side) g.pdf.SetXY(140, y+5) g.pdf.SetFont("Helvetica", "B", 9) g.pdf.SetFillColor(242, 242, 242) g.pdf.CellFormat(30, 5, "GST (10%)", "1", 0, "L", true, 0, "") g.pdf.SetFont("Helvetica", "", 9) gstStr := fmt.Sprintf("%v", data.GSTAmount) if val, ok := data.GSTAmount.(float64); ok { gstStr = fmt.Sprintf("%s%.2f", data.CurrencySymbol, val) } g.pdf.CellFormat(30, 5, gstStr, "1", 1, "C", false, 0, "") // Total row (right side) g.pdf.SetXY(140, y+10) g.pdf.SetFont("Helvetica", "B", 9) g.pdf.SetFillColor(242, 242, 242) g.pdf.CellFormat(30, 5, "TOTAL DUE", "1", 0, "L", true, 0, "") g.pdf.SetFont("Helvetica", "", 9) totalStr := fmt.Sprintf("%v", data.Total) if val, ok := data.Total.(float64); ok { totalStr = fmt.Sprintf("%s%.2f", data.CurrencySymbol, val) } g.pdf.CellFormat(30, 5, totalStr, "1", 1, "C", false, 0, "") // Calculate height of freight section (from y to bottom of total due) totalDueEndY := y + 15 // Header (5) + GST (5) + Total (5) freightDetailsHeight := 10.0 // Should span GST + Total rows // Draw shipping details box on left side with proper height g.pdf.SetFont("Helvetica", "", 8) g.pdf.SetXY(10, y+5) // Draw the border box first g.pdf.CellFormat(130, freightDetailsHeight, "", "1", 0, "L", false, 0, "") // Then draw the text inside without border g.pdf.SetXY(11, y+6) // Slight offset for padding g.pdf.MultiCell(128, 4, cleanHTMLLineBreaks(data.ShippingDetails), "", "L", false) // Make sure we're past the freight section g.pdf.SetY(totalDueEndY) } // Save saves the PDF to a file func (g *Generator) Save(filename string) error { g.pdf.AliasNbPages("") outputPath := filepath.Join(g.outputDir, filename) fmt.Printf("Generator.Save: Saving PDF to path: %s\n", outputPath) err := g.pdf.OutputFileAndClose(outputPath) if err != nil { fmt.Printf("Generator.Save: Error saving PDF: %v\n", err) } else { fmt.Printf("Generator.Save: PDF saved successfully to: %s\n", outputPath) } return err } // LineItem represents a line item for the PDF type LineItem struct { ItemNumber string Quantity string Title string Description string UnitPrice float64 TotalPrice float64 DiscountPercent float64 DiscountAmountUnit float64 DiscountAmountTotal float64 }