Files
techxcar/backend/pkg/pdf/pdf.go
T
2026-07-02 13:42:47 +01:00

217 lines
6.0 KiB
Go

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) }
hasLogo := addLogo(f, meta.CompanyLogo)
leftW := 120.0
if hasLogo {
leftW = 100.0
}
// Header
f.SetFont("Helvetica", "B", 18)
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "")
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false)
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// 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) 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: true}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
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: true}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes()))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
if _, err := os.Stat(logo); err == nil {
opts := fpdf.ImageOptions{ReadDpi: true}
f.ImageOptions(logo, 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
return false
}