PowerPoint HA MUERTO! Presentaciones en segundos

Vistas: 1
0:00 / 0:00
PowerPoint HA MUERTO! Presentaciones en segundos

¿Cuántas veces te ha pasado que tienes que preparar una presentación y acabas perdiendo más tiempo moviendo cajitas en PowerPoint, peleándote con los márgenes de LaTeX Beamer o buscando la plantilla perfecta en Canva que pensando en el contenido real? A mí me pasaba constantemente. Hasta que decidí que tenía que haber una forma mejor. Y la encontré. Te presento typst-ia, un CLI en Python que hace exactamente esto: tú le dices el tema —»Historia de Linux», «Comandos de Git», «Arquitectura de contenedores», lo que sea— la IA escribe el contenido siguiendo una plantilla profesional, y Typst lo convierte en un PDF impecable en cuestión de segundos. Todo desde la terminal, sin interfaces gráficas, sin suscripciones mensuales, sin perder la cabeza.

En los últimos episodios del podcast —concretamente desde el 817 hasta el 821— me he estado apoyando en presentaciones para los episodios. No solo porque ahora también se pueden ver en YouTube, sino porque tener un guión visual me ayuda a mantener el hilo y no saltarme nada importante. Y claro, al hacer tantas presentaciones, me di cuenta de que el proceso de maquetación era un cuello de botella enorme. Así que me puse manos a la obra para automatizarlo. El resultado es lo que te voy a contar hoy.

El problema de las presentaciones tradicionales

Mira, hagamos un ejercicio rápido. Piensa en la última vez que tuviste que preparar una presentación para una charla, una clase o una reunión. ¿Cuánto tiempo pasaste moviendo elementos gráficos, ajustando tamaños de fuente, alineando cajitas, buscando la imagen de fondo perfecta? Y al final, ¿cuánto de ese tiempo fue realmente productivo?

La cuestión es que las herramientas tradicionales tienen un problema de base. PowerPoint, Google Slides y Canva son herramientas WYSIWYG (What You See Is What You Get), que está muy bien para según qué cosas, pero que te obligan a hacer una cantidad ingente de trabajo manual repetitivo. Cada slide nueva implica crear la cajita, poner el texto, ajustar el tamaño, elegir la fuente, alinear… y si quieres cambiar el estilo de toda la presentación, a rehacerlo todo.

Por otro lado, LaTeX Beamer es la alternativa para los que preferimos escribir código. Pero LaTeX tiene sus propios problemas: la sintaxis es farragosa, la compilación es lenta —una presentación de 20 slides puede tardar 5 o 10 segundos en compilar— y la curva de aprendizaje es empinada. Y además, LaTeX no fue diseñado para presentaciones, sino para documentos académicos. Las presentaciones son un añadido tardío.

Así que después de darle muchas vueltas, recapacité. Lo que yo necesitaba era algo que combinara lo mejor de ambos mundos: la velocidad y el control del código, con una sintaxis limpia y moderna, y que además pudiera delegar la generación del contenido en una IA. Algo que me permitiera pasar de una idea a un PDF listo para proyectar en menos de 30 segundos. Y ahí es donde aparece Typst.

Typst: el sistema de composición moderno

Typst es un sistema de composición moderno escrito en Rust. Piensa en LaTeX, pero sin la parte dolorosa. La sintaxis es mucho más limpia, más parecida a Markdown, pero con capacidad de scripting real y un motor de composición tipográfica de primera categoría.

Te pongo unos ejemplos para que veas lo sencillo que es. En Typst, un heading nivel 1 se escribe con = Título. Un heading nivel 2 con == Subtítulo. Las listas con - para bullets, + para numeradas. El código con triple backtick, como en Markdown. Pero la gracia no es solo la sintaxis. La gracia es que Typst compila a velocidad luz. Literalmente. Una presentación de 20 slides en LaTeX Beamer puede tardar 5 o 10 segundos en compilar. Typst lo hace en menos de un segundo.

Y el resultado es un PDF impecable. Con fuentes bien colocadas, con código bien formateado, con tablas que no se desmadran y con un control tipográfico que le da mil vueltas a cualquier herramienta gráfica.

Vamos, que Typst es ese amigo que llega y te soluciona la papeleta sin hacer ruido.

Typst vs LaTeX vs Markdown

Si alguna vez has usado LaTeX o Markdown para hacer presentaciones, seguro que te has encontrado con las limitaciones de cada uno. Markdown es muy cómodo para escribir, pero cuando necesitas control fino sobre el diseño —como en una presentación— te quedas corto. LaTeX te da control absoluto, pero a costa de una sintaxis verbosa y una compilación lenta.

Typst se sitúa en un punto intermedio perfecto. Tiene la sencillez de Markdown para las tareas comunes, pero cuando necesitas hacer algo más complejo —como una tabla con formato condicional, un diseño a dos columnas o un bloque de código con tema personalizado— puedes hacerlo con una sintaxis mucho más limpia que la de LaTeX.

Además, Typst tiene un sistema de paquetes integrado llamado Universe donde puedes encontrar plantillas para todo tipo de documentos. Para presentaciones, el paquete más popular es Touying, que viene con 6 temas integrados, animaciones y notas de presentador. Pero para el podcast, yo he creado mi propia plantilla: slydst.typ.

Instalación de Typst

Lo bueno de Typst es que no tiene dependencias locas. No necesitas una instalación gigante de TeX Live, ni Java, ni nada raro. Typst es un único binario estático que puedes descargar y ejecutar al instante.

La instalación varía según tu distribución:

# Debian/Ubuntu
sudo apt install typst

# Arch Linux
sudo pacman -S typst

# Fedora
sudo dnf install typst

# macOS
brew install typst

Si prefieres el binario estático directamente desde GitHub —que siempre es la versión más reciente— puedes hacerlo así:

curl -fsSL https://github.com/typst/typst/releases/latest/download/typst-x86_64-unknown-linux-musl.tar.xz | tar xJ
sudo cp typst /usr/local/bin/

Y si eres usuario de Rust y tienes cargo instalado, también puedes compilarlo:

cargo install --locked typst-cli

Una vez instalado, verifica que funciona:

typst --version

En el momento de grabar este episodio, la versión actual es la 0.15.1, publicada el 17 de julio de 2026. La versión 0.15.0, de junio de 2026, trajo cambios importantes como soporte para variable fonts, exportación HTML nativa, múltiples bibliografías, colores spot para impresión offset y el nuevo CLI typst eval que reemplaza a typst query.

El template slydst.typ: la identidad visual del podcast

Si llevas un tiempo escuchando el podcast o viendo los episodios en YouTube, habrás notado que todas las presentaciones tienen un estilo reconocible. Eso es gracias a slydst.typ, el template que he ido puliendo a lo largo de los episodios 817, 819, 820 y 821.

El template tiene una función principal que se llama slides.with(). Y a esta función le pasas unos cuantos parámetros que definen el aspecto de la presentación:

  • title — el título de la presentación, obligatorio
  • subtitle — un subtítulo opcional
  • comment — para poner «Episodio 822 — atareao con Linux»
  • authors — el autor o autores
  • layout — puede ser small, medium o large
  • ratio — la relación de aspecto, 16/9 para formato panorámico
  • background — una imagen de fondo que tengas en la carpeta images/

El comportamiento del template es muy sencillo. Los heading nivel 1 —los = Título— generan portadas automáticas: el título se centra, se pone en grande, sin cabecera ni pie de página. Los heading nivel 2 —== Título— generan slides de contenido con cabecera, número de página y el estilo completo.

El template también incluye reglas de estilo para bloques de código, tablas y listas. Los bloques de código se renderizan con fondo oscuro (#1d2433), la fuente Fira Code, bordes redondeados y un tamaño de 10pt. Las tablas tienen un stroke gris de 0.5pt que las hace limpias y legibles. Y el sistema de páginas usa márgenes calculados automáticamente según el layout elegido: el layout small usa 9cm de alto, medium 10.5cm y large 12cm.

La imagen de fondo se aplica con un overlay oscuro de opacidad 70% para que el texto sea legible, y se colocan el logo del podcast en la esquina superior derecha y el código QR en la inferior derecha. Todo eso ocurre automáticamente, sin que tengas que preocuparte de nada.

Así se ve una presentación típica usando el template:

#import "template/slydst.typ": *

#show: slides.with(
  title: "Historia de Linux",
  subtitle: "Generado con IA",
  authors: "Lorenzo Carbonell",
  layout: "large",
  ratio: 16 / 9,
)

= Historia de Linux

== Los Orígenes: Unix
- 1969: Ken Thompson crea Unix en Bell Labs
- 1983: Richard Stallman anuncia el proyecto GNU
- 1991: Linus Torvalds lanza el núcleo Linux

Fíjate cómo el template se encarga de todo: centra la portada, pone el header en las slides de contenido, aplica los colores, la fuente, el fondo… Tú solo te preocupas del contenido. Y eso es justo lo que vamos a delegar en la IA.

OpenRouter: la puerta de entrada a la IA

Vale, tenemos Typst, tenemos el template. Ahora necesitamos que alguien escriba el contenido. Y para eso usamos OpenRouter.

OpenRouter es un proxy que te da acceso a decenas de modelos de IA con una sola API. No te tienes que casar con OpenAI, no necesitas una cuenta en Anthropic, ni en Google, ni en DeepSeek. Una sola API key, un solo endpoint, y eliges el modelo que quieras en cada llamada.

La estructura de la API es la misma que la de OpenAI —chat completions— así que si alguna vez has llamado a ChatGPT desde código, ya sabes cómo funciona. El endpoint es:

POST https://openrouter.ai/api/v1/chat/completions

Le pasas un header con tu API key, y en el body le dices: «usuario: genera una presentación sobre X tema». Y el modelo te devuelve el texto generado.

¿Qué modelo usar?

He estado probando varios modelos y te doy mi recomendación personal:

DeepSeek Chat — es mi recomendación por defecto. Cuesta 14 centavos por millón de tokens de entrada. Sí, has leído bien, centavos. Y la calidad es sorprendentemente buena. Genera Typst válido casi siempre. Para el 80% de los casos, con DeepSeek vas sobrado.

Claude Sonnet 3.5 — si necesitas algo más complejo, con más estructura, más tablas, más código. Cuesta 3 dólares por millón de tokens, veinte veces más caro que DeepSeek, pero para presentaciones importantes merece la pena.

Gemini Flash 2.0 — muy rápido, barato, y a veces incluso gratuito hasta ciertos límites. Pero ojo, a veces se salta las reglas de formato y tienes que limpiar más la respuesta.

Llama 3.3 70B — gratis en algunos tier, calidad aceptable, pero no te recomiendo modelos de menos de 7 mil millones de parámetros porque alucinan mucho con la sintaxis Typst.

Para el día a día, DeepSeek. Para las presentaciones importantes, Sonnet. Y si estás probando, Gemini Flash.

Configuración

Necesitas una API key. Te registras en openrouter.ai, pones 5 o 10 dólares —que te durarán meses si usas DeepSeek— y ya está. Luego la configuras como variable de entorno:

export OPENROUTER_API_KEY="sk-or-v1-tu-api-key-aqui"

Para verificar que todo funciona, puedes hacer una llamada de prueba:

curl -s https://openrouter.ai/api/v1/auth/key \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" | jq .

El script typst-ia: el pipeline completo

Pues ya tenemos todo. Tenemos Typst, tenemos el template, tenemos OpenRouter. Ahora toca unirlos con un script Python. Lo he llamado typst-ia y es un CLI que orquesta el proceso completo.

El script completo está disponible en mi propio gist, y te recomiendo que le eches un ojo porque es la pieza central de todo esto.

typst-ia.py
#!/usr/bin/env python3
"""
typst-ia: Genera presentaciones Typst con IA vía OpenRouter.

Uso:
  typst-ia "Tema de la presentación"
  typst-ia "Historia de Linux" --slides 10
  typst-ia "Comandos Git" --slides 8 --model "anthropic/claude-3.5-sonnet"
  typst-ia "Python async" --slides 10 --output python-async

Dependencias:
  pip install requests

Requiere Typst instalado:
  sudo apt install typst       # Debian/Ubuntu
  sudo pacman -S typst         # Arch
"""

import argparse
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path

import requests


# ── Constantes ──────────────────────────────────────────────────────────────

API_KEY = os.environ.get("OPENROUTER_API_KEY")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "deepseek/deepseek-chat"

# Directorio donde está este script
SCRIPT_DIR = Path(__file__).resolve().parent
TEMPLATE_FILE = SCRIPT_DIR / "template" / "slydst.typ"
OUTPUT_DIR = SCRIPT_DIR


# ── Funciones auxiliares ────────────────────────────────────────────────────

def leer_template():
    """Lee el template slydst.typ del directorio template/"""
    if not TEMPLATE_FILE.exists():
        raise FileNotFoundError(
            f"No se encuentra el template en {TEMPLATE_FILE}\n"
            f"Asegúrate de que existe template/slydst.typ junto al script"
        )
    return TEMPLATE_FILE.read_text()


def escapar_typst_string(texto):
    """Escapa caracteres especiales en strings Typst (comillas, saltos de línea)"""
    return texto.replace('"', '\\"').replace('\n', ' ')


def construir_system_prompt(tema, n_slides, template_content):
    """
    Construye el array de mensajes para OpenRouter.
    El system prompt incluye el template real para que la IA conozca
    las capacidades de la plantilla.
    """
    return [
        {
            "role": "system",
            "content": (
                "Eres un experto en crear presentaciones con Typst.\n"
                "Tu tarea es generar el contenido de una presentación en formato Typst.\n\n"
                "ESTRUCTURA:\n"
                "- `= Título` → heading level 1 (portada automática)\n"
                "- `== Título` → heading level 2 (nueva página, slide de contenido)\n"
                "- Listas con `- ` (bullet) o `+ ` (numerada)\n"
                "- Código con ```lenguaje ... ``` (tres backticks)\n"
                "- Tablas con #table(columns: ..., ...)\n"
                "- Imágenes con #image(\"images/placeholder.png\", width: 70%)\n"
                "- Dos columnas con #columns(2, gutter: 12pt)[ ... #colbreak() ... ]\n"
                "- Énfasis con _cursiva_ (guión bajo). NO uses * para nada — ni énfasis, ni comodines, ni wildcards (como `test_*`).\n\n"
                "REGLAS ESTRICTAS:\n"
                "1. NO incluyas '#import' — el script lo añade automáticamente\n"
                "2. NO incluyas '#show: slides.with(...)' — el script lo añade\n"
                "3. El primer heading DEBE ser `= ` (portada del tema) — la respuesta "
                "debe EMPEZAR directamente con `= `, sin texto ni comentarios previos\n"
                "4. Usa SIEMPRE `==` para slides de contenido\n"
                "5. NO uses caracteres como $, \\, # fuera de contexto Typst\n"
                "6. No envuelvas el código en bloques markdown (```)\n"
                "7. Máximo 6-8 bullet points por slide\n"
                "8. Incluye variedad: bullets, código, tablas según el tema\n"
                "9. NO uses NUNCA el carácter `*` en ningún contexto — ni para énfasis, ni para comodines (como `test_*`). En Typst `*` abre énfasis/itálica y rompe la compilación. Usa `_cursiva_` para énfasis y `- ` para viñetas.\n"
                "TEMPLATE DISPONIBLE:\n"
                f"{template_content}\n\n"
                f"Genera exactamente {n_slides} slides para el tema \"{tema}\".\n"
                "Incluye al menos un bloque de código y una tabla si el tema lo permite."
            ),
        },
        {
            "role": "user",
            "content": f"Tema: {tema}\nNúmero de slides: {n_slides}",
        },
    ]


def llamar_openrouter(messages, model):
    """
    Llama a OpenRouter API con reintentos ante rate limit, timeout o error de conexión.
    Devuelve el texto generado por la IA.
    """
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://atareao.es",
        "X-OpenRouter-Title": "typst-ia",
    }

    for intento in range(3):
        try:
            resp = requests.post(
                API_URL,
                headers=headers,
                json={
                    "model": model,
                    "messages": messages,
                    "temperature": 0.7,
                    "max_tokens": 4096,
                },
                timeout=120,
            )
            resp.raise_for_status()
            return resp.json()["choices"][0]["message"]["content"]

        except requests.exceptions.HTTPError as e:
            if resp.status_code == 429:
                wait = int(resp.headers.get("Retry-After", 10))
                print(f"  ⏳ Rate limit. Esperando {wait}s...")
                time.sleep(wait)
                continue
            elif resp.status_code == 401:
                sys.exit("❌ API Key inválida. Configura OPENROUTER_API_KEY")
            elif resp.status_code == 402:
                sys.exit("❌ Saldo insuficiente en OpenRouter. Añade créditos en openrouter.ai")
            else:
                sys.exit(f"❌ Error HTTP {resp.status_code}: {e}")

        except requests.exceptions.Timeout:
            if intento < 2:
                print(f"  ⏳ Timeout. Reintentando ({intento + 1}/3)...")
                time.sleep(2 ** intento)
                continue
            sys.exit("❌ Timeout tras 3 intentos. Prueba otro modelo o más tarde.")

        except requests.exceptions.ConnectionError:
            if intento < 2:
                print(f"  ⚠️  Error de conexión. Reintentando ({intento + 1}/3)...")
                time.sleep(2 ** intento)
                continue
            sys.exit("❌ Error de conexión con OpenRouter. Revisa tu conexión a Internet.")


def normalizar_listas(linea):
    """
    Convierte una línea de lista con `*` (que Typst interpreta como énfasis/itálica)
    en `-` (viñeta correcta de Typst).

    Solo afecta a líneas donde el asterisco va seguido de espacio o está al final
    de la línea (lista), NO a *texto* inline ni a **negrita** (estilo Markdown).
    """
    m = re.match(r'^(\s*)\*($|\s)', linea)
    if m and not linea.strip().startswith('**'):
        prefix = m.group(1)
        if m.group(2) == ' ':
            # * seguido de espacio: mantener el espacio
            return prefix + '- ' + linea[m.end():]
        # * al final de línea
        return prefix + '-'
    return linea


def convertir_negrita(linea):
    """
    Convierte **texto** (estilo Markdown) en _texto_ (strong de Typst).
    En Typst ** no es válido y genera el warning 'no text within stars'.
    """
    return re.sub(r'\*\*([^*\n]+)\*\*', r'_\1_', linea)


def escapar_asteriscos(linea):
    """
    Escapa asteriscos sueltos de una línea (fuera de bloques de código)
    que Typst interpretaría como énfasis y romperían la compilación.

    Convierte * a \\* para que Typst los muestre literalmente.
    Es un safety net para cuando la IA ignora la regla de no usar *.
    """
    # No tocar headings (= o ==): ahí * se usa para énfasis válido
    if linea.strip().startswith('='):
        return linea
    # No tocar asteriscos ya escapados
    return re.sub(r'(?<!\\)\*', '\\\\*', linea)


def limpiar_respuesta(texto):
    """
    Limpia la respuesta de la IA:
      - Conserva bloques de código ```lang ... ``` como raw blocks Typst válidos
        (y los cierra si la IA los dejó abiertos)
      - Quita líneas de #import o #show: slides.with (fuera de código)
      - Fuera de código: normaliza listas (* → -), convierte **x** → _x_,
        y escapa asteriscos sueltos
    """
    # ── Pasada 1: separar bloques de código, quitar imports ──
    lineas = []
    en_codigo = False
    for linea in texto.split('\n'):
        stripped = linea.strip()
        # Fence de apertura o cierre de bloque de código
        if stripped.startswith('```'):
            en_codigo = not en_codigo
            lineas.append(linea)
            continue
        if en_codigo:
            # Contenido de código: se conserva tal cual
            lineas.append(linea)
            continue
        if stripped.startswith('#import') or stripped.startswith('#show: slides.with'):
            continue
        lineas.append(linea)

    texto = '\n'.join(lineas)
    # Si quedó un bloque sin cerrar, cerrarlo (evita 'unclosed raw text')
    if en_codigo:
        texto += '\n```'

    # ── Pasada 2: procesar solo líneas fuera de bloques de código ──
    resultado = []
    en_codigo = False
    for linea in texto.split('\n'):
        stripped = linea.strip()
        if stripped.startswith('```'):
            en_codigo = not en_codigo
            resultado.append(linea)
            continue
        if en_codigo:
            resultado.append(linea)
            continue
        # Fuera de código: normalizar, negrita y escapar asteriscos
        linea = normalizar_listas(linea)
        linea = convertir_negrita(linea)
        linea = escapar_asteriscos(linea)
        resultado.append(linea)
    return '\n'.join(resultado)


def generar_archivo_typ(tema, contenido_typ, output_path, background="fondo-matrix-hd.jpg"):
    """
    Genera el archivo .typ completo con:
      - Import del template
      - Show rule con slides.with()
      - Configuración visual (raw, text, heading)
      - Contenido generado por la IA
    """
    with open(output_path, "w") as f:
        f.write('#import "template/slydst.typ": *\n')
        f.write('\n')
        f.write('#show: slides.with(\n')
        f.write(f'  title: "{escapar_typst_string(tema)}",\n')
        f.write('  subtitle: "Generado con IA",\n')
        f.write(f'  comment: "Generado con typst-ia",\n')
        f.write(f'  authors: "Lorenzo Carbonell <a.k.a atareao>",\n')
        f.write('  layout: "large",\n')
        f.write('  ratio: 16 / 9,\n')
        f.write(f'  background: "{background}",\n')
        f.write(')\n')
        f.write('\n')
        f.write('#show raw: set block(fill: rgb("1d2433"), width: 100%, inset: 0.6em)\n')
        f.write('#set text(size: 10pt)\n')
        f.write('#show heading: set text(size: 11pt)\n')
        f.write('\n')
        f.write(contenido_typ)
        f.write('\n')

    print(f"  📄 Archivo .typ guardado: {output_path}")


def compilar_typst(typ_path, pdf_path):
    """Compila .typ a PDF con typst compile. Devuelve True si éxito."""
    result = subprocess.run(
        ["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)],
        capture_output=True,
        text=True,
        cwd=typ_path.parent,
    )

    if result.returncode != 0:
        print(f"  ❌ Error de compilación Typst:")
        print(f"     {result.stderr[:600]}")
        return False

    print(f"  ✅ PDF generado: {pdf_path}")
    return True


def compilar_con_reintentos(typ_path, pdf_path, messages, model, max_intentos=2):
    """
    Compila y si falla, pide a la IA que corrija el error.
    Útil porque la IA no siempre genera Typst 100% válido a la primera.

    A diferencia de la versión anterior, cada intento usa mensajes de corrección
    FRESCOS para evitar acumulación de correcciones anteriores.
    """
    for intento in range(max_intentos + 1):
        contenido_actual = typ_path.read_text()

        result = subprocess.run(
            ["typst", "compile", "--root", ".", str(typ_path), str(pdf_path)],
            capture_output=True,
            text=True,
            cwd=typ_path.parent,
        )
        if result.returncode == 0:
            print(f"  ✅ PDF generado: {pdf_path}")
            return True
        if intento == max_intentos:
            print(f"  ❌ Error tras {max_intentos} correcciones:")
            print(f"     {result.stderr[:500]}")
            return False

        error_msg = result.stderr[:1000]
        print(f"  ⚠️  Error de compilación. Pidiendo corrección (intento {intento + 1}/{max_intentos})...")

        # Crear mensajes de corrección FRESCOS por intento (evitar acumulación)
        correccion_msgs = list(messages) + [
            {"role": "assistant", "content": contenido_actual},
            {
                "role": "user",
                "content": (
                    f"El código Typst tiene este error de compilación:\n"
                    f"{error_msg}\n\n"
                    "Corrige SOLO el error. Devuelve TODO el contenido corregido, "
                    "sin el #import ni el #show, solo el contenido de las slides."
                ),
            },
        ]

        contenido_corregido = llamar_openrouter(correccion_msgs, model)
        if contenido_corregido is None:
            print("  ❌ La IA no devolvió una corrección válida.")
            return False
        contenido_corregido = limpiar_respuesta(contenido_corregido)

        # Reescribir el .typ: preámbulo original + contenido corregido
        preambulo = ""
        for linea in contenido_actual.split("\n"):
            preambulo += linea + "\n"
            if linea.strip().startswith("#show heading:"):
                break
        typ_path.write_text(preambulo + contenido_corregido + "\n")

    return False


def abrir_pdf(pdf_path):
    """Abre el PDF con el visor por defecto (zathura > xdg-open)"""
    if pdf_path.exists():
        # Intentar zathura primero
        if subprocess.run(["which", "zathura"], capture_output=True).returncode == 0:
            subprocess.Popen(["zathura", str(pdf_path)])
            return
        # Fallback a xdg-open
        subprocess.run(["xdg-open", str(pdf_path)], check=False)


# ── Punto de entrada ────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="typst-ia: Genera presentaciones Typst con IA",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""\
Ejemplos:
  typst-ia "Historia de Linux"
  typst-ia "Comandos Git" --slides 8
  typst-ia "Arquitectura de contenedores" --slides 12 --model "anthropic/claude-3.5-sonnet"
  typst-ia "Python async/await" --slides 10 --output python-async
        """,
    )
    parser.add_argument("tema", help="Tema de la presentación (entre comillas si tiene espacios)")
    parser.add_argument(
        "--slides", type=int, default=10,
        help="Número de slides a generar (default: 10)"
    )
    parser.add_argument(
        "--model", default=DEFAULT_MODEL,
        help=f"Modelo en OpenRouter (default: {DEFAULT_MODEL})"
    )
    parser.add_argument(
        "--output", "-o",
        help="Nombre del archivo de salida (sin extensión). Por defecto: slug del tema"
    )
    parser.add_argument(
        "--retries", type=int, default=0,
        help="Intentos de corrección automática si falla la compilación (default: 0)"
    )
    parser.add_argument(
        "--no-open", action="store_true",
        help="No abrir el PDF automáticamente al terminar"
    )
    parser.add_argument(
        "--background", default="fondo-matrix-hd.jpg",
        help="Imagen de fondo en images/ (default: fondo-matrix-hd.jpg)"
    )

    args = parser.parse_args()

    # ── Validaciones ────────────────────────────────────────────────────

    if not API_KEY:
        sys.exit(
            "❌ Configura OPENROUTER_API_KEY como variable de entorno\n"
            "   export OPENROUTER_API_KEY=\"sk-or-v1-tu-api-key-aqui\""
        )

    # ── Slug para nombres de archivo ────────────────────────────────────

    if args.output:
        slug = args.output
    else:
        slug = re.sub(r'[^a-z0-9]+', '-', args.tema.lower()).strip('-')[:50]

    typ_path = OUTPUT_DIR / f"{slug}.typ"
    pdf_path = OUTPUT_DIR / f"{slug}.pdf"

    # ── Inicio ──────────────────────────────────────────────────────────

    print(f"🚀  typst-ia — Generando presentación")
    print(f"    Tema:   \"{args.tema}\"")
    print(f"    Slides: {args.slides}")
    print(f"    Modelo: {args.model}")
    print()

    # 1. Leer el template
    print("📖  Leyendo template...")
    try:
        template = leer_template()
    except FileNotFoundError as e:
        sys.exit(f"❌ {e}")
    print(f"    Template: {TEMPLATE_FILE.name} ({len(template)} caracteres)")
    print()

    # 2. Construir prompt y llamar a OpenRouter
    print(f"🤖  Llamando a OpenRouter ({args.model})...")
    print("    (esto puede tardar unos segundos)")
    messages = construir_system_prompt(args.tema, args.slides, template)
    sys.stdout.flush()
    contenido = llamar_openrouter(messages, args.model)
    if contenido is None:
        sys.exit("❌ No se pudo generar contenido tras varios intentos.")
    contenido = limpiar_respuesta(contenido)
    print(f"    Recibidos ~{len(contenido.split())} tokens / ~{len(contenido.split(chr(10)))} líneas")
    print()

    # 3. Guardar archivo .typ
    print("📝  Generando archivo Typst...")
    generar_archivo_typ(args.tema, contenido, typ_path, background=args.background)
    print()

    # 4. Compilar a PDF
    print("⚙️   Compilando a PDF...")
    if args.retries > 0:
        exito = compilar_con_reintentos(typ_path, pdf_path, messages, args.model, args.retries)
    else:
        exito = compilar_typst(typ_path, pdf_path)

    if not exito:
        print()
        print("🔍  Contenido generado (primeros 600 caracteres):")
        print("    " + "-" * 50)
        print(contenido[:600])
        print("    " + "-" * 50)
        print()
        print("💡  Sugerencias:")
        print("    - Revisa el archivo .typ y corrige errores manualmente")
        print("    - Ejecuta: typst compile --root . " + str(typ_path) + " " + str(pdf_path))
        print("    - Usa --retries 2 para que la IA intente corregir automáticamente")
        sys.exit(1)

    print()

    # 5. Abrir PDF
    if not args.no_open:
        print("📂  Abriendo presentación...")
        abrir_pdf(pdf_path)

    print(f"✨  ¡Listo! Presentación generada: {pdf_path}")


if __name__ == "__main__":
    main()

Cómo funciona

El pipeline es muy sencillo. El script hace exactamente esto:

  1. Recibe un tema —por ejemplo «Historia de Linux»
  2. Lee el template slydst.typ del directorio template/
  3. Construye un system prompt con el template incrustado
  4. Llama a OpenRouter con el modelo elegido
  5. La IA devuelve el código Typst de la presentación
  6. El script limpia la respuesta (quita imports duplicados, bloques markdown)
  7. Genera un archivo .typ completo con el preámbulo y el contenido
  8. Compila a PDF con typst compile --root .
  9. Abre el PDF en el visor

Todo en un solo comando. La estructura del script es muy sencilla, apenas 200 líneas de Python. Las dependencias son mínimas: solo requests. Ni FastAPI, ni Flask, ni frameworks raros. Un pip install requests y listo.

Las funciones clave

El script tiene siete funciones que se encadenan de forma lineal:

leer_template() — abre el archivo template/slydst.typ y lo devuelve como texto. Si no lo encuentra, lanza un error claro indicando qué falta.

construir_system_prompt() — aquí está la magia. Crea el array de mensajes para OpenRouter con un system prompt que incluye el template real incrustado. Esto es clave porque la IA necesita saber exactamente qué funciones tiene disponibles, qué parámetros aceptan y qué estilos aplica. Es como darle el manual de instrucciones antes de pedirle que monte el mueble.

El system prompt incluye reglas estrictas:

  • NO incluyas #import — el script lo añade automáticamente
  • NO incluyas #show: slides.with(...) — también lo añade el script
  • El primer heading DEBE ser = de portada
  • Usa SIEMPRE == para slides de contenido
  • NO envuelvas el código en bloques markdown
  • Devuelve SOLO el código Typst, sin explicaciones

llamar_openrouter() — hace la llamada HTTP a la API con reintentos ante rate limit, timeout o error de conexión. Si la API key es inválida o el saldo es insuficiente, termina con un mensaje claro.

limpiar_respuesta() — esta función es el safety net. La IA a veces ignora las reglas y pone #import o mete bloques markdown. Esta función los elimina. También normaliza asteriscos sueltos que la IA usa como viñetas pero que en Typst se interpretan como énfasis y rompen la compilación.

generar_archivo_typ() — escribe el archivo .typ completo con el import del template, la llamada a slides.with() con los parámetros, la configuración de raw blocks y texto, y el contenido generado por la IA.

compilar_typst() — ejecuta typst compile --root . con el archivo generado. Devuelve True si la compilación fue exitosa.

abrir_pdf() — abre el PDF con el visor por defecto. Intenta primero con zathura (mi visor favorito) y si no está disponible, usa xdg-open.

Los flags del script

El script acepta varios parámetros que lo hacen muy flexible:

  • tema (posicional, obligatorio) — el tema de la presentación
  • --slides N — número de slides a generar (default: 10)
  • --model M — modelo de OpenRouter (default: deepseek/deepseek-chat)
  • --output, -o — nombre personalizado del archivo de salida
  • --retries N — intentos de corrección automática si falla la compilación
  • --no-open — no abrir el PDF automáticamente
  • --background — imagen de fondo en images/

El sistema de reintentos

Una de las características más interesantes es el sistema de corrección automática. Si la compilación falla —porque la IA ha generado Typst inválido— el script puede pasarle el error a la IA y pedirle que lo corrija. Esto es posible gracias a que los mensajes de corrección se crean frescos en cada intento, evitando la acumulación de correcciones anteriores.

Demo en vivo: generando presentaciones

Vale, suficiente teoría. Vamos a verlo en acción.

Demo rápida: «Linux para vagos»

Ejecuto el comando:

./typst-ia "Linux para vagos" --slides 5 --model "deepseek/deepseek-chat"

El script lee el template, construye el prompt, llama a OpenRouter. DeepSeek tarda unos segundos, no más de 5 o 6. La respuesta llega: DeepSeek ha generado 5 slides con una portada, «Por qué Linux es para vagos», «Automatiza todo», «Aliases y scripts», y un resumen. Incluye hasta un bloque de código.

El script escribe el archivo .typ, lo compila… y abre el PDF. Listo. En menos de 15 segundos hemos pasado de una idea a una presentación de 5 slides, con nuestro branding, con código, con bullets, lista para proyectar.

Sin PowerPoint, sin Canva, sin mover ni un solo elemento gráfico.

Demo avanzada: «Historia de Linux» con Sonnet

Ahora vamos a por algo más serio. Con 10 slides y Claude Sonnet:

./typst-ia "Historia de Linux" --slides 10 --model "anthropic/claude-3.5-sonnet"

Este tarda un poco más porque Sonnet es más lento y más caro, pero el resultado es impresionante. La IA genera: portada, orígenes de Unix, el proyecto GNU, el nacimiento de Linux, la década de los 90, los 2000, distribuciones principales con tabla, Linux en la nube, y un resumen.

Fíjate que ha incluido una tabla comparativa de distribuciones —Debian, Red Hat, Arch— con columnas de base, gestor de paquetes y facilidad de uso. Eso no se lo he pedido explícitamente, pero el prompt le dice «incluye variedad» y la IA lo interpreta.

El truco está en que el prompt le pide «al menos un bloque de código y una tabla si el tema lo permite». Con «Historia de Linux» igual no hay mucho código, pero con «Comandos de Git» la IA te genera slides con ejemplos de git add, git commit, git merge, todo formateado como código.

Ejemplos de uso

Aquí tienes algunos ejemplos de lo que puedes hacer:

# Presentación rápida (5 slides, DeepSeek)
./typst-ia "Linux para vagos" --slides 5

# Presentación profesional (10 slides, Claude Sonnet)
./typst-ia "Historia de Linux" --slides 10 --model "anthropic/claude-3.5-sonnet"

# Con nombre personalizado
./typst-ia "Comandos de Git" --slides 8 -o git-esencial

# Con corrección automática
./typst-ia "Arquitectura de contenedores" --slides 12 --retries 2

# Prototipo rápido con Gemini
./typst-ia "Prueba" --slides 5 --model "google/gemini-2.0-flash-001"

Tipos de slide que puedes generar

El template slydst.typ soporta varios tipos de slide que la IA puede utilizar según el tema. Te los resumo:

Slide de bullet points — el más común, con lista de viñetas usando -.

Slide con lista numerada — usando + para pasos o secuencias ordenadas.

Slide con bloque de código — usando triple backtick con el lenguaje especificado. El template aplica fondo oscuro, Fira Code y bordes redondeados automáticamente.

Slide con tabla — usando #table() con columns y table.header(). El template aplica stroke gris automáticamente.

Slide con dos columnas — usando #columns(2, gutter: 12pt) con #colbreak() para separar las columnas.

Slide con imagen — usando #image("ruta", width: X%). Las imágenes deben estar en el directorio images/.

Slide mixto — combinación de código y tabla en dos columnas, o cualquier otra combinación.

Errores comunes y cómo solucionarlos

Como todo lo que implica IA, no siempre funciona a la primera. Te cuento los errores más típicos y cómo solucionarlos.

Error 1: La IA genera Typst inválido. La solución es triple: un system prompt muy estricto, una función que limpie la respuesta —quitando imports duplicados y bloques markdown— y un sistema de reintentos para que la IA se corrija a sí misma. Si todo falla, siempre puedes editar el .typ a mano.

Error 2: El template no se encuentra. El archivo .typ debe estar en el mismo directorio que la carpeta template/. Y al compilar, usa --root . para que las rutas relativas funcionen. Si ejecutas el script desde otro sitio, las rutas se rompen.

Error 3: La IA se pasa del límite de tokens. Con 4096 tokens a veces no es suficiente para 15 slides. Si ves que la respuesta se corta, aumenta max_tokens a 8192 si el modelo lo soporta. O reduce el número de slides.

Error 4: La IA genera más o menos slides de las pedidas. Le dices 10 y te da 7 o 13. El prompt dice «exactamente N slides», pero la IA no es precisa. La solución: pide siempre 2 o 3 más de las que necesitas, y luego recortas. Otra opción: si te da 13 y querías 10, dices que 13 era el plan desde el principio. Nadie va a comprobarlo 😊

Tips finales para sacarle partido

Un par de trucos más antes de cerrar.

Reutiliza las presentaciones. El script guarda tanto el .typ como el .pdf. Puedes editar el .typ a mano y recompilar. La IA te da el esqueleto, tú pones el toque final.

Nombres personalizados. El script genera un slug del tema para el nombre del archivo. "Historia de Linux" se convierte en historia-de-linux.pdf. Si quieres un nombre concreto, usa --output nombre-personalizado.

Trabajo en equipo. Si trabajas en equipo, puedes compartir los templates. El script busca template/slydst.typ en el mismo directorio. Si todos usáis el mismo template, todas las presentaciones tendrán el mismo estilo.

Sin imágenes, no pasa nada. Si no tienes imágenes, el script usa #image("images/placeholder.png") si la IA las pide. Puedes cambiar la imagen después.

Para probar rápido. Usa --slides 5 y DeepSeek. En menos de 20 segundos tienes una presentación.

Conclusiones

Llegados a este punto, ¿qué conclusión he sacado? Pues que la combinación de Typst, un buen template y una IA barata como DeepSeek es una de las formas más eficientes de generar presentaciones que he probado nunca.

Typst es un sistema de composición moderno que compila a PDF en milisegundos. El template slydst.typ da un estilo profesional y reconocible a todas las presentaciones. Y typst-ia es un script en Python que, con una sola llamada a OpenRouter, genera el contenido, lo compila y te abre el PDF.

De idea a presentación lista en menos de 30 segundos. Sin interfaces gráficas, sin maquetación manual, sin dolor.

¿Y para qué sirve esto? Pues para mucho más que hacer presentaciones para el podcast. Puedes usarlo para generar la presentación de una charla, para preparar material para una clase, para hacer un onboarding rápido en tu empresa, para documentar un proyecto, para cualquier cosa que requiera pasar de un concepto a slides listas en cuestión de segundos.

El código del script, el template y los ejemplos los tienes en las notas del episodio. Si pruebas typst-ia y generas tu primera presentación, cuéntame qué tal. Me gusta ver lo que estáis construyendo con estas herramientas.

Y si quieres que profundice en alguna parte en concreto —cómo tunear el template, cómo añadir más tipos de slide, cómo meter imágenes generadas por IA, cómo exportar a HTML— dímelo y lo vemos en otro episodio. Porque esto solo acaba de empezar.

En la terminal, tú mandas.


Más información

Deja una respuesta