package pdf import ( "bytes" "encoding/base64" "fmt" "net/http" "os" "path/filepath" "strings" "time" "github.com/go-pdf/fpdf" ) type DocMeta struct { CompanyName string CompanyNIF string CompanyAddress string CompanyIBAN string CompanyPhone string CompanyEmail string CompanyLogo string DocType string // "Fatura" or "Orçamento" DocNumber string // e.g. "FAT/2026/001" IssuedAt string // e.g. "30/06/2026" ClientName string ClientNIF string VehiclePlate string } type LineItem struct { Description string Qty float64 UnitPrice float64 DiscountPct float64 Total float64 } func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error { if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return fmt.Errorf("pdf: mkdir: %w", err) } f := fpdf.New("P", "mm", "A4", "") f.AddPage() f.SetMargins(15, 15, 15) tr := f.UnicodeTranslatorFromDescriptor("") t := func(s string) string { return tr(s) } headerTop := 15.0 leftX := 15.0 leftW := 120.0 rightX := 135.0 rightW := 60.0 // Header (left): logo always reserves its own vertical space. leftY := headerTop const logoSize = 24.0 if addLogo(f, meta.CompanyLogo, leftX, leftY, logoSize, logoSize) { leftY += logoSize + 3 } f.SetXY(leftX, leftY) f.SetFont("Helvetica", "B", 18) f.CellFormat(leftW, 10, t(meta.CompanyName), "", 1, "L", false, 0, "") leftY = f.GetY() f.SetFont("Helvetica", "", 9) if meta.CompanyNIF != "" { f.SetXY(leftX, leftY) f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 1, "L", false, 0, "") leftY = f.GetY() } if meta.CompanyAddress != "" { f.SetXY(leftX, leftY) f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false) leftY = f.GetY() } // Header (right): document meta. f.SetXY(rightX, headerTop) f.SetFont("Helvetica", "B", 14) f.CellFormat(rightW, 10, t(meta.DocType), "", 1, "R", false, 0, "") f.SetFont("Helvetica", "", 11) f.SetXY(rightX, headerTop+15) f.CellFormat(rightW, 6, t(meta.DocNumber), "", 1, "R", false, 0, "") f.SetXY(rightX, headerTop+26) f.CellFormat(rightW, 6, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "") // Continue after whichever side ended lower. rightBottom := headerTop + 32 if leftY < rightBottom { leftY = rightBottom } f.SetY(leftY + 4) // Client block if meta.ClientName != "" { f.SetFont("Helvetica", "B", 9) f.CellFormat(180, 5, t("Cliente"), "", 1, "L", false, 0, "") f.SetFont("Helvetica", "", 9) f.CellFormat(180, 5, t(meta.ClientName), "", 1, "L", false, 0, "") if meta.ClientNIF != "" { f.CellFormat(180, 5, t("NIF: ")+meta.ClientNIF, "", 1, "L", false, 0, "") } if meta.VehiclePlate != "" { f.CellFormat(180, 5, t("Matrícula: ")+meta.VehiclePlate, "", 1, "L", false, 0, "") } f.Ln(4) } // Table header f.SetFillColor(50, 50, 50) f.SetTextColor(255, 255, 255) f.SetFont("Helvetica", "B", 9) f.CellFormat(90, 7, t("Descrição"), "1", 0, "L", true, 0, "") f.CellFormat(20, 7, t("Qtd."), "1", 0, "C", true, 0, "") f.CellFormat(25, 7, t("Preço Unit."), "1", 0, "R", true, 0, "") f.CellFormat(20, 7, t("Desc.%"), "1", 0, "C", true, 0, "") f.CellFormat(25, 7, t("Total"), "1", 1, "R", true, 0, "") // Table rows f.SetFillColor(245, 245, 245) f.SetTextColor(0, 0, 0) f.SetFont("Helvetica", "", 9) fill := false for _, item := range items { f.CellFormat(90, 6, t(item.Description), "1", 0, "L", fill, 0, "") f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "") f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.Total), "1", 1, "R", fill, 0, "") fill = !fill } // Totals var subtotal float64 for _, item := range items { subtotal += item.Total } grandTotal := subtotal + staffTotal f.Ln(3) f.SetFont("Helvetica", "", 9) if staffTotal > 0 { f.CellFormat(155, 6, t("Subtotal peças/serviços"), "", 0, "R", false, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "") f.CellFormat(155, 6, t("Mão de obra"), "", 0, "R", false, 0, "") f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "") } f.SetFont("Helvetica", "B", 10) f.CellFormat(155, 7, t("TOTAL"), "", 0, "R", false, 0, "") f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "") // IBAN footer if meta.CompanyIBAN != "" { f.Ln(8) f.SetFont("Helvetica", "", 8) f.CellFormat(180, 5, t("IBAN: ")+meta.CompanyIBAN, "", 1, "C", false, 0, "") } return f.OutputFileAndClose(outPath) } func addLogo(f *fpdf.Fpdf, logo string, x, y, w, h float64) bool { logo = strings.TrimSpace(logo) if logo == "" { return false } if strings.HasPrefix(logo, "data:image/") { comma := strings.Index(logo, ",") if comma <= 0 { return false } meta := logo[:comma] payload := logo[comma+1:] if !strings.Contains(meta, ";base64") { return false } raw, err := base64.StdEncoding.DecodeString(payload) if err != nil { return false } imgType := "PNG" if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") { imgType = "JPG" } opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: false} f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw)) f.ImageOptions("company_logo", x, y, w, h, false, opts, 0, "") return true } if strings.HasPrefix(logo, "http://") || strings.HasPrefix(logo, "https://") { client := http.Client{Timeout: 5 * time.Second} resp, err := client.Get(logo) if err != nil { return false } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return false } buf := new(bytes.Buffer) if _, err := buf.ReadFrom(resp.Body); err != nil { return false } imgType := "PNG" ct := strings.ToLower(resp.Header.Get("Content-Type")) if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") { imgType = "JPG" } opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: false} f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes())) f.ImageOptions("company_logo", x, y, w, h, false, opts, 0, "") return true } if _, err := os.Stat(logo); err == nil { opts := fpdf.ImageOptions{ReadDpi: false} f.ImageOptions(logo, x, y, w, h, false, opts, 0, "") return true } return false }