Convertir HTML a PDF en Python: WeasyPrint, pdfkit y Playwright comparados
Generar PDFs desde HTML+CSS es uno de los problemas que parece simple hasta que llegan los detalles: ¿se respetan tus media queries de impresión? ¿salen las cabeceras y pies en cada página? ¿soporta JavaScript? ¿qué fuentes embebe? Esta guía compara las tres librerías Python que dominan el espacio en 2026 y muestra el código real para cada una.
Visión rápida
| Librería | Motor de render | JS support | Tamaño binario | Calidad CSS |
|---|---|---|---|---|
| WeasyPrint | Propio (Cairo + Pango) | ❌ no | ~50 MB con deps | ★★★★★ — soporta CSS Paged Media |
| pdfkit | wkhtmltopdf (WebKit antiguo) | ⚠ JS sin esperas | wkhtmltopdf ~80 MB | ★★★★ — sólido pero CSS3 incompleto |
| Playwright | Chromium completo | ✅ con esperas | Chromium ~280 MB | ★★★★★ — render moderno completo |
WeasyPrint — el favorito para documentos pdf-first
WeasyPrint está diseñado específicamente para producir PDFs de calidad profesional desde HTML+CSS. Implementa CSS Paged Media (saltos de página, cabeceras/pies repetidos, marcadores), perfectamente adecuado para facturas, certificados, catálogos y reports.
Instalación:
# Linux (Debian/Ubuntu)
sudo apt install libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0
pip install weasyprint
# macOS
brew install pango
pip install weasyprint
# Windows: se recomienda WSL o el instalador GTK manual
Uso básico:
from weasyprint import HTML, CSS
# Desde un string HTML
HTML(string='<h1>Hola</h1><p>Documento en PDF</p>').write_pdf('output.pdf')
# Desde una URL
HTML(url='https://example.com').write_pdf('site.pdf')
# Desde un archivo local
HTML(filename='factura.html').write_pdf('factura.pdf')
# Con CSS externo
HTML(filename='factura.html').write_pdf(
'factura.pdf',
stylesheets=[CSS(filename='print.css')]
)
CSS Paged Media — lo que hace a WeasyPrint imbatible:
@page {
size: A4;
margin: 2cm;
@top-center { content: "Factura " counter(page) " / " counter(pages); }
@bottom-right { content: "ACME Corp"; }
}
h1 { string-set: chapter content(); }
@page :first { @top-center { content: none; } }
.page-break { page-break-after: always; }
Esto te da numeración de páginas, cabeceras dinámicas, primera-página especial, y saltos forzados — todo CSS estándar que NINGÚN navegador soporta tan bien como WeasyPrint.
pdfkit — wkhtmltopdf wrapper para HTML legacy
pdfkit es un wrapper Python sobre wkhtmltopdf, un binario standalone basado en WebKit antiguo. Su ventaja es que renderiza HTML "como un navegador" sin esperas — ideal para HTML estático sin JS pesado.
Instalación:
# Linux
sudo apt install wkhtmltopdf
pip install pdfkit
# macOS
brew install --cask wkhtmltopdf
pip install pdfkit
# Windows: descarga wkhtmltopdf.exe y añade al PATH
Uso:
import pdfkit
# Desde URL
pdfkit.from_url('https://example.com', 'site.pdf')
# Desde archivo
pdfkit.from_file('factura.html', 'factura.pdf')
# Desde string
pdfkit.from_string('<h1>Hola</h1>', 'doc.pdf')
# Con opciones
options = {
'page-size': 'A4',
'margin-top': '20mm',
'margin-right': '15mm',
'margin-bottom': '20mm',
'margin-left': '15mm',
'encoding': 'UTF-8',
'enable-local-file-access': None,
'header-html': 'header.html',
'footer-html': 'footer.html',
'footer-right': 'Página [page] de [topage]',
}
pdfkit.from_file('factura.html', 'factura.pdf', options=options)
Limitación crítica: WebKit dentro de wkhtmltopdf data de 2016 — flexbox parcial, grid no, custom properties limitadas, sin soporte para JavaScript moderno. Para HTML moderno (Tailwind 3+, sites SPA) los resultados serán incorrectos.
Playwright — render Chromium completo, JS incluido
Cuando necesitas renderizar HTML moderno con JavaScript (gráficos hechos con Chart.js, mapas con Leaflet, tablas con DataTables), Playwright es la única opción robusta. Lanza un Chromium headless real y captura PDF.
Instalación:
pip install playwright
playwright install chromium
Uso:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Carga URL
page.goto('https://example.com', wait_until='networkidle')
# O carga HTML inline
# page.set_content('<h1>Hola</h1>', wait_until='networkidle')
page.pdf(
path='output.pdf',
format='A4',
margin={'top': '2cm', 'right': '1.5cm', 'bottom': '2cm', 'left': '1.5cm'},
print_background=True,
display_header_footer=True,
header_template='<div style="font-size:10px; width:100%; text-align:center;">Mi Empresa</div>',
footer_template='<div style="font-size:10px; width:100%; text-align:center;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>',
)
browser.close()
Versión async para producción concurrente:
from playwright.async_api import async_playwright
async def html_to_pdf(html: str, path: str):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.set_content(html, wait_until='networkidle')
await page.pdf(path=path, format='A4', print_background=True)
await browser.close()
Comparativa práctica: misma factura con cada uno
| Aspecto | WeasyPrint | pdfkit | Playwright |
|---|---|---|---|
| Tiempo render (factura simple) | 0.3s | 0.5s | 1.5s (cold start) / 0.5s (warm) |
| Memoria peak | 80 MB | 120 MB | 350 MB |
| Soporte CSS Grid | ✅ | ❌ | ✅ |
| Soporte JS (Chart.js) | ❌ | ⚠ parcial | ✅ |
| Cabeceras/pies CSS estándar | ✅ @page | ❌ HTML separado | ⚠ HTML templates |
| Embed fonts personalizadas | ✅ via @font-face | ✅ via @font-face | ✅ via @font-face |
| Producción Docker | ★★★★★ | ★★★ | ★★ (peso) |
| Licencia | BSD | LGPL | Apache 2.0 |
Decisión rápida
- Documentos generados desde plantillas HTML estáticas (facturas, certificados, contratos) → WeasyPrint
- Capturar páginas web existentes que NO usen JS pesado → pdfkit o WeasyPrint
- Renderizar dashboards, gráficos JS, SPAs → Playwright
- Quieres mínima dependencia y control absoluto del PDF → WeasyPrint
- Quieres "haz que el PDF se vea como en Chrome" → Playwright
Tip producción: caching de browser context
Lanzar Playwright/Chromium tiene un coste de ~1 segundo de cold start. Si generas muchos PDFs en serie, mantén un browser abierto:
from playwright.sync_api import sync_playwright
playwright = sync_playwright().start()
browser = playwright.chromium.launch()
def render(html: str) -> bytes:
page = browser.new_page()
page.set_content(html, wait_until='networkidle')
pdf_bytes = page.pdf(format='A4')
page.close()
return pdf_bytes
# Generar 100 PDFs sin cold start cada vez
for invoice in invoices:
pdf = render(invoice.html)
save(pdf)
browser.close()
playwright.stop()
Conversiones relacionadas
- HTML a PDF — versión online sin código
- Markdown a PDF — partir de .md en lugar de HTML
- DOCX a PDF — desde Word para entrega final
- PDF a HTML — operación inversa para edición
- PDF a TXT — extraer texto plano del PDF
- PDF a DOCX — convertir el PDF generado a Word editable