230 lines
6.4 KiB
Go
230 lines
6.4 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) }
|
|
|
|
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
|
|
}
|