Python – Crear un PDF con ReportLab

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.

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

canvasplatypus
Qué esdibujar en coordenadascomponer con bloques que fluyen
Tú decidesdónde va cada cosaqué va, y en qué orden
Saltos de páginaa manoautomáticos
Ajuste de líneaa manoautomático
Paraetiquetas, recibos, posiciones fijasinformes, 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 archivo
salidarecibo.pdf: 1706 bytes
cabecera: b'%PDF-1.3'
A4 en puntos: 595.3 x 841.9   (1 pulgada = 72 puntos)
El origen de coordenadas está abajo a la izquierda, no arriba, que es lo contrario de casi todo lo demás en informática. Por eso las posiciones se escriben como 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:

Las dos páginas del PDF generado: una factura con logotipo, tabla de conceptos y pie con el número de página
Las dos páginas, tal cual las genera el programa. El número de pie lo pone la función 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 €
Lo «correcto» sería 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 fijascanvas y drawString
Texto que fluye y salta de páginaSimpleDocTemplate y una lista de bloques
Negrita o color dentro de un párrafomarcado <b>, <font>
Una tabla con estiloTable + TableStyle
Cabecera o pie en cada páginaonFirstPage / onLaterPages
Una fuente propiapdfmetrics.registerFont(TTFont(...))
Devolverlo desde una webio.BytesIO() en vez de una ruta
Trabajar en milímetrosfrom 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.

1 comentario en «Python – Crear un PDF con ReportLab»

  1. 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?

    Responder

Deja un comentario