Por qué Markdown → PDF
Markdown se ha convertido en el formato de escritura técnica por excelencia: README de GitHub, documentación de proyectos, notas de Obsidian, posts de blogs. Sin embargo, para compartir con clientes o imprimir, PDF sigue siendo el estándar. La conversión Markdown → PDF es una de las tareas más frecuentes en flujos de trabajo de documentación.
Cuándo usar cada enfoque:
| Método | Mejor para |
|---|---|
| Pandoc | Documentos técnicos, control tipográfico total, LaTeX |
| WeasyPrint | Diseño visual con CSS, reportes corporativos |
| ReportLab | PDFs programáticos con gráficas, tablas dinámicas |
| md-to-pdf | Rápido, Node.js disponible, salida cromática |
Método 1: Pandoc (el más potente)
Pandoc es el convertidor de documentos más completo que existe. Puede transformar Markdown en PDF, DOCX, EPUB, HTML, LaTeX y docenas de formatos más.
# Instalación
# Ubuntu/Debian:
sudo apt install pandoc texlive-xetex
# macOS:
brew install pandoc basictex
# Windows: descargar desde pandoc.org
import subprocess
from pathlib import Path
def md_a_pdf_pandoc(md_entrada, pdf_salida=None, titulo=None,
autor=None, fecha=None, fuente='DejaVu Sans',
tamaño_fuente='11pt', motor='xelatex'):
"""
Convierte Markdown a PDF con Pandoc.
motor: 'xelatex' (mejor soporte de fuentes) o 'pdflatex'
"""
md_path = Path(md_entrada)
if pdf_salida is None:
pdf_salida = md_path.with_suffix('.pdf')
cmd = [
'pandoc', str(md_path),
'-o', str(pdf_salida),
f'--pdf-engine={motor}',
f'-V', f'fontsize={tamaño_fuente}',
'-V', 'geometry:margin=2.5cm',
'-V', 'colorlinks=true',
'-V', 'linkcolor=blue',
'--toc', # tabla de contenidos
'--number-sections', # numerar secciones
'--highlight-style=tango', # resaltado de código
]
# Metadatos opcionales
if titulo:
cmd += ['-M', f'title={titulo}']
if autor:
cmd += ['-M', f'author={autor}']
if fecha:
cmd += ['-M', f'date={fecha}']
# Fuente personalizada (solo con xelatex)
if motor == 'xelatex' and fuente:
cmd += ['-V', f'mainfont={fuente}']
resultado = subprocess.run(cmd, capture_output=True, text=True)
if resultado.returncode == 0:
tamaño = Path(pdf_salida).stat().st_size / 1024
print(f"PDF generado con Pandoc: {pdf_salida} ({tamaño:.1f} KB)")
return str(pdf_salida)
else:
raise RuntimeError(f"Error Pandoc:\n{resultado.stderr}")
# Ejemplo básico
md_a_pdf_pandoc('README.md', 'doc.pdf', titulo='Mi Documentación',
autor='El Equipo', fecha='2024-01-15')
Pandoc con plantilla personalizada
def crear_plantilla_pandoc():
"""Crea una plantilla LaTeX personalizada para Pandoc."""
plantilla = r'''
\documentclass[$fontsize$,a4paper]{article}
\usepackage{fontspec}
\usepackage{xcolor}
\usepackage{geometry}
\usepackage{fancyhdr}
\usepackage{hyperref}
\usepackage{listings}
\usepackage{graphicx}
% Colores corporativos
\definecolor{azulprimario}{HTML}{4361EE}
\definecolor{magenta}{HTML}{F72585}
\definecolor{grisclaro}{HTML}{F8F9FA}
\geometry{
top=2.5cm, bottom=2.5cm,
left=3cm, right=2.5cm,
headheight=14pt
}
\setmainfont{$if(mainfont)$$mainfont$$else$DejaVu Sans$endif$}
\setmonofont{DejaVu Sans Mono}
% Cabecera y pie de página
\pagestyle{fancy}
\fancyhf{}
\rhead{\textcolor{azulprimario}{\small $title$}}
\lhead{\small\textcolor{gray}{$date$}}
\rfoot{\textcolor{gray}{\small Página \thepage}}
% Estilo de bloques de código
\lstset{
backgroundcolor=\color{grisclaro},
basicstyle=\ttfamily\small,
breaklines=true,
frame=single,
rulecolor=\color{azulprimario},
}
\hypersetup{
colorlinks=true,
linkcolor=azulprimario,
urlcolor=magenta,
citecolor=azulprimario,
}
\begin{document}
$if(title)$
\begin{center}
{\Huge \textcolor{azulprimario}{\textbf{$title$}}}\\[0.5em]
$if(author)${\large $author$}\\[0.3em]$endif$
$if(date)${\small $date$}$endif$
\end{center}
\vspace{2em}
$endif$
$if(toc)$
\tableofcontents
\newpage
$endif$
$body$
\end{document}
'''
Path('plantilla_corporativa.latex').write_text(plantilla, encoding='utf-8')
print("Plantilla creada: plantilla_corporativa.latex")
return 'plantilla_corporativa.latex'
def md_con_plantilla(md_entrada, pdf_salida, titulo, autor):
"""Genera PDF con plantilla corporativa personalizada."""
plantilla = crear_plantilla_pandoc()
cmd = [
'pandoc', md_entrada,
'-o', pdf_salida,
'--pdf-engine=xelatex',
f'--template={plantilla}',
'-M', f'title={titulo}',
'-M', f'author={autor}',
'-M', f'date=Enero 2024',
'--toc',
]
resultado = subprocess.run(cmd, capture_output=True, text=True)
if resultado.returncode == 0:
print(f"PDF corporativo: {pdf_salida}")
else:
print(f"Error: {resultado.stderr}")
Método 2: WeasyPrint (Markdown → HTML → PDF)
WeasyPrint renderiza HTML/CSS a PDF con calidad de impresión. El flujo es: Markdown → HTML → PDF.
def md_a_pdf_weasyprint(md_entrada, pdf_salida=None, css_personalizado=None):
"""
Convierte Markdown a PDF vía HTML+CSS con WeasyPrint.
pip install markdown weasyprint
"""
try:
import markdown
from weasyprint import HTML, CSS
except ImportError:
raise ImportError("pip install markdown weasyprint")
md_path = Path(md_entrada)
if pdf_salida is None:
pdf_salida = md_path.with_suffix('.pdf')
# Leer y convertir Markdown a HTML
md_texto = md_path.read_text(encoding='utf-8')
html_body = markdown.markdown(
md_texto,
extensions=[
'tables', 'fenced_code', 'footnotes',
'toc', 'attr_list', 'def_list', 'codehilite'
]
)
# CSS por defecto para PDF (aspecto profesional)
css_defecto = '''
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@page {
size: A4;
margin: 2.5cm 2cm;
@top-right {
content: counter(page);
font-size: 10pt;
color: #888;
}
}
body {
font-family: 'Inter', Arial, sans-serif;
font-size: 11pt;
line-height: 1.65;
color: #1a1a2e;
max-width: none;
}
h1 { font-size: 24pt; color: #4361ee; border-bottom: 3px solid #4361ee;
padding-bottom: 0.3em; margin-top: 1.5em; }
h2 { font-size: 18pt; color: #3a0ca3; margin-top: 1.2em; }
h3 { font-size: 14pt; color: #7209b7; }
code {
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: #f4f4f8;
padding: 0.1em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
pre {
background: #1a1a2e;
color: #e0e0e0;
padding: 1em 1.2em;
border-radius: 6px;
overflow-x: auto;
font-size: 9pt;
line-height: 1.5;
}
pre code {
background: none;
padding: 0;
color: inherit;
font-size: inherit;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 10pt;
}
th {
background: #4361ee;
color: white;
padding: 0.6em 1em;
text-align: left;
}
td {
padding: 0.5em 1em;
border-bottom: 1px solid #e0e0e0;
}
tr:nth-child(even) { background: #f8f9fa; }
blockquote {
border-left: 4px solid #4361ee;
margin: 1em 0;
padding: 0.5em 1em;
background: #f0f4ff;
border-radius: 0 4px 4px 0;
}
a { color: #f72585; }
img { max-width: 100%; }
'''
css_final = css_defecto
if css_personalizado:
css_final += '\n' + Path(css_personalizado).read_text()
# HTML completo
html_completo = f'''<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<style>{css_final}</style>
</head>
<body>
{html_body}
</body>
</html>'''
# Renderizar con WeasyPrint
HTML(string=html_completo).write_pdf(
str(pdf_salida),
stylesheets=[CSS(string=css_final)]
)
tamaño = Path(pdf_salida).stat().st_size / 1024
print(f"PDF WeasyPrint: {pdf_salida} ({tamaño:.1f} KB)")
return str(pdf_salida)
md_a_pdf_weasyprint('documentacion.md', 'doc_weasyprint.pdf')
Método 3: Conversión por lotes de múltiples Markdowns
def lote_md_a_pdf(patron_o_directorio, directorio_salida=None,
metodo='pandoc', **kwargs):
"""
Convierte todos los archivos Markdown de un directorio a PDF.
"""
if isinstance(patron_o_directorio, str) and '*' in patron_o_directorio:
archivos = sorted(Path('.').glob(patron_o_directorio))
else:
carpeta = Path(patron_o_directorio)
archivos = sorted(carpeta.glob('*.md'))
if directorio_salida:
dest = Path(directorio_salida)
dest.mkdir(parents=True, exist_ok=True)
else:
dest = None
resultados = {'ok': 0, 'error': 0}
print(f"Convirtiendo {len(archivos)} archivos Markdown a PDF...")
for md in archivos:
if dest:
pdf = dest / md.with_suffix('.pdf').name
else:
pdf = md.with_suffix('.pdf')
try:
if metodo == 'pandoc':
md_a_pdf_pandoc(str(md), str(pdf), **kwargs)
elif metodo == 'weasyprint':
md_a_pdf_weasyprint(str(md), str(pdf))
resultados['ok'] += 1
except Exception as e:
print(f" ✗ Error en {md.name}: {e}")
resultados['error'] += 1
print(f"\nResultado: {resultados['ok']} convertidos, "
f"{resultados['error']} errores")
return resultados
# Convertir toda la carpeta de documentación
lote_md_a_pdf('docs/', 'docs_pdf/', metodo='pandoc',
titulo='Documentación del Proyecto', autor='El Equipo')
Markdown a PDF con pypandoc (wrapper Python)
def md_a_pdf_pypandoc(md_contenido_o_ruta, pdf_salida, **kwargs):
"""
Usa pypandoc para llamar a Pandoc desde Python de forma más Pythónica.
pip install pypandoc
pypandoc.download_pandoc() # descarga pandoc automáticamente
"""
try:
import pypandoc
except ImportError:
raise ImportError("pip install pypandoc")
# Detectar si es contenido directo o ruta de archivo
if Path(md_contenido_o_ruta).exists():
salida = pypandoc.convert_file(
str(md_contenido_o_ruta),
to='pdf',
outputfile=str(pdf_salida),
extra_args=['--pdf-engine=xelatex', '--toc',
'-V', 'geometry:margin=2.5cm']
)
else:
# Convertir desde string directamente
salida = pypandoc.convert_text(
md_contenido_o_ruta,
to='pdf',
format='md',
outputfile=str(pdf_salida),
extra_args=['--pdf-engine=xelatex']
)
print(f"pypandoc: {pdf_salida}")
return str(pdf_salida)
Conclusión
Para convertir Markdown a PDF con Python, la elección depende del contexto:
- Pandoc: máxima calidad tipográfica, control total sobre el resultado; ideal para documentación técnica y libros
- WeasyPrint: cuando el diseño visual importa y quieres controlar el aspecto con CSS; ideal para reportes corporativos
- pypandoc: cuando quieres llamar a Pandoc desde Python sin subprocesos manuales
Para flujos CI/CD (GitHub Actions, GitLab CI), Pandoc en Docker es la solución más robusta: una imagen pandoc/latex incluye todo lo necesario para generar PDFs profesionales de forma reproducible.
Conversiones relacionadas
Conversiones de documento que siguen este tema: