"""Generador del PDF de Cotización (formato maestro) sin dependencias de sistema. Compone un PDF 1.4 válido byte a byte (fuente Helvetica) e incrusta el logo como imagen JPEG (XObject /DCTDecode) usando Pillow para normalizarlo. El branding del emisor (nombre, RFC, dirección, contacto, color) viene de la configuración por tenant. """ from __future__ import annotations import io from decimal import Decimal _PAGE_W = 612 _PAGE_H = 792 _MARGIN = 50 CONCEPT_LABELS = { "flete_internacional": "Flete internacional", "transporte_terrestre": "Transporte terrestre", "despacho_aduanal": "Despacho aduanal", "gastos_destino": "Gastos en destino", "otros": "Otros cargos", } def _esc(text: str) -> str: out = (str(text) if text is not None else "").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} " if currency else "") + f"{d:,.2f}" def _wrap(text: str, width: int) -> list[str]: words = (text or "").split() if not words: return [] lines, cur = [], "" for w in words: cand = f"{cur} {w}".strip() if len(cand) > width and cur: lines.append(cur) cur = w else: cur = cand if cur: lines.append(cur) return lines def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: try: h = (hexs or "#2f6bf0").lstrip("#") return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] except Exception: return (0.184, 0.42, 0.94) def _prep_logo(logo_bytes: bytes | None): """Normaliza el logo a JPEG RGB. Devuelve (jpeg_bytes, w, h) o None.""" if not logo_bytes: return None try: from PIL import Image im = Image.open(io.BytesIO(logo_bytes)).convert("RGB") im.thumbnail((600, 300)) buf = io.BytesIO() im.save(buf, format="JPEG", quality=85) return buf.getvalue(), im.width, im.height except Exception: return None def _kv_lines(pairs: list[tuple[str, str]]) -> list[tuple[str, int]]: out: list[tuple[str, int]] = [] for k, v in pairs: if v not in (None, "", "None"): out.append((f"{k}: {v}", 10)) return out def build_quote_pdf( *, emitter: dict, head: dict, client: dict, cargo: list[tuple[str, str]], route: list[tuple[str, str]], items: list[dict], currency: str, subtotal, terms: str | None, footer: str | None, logo_bytes: bytes | None = None, accent: str | None = "#2f6bf0", ) -> bytes: accent_rgb = _hex_rgb(accent) logo = _prep_logo(logo_bytes) # ---- Cuerpo (debajo del encabezado) ---- body: list[tuple[str, int]] = [] def section(title: str): body.append(("", 6)) body.append((title.upper(), 11)) body.append(("_" * 92, 8)) # Cliente section("Cliente") for line in _kv_lines([ ("Cliente", client.get("name")), ("RFC", client.get("rfc")), ("Correo", client.get("email")), ("Teléfono", client.get("phone")), ]): body.append(line) # Carga / Ruta if cargo: section("Información de la carga") for line in _kv_lines(cargo): body.append(line) if route: section("Ruta logística") for line in _kv_lines(route): body.append(line) # Costos section("Costos cotizados") body.append(("Concepto Cant. Tarifa Importe", 9)) body.append(("-" * 92, 8)) for it in items: code = str(it.get("concept") or "") label = CONCEPT_LABELS.get(code, code) desc = str(it.get("description") or "") if desc: label = f"{label} — {desc}" qty = Decimal(str(it.get("quantity") or 0)) unit = Decimal(str(it.get("unit_sale") or 0)) amount = (qty * unit).quantize(Decimal("0.01")) row = f"{label[:40].ljust(40)} {qty:>6.2f} {unit:>14,.2f} {amount:>14,.2f}" body.append((row, 9)) body.append(("-" * 92, 8)) body.append((f"Subtotal {currency}: {_money(subtotal)}", 11)) body.append(("IVA: según aplique", 9)) body.append((f"Total {currency}: {_money(subtotal)} + IVA", 12)) # Condiciones if terms: section("Condiciones comerciales") for para in terms.splitlines(): for line in _wrap(para, 105) or [""]: body.append((line, 9)) # ---- Paginación (página 1 con encabezado; siguientes solo cuerpo) ---- p1_top = _PAGE_H - 150 # y donde inicia el cuerpo en la página 1 pN_top = _PAGE_H - _MARGIN line_h = 14 pages: list[list[tuple[float, tuple[str, int]]]] = [] cur: list[tuple[float, tuple[str, int]]] = [] y = p1_top for item in body: if y < _MARGIN + 40: pages.append(cur) cur = [] y = pN_top cur.append((y, item)) y -= line_h pages.append(cur) # ---- Content streams ---- streams: list[bytes] = [] for pi, page in enumerate(pages): parts: list[str] = [] if pi == 0: # barra de acento arriba r, g, b = accent_rgb parts.append(f"{r:.3f} {g:.3f} {b:.3f} rg") parts.append(f"0 {_PAGE_H - 8} {_PAGE_W} 8 re f") # logo logo_y = _PAGE_H - 30 if logo: _, lw, lh = logo dw = 150.0 dh = dw * lh / lw if dh > 60: dh = 60.0 dw = dh * lw / lh parts.append(f"q {dw:.2f} 0 0 {dh:.2f} {_MARGIN} {logo_y - dh:.2f} cm /Im0 Do Q") # emisor (columna derecha) ex = 330 ey = logo_y - 6 parts.append("BT /F1 12 Tf 0.09 0.14 0.24 rg") parts.append(f"1 0 0 1 {ex} {ey} Tm ({_esc(emitter.get('name') or 'Emisor')}) Tj") parts.append("/F1 9 Tf 0.35 0.41 0.5 rg 13 TL") em_lines = [] if emitter.get("rfc"): em_lines.append(f"RFC: {emitter['rfc']}") for a in (emitter.get("address") or "").splitlines(): if a.strip(): em_lines.append(a.strip()) contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x]) if contact: em_lines.append(contact) for ln in em_lines[:5]: parts.append(f"T* ({_esc(ln)}) Tj") parts.append("ET") # título parts.append("BT /F1 22 Tf 0.09 0.14 0.24 rg") parts.append(f"1 0 0 1 {_MARGIN} {_PAGE_H - 120} Tm (COTIZACION) Tj ET") # datos de cabecera (derecha del título) parts.append("BT /F1 9 Tf 0.2 0.25 0.35 rg 12 TL") parts.append(f"1 0 0 1 330 {_PAGE_H - 100} Tm ({_esc('No.: ' + str(head.get('reference') or '-'))}) Tj") for hl in [ f"Fecha: {head.get('issue_date') or '-'}", f"Vigencia: {head.get('valid_until') or '-'}", f"Ejecutivo: {head.get('owner') or '-'} Estatus: {head.get('status') or '-'}", ]: parts.append(f"T* ({_esc(hl)}) Tj") parts.append("ET") # cuerpo for yy, (text, size) in page: parts.append(f"BT /F1 {size} Tf 0 0 0 rg 1 0 0 1 {_MARGIN} {yy:.2f} Tm ({_esc(text)}) Tj ET") # pie if footer: parts.append(f"BT /F1 8 Tf 0.5 0.5 0.5 rg 1 0 0 1 {_MARGIN} {_MARGIN - 20} Tm ({_esc(footer[:110])}) Tj ET") streams.append("\n".join(parts).encode("latin-1", "replace")) # ---- Ensamblado de objetos ---- objects: list[bytes] = [] def add(obj: bytes) -> int: objects.append(obj) return len(objects) n_pages = len(pages) has_img = 1 if logo else 0 font_num = 3 img_num = 4 if has_img else None base = 5 if has_img else 4 page_nums = list(range(base, base + n_pages)) content_nums = list(range(base + n_pages, base + 2 * n_pages)) kids = " ".join(f"{n} 0 R" for n in page_nums) add(b"<< /Type /Catalog /Pages 2 0 R >>") add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1")) add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") if logo: jpeg, lw, lh = logo img_obj = ( f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} " f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n" ).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream" add(img_obj) for i in range(n_pages): res = f"/Font << /F1 {font_num} 0 R >>" if has_img and i == 0: res += f" /XObject << /Im0 {img_num} 0 R >>" page_dict = ( f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" ) add(page_dict.encode("latin-1")) for stream in streams: add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream") 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) total = len(objects) + 1 out += f"xref\n0 {total}\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 {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1") return bytes(out)