Por qué Markdown → HTML
Markdown es el lenguaje de marcado más popular para escritura técnica, pero los navegadores solo entienden HTML. La conversión Markdown → HTML es necesaria para:
- Generadores de sitios estáticos: Hugo, Jekyll, MkDocs
- Blogs y CMS: mostrar contenido Markdown en el navegador
- Documentación técnica: ReadTheDocs, Docusaurus
- Emails HTML: newsletters escritos en Markdown
- Editores WYSIWYG: previsualización en tiempo real
Python tiene varias bibliotecas para esta conversión, cada una con fortalezas distintas.
Biblioteca 1: python-markdown (la más extensible)
pip install markdown pymdown-extensions
import markdown
from pathlib import Path
def md_a_html_basico(md_texto, extensiones=None):
"""Convierte Markdown a HTML con extensiones."""
exts = extensiones or [
'tables', # tablas GFM
'fenced_code', # bloques de código con ```
'codehilite', # resaltado de sintaxis
'toc', # tabla de contenidos
'footnotes', # notas al pie
'attr_list', # atributos en elementos
'def_list', # listas de definición
'abbr', # abreviaciones
'meta', # metadatos YAML al inicio
'admonition', # bloques NOTE/WARNING/TIP
'nl2br', # saltos de línea → <br>
]
# Configuración de extensiones
extension_configs = {
'codehilite': {
'css_class': 'highlight',
'linenums': False,
'guess_lang': True,
},
'toc': {
'permalink': True,
'toc_depth': '2-4',
},
}
md = markdown.Markdown(
extensions=exts,
extension_configs=extension_configs,
output_format='html5'
)
return md.convert(md_texto)
def md_archivo_a_html(md_ruta, html_salida=None, titulo=None, css=None):
"""
Convierte un archivo .md a un .html completo con CSS incluido.
"""
md_path = Path(md_ruta)
md_texto = md_path.read_text(encoding='utf-8')
if html_salida is None:
html_salida = md_path.with_suffix('.html')
# CSS de ejemplo (estilos GitHub-like)
css_defecto = '''
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
Arial, sans-serif;
font-size: 16px; line-height: 1.6;
color: #24292e; max-width: 800px;
margin: 0 auto; padding: 2rem;
}
h1,h2,h3 { font-weight: 600; line-height: 1.25; margin-top: 1.5rem; }
h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid #eaecef; }
h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid #eaecef; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
pre { background: #f6f8fa; border-radius: 6px; padding: 1rem; overflow: auto; }
code { background: #f6f8fa; border-radius: 3px; padding: 0.2em 0.4em;
font-family: "SFMono-Regular", Consolas, monospace; font-size: 85%; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; }
th,td { border: 1px solid #dfe2e5; padding: 6px 13px; }
th { background: #f6f8fa; font-weight: 600; }
tr:nth-child(even) { background: #f6f8fa; }
blockquote { margin: 0; padding: 0 1em; color: #6a737d;
border-left: 4px solid #dfe2e5; }
img { max-width: 100%; }
'''
css_final = css or css_defecto
html_body = md_a_html_basico(md_texto)
titulo_final = titulo or md_path.stem.replace('-', ' ').replace('_', ' ').title()
html_completo = f'''<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{titulo_final}</title>
<style>{css_final}</style>
</head>
<body>
{html_body}
</body>
</html>'''
Path(html_salida).write_text(html_completo, encoding='utf-8')
tamaño = Path(html_salida).stat().st_size / 1024
print(f"HTML generado: {html_salida} ({tamaño:.1f} KB)")
return html_completo
md_archivo_a_html('README.md', titulo='Documentación del Proyecto')
Extensiones PyMdown (GFM y más)
def md_con_pymdown(md_texto):
"""
Usa PyMdown Extensions para funcionalidades avanzadas.
Compatible con GitHub Flavored Markdown (GFM) y más.
pip install pymdown-extensions
"""
exts = [
'pymdownx.superfences', # bloques de código anidados, diagramas Mermaid
'pymdownx.highlight', # resaltado avanzado con número de líneas
'pymdownx.inlinehilite', # código inline con resaltado
'pymdownx.tabbed', # pestañas de contenido
'pymdownx.details', # bloques desplegables <details>
'pymdownx.tasklist', # listas de tareas [ ] / [x]
'pymdownx.emoji', # emojis :smile: :rocket:
'pymdownx.keys', # teclas ++Ctrl+S++
'pymdownx.mark', # ==texto marcado==
'pymdownx.caret', # ^superíndice^
'pymdownx.tilde', # ~subíndice~ y ~~tachado~~
'pymdownx.smartsymbols', # (c) → ©, (tm) → ™
'tables',
'footnotes',
'toc',
]
md = markdown.Markdown(
extensions=exts,
extension_configs={
'pymdownx.highlight': {
'line_spans': '__span',
'pygments_lang_class': True,
},
'pymdownx.superfences': {
'custom_fences': [{
'name': 'mermaid',
'class': 'mermaid',
'format': lambda s, ln, cls, o: f'<div class="mermaid">{s}</div>',
}]
}
}
)
return md.convert(md_texto)
# Ejemplo con características avanzadas
md_avanzado = '''
# Demostración de PyMdown
## Lista de tareas
- [x] Tarea completada
- [ ] Tarea pendiente
- [ ] Otra tarea
## Pestañas de código
=== "Python"
```python
print("Hola mundo")
```
=== "JavaScript"
```javascript
console.log("Hola mundo");
```
## Texto especial
==Texto marcado== | ~~tachado~~ | ^superíndice^ | H~2~O
## Diagrama Mermaid
```mermaid
graph LR
A[Markdown] --> B[Python]
B --> C[HTML]
''' html = md_con_pymdown(md_avanzado)
## Biblioteca 2: mistune (la más rápida)
```python
def md_con_mistune(md_texto, sanitizar=True):
"""
mistune: parser Markdown más rápido en Python puro.
pip install mistune
Ideal para sitios con mucho contenido o tiempo real.
"""
try:
import mistune
except ImportError:
raise ImportError("pip install mistune")
# Renderer con opciones de seguridad
renderer = mistune.HTMLRenderer(escape=sanitizar)
md = mistune.create_markdown(
renderer=renderer,
plugins=['table', 'url', 'strikethrough', 'footnotes',
'task_lists', 'math', 'abbr', 'mark'],
)
return md(md_texto)
def md_con_renderer_personalizado(md_texto):
"""Renderer personalizado para control total del HTML generado."""
import mistune
class RendererPersonalizado(mistune.HTMLRenderer):
def heading(self, token, state):
"""Añade un ID ancla a cada encabezado."""
nivel = token['attrs']['level']
hijos = self.render_children(token, state)
# Generar ID a partir del texto
import re
id_texto = re.sub(r'[^\w\s-]', '', hijos.lower())
id_texto = re.sub(r'[\s]+', '-', id_texto.strip())
return f'<h{nivel} id="{id_texto}">{hijos}</h{nivel}>\n'
def image(self, token, state):
"""Añade lazy loading y alt text a las imágenes."""
src = token['attrs']['url']
alt = token.get('children', [{}])[0].get('raw', '')
title = token['attrs'].get('title', '')
title_attr = f' title="{title}"' if title else ''
return (f'<figure>'
f'<img src="{src}" alt="{alt}" loading="lazy"{title_attr}>'
f'<figcaption>{alt}</figcaption>'
f'</figure>\n')
def block_code(self, token, state):
"""Añade botón de copiar a los bloques de código."""
info = token['attrs'].get('info', '')
code = token['raw'].strip()
lang = info.split()[0] if info else ''
lang_class = f' class="language-{lang}"' if lang else ''
return (f'<div class="code-block">'
f'<button onclick="navigator.clipboard.writeText(this.parentElement.querySelector(\'code\').textContent)">'
f'Copiar</button>'
f'<pre><code{lang_class}>{code}</code></pre>'
f'</div>\n')
md = mistune.create_markdown(
renderer=RendererPersonalizado(),
plugins=['table', 'strikethrough', 'task_lists']
)
return md(md_texto)
Generador de sitio estático mínimo
import shutil
from pathlib import Path
def generar_sitio_estatico(carpeta_md, carpeta_salida, css_ruta=None,
plantilla_html=None):
"""
Genera un sitio web estático desde una carpeta de archivos Markdown.
Similar a un MkDocs mínimo.
"""
carpeta_md = Path(carpeta_md)
carpeta_html = Path(carpeta_salida)
carpeta_html.mkdir(parents=True, exist_ok=True)
css = css_ruta or ''
plantilla = plantilla_html or '''<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{titulo}</title>
<link rel="stylesheet" href="{css_ruta}">
</head>
<body>
<nav id="sidebar">{navegacion}</nav>
<main>{contenido}</main>
</body>
</html>'''
# Recolectar todos los Markdowns
md_archivos = sorted(carpeta_md.rglob('*.md'))
print(f"Generando sitio: {len(md_archivos)} páginas...")
# Construir navegación
nav_items = []
for md in md_archivos:
rel = md.relative_to(carpeta_md)
href = str(rel.with_suffix('.html')).replace('\\', '/')
titulo = md.stem.replace('-', ' ').replace('_', ' ').title()
nav_items.append(f'<a href="{href}">{titulo}</a>')
navegacion = '\n'.join(nav_items)
# Convertir cada archivo
for md in md_archivos:
texto = md.read_text(encoding='utf-8')
html_body = md_a_html_basico(texto)
titulo = md.stem.replace('-', ' ').replace('_', ' ').title()
rel = md.relative_to(carpeta_md)
html_p = carpeta_html / rel.with_suffix('.html')
html_p.parent.mkdir(parents=True, exist_ok=True)
css_ruta_rel = '../' * len(rel.parts[:-1]) + 'estilos.css'
html = plantilla.format(
titulo=titulo,
css_ruta=css_ruta_rel,
navegacion=navegacion,
contenido=html_body
)
html_p.write_text(html, encoding='utf-8')
print(f" ✓ {rel} → {html_p.relative_to(carpeta_html)}")
print(f"\nSitio generado en: {carpeta_salida}")
return len(md_archivos)
# Uso
generar_sitio_estatico('docs/', 'sitio_web/')
Sanitización: HTML seguro desde Markdown de usuario
def md_sanitizado(md_texto):
"""
Convierte Markdown a HTML eliminando JavaScript y contenido malicioso.
Imprescindible cuando el Markdown viene de usuarios no confiables.
pip install bleach
"""
try:
import bleach
from bleach.linkifier import LinkifyFilter
except ImportError:
raise ImportError("pip install bleach")
# Primero convertir Markdown a HTML
html_sucio = md_a_html_basico(md_texto)
# Tags y atributos seguros (lista blanca)
tags_permitidos = [
'p', 'br', 'strong', 'em', 'u', 's', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'blockquote', 'pre', 'code', 'hr',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'a', 'img', 'figure', 'figcaption',
'details', 'summary',
]
atributos_permitidos = {
'a': ['href', 'title', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height', 'loading'],
'code': ['class'],
'th': ['align'], 'td': ['align'],
}
html_limpio = bleach.clean(
html_sucio,
tags=tags_permitidos,
attributes=atributos_permitidos,
strip=True, # eliminar tags no permitidos (no escapar)
strip_comments=True,
)
# Añadir rel="nofollow noopener" a enlaces externos
html_limpio = bleach.linkify(
html_limpio,
callbacks=[bleach.callbacks.nofollow]
)
return html_limpio
# Ejemplo con Markdown malicioso
md_malicioso = '''
# Título normal
**Texto en negrita**
[Enlace](javascript:alert('XSS'))
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
```python
print("código seguro")
''' html_seguro = md_sanitizado(md_malicioso) print("HTML sanitizado:", html_seguro)
## Conclusión
Para convertir Markdown a HTML en Python, **python-markdown** es la elección más común por su ecosistema de extensiones (PyMdown, codehilite, toc). **mistune** es la más rápida (2-5× más que python-markdown) e ideal para conversión en tiempo real. Para contenido de usuarios no confiables, combina siempre la conversión con **bleach** para sanitizar el HTML resultante y evitar XSS. Para sitios estáticos completos, considera MkDocs (construido sobre python-markdown) o Pelican.
## Conversiones relacionadas
Conversiones de documento que siguen este tema:
- [PDF a DOCX](/es/convert/pdf-a-docx)
- [DOCX a PDF](/es/convert/docx-a-pdf)
- [MD a PDF](/es/convert/md-a-pdf)
- [HTML a PDF](/es/convert/html-a-pdf)
- [ODT a PDF](/es/convert/odt-a-pdf)
- [PDF a TXT](/es/convert/pdf-a-txt)