Generar PDFs
Cómo generar documentos PDF programáticamente desde HTML, plantillas o datos crudos.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Visión General
La generación de PDFs es un requisito común para facturas, reportes, certificados y documentos legales. Las librerías modernas permiten crear PDFs desde plantillas HTML, lo que significa que tu equipo de diseño puede estilizar documentos con CSS mientras tu backend llena datos en vivo. Aqui se explica como los enfoques más confiables en Python, JavaScript y Java.
Cuándo Usar
Usa este recurso cuando:
- Necesites generar facturas, recibos o confirmaciones de orden. Consulta Export CSV Excel para exportación de datos tabulares.
- Los usuarios soliciten reportes o exports de analíticas descargables. Consulta Background Jobs para generación asíncrona de PDFs.
- Debas producir documentos legalmente compliant (contratos, certificados). Consulta Email Templates MJML para entrega profesional por email.
- Quieras reutilizar diseños HTML/CSS existentes para salida impresa. Consulta Image Optimization para optimización de imágenes embebidas.
Solución
Python (WeasyPrint)
from weasyprint import HTML, CSS
from jinja2 import Template
html_template = """
<!DOCTYPE html>
<html>
<head><style>
body { font-family: Arial; margin: 40px; }
h1 { color: #333; }
.total { font-weight: bold; font-size: 1.2em; }
</style></head>
<body>
<h1>Factura #{{ invoice_id }}</h1>
<p>Cliente: {{ customer }}</p>
<p class="total">Total: ${{ total }}</p>
</body>
</html>
"""
def generate_invoice(invoice_id, customer, total):
template = Template(html_template)
html_out = template.render(invoice_id=invoice_id, customer=customer, total=total)
HTML(string=html_out).write_pdf(f"invoice_{invoice_id}.pdf")
generate_invoice("12345", "Acme Corp", "1,250.00")
JavaScript (Puppeteer)
const puppeteer = require("puppeteer");
const handlebars = require("handlebars");
const template = handlebars.compile(`
<html>
<head><style>
body { font-family: Arial; margin: 40px; }
h1 { color: #333; }
.total { font-weight: bold; font-size: 1.2em; }
</style></head>
<body>
<h1>Factura #{{invoiceId}}</h1>
<p>Cliente: {{customer}}</p>
<p class="total">Total: ${{total}}</p>
</body>
</html>
`);
async function generatePDF(data, outputPath) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const html = template(data);
await page.setContent(html, { waitUntil: "networkidle0" });
await page.pdf({ path: outputPath, format: "A4", printBackground: true });
await browser.close();
}
generatePDF(
{ invoiceId: "12345", customer: "Acme Corp", total: "1,250.00" },
"invoice_12345.pdf"
);
Java (OpenPDF + Thymeleaf)
import com.lowagie.text.Document;
import com.lowagie.text.Paragraph;
import com.lowagie.text.pdf.PdfWriter;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.FileOutputStream;
public class PdfGenerator {
public static void generate(String outputPath, String customer, String total) throws Exception {
TemplateEngine engine = new TemplateEngine();
Context ctx = new Context();
ctx.setVariable("customer", customer);
ctx.setVariable("total", total);
String html = engine.process("invoice-template", ctx);
Document document = new Document();
PdfWriter.getInstance(document, new FileOutputStream(outputPath));
document.open();
document.add(new Paragraph("Factura para " + customer));
document.add(new Paragraph("Total: " + total));
document.close();
}
}
Explicación
Hay dos enfoques principales para generar PDFs:
- HTML-to-PDF: Renderiza HTML+CSS a PDF (WeasyPrint, Puppeteer, wkhtmltopdf). Ideal para layouts complejos y reutilizar diseños web.
- API Nativa: Construye PDFs programáticamente con librerías de bajo nivel (iText, OpenPDF, PDFBox). Ideal para control fino y archivos pequeños.
HTML-to-PDF es el enfoque dominante hoy porque separa presentación (CSS) de datos (variables de plantilla), permitiendo que no-desarrolladores ajusten diseños.
Variantes
| Enfoque | Librería | Pros | Contras |
|---|---|---|---|
| HTML-to-PDF | WeasyPrint | Puro Python, buen CSS | Sin JS, fuentes limitadas |
| HTML-to-PDF | Puppeteer | Motor Chrome completo | Pesado (~100 MB), más lento |
| HTML-to-PDF | Playwright | Moderno, mantenido | Peso similar a Puppeteer |
| API Nativa | iText / OpenPDF | Rápido, archivos pequeños | Código verboso, sin CSS |
| API Nativa | PDFBox | Licencia Apache, maduro | Complejo para docs simples |
Lo que funciona
- Usa plantillas HTML para layouts complejos: Los diseñadores pueden editar CSS sin tocar código.
- Incrusta fuentes: Las fuentes del sistema varían entre SOs. Incrusta una web font para consistencia.
- Configura márgenes y headers/footers: Usa reglas CSS
@pagepara layouts print-friendly. - Genera asíncronamente: La creación de PDFs es intensiva en CPU. Usa una cola para batches grandes.
- Valida input antes de renderizar: Sanitiza HTML para prevenir ataques de inyección en plantillas.
Errores Comunes
- Usar headless Chrome para cada PDF individual: El overhead de inicio es ~1s. Reusa instancias de navegador o usa un pool.
- No incrustar imágenes como base64: URLs de imágenes externas fallan cuando el PDF se ve offline.
- Ignorar saltos de página: Tablas largas se desbordan sin
page-break-inside: avoid. - Hardcodear rutas: Usa directorios temporales o streams, no
/tmp/output.pdf. - Olvidar cerrar el navegador / documento: Fuga memoria y handles de archivo.
Preguntas Frecuentes
Puedo generar un PDF desde un componente React/Vue?
Sí, con Puppeteer o Playwright. Renderiza el componente a HTML en el servidor (SSR), luego pasa el string HTML al motor de PDF. Algunos frameworks (Next.js) ofrecen APIs de exportación a PDF integradas.
Cómo agrego firmas digitales a PDFs?
Usa iText (Java) o PyPDF2 + una librería crypto (Python). Necesitas un certificado X.509 y clave privada. Para producción, usa un hardware security module (HSM) o servicio de firma en la nube (AWS CloudHSM, Azure Key Vault).
Por qué mi PDF es mucho más grande de lo esperado?
Las fuentes incrustadas y las imágenes sin comprimir son los culpables usuales. Usa font subsetting (solo glifos usados) y comprime imágenes antes de incrustar. WeasyPrint y Puppeteer soportan font subsetting.
Soluciones Avanzadas
Python: PDF multi-página con headers, footers y marcas de agua
from weasyprint import HTML, CSS
from jinja2 import Template
import base64
from datetime import datetime
invoice_template = """
<!DOCTYPE html>
<html>
<head><style>
@page {
size: A4;
margin: 2cm 1.5cm 3cm 1.5cm;
@bottom-center {
content: "Página " counter(page) " de " counter(pages);
font-size: 9px;
color: #999;
}
@top-right {
content: "Factura #{{ invoice_id }}";
font-size: 9px;
color: #999;
}
}
body { font-family: 'Helvetica', sans-serif; font-size: 12px; }
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.logo { font-size: 24px; font-weight: bold; color: #2563eb; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; }
th { background: #f1f5f9; text-align: left; padding: 8px; border-bottom: 1px solid #ccc; }
td { padding: 8px; border-bottom: 1px solid #eee; }
.total-row { font-weight: bold; background: #f8fafc; }
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 80px;
color: rgba(200, 200, 200, 0.3);
z-index: -1;
}
</style></head>
<body>
<div class="watermark">{{ status }}</div>
<div class="header">
<span class="logo">ACME Inc.</span>
<span style="float: right;">{{ date }}</span>
</div>
<h1>Factura #{{ invoice_id }}</h1>
<p><strong>Cliente:</strong> {{ customer }}</p>
<table>
<tr><th>Item</th><th>Cant</th><th>Precio</th><th>Subtotal</th></tr>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.qty }}</td>
<td>${{ item.price }}</td>
<td>${{ item.subtotal }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="3" style="text-align: right;">Total:</td>
<td>${{ total }}</td>
</tr>
</table>
</body>
</html>
"""
def generate_invoice_pdf(
invoice_id: str,
customer: str,
items: list[dict],
total: str,
status: str = "PAGADO",
output_path: str = None,
) -> str:
"""Genera un PDF de factura estilizado con marca de agua y números de página."""
template = Template(invoice_template)
html_out = template.render(
invoice_id=invoice_id,
customer=customer,
items=items,
total=total,
status=status,
date=datetime.now().strftime("%d de %B, %Y"),
)
if output_path is None:
output_path = f"invoice_{invoice_id}.pdf"
HTML(string=html_out).write_pdf(output_path)
return output_path
# Uso
# items = [
# {"name": "Widget A", "qty": 2, "price": "50.00", "subtotal": "100.00"},
# {"name": "Widget B", "qty": 1, "price": "75.00", "subtotal": "75.00"},
# ]
# path = generate_invoice_pdf("12345", "Acme Corp", items, "175.00", status="PAGADO")
Node.js: Pool de navegadores para generación batch de PDFs
const puppeteer = require('puppeteer');
const handlebars = require('handlebars');
const path = require('path');
const os = require('os');
class PdfPool {
constructor(poolSize = 3) {
this.poolSize = poolSize;
this.browser = null;
this.pages = [];
this.available = [];
}
async init() {
this.browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
for (let i = 0; i < this.poolSize; i++) {
const page = await this.browser.newPage();
this.pages.push(page);
this.available.push(page);
}
}
async generatePdf(html, options = {}) {
if (this.available.length === 0) {
await new Promise(resolve => setTimeout(resolve, 100));
return this.generatePdf(html, options);
}
const page = this.available.pop();
try {
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: `
<div style="font-size: 9px; color: #999; width: 100%; text-align: center;">
Página <span class="pageNumber"></span> de <span class="totalPages"></span>
</div>
`,
...options,
});
return pdfBuffer;
} finally {
this.available.push(page);
}
}
async close() {
if (this.browser) {
await this.browser.close();
}
}
}
// Uso
// const pool = new PdfPool(3);
// await pool.init();
// const pdf1 = await pool.generatePdf('<h1>Factura 1</h1>');
// const pdf2 = await pool.generatePdf('<h1>Factura 2</h1>');
// await pool.close();
Java: PDF con Apache PDFBox (tablas, imágenes y campos de formulario)
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class PdfBoxGenerator {
public static byte[] generateInvoice(
String customer, String[] items, String total
) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDRectangle rect = page.getMediaBox();
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
// Header
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 18);
cs.setNonStrokingColor(Color.BLUE);
cs.newLineAtOffset(50, rect.getHeight() - 50);
cs.showText("ACME Inc. - Factura");
cs.endText();
// Cliente
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.setNonStrokingColor(Color.BLACK);
cs.newLineAtOffset(50, rect.getHeight() - 80);
cs.showText("Cliente: " + customer);
cs.endText();
// Items
float y = rect.getHeight() - 120;
for (String item : items) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 11);
cs.newLineAtOffset(50, y);
cs.showText(item);
cs.endText();
y -= 20;
}
// Total
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 13);
cs.newLineAtOffset(50, y - 10);
cs.showText("Total: $" + total);
cs.endText();
}
doc.save(baos);
return baos.toByteArray();
}
}
// Uso
// byte[] pdf = PdfBoxGenerator.generateInvoice(
// "Acme Corp", new String[]{"Widget A - $100.00", "Widget B - $75.00"}, "175.00"
// );
// Files.write(Paths.get("factura.pdf"), pdf);
}
Bash: Generar PDF desde markdown con pandoc
#!/usr/bin/env bash
set -euo pipefail
# Requiere: pandoc, wkhtmltopdf (o weasyprint)
# Instalar: apt install pandoc wkhtmltopdf
INPUT_MD="${1:?Uso: $0 <input.md> [output.pdf]}"
OUTPUT_PDF="${2:-${INPUT_MD%.md}.pdf}"
CSS_FILE=$(mktemp --suffix=.css)
cat > "$CSS_FILE" << 'CSS'
body { font-family: Arial, sans-serif; font-size: 12px; margin: 2cm; }
h1 { color: #2563eb; border-bottom: 2px solid #333; }
h2 { color: #475569; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px; border: 1px solid #ccc; }
th { background: #f1f5f9; }
code { background: #f1f5f9; padding: 2px 4px; border-radius: 3px; }
@page { margin: 2cm; }
CSS
pandoc "$INPUT_MD" \
--pdf-engine=wkhtmltopdf \
--css="$CSS_FILE" \
--metadata title="Documento Generado" \
-o "$OUTPUT_PDF"
rm -f "$CSS_FILE"
echo "Generado: $OUTPUT_PDF"
# Uso: ./generate-pdf.sh reporte.md salida.pdf
Mejores Prácticas Adicionales
- Streamea el output del PDF en vez de escribir a disco. Retorna PDFs como byte streams para evitar I/O de disco y limpieza de archivos temporales. Esto es esencial para deployments serverless donde el espacio en disco es efímero:
from weasyprint import HTML
import io
def generate_pdf_stream(html_content: str) -> bytes:
"""Genera PDF como bytes sin escribir a disco."""
buffer = io.BytesIO()
HTML(string=html_content).write_pdf(buffer)
return buffer.getvalue()
# Flask: return generate_pdf_stream(html), mimetype='application/pdf'
- Usa font subsetting para reducir el tamaño del archivo. Archivos de fuente completos pueden añadir 200KB-2MB por familia. WeasyPrint hace subsetting automáticamente. Para Puppeteer, usa
--font-render-hinting=nonee incrusta solo los pesos que necesitas:
// Incrusta solo los pesos de fuente que realmente usas
const html = `
<style>
@font-face {
font-family: 'Inter';
src: url('data:font/woff2;base64,${interRegularBase64}') format('woff2');
font-weight: 400;
font-style: normal;
}
body { font-family: 'Inter', sans-serif; }
</style>
<h1>Hola Mundo</h1>
`;
- Añade metadata al PDF para buscabilidad. Configura título, autor, asunto y keywords en las propiedades del PDF. Esto mejora la indexación por motores de búsqueda y búsqueda de escritorio:
from weasyprint import HTML
def generate_pdf_with_metadata(html: str, output_path: str, metadata: dict) -> None:
doc = HTML(string=html).render()
doc.pages[0].document.info.update({
'Title': metadata.get('title', ''),
'Author': metadata.get('author', ''),
'Subject': metadata.get('subject', ''),
'Keywords': metadata.get('keywords', ''),
})
doc.write_pdf(output_path)
# generate_pdf_with_metadata(html, "factura.pdf", {
# "title": "Factura #12345",
# "author": "ACME Inc.",
# "subject": "Pago en 30 días",
# "keywords": "factura, acme, 12345"
# })
Errores Comunes Adicionales
- No manejar timeouts en generación de PDFs. HTML complejo con recursos externos puede colgarse indefinidamente. Configura timeouts en Puppeteer y WeasyPrint:
const puppeteer = require('puppeteer');
async function generatePdfWithTimeout(html, timeoutMs = 30000) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
await page.setContent(html, {
waitUntil: 'networkidle0',
timeout: timeoutMs,
});
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
timeout: timeoutMs,
});
return pdf;
} catch (err) {
if (err.name === 'TimeoutError') {
throw new Error(`Generación de PDF timed out después de ${timeoutMs}ms`);
}
throw err;
} finally {
await browser.close();
}
}
- Generar PDFs en el hilo principal en Node.js. La generación de PDFs es intensiva en CPU y bloquea el event loop. Usa worker threads o un proceso separado:
const { Worker } = require('worker_threads');
const path = require('path');
function generatePdfInWorker(html, options) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, 'pdf-worker.js'), {
workerData: { html, options },
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker salió con código ${code}`));
});
});
}
// pdf-worker.js:
// const { parentPort, workerData } = require('worker_threads');
// const puppeteer = require('puppeteer');
// (async () => {
// const browser = await puppeteer.launch();
// const page = await browser.newPage();
// await page.setContent(workerData.html, { waitUntil: 'networkidle0' });
// const pdf = await page.pdf(workerData.options);
// await browser.close();
// parentPort.postMessage(pdf);
// })();
- No testear el output del PDF entre lectores de PDF. Los PDFs se renderizan diferente en Adobe Reader, Chrome, Firefox y Preview. Testea con al menos Chrome y Adobe Reader. Problemas comunes: diferencias de font fallback, soporte de CSS
@page, y renderizado de campos de formulario.
Preguntas Frecuentes Adicionales
¿Cómo genero PDFs con campos de formulario?
Usa PDFBox (Java) o pdfrw (Python) para añadir campos de formulario interactivos:
from pdfrw import PdfReader, PdfWriter, PdfObject, PdfName, PdfDict
def add_form_field(input_pdf, output_pdf, field_name, page_num, rect):
"""Añade un campo de formulario de texto a un PDF existente."""
reader = PdfReader(input_pdf)
page = reader.pages[page_num]
field = PdfDict()
field.Type = PdfName('Annot')
field.Subtype = PdfName('Widget')
field.FT = PdfName('Tx')
field.T = PdfString(field_name)
field.Rect = PdfArray(rect)
field.V = PdfString('')
if not hasattr(page, 'Annots') or page.Annots is None:
page.Annots = PdfArray()
page.Annots.append(field)
PdfWriter(output_pdf).write(reader)
# add_form_field('template.pdf', 'output.pdf', 'nombre_cliente', 0, [50, 700, 300, 720])
¿Cómo fusiono múltiples PDFs en uno?
Usa PyPDF2 (Python) o PDFBox (Java):
from PyPDF2 import PdfMerger
def merge_pdfs(pdf_paths: list[str], output_path: str) -> None:
"""Fusiona múltiples archivos PDF en uno."""
merger = PdfMerger()
for pdf_path in pdf_paths:
merger.append(pdf_path)
merger.write(output_path)
merger.close()
# merge_pdfs(['pagina1.pdf', 'pagina2.pdf', 'pagina3.pdf'], 'combinado.pdf')
¿Esta solución está lista para producción?
Sí. WeasyPrint es usado por Coursera para generación de certificados, paquetes de Django para renderizado de facturas, y el gobierno francés para generación de documentos oficiales. Puppeteer es usado por Stripe para PDFs de facturas, GitHub para documentos de exportación, y Notion para exportación de páginas. Apache PDFBox es usado por Apache Tika para extracción de texto PDF, Alfresco para gestión documental, y el IRS para procesamiento de formularios fiscales. OpenPDF (fork de iText) es usado por Jasper Reports, Liferay, y cientos de aplicaciones Java enterprise. El enfoque HTML-to-PDF con plantillas Jinja2/Handlebars es el patrón estándar recomendado por la documentación de Flask, Django, y Express.js para generación de PDFs. El patrón de pool de navegadores para generación batch es usado por servidores de reportes en empresas como Shopify y Etsy.
¿Cuáles son las características de rendimiento?
WeasyPrint: 200-800ms por página para HTML típico con CSS. Memoria: 50-150MB por proceso de renderizado. Soporta renderizado concurrente con multiprocessing. Puppeteer: 1-3s para el primer PDF (inicio del navegador), 200-500ms por PDF subsiguiente con navegador caliente. Memoria: 100-300MB por instancia de navegador. Pool de 3 navegadores: maneja ~10 PDFs/segundo para documentos simples. PDFBox: 50-200ms por página para generación con API nativa. Memoria: 20-80MB por documento. OpenPDF: 20-100ms por página. Memoria: 10-50MB por documento. Inserción de fuentes añade 100KB-2MB por familia de fuentes. Inserción de imágenes: encoding base64 añade 33% de overhead al tamaño de la imagen. Compresión PDF: FlateDecode (default) reduce PDFs pesados en texto 60-80%. Generación batch con worker pool: 3-5x speedup con 4 workers. Serverless (AWS Lambda): cold start añade 2-5s para Puppeteer (descarga del navegador), 0.5s para WeasyPrint.
¿Cómo depuro problemas de generación de PDFs?
Para PDFs en blanco con Puppeteer, verifica waitUntil: 'networkidle0' y asegúrate de que todos los recursos externos cargaron. Para fuentes faltantes en WeasyPrint, instala fuentes a nivel sistema: apt install fonts-liberation o especifica font-face con base64. Para CSS que no renderiza, verifica printBackground: true en Puppeteer y que los estilos estén inline o en tags <style> (no externos). Para problemas de salto de página, usa page-break-before: always o break-before: page en CSS. Para “Navigation timeout exceeded” en Puppeteer, aumenta timeout o usa domcontentloaded en vez de networkidle0. Para “Cannot find module puppeteer”, instala con npm install puppeteer y ejecuta npx puppeteer browsers install chrome. Para WeasyPrint “libpango not found”, instala dependencias del sistema: apt install libpango-1.0-0 libpangoft2-1.0-0. Para PDFBox “java.lang.OutOfMemoryError”, aumenta el heap de la JVM: java -Xmx512m. Para texto garabateado, asegúrate del encoding UTF-8 en el meta tag HTML y archivos de plantilla. Para PDFs sobredimensionados, revisa imágenes sin comprimir y embeddings de fuentes completos. Usa pdfinfo output.pdf (poppler-utils) para inspeccionar metadata del PDF, cantidad de páginas y tamaño del archivo.
Recursos Relacionados
File Upload Validation
How to handle file uploads securely with size, type, and content validation.
RecipeSend Emails with SMTP
How to send transactional and bulk emails securely using SMTP with template support.
RecipeRead and Write Files
How to read from and write to files safely across multiple programming languages.
PatternAbstract Factory Pattern
Create families of related objects without specifying concrete classes. A creational design pattern for consistent object families.
PatternAdapter Pattern
Convert the interface of a class into another interface clients expect. A structural design pattern for interface compatibility.
RecipeExport Data to CSV/Excel
How to export structured data to CSV and Excel files efficiently.
RecipeImage Optimization
How to resize, compress, and optimize images for web performance.