"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema. Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección, tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado (JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant. """ from __future__ import annotations import io from decimal import Decimal _W = 612 _H = 792 _ML = 50 # margen izquierdo _MR = 562 # margen derecho (x) CONCEPT_LABELS = { "flete_internacional": "Flete internacional", "transporte_terrestre": "Transporte terrestre", "despacho_aduanal": "Despacho aduanal", "gastos_destino": "Gastos en destino", "otros": "Otros cargos", } _TRANSLATE = str.maketrans({"—": "-", "–": "-", "“": '"', "”": '"', "‘": "'", "’": "'", "•": "-", "…": "...", "\t": " "}) def _esc(text) -> str: s = ("" if text is None else str(text)).translate(_TRANSLATE) s = s.encode("latin-1", "replace").decode("latin-1") return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") def _money(value) -> str: return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}" def _num(value) -> str: return f"{Decimal(str(value or 0)):,.2f}" # Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar) def _text_w(s: str, size: float, bold: bool = False) -> float: return len(s) * size * (0.56 if bold else 0.52) def _fit(s: str, size: float, max_w: float) -> str: s = s or "" if _text_w(s, size) <= max_w: return s while s and _text_w(s + "…", size) > max_w: s = s[:-1] return s + "…" def _wrap(text: str, width_chars: int) -> list[str]: words = (text or "").split() if not words: return [] out, cur = [], "" for w in words: cand = f"{cur} {w}".strip() if len(cand) > width_chars and cur: out.append(cur) cur = w else: cur = cand if cur: out.append(cur) return out def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: try: h = (hexs or "#12294c").lstrip("#") return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] except Exception: return (0.07, 0.16, 0.30) def _prep_logo(logo_bytes: bytes | 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=88) return buf.getvalue(), im.width, im.height except Exception: return None class _Canvas: """Acumula operadores de contenido con paginación simple.""" def __init__(self): self.pages: list[list[str]] = [[]] self.y = _H @property def ops(self) -> list[str]: return self.pages[-1] def new_page(self): self.pages.append([]) self.y = _H - 50 def ensure(self, needed: float): if self.y - needed < 50: self.new_page() def rect(self, x, y, w, h, rgb): r, g, b = rgb self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f") def line(self, x1, y1, x2, y2, rgb, width=0.6): r, g, b = rgb self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S") def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False): font = "F2" if bold else "F1" r, g, b = rgb tx = x - _text_w(str(s), size, bold) if right else x self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET") 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 = "#12294c", ) -> bytes: ACC = _hex_rgb(accent) INK = (0.10, 0.15, 0.24) GRAY = (0.42, 0.47, 0.55) LINE = (0.80, 0.84, 0.90) ZEBRA = (0.955, 0.965, 0.980) logo = _prep_logo(logo_bytes) c = _Canvas() # ---------------- Encabezado ---------------- c.rect(0, _H - 12, _W, 12, ACC) # banda superior logo_bottom = _H - 95 if logo: _, lw, lh = logo dw, dh = 150.0, 150.0 * lh / lw if dh > 55: dh, dw = 55.0, 55.0 * lw / lh c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q") else: c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True) # Emisor (derecha) ex, ey = 320, _H - 42 c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True) ey -= 14 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]: c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY) ey -= 11 # Título + regla c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True) c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2) # Panel de datos (derecha) px, pw = 320, _MR - 320 py_top = _H - 128 ph = 74 c.rect(px, py_top - ph, pw, ph, ZEBRA) c.line(px, py_top, px, py_top - ph, LINE) hy = py_top - 15 info = [ ("No.", head.get("reference") or "-"), ("Fecha", head.get("issue_date") or "-"), ("Vigencia", head.get("valid_until") or "-"), ("Ejecutivo", head.get("owner") or "-"), ("Estatus", str(head.get("status") or "-").capitalize()), ] for k, v in info: c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True) c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK) hy -= 12.5 c.y = _H - 215 # ---------------- Helpers de sección ---------------- def section(title: str): c.ensure(30) c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC) c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True) c.y -= 26 def kv_block(pairs: list[tuple[str, str]]): rows = [(k, v) for k, v in pairs if v not in (None, "", "None")] if not rows: return False col_w = (_MR - _ML) / 2 i = 0 while i < len(rows): c.ensure(16) for col in range(2): if i + col < len(rows): k, v = rows[i + col] x = _ML + 6 + col * col_w c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True) c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK) c.y -= 16 i += 2 c.y -= 4 return True # ---------------- Cliente ---------------- section("Cliente") if not kv_block([ ("Cliente", client.get("name")), ("RFC", client.get("rfc")), ("Correo", client.get("email")), ("Teléfono", client.get("phone")), ]): c.text(_ML + 6, c.y - 11, "—", 9, GRAY) c.y -= 16 # ---------------- Carga / Ruta (solo si hay datos) ---------------- if [v for _, v in cargo if v not in (None, "", "None")]: section("Información de la carga") kv_block(cargo) if [v for _, v in route if v not in (None, "", "None")]: section("Ruta logística") kv_block(route) # ---------------- Costos ---------------- section("Costos cotizados") x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6 row_h = 18 # encabezado de tabla c.ensure(row_h) c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC) c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True) c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True) c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True) c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True) c.y -= row_h z = False 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")) c.ensure(row_h) if z: c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA) c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK) c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True) c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True) c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True) c.y -= row_h z = not z if not items: c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY) c.y -= row_h # borde de la tabla c.line(_ML, c.y, _MR, c.y, LINE) c.y -= 12 # ---------------- Totales (caja derecha) ---------------- tb_x, tb_w = 360, _MR - 360 c.ensure(58) c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA) c.line(tb_x, c.y, tb_x, c.y - 58, LINE) ty = c.y - 16 c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True) c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True) ty -= 15 c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True) c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True) ty -= 6 c.rect(tb_x, ty - 20, tb_w, 20, ACC) c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True) c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True) c.y -= 70 # ---------------- Condiciones ---------------- if terms: section("Condiciones comerciales") for para in terms.splitlines(): for ln in (_wrap(para, 108) or [""]): c.ensure(13) c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY) c.y -= 12 c.y -= 4 # pie en todas las páginas for ops in c.pages: if footer: r, g, b = GRAY ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET") ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f") # ---------------- Ensamblado ---------------- streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages] objects: list[bytes] = [] def add(obj: bytes): objects.append(obj) n_pages = len(c.pages) has_img = 1 if logo else 0 # numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams img_num = 5 if has_img else None base = 6 if has_img else 5 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 >>") add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") if logo: jpeg, lw, lh = logo add( ( 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" ) for i in range(n_pages): res = "/Font << /F1 3 0 R /F2 4 0 R >>" if has_img and i == 0: res += f" /XObject << /Im0 {img_num} 0 R >>" add( ( f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] " f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" ).encode("latin-1") ) for stream in streams: add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream") out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") offsets = [] 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") + 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)