---
name: pdf-report
description: Genera informes en PDF reproducibles desde Python con ReportLab - portada, tablas que no se cortan, tipografías embebidas y salida idéntica en cada ejecución. Úsala al exportar a PDF un informe de estado, una auditoría, un CV o cualquier documento que se vaya a enviar fuera.
version: 1.0.0
license: CC-BY-4.0
updated: 2026-08-16
---

# Exportar un informe a PDF

Un PDF que sale de un script se envía a gente que no va a poder pedir
correcciones. Se genera entero desde el dato, no a mano, y **dos ejecuciones
con los mismos datos producen el mismo fichero**.

## Elegir la herramienta

| Caso | Herramienta |
|---|---|
| Informe con tablas y texto fluido | **ReportLab Platypus** |
| Maquetación pixel a pixel (tarjeta, diploma, CV) | **ReportLab canvas** |
| Ya existe una versión HTML fiel | **Playwright** → `page.pdf()` |

No mezcles: elegir canvas para un informe de 12 páginas significa calcular
saltos de página a mano, y ese cálculo siempre acaba mal.

## Informe con Platypus

```python
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.platypus import (
    SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak,
)
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle

doc = SimpleDocTemplate(
    str(salida), pagesize=A4,
    leftMargin=20 * mm, rightMargin=20 * mm,
    topMargin=18 * mm, bottomMargin=18 * mm,
    title="Estado del proyecto", author="…",   # metadatos: se ven en el visor
)

estilos = getSampleStyleSheet()
cuerpo = ParagraphStyle('cuerpo', parent=estilos['BodyText'],
                        fontName='Inter', fontSize=9.5, leading=13.5)

historia = [
    Paragraph("Estado del proyecto", estilos['Title']),
    Spacer(1, 6 * mm),
    Paragraph(resumen, cuerpo),
]
doc.build(historia, onFirstPage=portada, onLaterPages=pie)
```

**El texto va siempre en `Paragraph`, nunca como cadena suelta**: solo el
párrafo sabe partir líneas. Y `Paragraph` interpreta un subconjunto de HTML
(`<b>`, `<i>`, `<font>`), así que cualquier dato que venga de fuera hay que
escaparlo: un `&` sin escapar rompe la generación entera.

## Tablas que sobreviven al salto de página

```python
tabla = Table(filas, colWidths=[70*mm, 30*mm, 40*mm, 30*mm], repeatRows=1)
tabla.setStyle(TableStyle([
    ('FONTNAME',   (0, 0), (-1, 0), 'Inter-Bold'),
    ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1efe8')),
    ('ALIGN',      (1, 1), (-1, -1), 'RIGHT'),     # números a la derecha
    ('VALIGN',     (0, 0), (-1, -1), 'TOP'),
    ('LINEBELOW',  (0, 0), (-1, 0), 0.5, colors.HexColor('#e0dfd8')),
    ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#faf9f6')]),
]))
```

- `repeatRows=1` repite la cabecera en cada página. Sin ella, la página 3 es
  una tabla de números sin significado.
- **Anchos de columna explícitos.** El automático reparte mal en cuanto una
  celda es larga.
- Las celdas con texto largo también van en `Paragraph`, o se salen de la
  celda sin avisar.
- Números alineados a la derecha y con el mismo número de decimales. Una
  columna de importes desalineada es el detalle que hace dudar del resto.

## Tipografías y colores

```python
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

pdfmetrics.registerFont(TTFont('Inter', ruta / 'Inter-Regular.ttf'))
pdfmetrics.registerFont(TTFont('Inter-Bold', ruta / 'Inter-Bold.ttf'))
```

Registra las TTF del proyecto: las Type1 por defecto (Helvetica) no llevan
acentos completos según la codificación y el PDF sale con huecos. Y usa la
misma paleta que el resto de la marca en `colors.HexColor`, para que el PDF no
parezca de otra empresa.

## Encabezado, pie y numeración

```python
def pie(canvas, doc):
    canvas.saveState()
    canvas.setFont('Inter', 7.5)
    canvas.setFillColor(colors.HexColor('#4a4943'))
    canvas.drawString(20 * mm, 12 * mm, f"{titulo} · {fecha:%d/%m/%Y}")
    canvas.drawRightString(A4[0] - 20 * mm, 12 * mm, f"{doc.page}")
    canvas.restoreState()
```

`saveState`/`restoreState` siempre: sin ellos, la fuente del pie se filtra a la
página siguiente. Para "página X de Y" hace falta una pasada doble
(`doc.multiBuild` con un marcador), porque el total no se conoce hasta el final.

## Checklist antes de enviar

- [ ] Fecha de generación y origen del dato visibles en el documento.
- [ ] Acentos, `ñ` y `€` correctos —abre el PDF, no confíes en que no falló—.
- [ ] Ninguna tabla cortada a mitad de fila ni texto fuera de márgenes.
- [ ] Metadatos (`title`, `author`) puestos: es lo que se ve en la pestaña del
      visor y al adjuntarlo.
- [ ] Salida a un directorio de build, nunca sobreescribiendo la fuente.
- [ ] Script idempotente: reejecutarlo no cambia nada más que el fichero.
- [ ] Sin datos personales o confidenciales que no correspondan al
      destinatario. Un PDF se reenvía solo.
