feat(crm): rediseño profesional del PDF de cotización
Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de sección en color, tabla de costos con encabezado y filas alternadas, caja de totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1); oculta secciones sin datos; color por defecto navy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
"""Generador del PDF de Cotización (formato maestro) sin dependencias de sistema.
|
||||
"""Generador del PDF de Cotización — diseño profesional, 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.
|
||||
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
|
||||
@@ -11,9 +10,10 @@ from __future__ import annotations
|
||||
import io
|
||||
from decimal import Decimal
|
||||
|
||||
_PAGE_W = 612
|
||||
_PAGE_H = 792
|
||||
_MARGIN = 50
|
||||
_W = 612
|
||||
_H = 792
|
||||
_ML = 50 # margen izquierdo
|
||||
_MR = 562 # margen derecho (x)
|
||||
|
||||
CONCEPT_LABELS = {
|
||||
"flete_internacional": "Flete internacional",
|
||||
@@ -23,44 +23,63 @@ CONCEPT_LABELS = {
|
||||
"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"\)")
|
||||
_TRANSLATE = str.maketrans({"—": "-", "–": "-", "“": '"', "”": '"', "‘": "'", "’": "'", "•": "-", "…": "...", "\t": " "})
|
||||
|
||||
|
||||
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 _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 _wrap(text: str, width: int) -> list[str]:
|
||||
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 []
|
||||
lines, cur = [], ""
|
||||
out, cur = [], ""
|
||||
for w in words:
|
||||
cand = f"{cur} {w}".strip()
|
||||
if len(cand) > width and cur:
|
||||
lines.append(cur)
|
||||
if len(cand) > width_chars and cur:
|
||||
out.append(cur)
|
||||
cur = w
|
||||
else:
|
||||
cur = cand
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
return lines
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
|
||||
try:
|
||||
h = (hexs or "#2f6bf0").lstrip("#")
|
||||
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.184, 0.42, 0.94)
|
||||
return (0.07, 0.16, 0.30)
|
||||
|
||||
|
||||
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:
|
||||
@@ -69,18 +88,44 @@ def _prep_logo(logo_bytes: bytes | None):
|
||||
im = Image.open(io.BytesIO(logo_bytes)).convert("RGB")
|
||||
im.thumbnail((600, 300))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, format="JPEG", quality=85)
|
||||
im.save(buf, format="JPEG", quality=88)
|
||||
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
|
||||
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(
|
||||
@@ -96,105 +141,33 @@ def build_quote_pdf(
|
||||
terms: str | None,
|
||||
footer: str | None,
|
||||
logo_bytes: bytes | None = None,
|
||||
accent: str | None = "#2f6bf0",
|
||||
accent: str | None = "#12294c",
|
||||
) -> bytes:
|
||||
accent_rgb = _hex_rgb(accent)
|
||||
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)
|
||||
|
||||
# ---- Cuerpo (debajo del encabezado) ----
|
||||
body: list[tuple[str, int]] = []
|
||||
c = _Canvas()
|
||||
|
||||
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
|
||||
# ---------------- Encabezado ----------------
|
||||
c.rect(0, _H - 12, _W, 12, ACC) # banda superior
|
||||
logo_bottom = _H - 95
|
||||
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")
|
||||
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']}")
|
||||
@@ -205,41 +178,161 @@ def build_quote_pdf(
|
||||
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"))
|
||||
c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY)
|
||||
ey -= 11
|
||||
|
||||
# ---- Ensamblado de objetos ----
|
||||
# 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) -> int:
|
||||
def add(obj: bytes):
|
||||
objects.append(obj)
|
||||
return len(objects)
|
||||
|
||||
n_pages = len(pages)
|
||||
n_pages = len(c.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
|
||||
# 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))
|
||||
|
||||
@@ -247,35 +340,36 @@ def build_quote_pdf(
|
||||
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
|
||||
img_obj = (
|
||||
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"
|
||||
add(img_obj)
|
||||
)
|
||||
for i in range(n_pages):
|
||||
res = f"/Font << /F1 {font_num} 0 R >>"
|
||||
res = "/Font << /F1 3 0 R /F2 4 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}] "
|
||||
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")
|
||||
)
|
||||
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] = []
|
||||
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")
|
||||
out += b"0000000000 65535 f \n"
|
||||
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")
|
||||
|
||||
@@ -98,7 +98,7 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int)
|
||||
"email": settings.emitter_email if settings else None,
|
||||
"website": settings.emitter_website if settings else None,
|
||||
}
|
||||
accent = (settings.accent_color if settings else None) or "#2f6bf0"
|
||||
accent = (settings.accent_color if settings else None) or "#12294c"
|
||||
prefix = (settings.quote_prefix if settings else None) or "COT"
|
||||
terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS
|
||||
footer = settings.footer_note if settings else None
|
||||
|
||||
Reference in New Issue
Block a user