#!/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'(?",\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()