"""Generador de PDF de factura sin dependencias externas. Se evita ``pdfkit`` (requiere el binario ``wkhtmltopdf``, ausente en el contenedor) y librerías extra. Produce un PDF válido de una o varias páginas con la fuente estándar Helvetica (no requiere incrustar fuentes). El texto se codifica en WinAnsi/Latin-1; los caracteres fuera de ese rango se sustituyen para no romper el flujo de contenido. """ from __future__ import annotations from decimal import Decimal from typing import Sequence _PAGE_W = 612 # carta (8.5in) en puntos _PAGE_H = 792 # carta (11in) _MARGIN = 56 _LINE_H = 16 _LINES_PER_PAGE = 42 def _esc(text: str) -> str: """Escapa y codifica una cadena para un literal de texto PDF (WinAnsi).""" out = (text or "").encode("latin-1", "replace").decode("latin-1") return out.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") def _money(value, currency: str) -> str: d = Decimal(str(value or 0)).quantize(Decimal("0.01")) return f"{currency} {d:,.2f}" def _wrap(text: str, width: int) -> list[str]: text = text or "" words = text.split() if not words: return [""] lines: list[str] = [] current = "" for word in words: candidate = f"{current} {word}".strip() if len(candidate) > width and current: lines.append(current) current = word else: current = candidate if current: lines.append(current) return lines def _build_lines( *, folio: str, issue_date: str, due_date: str, account_name: str, currency: str, items: Sequence[dict], subtotal, tax_rate, tax_amount, total, paid, balance, bank_info: str | None, notes: str | None, ) -> list[tuple[str, int]]: """Devuelve una lista de (texto, tamaño_fuente) que compone el cuerpo.""" L: list[tuple[str, int]] = [] L.append(("FACTURA", 20)) L.append((f"Folio: {folio or 's/f'}", 11)) L.append((f"Fecha de emision: {issue_date or '-'} Vencimiento: {due_date or '-'}", 11)) L.append(("", 11)) L.append((f"Cliente: {account_name or '-'}", 12)) L.append(("", 11)) L.append(("Conceptos", 13)) L.append(("-" * 78, 10)) L.append(("Cant. Concepto P. unitario Importe", 10)) L.append(("-" * 78, 10)) for it in items: concept = str(it.get("concept") or "") desc = str(it.get("description") or "") qty = Decimal(str(it.get("quantity") or 0)) unit = Decimal(str(it.get("unit_amount") or 0)) amount = (qty * unit).quantize(Decimal("0.01")) label = concept if not desc else f"{concept} — {desc}" label = label[:42].ljust(42) row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}" L.append((row, 10)) L.append(("-" * 78, 10)) L.append(("", 11)) L.append((f"Subtotal: {_money(subtotal, currency)}", 11)) L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11)) L.append((f"Total: {_money(total, currency)}", 13)) L.append((f"Pagado: {_money(paid, currency)}", 11)) L.append((f"Saldo: {_money(balance, currency)}", 12)) if bank_info: L.append(("", 11)) L.append(("Datos bancarios / de pago", 12)) for line in _wrap(bank_info, 90): L.append((line, 10)) if notes: L.append(("", 11)) L.append(("Notas", 12)) for line in _wrap(notes, 90): L.append((line, 10)) return L def build_invoice_pdf(**kwargs) -> bytes: """Construye el PDF de la factura y devuelve los bytes.""" lines = _build_lines(**kwargs) # Paginar el cuerpo pages: list[list[tuple[str, int]]] = [] for i in range(0, len(lines), _LINES_PER_PAGE): pages.append(lines[i : i + _LINES_PER_PAGE]) if not pages: pages = [[("FACTURA", 20)]] # Un content stream por página content_streams: list[bytes] = [] for page_lines in pages: parts = ["BT", f"/F1 11 Tf", f"1 0 0 1 {_MARGIN} {_PAGE_H - _MARGIN} Tm", f"{_LINE_H} TL"] first = True for text, size in page_lines: parts.append(f"/F1 {size} Tf") if first: parts.append(f"({_esc(text)}) Tj") first = False else: parts.append(f"T* ({_esc(text)}) Tj") parts.append("ET") content_streams.append("\n".join(parts).encode("latin-1", "replace")) # Ensamblado de objetos PDF objects: list[bytes] = [] def add(obj: bytes) -> int: objects.append(obj) return len(objects) # número de objeto (1-indexado) # Reservamos números: catalog(1), pages(2), font(3), luego páginas y streams font_obj_num = 3 page_obj_nums: list[int] = [] content_obj_nums: list[int] = [] # Precalcular números de páginas y streams next_num = 4 for _ in pages: page_obj_nums.append(next_num) next_num += 1 for _ in pages: content_obj_nums.append(next_num) next_num += 1 kids = " ".join(f"{n} 0 R" for n in page_obj_nums) add(f"<< /Type /Catalog /Pages 2 0 R >>".encode("latin-1")) add(f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode("latin-1")) add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") for i, _ in enumerate(pages): page_dict = ( f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " f"/Resources << /Font << /F1 {font_obj_num} 0 R >> >> " f"/Contents {content_obj_nums[i]} 0 R >>" ) add(page_dict.encode("latin-1")) for stream in content_streams: obj = b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream" add(obj) # Serialización con tabla xref out = bytearray() out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" offsets: list[int] = [] for i, obj in enumerate(objects, start=1): offsets.append(len(out)) out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n" xref_pos = len(out) n = len(objects) + 1 out += f"xref\n0 {n}\n".encode("latin-1") out += b"0000000000 65535 f \n" for off in offsets: out += f"{off:010d} 00000 n \n".encode("latin-1") out += f"trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1") return bytes(out)