diff --git a/Dockerfile.local.go b/Dockerfile.local.go
index 2c37cd43..468cb3e8 100644
--- a/Dockerfile.local.go
+++ b/Dockerfile.local.go
@@ -2,6 +2,19 @@ FROM golang:1.24-alpine
WORKDIR /app
+# Install Chromium and dependencies for chromedp
+RUN apk add --no-cache \
+ chromium \
+ nss \
+ freetype \
+ harfbuzz \
+ ca-certificates \
+ ttf-freefont
+
+# Set environment variable for chromedp to find chromium
+ENV CHROME_BIN=/usr/bin/chromium-browser \
+ CHROME_PATH=/usr/bin/chromium-browser
+
# Copy go.mod and go.sum first
COPY go/go.mod go/go.sum ./
@@ -19,6 +32,9 @@ COPY go/ .
# Generate sqlc code
RUN sqlc generate
+# Ensure static assets are available
+COPY go/static ./static
+
# Copy Air config
COPY go/.air.toml .air.toml
diff --git a/Dockerfile.prod.go b/Dockerfile.prod.go
index b3f8fd84..9f334d82 100644
--- a/Dockerfile.prod.go
+++ b/Dockerfile.prod.go
@@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o vault cmd/vault/m
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
-WORKDIR /root/
+WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /app/vault .
COPY go/templates ./templates
diff --git a/Dockerfile.stg.go b/Dockerfile.stg.go
index e34d4f22..6d185875 100644
--- a/Dockerfile.stg.go
+++ b/Dockerfile.stg.go
@@ -12,7 +12,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
-WORKDIR /root/
+WORKDIR /app
COPY --from=builder /app/server .
COPY go/templates ./templates
COPY go/static ./static
diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go
index 7d98c1ee..64495b0f 100644
--- a/go/cmd/server/main.go
+++ b/go/cmd/server/main.go
@@ -84,6 +84,7 @@ func main() {
// PDF generation routes (under /api/pdf/* to avoid conflict with file server)
goRouter.HandleFunc("/api/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST")
+ goRouter.HandleFunc("/api/pdf/generate-invoice-html", handlers.GenerateInvoicePDFHTML).Methods("POST") // HTML version
goRouter.HandleFunc("/api/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST")
goRouter.HandleFunc("/api/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST")
diff --git a/go/internal/cmc/handlers/pdf_api.go b/go/internal/cmc/handlers/pdf_api.go
index 30c74b4b..adf25b08 100644
--- a/go/internal/cmc/handlers/pdf_api.go
+++ b/go/internal/cmc/handlers/pdf_api.go
@@ -4,21 +4,31 @@ import (
"database/sql"
"encoding/json"
"fmt"
+ "html"
"log"
"net/http"
"os"
+ "strings"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf"
)
+// escapeToHTML converts plain text to HTML with newlines as
+func escapeToHTML(s string) string {
+ s = html.EscapeString(s)
+ s = strings.ReplaceAll(s, "\n", "
")
+ return s
+}
+
// InvoiceLineItemRequest is the JSON shape for a single line item.
type InvoiceLineItemRequest struct {
ItemNumber string `json:"item_number"`
Quantity string `json:"quantity"`
Title string `json:"title"`
Description string `json:"description"`
+ IsHTML bool `json:"is_html"` // Flag to indicate description contains HTML
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
NetUnitPrice float64 `json:"net_unit_price"`
@@ -35,6 +45,7 @@ type InvoiceLineItemRequest struct {
// InvoicePDFRequest is the expected payload from the PHP app.
type InvoicePDFRequest struct {
DocumentID int32 `json:"document_id"`
+ InvoiceNumber string `json:"invoice_number"` // e.g. "INV-001234"
InvoiceTitle string `json:"invoice_title"`
CustomerName string `json:"customer_name"`
ContactEmail string `json:"contact_email"`
@@ -106,11 +117,15 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{
- ItemNumber: li.ItemNumber,
- Quantity: li.Quantity,
- Title: li.Title,
- GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
- GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
+ ItemNumber: li.ItemNumber,
+ Quantity: li.Quantity,
+ Title: li.Title,
+ Description: li.Description,
+ GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
+ GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
+ DiscountPercent: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountPercent), Valid: li.DiscountPercent > 0},
+ DiscountAmountUnit: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountUnit), Valid: li.DiscountAmountUnit > 0},
+ DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
}
}
@@ -154,6 +169,111 @@ func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
+// GenerateInvoicePDFHTML generates invoice using HTML template and chromedp
+func GenerateInvoicePDFHTML(w http.ResponseWriter, r *http.Request) {
+ var req InvoicePDFRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON payload", http.StatusBadRequest)
+ return
+ }
+
+ if req.InvoiceTitle == "" || req.CustomerName == "" {
+ http.Error(w, "invoice_title and customer_name are required", http.StatusBadRequest)
+ return
+ }
+
+ issueDate := time.Now()
+ if req.IssueDate != "" {
+ if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
+ issueDate = parsed
+ }
+ }
+
+ outputDir := req.OutputDir
+ if outputDir == "" {
+ outputDir = os.Getenv("PDF_OUTPUT_DIR")
+ }
+ if outputDir == "" {
+ outputDir = "../php/app/webroot/pdf"
+ }
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ log.Printf("GenerateInvoicePDFHTML: failed to create output dir: %v", err)
+ http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
+ return
+ }
+
+ // Map request into the existing PDF generation types
+ doc := &db.Document{ID: req.DocumentID, CmcReference: req.InvoiceNumber}
+ inv := &db.Invoice{Title: req.InvoiceTitle}
+ cust := &db.Customer{Name: req.CustomerName}
+
+ log.Printf("GenerateInvoicePDFHTML: Setting invoice number to: %s", req.InvoiceNumber)
+
+ lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
+ for i, li := range req.LineItems {
+ // Escape description if it's not HTML
+ desc := li.Description
+ if !li.IsHTML {
+ // Escape plain text to HTML entities
+ desc = escapeToHTML(desc)
+ }
+
+ // Calculate the final price after discount
+ finalPrice := li.TotalPrice - li.DiscountAmountTotal
+
+ lineItems[i] = db.GetLineItemsTableRow{
+ ItemNumber: li.ItemNumber,
+ Quantity: li.Quantity,
+ Title: li.Title,
+ Description: desc,
+ GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
+ GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", finalPrice), Valid: true},
+ DiscountAmountTotal: sql.NullString{String: fmt.Sprintf("%.2f", li.DiscountAmountTotal), Valid: li.DiscountAmountTotal > 0},
+ }
+ }
+
+ data := &pdf.InvoicePDFData{
+ Document: doc,
+ Invoice: inv,
+ Customer: cust,
+ LineItems: lineItems,
+ CurrencySymbol: req.CurrencySymbol,
+ CurrencyCode: req.CurrencyCode,
+ ShowGST: req.ShowGST,
+ ShipVia: req.ShipVia,
+ FOB: req.FOB,
+ IssueDate: issueDate,
+ IssueDateString: req.IssueDateString,
+ EmailTo: req.ContactEmail,
+ Attention: req.ContactName,
+ FromName: fmt.Sprintf("%s %s", req.UserFirstName, req.UserLastName),
+ FromEmail: req.UserEmail,
+ YourReference: req.YourReference,
+ BillTo: req.BillTo,
+ ShipTo: req.ShipTo,
+ ShippingDetails: req.ShippingDetails,
+ CustomerOrderNumber: req.CustomerOrderNumber,
+ JobTitle: req.JobTitle,
+ PaymentTerms: req.PaymentTerms,
+ CustomerABN: req.CustomerABN,
+ Subtotal: req.Subtotal,
+ GSTAmount: req.GSTAmount,
+ Total: req.Total,
+ }
+
+ // Use HTML generator instead of gofpdf
+ htmlGen := pdf.NewHTMLInvoiceGenerator(outputDir)
+ filename, err := htmlGen.GenerateInvoicePDF(data)
+ if err != nil {
+ log.Printf("GenerateInvoicePDFHTML: failed to generate PDF: %v", err)
+ http.Error(w, fmt.Sprintf("failed to generate PDF: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
+}
+
// QuoteLineItemRequest reuses the invoice item shape
type QuoteLineItemRequest = InvoiceLineItemRequest
diff --git a/go/internal/cmc/pdf/description_formatter.go b/go/internal/cmc/pdf/description_formatter.go
new file mode 100644
index 00000000..5b75613f
--- /dev/null
+++ b/go/internal/cmc/pdf/description_formatter.go
@@ -0,0 +1,7 @@
+package pdf
+
+// formatDescription passes through descriptions as-is
+// HTML formatting should be applied in PHP before sending to this API
+func formatDescription(text string) string {
+ return text
+}
diff --git a/go/internal/cmc/pdf/description_formatter_test.go b/go/internal/cmc/pdf/description_formatter_test.go
new file mode 100644
index 00000000..891c8ff8
--- /dev/null
+++ b/go/internal/cmc/pdf/description_formatter_test.go
@@ -0,0 +1,115 @@
+package pdf
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestFormatDescription(t *testing.T) {
+ input := `Item Code: B25SEZ22B0
+Item Description: SE Sensor Zone 22
+Type: SE - To control the function of the rupture disc
+Cable Length: 2m
+Ui< 40V
+li<57mA
+Li, Ci negligible
+II 2G Ex ib IIC T6 (Gb)
+II 2D Ex ib IIC T 80 deg C IP65 ((Db) -25 deg C < Ta < +80 deg C
+IBEx U1ATEX1017
+Includes installation instruction
+
+With standard Angle Bracket to suit Brilex Non-Insulated Explosion Vents
+(If Insulated panels are used a modified (vertically extended) bracket needs to be used)
+See attached EC Conformity Declaration for the SE Sensor
+
+Testing at
+
+1. -4 deg C
+2. -30 deg C
+3. 20 deg C`
+
+ output := formatDescription(input)
+
+ // Check that key: value pairs are bolded
+ if !strings.Contains(output, "Item Code:") {
+ t.Error("Item Code should be bolded")
+ }
+
+ if !strings.Contains(output, "Item Description:") {
+ t.Error("Item Description should be bolded")
+ }
+
+ // Check that list items are in
- tags
+ if !strings.Contains(output, "
") {
+ t.Error("Ordered list should have tag")
+ }
+
+ if !strings.Contains(output, "- -4 deg C
") {
+ t.Error("List item 1 not formatted correctly")
+ }
+
+ // Check that italic patterns are applied
+ if !strings.Contains(output, "See attached EC Conformity Declaration for the SE Sensor") {
+ t.Error("Italic pattern not applied to EC Conformity Declaration text")
+ }
+
+ // Verify HTML tags are properly balanced (count opening/closing tag pairs)
+ strongOpens := strings.Count(output, "")
+ strongCloses := strings.Count(output, "")
+ if strongOpens != strongCloses {
+ t.Errorf("Unbalanced tags: %d opens, %d closes", strongOpens, strongCloses)
+ }
+
+ emOpens := strings.Count(output, "")
+ emCloses := strings.Count(output, "")
+ if emOpens != emCloses {
+ t.Errorf("Unbalanced tags: %d opens, %d closes", emOpens, emCloses)
+ }
+
+ olOpens := strings.Count(output, "")
+ olCloses := strings.Count(output, "
")
+ if olOpens != olCloses {
+ t.Errorf("Unbalanced tags: %d opens, %d closes", olOpens, olCloses)
+ }
+
+ t.Logf("Formatted output:\n%s", output)
+}
+
+func TestIsOrderedListItem(t *testing.T) {
+ tests := []struct {
+ input string
+ expected bool
+ }{
+ {"1. Item one", true},
+ {"2. Item two", true},
+ {"10. Item ten", true},
+ {"Item without number", false},
+ {"1 Item without dot", false},
+ {"Item: with colon", false},
+ }
+
+ for _, test := range tests {
+ result := isOrderedListItem(test.input)
+ if result != test.expected {
+ t.Errorf("isOrderedListItem(%q) = %v, want %v", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestFormatLine(t *testing.T) {
+ tests := []struct {
+ input string
+ contains string
+ }{
+ {"Item Code: B25SEZ22B0", "Item Code:"},
+ {"Type: SE - To control", "Type:"},
+ {"Random text here", "Random text here"},
+ }
+
+ for _, test := range tests {
+ result := formatLine(test.input)
+ if !strings.Contains(result, test.contains) {
+ t.Errorf("formatLine(%q) should contain %q, got %q", test.input, test.contains, result)
+ }
+ }
+}
diff --git a/go/internal/cmc/pdf/generator.go b/go/internal/cmc/pdf/generator.go
index ae50c8c0..06f1e0cf 100644
--- a/go/internal/cmc/pdf/generator.go
+++ b/go/internal/cmc/pdf/generator.go
@@ -1,12 +1,467 @@
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
@@ -37,85 +492,81 @@ func (g *Generator) AddPage() {
// Page1Header adds the standard header for page 1
func (g *Generator) Page1Header() {
- g.pdf.SetY(10)
-
- // Set text color to blue
- g.pdf.SetTextColor(0, 0, 152)
-
- // Add logo if available (assuming logo is in static directory)
+ // Add logo at top left (above the line)
logoPath := filepath.Join("static", "images", "CMC-Mobile-Logo.png")
- // Try to add logo, but don't fail if it doesn't exist or isn't a proper image
g.pdf.ImageOptions(logoPath, 10, 10, 0, 28, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "http://www.cmctechnologies.com.au")
- // Company name
+ // Company name centered
+ g.pdf.SetTextColor(0, 0, 152)
g.pdf.SetFont("Helvetica", "B", 30)
- g.pdf.SetX(40)
- g.pdf.CellFormat(0, 0, g.headerText, "", 1, "C", false, 0, "")
+ g.pdf.SetXY(10, 15)
+ g.pdf.CellFormat(190, 10, g.headerText, "", 1, "C", false, 0, "")
- // Company details
+ // Company details centered with more spacing
g.pdf.SetFont("Helvetica", "", 10)
- g.pdf.SetY(22)
- g.pdf.SetX(40)
- g.pdf.CellFormat(0, 0, "PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224", "", 1, "C", false, 0, "")
+ 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(43, 24, 200, 24)
+ g.pdf.Line(10, lineY, 200, lineY)
- // Contact details in table format
- g.pdf.SetTextColor(0, 0, 0)
- g.pdf.SetFont("Helvetica", "", 10)
-
- startY := 32.0
- labelX := 45.0
- valueX := 65.0
- addressX := 150.0
- lineHeight := 5.0
-
- // Row 1: Phone
- g.pdf.SetXY(labelX, startY)
- g.pdf.Cell(20, lineHeight, "Phone:")
- g.pdf.SetXY(valueX, startY)
- g.pdf.Cell(55, lineHeight, "+61 2 9669 4000")
- g.pdf.SetXY(addressX, startY)
- g.pdf.Cell(52, lineHeight, "Unit 19, 77 Bourke Rd")
-
- // Row 2: Fax
- g.pdf.SetXY(labelX, startY+lineHeight)
- g.pdf.Cell(20, lineHeight, "Fax:")
- g.pdf.SetXY(valueX, startY+lineHeight)
- g.pdf.Cell(55, lineHeight, "+61 2 9669 4111")
- g.pdf.SetXY(addressX, startY+lineHeight)
- g.pdf.Cell(52, lineHeight, "Alexandria NSW 2015")
-
- // Row 3: Email
- g.pdf.SetXY(labelX, startY+lineHeight*2)
- g.pdf.Cell(20, lineHeight, "Email:")
- g.pdf.SetXY(valueX, startY+lineHeight*2)
- g.pdf.Cell(55, lineHeight, "sales@cmctechnologies.com.au")
- g.pdf.SetXY(addressX, startY+lineHeight*2)
- g.pdf.Cell(52, lineHeight, "AUSTRALIA")
-
- // Row 4: Web Site
- g.pdf.SetXY(labelX, startY+lineHeight*3)
- g.pdf.Cell(20, lineHeight, "Web Site:")
- g.pdf.SetXY(valueX, startY+lineHeight*3)
- g.pdf.Cell(55, lineHeight, "www.cmctechnologies.net.au")
-
- // Engineering text on left
+ // Engineering text on LEFT below the line, beneath the logo
g.pdf.SetTextColor(0, 0, 152)
- g.pdf.SetFont("Helvetica", "B", 10)
- engineeringX := 10.0
- engineeringY := 37.0
- g.pdf.SetXY(engineeringX, engineeringY)
- g.pdf.Cell(30, lineHeight, "Engineering &")
- g.pdf.SetXY(engineeringX, engineeringY+lineHeight)
- g.pdf.Cell(30, lineHeight, "Industrial")
- g.pdf.SetXY(engineeringX, engineeringY+lineHeight*2)
- g.pdf.Cell(30, lineHeight, "Instrumentation")
+ g.pdf.SetFont("Helvetica", "B", 9)
+ startY := lineY + 2
+ lineHeight := 4.0
- // Reset text color to black for subsequent content
+ 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
@@ -278,11 +729,37 @@ func (g *Generator) AddLineItemsTable(items []LineItem, currencySymbol string, s
g.pdf.SetFont("Helvetica", "", 9)
}
- g.pdf.CellFormat(itemWidth, 6, item.ItemNumber, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(qtyWidth, 6, item.Quantity, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(descWidth, 6, item.Title, "1", 0, "L", false, 0, "")
- g.pdf.CellFormat(unitWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, item.UnitPrice), "1", 0, "R", false, 0, "")
- g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, item.TotalPrice), "1", 1, "R", false, 0, "")
+ 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
}
@@ -376,7 +853,7 @@ func (g *Generator) AddQuoteDetailsTable(data *QuotePDFData) {
g.pdf.SetFont("Helvetica", "B", 8)
g.pdf.CellFormat(colWidth, 4, "Payment Terms:", "", 0, "L", false, 0, "")
g.pdf.SetFont("Helvetica", "", 8)
- g.pdf.CellFormat(0, 4, data.PaymentTerms, "R", 1, "L", false, 0, "")
+ g.pdf.MultiCell(0, 4, cleanHTMLLineBreaks(data.PaymentTerms), "R", "L", false)
}
// Row 2: Delivery Point | Days Valid
@@ -402,107 +879,82 @@ func (g *Generator) AddQuoteDetailsTable(data *QuotePDFData) {
func (g *Generator) AddInvoiceAddressBoxes(data *InvoicePDFData) {
g.pdf.SetFont("Helvetica", "", 9)
- // Top row with headers and invoice details
- x := g.pdf.GetX()
+ // Start position
+ x := 10.0
y := g.pdf.GetY()
+ boxHeight := 30.0
- // Left: Sold To header
+ // 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: Delivery Address header
+ // Middle box: Delivery Address header
g.pdf.CellFormat(58, 5, "Delivery Address:", "1", 0, "L", true, 0, "")
- // Right: Invoice details (starts here, spans 2 rows)
+ // 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 with address content
+ // Second row - address content
y += 5
g.pdf.SetXY(x, y)
- g.pdf.SetFont("Helvetica", "", 9)
+ g.pdf.SetFont("Helvetica", "", 8)
- // Sold To content
- billToHeight := 30.0
- g.pdf.MultiCell(58, 4, data.BillTo, "1", "L", false)
+ // Sold To content with borders
+ g.pdf.MultiCell(58, 4, cleanHTMLLineBreaks(data.BillTo), "1", "L", false)
soldToEndY := g.pdf.GetY()
- // Ship To content
+ // Ship To content - positioned next to Sold To
g.pdf.SetXY(x+58, y)
- g.pdf.MultiCell(58, 4, data.ShipTo, "1", "L", false)
+ g.pdf.MultiCell(58, 4, cleanHTMLLineBreaks(data.ShipTo), "1", "L", false)
shipToEndY := g.pdf.GetY()
- // Ensure both cells have same height
+ // Calculate max end Y for right column positioning
maxEndY := soldToEndY
if shipToEndY > maxEndY {
maxEndY = shipToEndY
}
- if maxEndY < y+billToHeight {
- maxEndY = y + billToHeight
+ if maxEndY < y+boxHeight {
+ maxEndY = y + boxHeight
}
- // Add remaining invoice details on the right
+ // Right column: Date and Page on first two lines
g.pdf.SetXY(x+122, y)
- g.pdf.SetFont("Helvetica", "", 9)
+ 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}")
- g.pdf.Ln(2)
- g.pdf.SetFont("Helvetica", "U", 9)
- g.pdf.CellFormat(0, 4, "MAKE PAYMENT TO:", "", 1, "L", false, 0, "")
- g.pdf.SetFont("Helvetica", "", 8)
+ // 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 based on currency in table format
- bankY := g.pdf.GetY()
+ // Bank details
+ g.pdf.SetFont("Helvetica", "", 7)
+ bankY := y + 14
bankLineHeight := 3.0
+
switch data.CurrencyCode {
case "EUR":
- g.pdf.SetXY(10, bankY)
- g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd")
- g.pdf.SetXY(10, bankY+bankLineHeight)
- g.pdf.Cell(0, bankLineHeight, "Account Number/IBAN: 06200015682004")
- g.pdf.SetXY(10, bankY+bankLineHeight*2)
- g.pdf.Cell(0, bankLineHeight, "Branch code: 06200")
- g.pdf.SetXY(10, bankY+bankLineHeight*3)
- g.pdf.Cell(0, bankLineHeight, "SWIFT Code/BIC: CTBAAU2S")
- g.pdf.SetY(bankY + bankLineHeight*4)
+ 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(10, bankY)
- g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd")
- g.pdf.SetXY(10, bankY+bankLineHeight)
- g.pdf.Cell(0, bankLineHeight, "Account Number/IBAN: 06200015642694")
- g.pdf.SetXY(10, bankY+bankLineHeight*2)
- g.pdf.Cell(0, bankLineHeight, "Branch code: 06200")
- g.pdf.SetXY(10, bankY+bankLineHeight*3)
- g.pdf.Cell(0, bankLineHeight, "SWIFT Code/BIC: CTBAAU2S")
- g.pdf.SetY(bankY + bankLineHeight*4)
+ 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(10, bankY)
- g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd")
- g.pdf.SetXY(10, bankY+bankLineHeight)
- g.pdf.Cell(0, bankLineHeight, "Account Number/IBAN: 06200015681984")
- g.pdf.SetXY(10, bankY+bankLineHeight*2)
- g.pdf.Cell(0, bankLineHeight, "Branch code: 06200")
- g.pdf.SetXY(10, bankY+bankLineHeight*3)
- g.pdf.Cell(0, bankLineHeight, "SWIFT Code/BIC: CTBAAU2S")
- g.pdf.SetY(bankY + bankLineHeight*4)
+ 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(10, bankY)
- g.pdf.Cell(0, bankLineHeight, "Account Name: CMC Technologies Pty Ltd")
- g.pdf.SetXY(10, bankY+bankLineHeight)
- g.pdf.Cell(0, bankLineHeight, "Bank Number BSB#: 062-458")
- g.pdf.SetXY(10, bankY+bankLineHeight*2)
- g.pdf.Cell(0, bankLineHeight, "Account Number: 10067982")
- g.pdf.SetXY(10, bankY+bankLineHeight*3)
- g.pdf.Cell(0, bankLineHeight, "SWIFT Code: CTBAAU2S")
- g.pdf.SetXY(10, bankY+bankLineHeight*4)
- g.pdf.Cell(0, bankLineHeight, "IBAN: 06245810067982")
- g.pdf.SetY(bankY + bankLineHeight*5)
+ 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)
}
@@ -521,11 +973,30 @@ func (g *Generator) AddInvoiceDetailsTable(data *InvoicePDFData) {
// Data row
g.pdf.SetFont("Helvetica", "", 9)
- g.pdf.CellFormat(colWidth, 5, data.CustomerOrderNumber, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(colWidth, 5, data.JobTitle, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(colWidth, 5, data.FOB, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(colWidth, 5, data.PaymentTerms, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(colWidth, 5, data.CustomerABN, "1", 1, "C", false, 0, "")
+
+ // 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
@@ -557,8 +1028,16 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) {
// Line items
for i, item := range data.LineItems {
- // Check if we need a new page (leave room for footer/totals)
- if g.pdf.GetY() > 240 && i > 0 {
+ // 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)
@@ -580,27 +1059,158 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) {
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 {
+ if item.GrossUnitPrice.Valid && item.GrossUnitPrice.String != "" {
fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice)
}
- if item.GrossPrice.Valid {
+ 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)
+ }
- g.pdf.CellFormat(15, 5, item.ItemNumber, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(15, 5, item.Quantity, "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(100, 5, item.Title, "1", 0, "L", false, 0, "")
- g.pdf.CellFormat(30, 5, fmt.Sprintf("%s%.2f", data.CurrencySymbol, unitPrice), "1", 0, "C", false, 0, "")
- g.pdf.CellFormat(30, 5, fmt.Sprintf("%s%.2f", data.CurrencySymbol, totalPrice), "1", 1, "C", false, 0, "")
+ // 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)
- // Freight details (left side, spans 2 rows)
+ // 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
@@ -612,13 +1222,7 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) {
}
g.pdf.CellFormat(30, 5, subtotalStr, "1", 1, "C", false, 0, "")
- // Shipping details text (left side)
- g.pdf.SetFont("Helvetica", "", 8)
- g.pdf.SetXY(10, y+5)
- g.pdf.MultiCell(130, 4, data.ShippingDetails, "1", "L", false)
- shippingEndY := g.pdf.GetY()
-
- // GST row
+ // GST row (right side)
g.pdf.SetXY(140, y+5)
g.pdf.SetFont("Helvetica", "B", 9)
g.pdf.SetFillColor(242, 242, 242)
@@ -630,9 +1234,9 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) {
}
g.pdf.CellFormat(30, 5, gstStr, "1", 1, "C", false, 0, "")
- // Total row
+ // Total row (right side)
+ g.pdf.SetXY(140, y+10)
g.pdf.SetFont("Helvetica", "B", 9)
- g.pdf.CellFormat(130, 5, "", "", 0, "L", false, 0, "")
g.pdf.SetFillColor(242, 242, 242)
g.pdf.CellFormat(30, 5, "TOTAL DUE", "1", 0, "L", true, 0, "")
g.pdf.SetFont("Helvetica", "", 9)
@@ -642,10 +1246,23 @@ func (g *Generator) AddInvoiceLineItemsContent(data *InvoicePDFData) {
}
g.pdf.CellFormat(30, 5, totalStr, "1", 1, "C", false, 0, "")
- // Make sure we're past the shipping details
- if g.pdf.GetY() < shippingEndY {
- g.pdf.SetY(shippingEndY)
- }
+ // 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
@@ -664,9 +1281,13 @@ func (g *Generator) Save(filename string) error {
// LineItem represents a line item for the PDF
type LineItem struct {
- ItemNumber string
- Quantity string
- Title string
- UnitPrice float64
- TotalPrice float64
+ ItemNumber string
+ Quantity string
+ Title string
+ Description string
+ UnitPrice float64
+ TotalPrice float64
+ DiscountPercent float64
+ DiscountAmountUnit float64
+ DiscountAmountTotal float64
}
diff --git a/go/internal/cmc/pdf/html_generator.go b/go/internal/cmc/pdf/html_generator.go
new file mode 100644
index 00000000..737892c7
--- /dev/null
+++ b/go/internal/cmc/pdf/html_generator.go
@@ -0,0 +1,389 @@
+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
+}
diff --git a/go/internal/cmc/pdf/html_generator.go.applied b/go/internal/cmc/pdf/html_generator.go.applied
new file mode 100644
index 00000000..e69de29b
diff --git a/go/internal/cmc/pdf/html_generator.go.backup b/go/internal/cmc/pdf/html_generator.go.backup
new file mode 100644
index 00000000..e69de29b
diff --git a/go/internal/cmc/pdf/html_generator.go.patch b/go/internal/cmc/pdf/html_generator.go.patch
new file mode 100644
index 00000000..e69de29b
diff --git a/go/internal/cmc/pdf/templates.go b/go/internal/cmc/pdf/templates.go
index 6c5847ec..ef75e6ef 100644
--- a/go/internal/cmc/pdf/templates.go
+++ b/go/internal/cmc/pdf/templates.go
@@ -202,11 +202,12 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
gen.AddPage()
gen.Page1Header()
- // Title
- gen.pdf.SetFont("Helvetica", "B", 18)
+ // Title - positioned center with proper spacing
+ gen.pdf.SetFont("Helvetica", "B", 20)
gen.pdf.SetTextColor(0, 0, 0)
- gen.pdf.CellFormat(0, 10, "TAX INVOICE", "", 1, "C", false, 0, "")
- gen.pdf.Ln(5)
+ gen.pdf.SetY(54)
+ gen.pdf.CellFormat(0, 8, "TAX INVOICE", "", 1, "C", false, 0, "")
+ gen.pdf.Ln(3)
// Sold To / Delivery Address boxes
gen.AddInvoiceAddressBoxes(data)
@@ -230,9 +231,10 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
}
// Merge with actual terms and conditions PDF using pdfcpu
+ // The T&C file should be mounted in the same directory as output PDFs in stg/prod
termsPath := filepath.Join(outputDir, "CMC_terms_and_conditions2006_A4.pdf")
- // Check if terms file exists
+ // Check if terms file exists and merge
if _, err := os.Stat(termsPath); err == nil {
// Terms file exists, merge it
finalPath := invoicePath
diff --git a/go/internal/cmc/pdf/templates/invoice.html b/go/internal/cmc/pdf/templates/invoice.html
new file mode 100644
index 00000000..7ebb7cce
--- /dev/null
+++ b/go/internal/cmc/pdf/templates/invoice.html
@@ -0,0 +1,441 @@
+
+
+
+
+ {{if .InvoiceNumber}}{{.InvoiceNumber}}{{else}}CMC Invoice{{end}}
+
+
+
+
+
+

+
+
+
CMC TECHNOLOGIES
+
PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224
+
+
+
+
+
+ Unit 19, 77 Bourke Rd
+ Alexandria NSW 2015
+ AUSTRALIA
+
+
+
+
+
+
+
+
+
+
TAX INVOICE
+
+
+ {{if .InvoiceNumber}}
+
INVOICE# {{.InvoiceNumber}}
+ {{end}}
+ {{if .IssueDateString}}
+
Date: {{.IssueDateString}}
+ {{end}}
+ {{if .PageCount}}
+
Page: {{.CurrentPage}} of {{.PageCount}}
+ {{end}}
+
+
+
+
+
+
+
Sold To / Invoice Address:
+
{{.BillTo}}
+
+
+
Delivery Address:
+
{{.ShipTo}}
+
+
+
+
+
+
+
+ | CUSTOMER ORDER NO |
+ CMC JOB # |
+ INCOTERMS 2010 |
+ PAYMENT TERMS |
+ CUSTOMER ABN |
+
+
+
+
+ | {{.CustomerOrderNumber}} |
+ {{.JobTitle}} |
+ {{.FOB}} |
+ {{.PaymentTerms}} |
+ {{.CustomerABN}} |
+
+
+
+
+
+
+ Shown in {{.CurrencyCode}}
+
+
+
+
+
+
+ | DESCRIPTION |
+ QTY |
+ UNIT PRICE |
+ DISCOUNT |
+ TOTAL |
+
+
+
+ {{range .LineItems}}
+
+ {{.Title}} {{.Description}} |
+ {{.Quantity}} |
+ {{formatPrice .GrossUnitPrice}} |
+ {{formatDiscount .DiscountAmountTotal}} |
+ {{formatPrice .GrossPrice}} |
+
+ {{end}}
+
+
+
+
+
+
+
MAKE PAYMENT TO:
+
+ | Account Name: | CMC Technologies Pty Ltd |
+ | BSB: | 062-458 |
+ | Account Number: | 10067982 |
+ | SWIFT Code: | CTBAAU2S |
+ | IBAN: | 0624581006782 |
+
+
+
+
+ | Subtotal: | {{formatTotal .Subtotal}} |
+ {{if .ShowGST}}
+ | GST (10%): | {{formatTotal .GSTAmount}} |
+ {{end}}
+ | TOTAL: | {{formatTotal .Total}} |
+
+
+
+
+
diff --git a/php/app/vendors/description_formatter.php b/php/app/vendors/description_formatter.php
new file mode 100644
index 00000000..a57fade3
--- /dev/null
+++ b/php/app/vendors/description_formatter.php
@@ -0,0 +1,214 @@
+ 0) {
+ $result[] = self::formatOrderedList($listItems);
+ $listItems = array();
+ $inOrderedList = false;
+ }
+ $result[] = '';
+ continue;
+ }
+
+ // Check if this is an ordered list item (starts with "1. ", "2. ", etc.)
+ if (self::isOrderedListItem($trimmed)) {
+ if (!$inOrderedList) {
+ $inOrderedList = true;
+ $listItems = array();
+ }
+ // Extract text after the number
+ $listItems[] = self::extractListItemText($trimmed);
+ } else {
+ // Flush any pending list before processing non-list line
+ if ($inOrderedList && count($listItems) > 0) {
+ $result[] = self::formatOrderedList($listItems);
+ $listItems = array();
+ $inOrderedList = false;
+ }
+
+ // Process regular line
+ $formatted = self::formatLine($trimmed);
+ $result[] = $formatted;
+ }
+ }
+
+ // Flush any remaining list
+ if ($inOrderedList && count($listItems) > 0) {
+ $result[] = self::formatOrderedList($listItems);
+ }
+
+ // Join with line breaks
+ $html = implode('
', $result);
+
+ // Clean up excessive line breaks
+ $html = preg_replace('/
+/', '
', $html);
+
+ return $html;
+ }
+
+ /**
+ * Check if a line is an ordered list item (e.g., "1. text")
+ */
+ private static function isOrderedListItem($line) {
+ return preg_match('/^\d+\.\s+/', $line) === 1;
+ }
+
+ /**
+ * Extract the text part of a list item, removing the number
+ */
+ private static function extractListItemText($line) {
+ $matches = array();
+ if (preg_match('/^\d+\.\s+(.*)$/', $line, $matches)) {
+ return isset($matches[1]) ? $matches[1] : $line;
+ }
+ return $line;
+ }
+
+ /**
+ * Convert an array of list items to HTML ordered list
+ */
+ private static function formatOrderedList($items) {
+ if (count($items) === 0) {
+ return '';
+ }
+
+ $html = '';
+ foreach ($items as $item) {
+ $html .= '- ' . $item . '
';
+ }
+ $html .= '
';
+
+ return $html;
+ }
+
+ /**
+ * Format a single line - mainly applying bold to key labels
+ */
+ private static function formatLine($line) {
+ // Match "Key: value" pattern
+ if (preg_match('/^([^:]+):\s*(.*)$/', $line, $matches)) {
+ $key = $matches[1];
+ $value = isset($matches[2]) ? $matches[2] : '';
+
+ // Check if this key should be bolded
+ if (self::shouldBoldKey($key)) {
+ $line = '' . htmlspecialchars($key, ENT_QUOTES, 'UTF-8') . ': ' . $value;
+ }
+ }
+
+ // Apply italics to specific phrases
+ $line = self::applyItalics($line);
+
+ return $line;
+ }
+
+ /**
+ * Determine if a key should be bolded (heuristic)
+ */
+ private static function shouldBoldKey($key) {
+ $key = trim($key);
+
+ // List of known keys that should be bolded
+ $knownKeys = array(
+ 'Item Code' => true,
+ 'Item Description' => true,
+ 'Type' => true,
+ 'Cable Length' => true,
+ 'Ui' => true,
+ 'li' => true,
+ 'Li, Ci' => true,
+ 'II 2G' => true,
+ 'II 2D' => true,
+ 'IBEx' => true,
+ 'Includes' => true,
+ 'With standard' => true,
+ 'See attached' => true,
+ 'Testing at' => true,
+ );
+
+ // Check exact matches
+ if (isset($knownKeys[$key])) {
+ return true;
+ }
+
+ // Check partial matches for common patterns
+ $lowerKey = strtolower($key);
+ $commonPatterns = array(
+ 'code',
+ 'description',
+ 'type',
+ 'cable',
+ 'temperature',
+ 'pressure',
+ 'includes',
+ 'testing',
+ );
+
+ foreach ($commonPatterns as $pattern) {
+ if (strpos($lowerKey, $pattern) !== false) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Apply italic formatting to specific phrases
+ */
+ private static function applyItalics($line) {
+ $italicPatterns = array(
+ 'See attached EC Conformity Declaration for the SE Sensor',
+ 'If Insulated panels are used',
+ );
+
+ foreach ($italicPatterns as $pattern) {
+ if (strpos($line, $pattern) !== false) {
+ $escapedPattern = htmlspecialchars($pattern, ENT_QUOTES, 'UTF-8');
+ $line = str_replace($escapedPattern, '' . $escapedPattern . '', $line);
+ }
+ }
+
+ return $line;
+ }
+}
+
+?>
diff --git a/php/app/views/documents/pdf_invoice.ctp b/php/app/views/documents/pdf_invoice.ctp
index 4ddd6618..27cbb5f4 100755
--- a/php/app/views/documents/pdf_invoice.ctp
+++ b/php/app/views/documents/pdf_invoice.ctp
@@ -2,7 +2,7 @@
// Generate the Invoice PDF by calling the Go service instead of TCPDF.
$goBaseUrl = AppController::getGoBaseUrlOrFail();
-$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice';
+$goEndpoint = $goBaseUrl . '/go/api/pdf/generate-invoice-html';
$outputDir = Configure::read('pdf_directory');
@@ -13,6 +13,7 @@ foreach ($document['LineItem'] as $li) {
'quantity' => $li['quantity'],
'title' => $li['title'],
'description' => isset($li['description']) ? $li['description'] : '',
+ 'is_html' => true, // Description is always HTML
'unit_price' => floatval($li['gross_unit_price']),
'total_price' => floatval($li['gross_price']),
'net_unit_price' => floatval($li['net_unit_price']),
@@ -27,8 +28,17 @@ foreach ($document['LineItem'] as $li) {
);
}
+$invoiceNumber = '';
+if (!empty($document['Document']['cmc_reference'])) {
+ $invoiceNumber = $document['Document']['cmc_reference'];
+} elseif (!empty($document['Invoice']['title'])) {
+ // Fallback so the Go service always receives a number-like value
+ $invoiceNumber = $document['Invoice']['title'];
+}
+
$payload = array(
'document_id' => intval($document['Document']['id']),
+ 'invoice_number' => $invoiceNumber,
'invoice_title' => $document['Invoice']['title'],
'customer_name' => $enquiry['Customer']['name'],
'contact_email' => $enquiry['Contact']['email'],
diff --git a/php/app/webroot/pdf/CMC Invoice.pdf b/php/app/webroot/pdf/CMC Invoice.pdf
new file mode 100644
index 00000000..2eb2c568
Binary files /dev/null and b/php/app/webroot/pdf/CMC Invoice.pdf differ
diff --git a/php/app/webroot/pdf/CMCIN9387.pdf b/php/app/webroot/pdf/CMCIN9387.pdf
index 9108a610..dae96709 100644
Binary files a/php/app/webroot/pdf/CMCIN9387.pdf and b/php/app/webroot/pdf/CMCIN9387.pdf differ
diff --git a/php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf b/php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf
new file mode 100644
index 00000000..bb44ac92
Binary files /dev/null and b/php/app/webroot/pdf/CMC_terms_and_conditions2006_A4.pdf differ