ReportLab genera PDF desde Python sin depender de ningún programa externo. Esta página rehace de arriba abajo un ejemplo que teníamos de 2017: las dos formas de usarlo, una factura completa con tabla y pie de página, y los importes con la coma decimal que toca.
También te puede interesar
Instalación
pip install reportlab
salidaPython 3.14.8 reportlab 5.0.1
No hace falta nada más: ReportLab no depende de ningún programa externo, al contrario que las herramientas que generan PDF pasando por LaTeX o por un navegador.
Dos formas de hacerlo, y cuál elegir
canvas | platypus | |
|---|---|---|
| Qué es | dibujar en coordenadas | componer con bloques que fluyen |
| Tú decides | dónde va cada cosa | qué va, y en qué orden |
| Saltos de página | a mano | automáticos |
| Ajuste de línea | a mano | automático |
| Para | etiquetas, recibos, posiciones fijas | informes, facturas, cartas |
La regla práctica: si el contenido tiene una longitud que no controlas —una lista de pedidos, un texto largo—, platypus. Si lo que haces es rellenar un formulario con huecos fijos, canvas.
El camino corto: canvas
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
ANCHO, ALTO = A4
c = canvas.Canvas("recibo.pdf", pagesize=A4)
c.setTitle("Recibo 2026-0042") # sale en la pestaña del visor
c.setFont("Helvetica-Bold", 18)
c.drawString(25*mm, ALTO - 30*mm, "Recibo nº 2026-0042")
c.setFont("Helvetica", 11)
c.drawString(25*mm, ALTO - 40*mm, "Cliente: Ana Pérez")
c.drawRightString(ANCHO - 25*mm, ALTO - 40*mm, "7 de octubre de 2026")
c.setStrokeColorRGB(0.12, 0.39, 0.69)
c.line(25*mm, ALTO - 45*mm, ANCHO - 25*mm, ALTO - 45*mm)
c.drawCentredString(ANCHO/2, 40*mm, "Gracias por su compra")
c.showPage() # cierra la página
c.save() # y escribe el archivosalidarecibo.pdf: 1706 bytes cabecera: b'%PDF-1.3' A4 en puntos: 595.3 x 841.9 (1 pulgada = 72 puntos)
ALTO - 30*mm: «treinta milímetros desde arriba». Es el despiste número uno con canvas.Y la unidad del PDF es el punto: 72 por pulgada. Trabajar en puntos a mano es incómodo, así que se importa mm (o cm, o inch) y se multiplica.
El camino normal: platypus
Aquí no se dibuja: se prepara una lista de bloques —párrafos, tablas, imágenes, espacios— y el documento se encarga de colocarlos, partir el texto y pasar de página.
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_JUSTIFY
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
TableStyle, Image, PageBreak)
estilos = getSampleStyleSheet()
estilos.add(ParagraphStyle("Justificado", parent=estilos["BodyText"],
alignment=TA_JUSTIFY, leading=15))
estilos.add(ParagraphStyle("TituloAzul", parent=estilos["Heading1"],
textColor=MARCA, spaceAfter=10))
historia = [
Image("logo.png", 18*mm, 18*mm, hAlign="LEFT"),
Spacer(1, 4*mm),
Paragraph("Factura 2026-0042", estilos["TituloAzul"]),
Paragraph("Emitida el <b>7 de octubre de 2026</b> a nombre de "
"<font color='#1e64af'>Ana Pérez</font>.", estilos["BodyText"]),
Spacer(1, 8*mm),
]Fíjate en el parent= al crear los estilos: hereda del estilo de serie y solo cambia lo que le dices. Y en que un Paragraph entiende marcado parecido al HTML: <b>, <i>, <u>, <font color=…> y <br/>.
La tabla, que es para lo que más se usa
datos = [["Concepto", "Uds.", "Precio", "Importe"]]
total = 0.0
for nombre, uds, precio in lineas:
importe = uds * precio
total += importe
datos.append([nombre, str(uds), euros(precio), euros(importe)])
datos.append(["", "", "Total", euros(total)])
tabla = Table(datos, colWidths=[75*mm, 20*mm, 30*mm, 30*mm], hAlign="LEFT")
tabla.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), AZUL),
("TEXTCOLOR", (0,0), (-1,0), colors.white),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("ALIGN", (1,0), (-1,-1), "RIGHT"),
("ROWBACKGROUNDS", (0,1), (-1,-2), [colors.white, colors.HexColor("#f5f8fb")]),
("LINEABOVE", (0,-1), (-1,-1), 1, MARCA),
("FONTNAME", (0,-1), (-1,-1), "Helvetica-Bold"),
("GRID", (0,0), (-1,-2), 0.4, colors.HexColor("#c9d6e4")),
("VALIGN", (0,0), (-1,-1), "MIDDLE"),
("TOPPADDING", (0,0), (-1,-1), 5),
("BOTTOMPADDING", (0,0), (-1,-1), 5),
]))Las órdenes de TableStyle van siempre igual: el nombre, la celda de inicio y la de fin, y los parámetros. Las coordenadas son (columna, fila) —en ese orden, no al revés— y admiten negativos: (-1,-1) es la última celda, y (0,0), (-1,0) es toda la primera fila.
ROWBACKGROUNDS con una lista de dos colores hace el rayado de cebra solo, sin contar filas.
El pie con el número de página
def pie(canvas_, doc):
canvas_.saveState()
canvas_.setFont("Helvetica", 8)
canvas_.setFillColor(colors.HexColor("#5b6b7c"))
canvas_.drawString(20*mm, 12*mm, "decodigo.com · factura de ejemplo")
canvas_.drawRightString(A4[0] - 20*mm, 12*mm, f"Página {doc.page}")
canvas_.restoreState()
doc = SimpleDocTemplate("factura.pdf", pagesize=A4,
leftMargin=20*mm, rightMargin=20*mm,
topMargin=20*mm, bottomMargin=20*mm,
title="Factura 2026-0042", author="decodigo.com")
doc.build(historia, onFirstPage=pie, onLaterPages=pie)Aquí se juntan los dos mundos: el contenido lo compone platypus y el pie lo dibuja una función con canvas, que recibe el documento y puede preguntarle por doc.page. El saveState() y el restoreState() no son opcionales: sin ellos, el color y la fuente que pongas ahí se quedan puestos para el resto del documento.
Y esto es lo que sale:

pie.Los importes, en español
def euros(x: float) -> str:
"""1234.5 -> '1.234,50 €'. Sin depender del locale del sistema."""
s = f"{x:,.2f}" # 1,234.50 (formato de C)
return s.replace(",", "\u00a0").replace(".", ",").replace("\u00a0", ".") + " €"salida12.5 -> 12,50 €
1234.5 -> 1.234,50 €
0.07 -> 0,07 €
1234567.891 -> 1.234.567,89 €locale, y por eso no es la primera opción. Lo probé en el contenedor y falla: «unsupported locale setting», porque la configuración regional es_ES.UTF-8 no está generada en esa máquina. Depende del sistema donde corra tu programa, no de tu programa, y además locale es global al proceso. El truco del espacio duro como marcador intermedio funciona en todas partes.Acentos y fuentes propias
Las fuentes de serie —Helvetica, Times, Courier— llevan bien los acentos, la eñe, los signos de apertura y el euro. Si eso basta, no hay nada que hacer.
Para usar una fuente tuya hay que registrarla antes:
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont("DejaVu", "/ruta/DejaVuSans.ttf"))
estilos.add(ParagraphStyle("Propia", parent=estilos["BodyText"], fontName="DejaVu"))salidaregistrada DejaVu desde /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf fuente.pdf: 23842 bytes (la fuente va incrustada)
Fíjate en el tamaño: el mismo PDF con la fuente de serie ocupaba 1,5 KB y con la fuente propia pasa a 23,8. Es porque la fuente se incrusta en el archivo, que es lo que hace que se vea igual en cualquier ordenador. Si usas negrita y cursiva, hay que registrar cada variante y agruparlas con registerFontFamily.
Generarlo en memoria, para una web
import io buf = io.BytesIO() doc = SimpleDocTemplate(buf, pagesize=A4) doc.build(historia) pdf = buf.getvalue()
salida1581 bytes en memoria, empieza por b'%PDF-1.4'
Todo lo que acepta una ruta acepta también un objeto de archivo, así que no hace falta escribir en disco para devolver un PDF desde un servidor:
# Flask
return Response(pdf, mimetype="application/pdf",
headers={"Content-Disposition": "attachment; filename=factura.pdf"})
# Django
return HttpResponse(pdf, content_type="application/pdf")Con attachment el navegador lo descarga; con inline lo abre en su visor.
Resumen
| Quieres… | Usa |
|---|---|
| Posiciones fijas | canvas y drawString |
| Texto que fluye y salta de página | SimpleDocTemplate y una lista de bloques |
| Negrita o color dentro de un párrafo | marcado <b>, <font> |
| Una tabla con estilo | Table + TableStyle |
| Cabecera o pie en cada página | onFirstPage / onLaterPages |
| Una fuente propia | pdfmetrics.registerFont(TTFont(...)) |
| Devolverlo desde una web | io.BytesIO() en vez de una ruta |
| Trabajar en milímetros | from reportlab.lib.units import mm |
Lo que ReportLab no hace es leer PDF ni modificar uno existente; para eso están pypdf y PyMuPDF. Y si lo que tienes ya es una plantilla en HTML, puede salir más a cuenta WeasyPrint, que convierte HTML y CSS en PDF directamente.
Todo el código se ejecutó con Python 3.14 en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.
Excelente ejemplo, justo lo que estaba buscando, solo me quedó una duda, para poder asignarle el title al PDF para que no aparezca: (anonymous) como se podría hacer?