# AutomaticEDA — contrato de capítulos

Documento autoritativo para **escribir capítulos** del informe AutomaticEDA. Léelo
entero antes de añadir un capítulo: define el modelo de bloques, la firma del builder,
el versionado, dónde colocar el módulo, cómo se registra en el orden del documento, qué
claves del `profile` consume cada capítulo y un ejemplo completo de capítulo de
referencia (OVERVIEW).

AutomaticEDA es la capa intermedia entre **contenido** (lo que un capítulo quiere
decir) y **formato de salida** (PDF móvil + PPTX para compartir). Un mismo documento por
capítulos se renderiza a los dos formatos con garantía de **no-corte**: el texto se
envuelve a líneas completas, las tablas largas se parten por filas repitiendo la
cabecera, y figuras/imágenes se escalan para caber enteras.

- Código del motor: `python/functions/datascience/automatic_eda/` (paquete de soporte).
- Funciones públicas del registry (grupo `eda`): `render_automatic_eda_pdf`,
  `render_automatic_eda_pptx`.
- Sustituye evolutivamente a `render_eda_pdf` **de forma aditiva** (ese sigue activo en
  `profile_table(emit_pdf=True)`).

---

## 1. Modelo de documento

```
Document  = list[Chapter]
Chapter   = { id: str, title: str, version: str, blocks: list[Block] }
Block     = Heading | Markdown | KVTable | DataTable | Figure | Image | Caption
          | Note | Group | GlossaryEntry
```

Importa el modelo desde `datascience.automatic_eda.model` (o
`from datascience.automatic_eda import ...`). Todos los bloques son dataclasses; los
renderers también aceptan **dicts** con la clave `kind` (lectura defensiva: lo no
reconocido se degrada a `Note`, nunca lanza).

### Bloques

| Bloque | Construcción | Qué hace en el render |
|---|---|---|
| `Heading(text, level=1)` | título de sección, `level` 1 (grande) … 3 (chico) | una o varias líneas en negrita; nivel 1 lleva subrayado de acento |
| `Markdown(text)` | texto markdown ligero | ver subset abajo; **nunca corta a media línea** |
| `KVTable(rows, title=None)` | `rows = [(clave, valor), ...]` | tabla de 2 columnas etiqueta/valor; el valor se envuelve |
| `DataTable(header, rows, title=None, note=None)` | `header=[...]`, `rows=[[...],...]` | tabla con cabecera; **si cabe** como texto se parte por filas repitiendo cabecera; **si NO cabe** (demasiadas columnas) se rasteriza entera como imagen de alta resolución para hacer zoom. Ver §11.4 |
| `Figure(fig=None, make=None, caption=None, height_in=None)` | una `matplotlib.figure.Figure` ya construida (`fig`) o un callable `make()->Figure` (perezoso) | se rasteriza y escala para caber entera (nunca recortada) |
| `Image(path, caption=None, height_in=None)` | ruta a PNG/JPG | se escala para caber entera |
| `Caption(text)` / `Note(text)` | texto auxiliar pequeño | pie/nota en gris; `Note` es además el fallback de lo desconocido |
| `Group(blocks, title=None, page_break_before=False, layout="stack")` | unidad **keep-together**: sus bloques se mantienen juntos | el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe; encoge la figura para dejar sitio al título+texto. `layout="side_by_side"` coloca tabla+figura en dos columnas (solo PPTX). Ver §11 y §11.4 |
| `GlossaryEntry(key, label, definition)` | una entrada del glosario (destino clicable) | la genera el capítulo `glosario`; registra su posición como destino de los términos marcados. Ver §11 |
| `TocEntry(label, target_id)` | una entrada de **índice clicable** en la portada | la genera el capítulo `portada`; el renderer la cablea como salto al inicio del capítulo cuyo `id` o `title` coincide con `target_id`. Ver §11.4 |

`Figure`/`Image` aceptan `height_in` (hint): el renderer **clampa** la figura a esa altura máxima (lo usa `Group` para encoger la figura). Toda figura escala dejando sitio a su caption en la misma página/slide; en PPTX el caption es **siempre** visible (si no se da `caption`, cae al último heading o a "Figura").

### Subset de markdown soportado (`Markdown`)

`#`/`##`/`###` → headings; `-`/`*` → viñetas; líneas `| a | b |` consecutivas → una
`DataTable`; línea en blanco → separación de párrafo; `**bold**`/`__bold__`/`` `code` ``
→ se quitan los marcadores y se conserva el texto. Todo lo demás se renderiza tal cual.
Garantía: ningún carácter se pierde; lo que no cabe se envuelve o pasa de página/slide.

---

## 2. Firma del builder de capítulo (OBLIGATORIA)

Cada capítulo es un módulo `python/functions/datascience/automatic_eda/chapters/<id>.py`
que expone **dos** símbolos:

```python
CHAPTER_VERSION = "1.0.0"   # semver de generación del capítulo (ver §4)

def build_<id>(profile: dict, ctx: dict) -> "Chapter | None":
    """Construye el capítulo desde el TableProfile y el contexto de presentación.

    Devuelve None si el capítulo NO aplica a este dataset (p.ej. timeseries sin
    columna fecha). Lee SIEMPRE defensivamente con .get y NUNCA lanza.
    """
```

- El nombre de la función es exactamente `build_<id>` donde `<id>` es el del módulo y
  el de `CHAPTER_ORDER` (§3). Ej.: `chapters/num_distr.py` → `build_num_distr`.
- Devuelve un `model.Chapter(id, title, version=CHAPTER_VERSION, blocks=[...])` o `None`.
- Un capítulo que devuelve `None` o cuyos `blocks` quedan vacíos se omite del documento.

---

## 3. Registro y orden del documento

El orden canónico está **pre-declarado** en
`python/functions/datascience/automatic_eda/chapters_registry.py`:

```python
CHAPTER_ORDER = [
    "portada", "overview", "analisis_llm", "num_distr", "cat_distr", "calidad",
    "correlacion", "modelos", "timeseries", "geospatial", "agregacion",
    "glosario",
]
```

`build_document(profile, ctx)` recorre este orden, importa perezosamente
`chapters/<id>.py` y llama `build_<id>`. **Para añadir un capítulo NO se edita
`chapters_registry.py`**: basta crear el módulo `chapters/<id>.py` (con su `<id>` ya en
`CHAPTER_ORDER`) y aparecerá automáticamente en su posición. Esto permite que muchos
agentes trabajen **en paralelo** sin contención: cada uno toca solo su archivo.

**Dos capítulos tienen posición especial** (los gestiona `build_document`, no toques esto):

- `portada`: se **construye el último** (después del cuerpo) para poder resumir el
  análisis, pero se **coloca el primero**. Recibe `ctx['document_summary']` (ver §5) con
  un resumen agregado del resto. Decisión del usuario: la portada refleja hallazgos.
- `glosario`: se construye y se **coloca el último**. Lee los términos que los demás
  capítulos registraron en `ctx['glossary']` (ver §11). Si no se registró ninguno, el
  capítulo devuelve `None` y desaparece.

Si tu capítulo usa un `<id>` que aún no está en `CHAPTER_ORDER`, añádelo en la posición
correcta (única edición compartida; coordínala con el orquestador).

`build_document` nunca lanza: un capítulo cuyo módulo no existe se salta, y uno que falla
o devuelve `None` se omite.

---

## 4. Versionado por capítulo + manifiesto

- `CHAPTER_VERSION` (semver) identifica la **generación** del capítulo. Bumpéalo cuando
  cambies qué/cómo emite el capítulo (no en cada corrida). Se estampa en el pie de cada
  página/slide: `<Título> · v<version>`.
- `ENGINE_VERSION` (en `model.py`) versiona el motor global.
- Al renderizar se escribe `automatic_eda_manifest.json` junto a la salida:

```json
{
  "engine": "AutomaticEDA",
  "engine_version": "1.0.0",
  "generated_at": "2026-06-30 12:20:56 UTC",
  "chapters": {
    "portada":  { "version": "1.0.0", "n_pages": 1, "n_slides": 1 },
    "overview": { "version": "1.0.0", "n_pages": 2, "n_slides": 2 }
  }
}
```

Llamar a uno o ambos renderers crea/actualiza el manifiesto (read-modify-write
defensivo). Esto habilita el **seguimiento y la mejora continua por capítulo**.

---

## 5. `ctx` — contexto de presentación

`ctx` lleva metadatos que **no están** en el `TableProfile` (lo aporta el caller via
`meta['ctx']`). Claves convencionales (todas opcionales):

| Clave | Uso |
|---|---|
| `dataset_name` | nombre del dataset (portada). Default: `profile['table']` |
| `source_origin` | de dónde viene el dataset (portada). Default: `profile['source']` |
| `storage` | tecnología de almacenamiento (portada). Default: inferido de `source` |
| `generated_at` | fecha de generación (portada/manifiesto). Default: `profiled_at`/ahora |
| `description` | frase de descripción del dataset (portada) |
| `granularity` | "Cada fila es…" (portada). Default: derivado de `key_candidates` |
| `quality_criteria` | criterios del score de calidad (portada) |
| `head_rows` | `list[dict]` con `df.head` (overview). Ver §7 |
| `glossary` | `GlossaryCollector` compartido — los capítulos registran términos en él. Lo crea `build_document`; ver §11 |
| `document_summary` | dict con el resumen agregado del cuerpo (n_rows, n_cols, quality_score, n_numeric, n_categorical, chapter_titles, …). Lo calcula `build_document` y lo consume la portada |

Un capítulo puede definir y consumir sus propias claves `ctx` — documenta cuáles en su
docstring.

---

## 6. Claves del `profile` que consume cada capítulo

El `TableProfile` lo produce `profile_table(...)["profile"]` (grupo `eda`). Claves de
nivel superior: `table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows,
duplicate_pct, null_cell_pct, constant_cols, all_null_cols, quality_score,
type_breakdown, key_candidates, columns[], correlations, llm, models, series, caveats`.

Cada `columns[i]`: `name, inferred_type, semantic_type, physical_type, distinct_count,
unique_pct, null_count, null_pct, empty_count, empty_pct, flags, quality_score,
numeric{min,max,mean,median,std,variance,cv,iqr,skew,kurtosis,p1..p99,mode,n_outliers,
outlier_pct,zero_pct,negative_pct,distribution_type,histogram[{lo,hi,count}]},
categorical{top[{value,count,pct}],mode,n_distinct,entropy,imbalance,len_min/mean/max},
reexpression, series{...}`.

| Capítulo | Claves del profile que consume |
|---|---|
| `portada` | `table, source, profiled_at, n_rows, n_cols, quality_score, key_candidates` + `ctx` |
| `overview` | `columns[].{name,inferred_type,semantic_type,physical_type,null_pct,null_count,categorical.top,numeric.{min,median,max,mean,std}}`, `head_rows` (ver §7) |
| `num_distr` (pendiente) | `columns[] numeric.{histogram,mean,median,std,outlier_pct,...}` |
| `cat_distr` (pendiente) | `columns[] categorical.{top,entropy,imbalance}` |
| `calidad` (pendiente) | `quality_score`, `columns[].{quality_score,flags,issues}`, `duplicate_*`, `null_cell_pct`, `constant_cols`, `all_null_cols` |
| `correlacion` (pendiente) | `correlations.pairs[{a,b,value,method}]`, `correlations.levels_caveat` |
| `modelos` (pendiente) | `models.{pca,kmeans,outliers,normality}` |
| `analisis_llm` (pendiente) | `llm` |
| `timeseries` (pendiente) | `series{col:{stationarity,acf_pacf,stl,levels_*}}` |
| `geospatial` (pendiente) | columnas con `semantic_type` geográfico (lat/lon) |
| `agregacion` (pendiente) | `columns[]` + agregados que la fase de cálculo añada |

---

## 7. Claves nuevas del profile que la fase de cálculo debe añadir

El `TableProfile` actual **no** trae estas claves; el capítulo OVERVIEW las consume y, si
faltan, degrada honestamente (placeholder + derivación de valores reales). Para un
overview completo, la fase de cálculo (otro agente) debe añadir:

- `profile['head_rows']`: `list[dict]` con las primeras N filas (`df.head`), una por
  dict `{columna: valor}`. Mientras tanto OVERVIEW muestra un placeholder.
- `columns[i]['examples']`: `list` de hasta N valores **no nulos** crudos de la columna.
  Mientras tanto OVERVIEW deriva ejemplos de `categorical.top[].value` (categóricas) y de
  `numeric.{min,median,max}` (numéricas) — son valores reales, no inventados.

Sugerencia de implementación (no obligatoria en esta fase): una función del registry que
muestree `head_rows`/`examples` desde DuckDB y las inyecte en el profile antes de
renderizar (delegar a `fn-constructor`, tag `eda`).

---

## 8. Ejemplo COMPLETO de capítulo de referencia (OVERVIEW)

Copia este patrón. Archivo real:
`python/functions/datascience/automatic_eda/chapters/overview.py`.

```python
from .. import model

CHAPTER_VERSION = "1.0.0"
CHAPTER_ID = "overview"
CHAPTER_TITLE = "Overview"

def _fmt_num(v, d=3):
    # ... formateo defensivo (None -> "—", floats compactos) ...
    ...

def _examples_for(col: dict) -> str:
    # 1) col['examples'] si existe; 2) categorical.top[].value;
    # 3) numeric.{min,median,max}. Nunca celda vacía ni inventada.
    ...

def build_overview(profile: dict, ctx: dict):
    profile = profile or {}
    ctx = ctx or {}
    cols = profile.get("columns") or []
    if not cols and not (ctx.get("head_rows") or profile.get("head_rows")):
        return None  # no aplica.

    blocks = [
        model.Heading(text="Primeras filas (df.head)", level=2),
        _head_block(profile, ctx),  # DataTable(df.head) o Note si falta head_rows.
    ]
    cols_block = _columns_block(profile)  # DataTable: nombre/tipo/nulos/ejemplos.
    if cols_block is not None:
        blocks.append(model.Heading(text="Diccionario de columnas", level=2))
        blocks.append(cols_block)
    desc_block = _describe_block(profile)  # DataTable: mean/median/min/max/std.
    if desc_block is not None:
        blocks.append(model.Heading(text="Resumen estadístico numérico", level=2))
        blocks.append(desc_block)

    return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
                         version=CHAPTER_VERSION, blocks=blocks)
```

Puntos clave que todo capítulo debe respetar:

1. **Lectura defensiva**: `profile.get(...)`, `or []`, comprobar `isinstance` — nunca
   asumir que una clave existe ni lanzar.
2. **`None` si no aplica**: devuelve `None` (o `blocks` vacíos) cuando el dataset no tiene
   lo que el capítulo necesita.
3. **No inventar**: si falta un dato (p.ej. `df.head`), muestra un placeholder honesto o
   deriva de valores reales del perfil; deja el hueco documentado.
4. **Tablas vía `DataTable`**: deja que el renderer las parta y repita cabecera; no
   pre-pagines tú.
5. **Figuras vía `Figure(make=...)`**: pásalas perezosas; las dibuja y escala el renderer.

---

## 9. Cómo se prueba un capítulo

```python
from datascience.automatic_eda import build_document, render_pdf, render_pptx
chapters = build_document(profile, ctx={"dataset_name": "..."})
render_pdf(chapters, "reports/x.pdf", {"title": "EDA"})
render_pptx(chapters, "reports/x.pptx", {"title": "EDA"})
```

O directo desde las funciones públicas con el profile entero (construyen los capítulos):

```python
from datascience import render_automatic_eda_pdf, render_automatic_eda_pptx
render_automatic_eda_pdf(profile, "reports/x.pdf", {"ctx": {...}})
render_automatic_eda_pptx(profile, "reports/x.pptx", {"ctx": {...}})
```

Añade un test self-contained por capítulo (perfil sintético, sin DuckDB) que verifique
sus bloques presentes y el no-corte (texto largo intacto en la salida). Patrón:
`render_automatic_eda_pdf_test.py`.

---

## 11. Glosario, keep-together y zebra (motor, fase 4a)

Tres capacidades transversales del motor que **todos** los capítulos pueden usar. La 6.1
(glosario) requiere que el capítulo coopere (registrar + marcar términos); la 6.2
(keep-together) es opt-in por capítulo (envolver bloques en `Group`); la 6.3 (zebra) es
automática (no hay nada que hacer).

### 11.1 Glosario con términos clicables

El glosario es un capítulo nuevo (`chapters/glosario.py`) que se renderiza **siempre el
último** y lista cada término técnico que algún capítulo haya registrado. Cada aparición
del término en el texto se vuelve un **clic real** que salta a su entrada: en PDF como
*link annotation* interno (post-proceso con PyMuPDF, porque `PdfPages` no soporta
hyperlinks internos), en PPTX como *slide-jump* nativo (`ppaction://hlinksldjump`).

**API exacta para un capítulo (dos pasos):**

1. **Registrar el término** en el colector compartido `ctx['glossary']` (un
   `model.GlossaryCollector`, creado por `build_document` y pasado a todos los capítulos):

   ```python
   glossary = ctx.get("glossary")
   if isinstance(glossary, model.GlossaryCollector):
       glossary.add("entropia", "Entropía (de Shannon)", "Medida, en bits, de …")
   ```

   `add(key, label, definition)` es idempotente (la primera definición de cada `key` gana).
   `key` debe ser `[A-Za-z0-9_]+`. Si no hay colector en `ctx` (renderizado suelto), el
   capítulo simplemente no marca términos — degrada sin romper.

2. **Marcar cada aparición** en el texto de un bloque `Markdown` con el span inline
   `[[term:KEY]]texto visible[[/term]]`. El texto visible puede llevar `**negrita**`. El
   marcador no altera el texto visible (se elimina como cualquier marcador inline); solo
   añade el destino clicable.

   ```python
   # En cat_distr (ejemplo real ya implementado):
   "La [[term:entropia]]**entropía de Shannon**[[/term]] mide cómo de repartidos…"
   ```

Eso es todo: el capítulo `glosario` recoge los términos (orden alfabético por `label`),
emite un `GlossaryEntry` por término, y los renderers cablean los enlaces automáticamente.
Si ningún capítulo registró términos, el glosario no aparece.

**Helpers de `text_layout` (no reimplementar):** `parse_inline_rich(text)` →
`[(texto, is_bold, term_key), …]`; `wrap_rich_terms(text, max_chars)` → líneas de esos
spans sin corte. `strip_inline_md` ya elimina los marcadores `[[term:…]]`/`[[/term]]`.
(Las funciones previas `parse_inline_bold` / `wrap_rich` siguen existiendo, sin términos.)

**Funciones del registry que cablean los enlaces** (grupo `eda`, ya invocadas por los
renderers; degradan en silencio si faltan): `add_pdf_internal_links_py_datascience`
(PyMuPDF, link GOTO) y `pptx_link_run_to_slide_py_datascience` (salto a slide nativo).
Dependencia: `pymupdf` (declarada en `python/pyproject.toml`).

**Trabajo de la siguiente fase — enganchar más términos.** El mecanismo está hecho y
probado de extremo a extremo con `entropia` (en `cat_distr`). Cada capítulo debe registrar
y marcar SUS términos con el mismo patrón de dos pasos. Candidatos por capítulo:

| Capítulo | Términos a enganchar (key sugerida) |
|---|---|
| `cat_distr` | `entropia` ✅ (hecho) |
| `calidad` | `completitud`, `validez`, `consistencia` |
| `correlacion` | `cramers_v`, `fdr` (comparaciones múltiples), método de correlación usado |
| `modelos` | `pca`, `silhouette`, `isolation_forest` |
| `timeseries` | `estacionariedad`, `acf_pacf`, `stl` |
| `num_distr` | `iqr`, `curtosis`, `outlier` (vallas de Tukey) |

Define la definición de cada término en su capítulo (constante local, como
`_TERM_ENTROPIA_DEF` en `cat_distr`) y márcalo en su primera aparición.

### 11.2 Keep-together: gráfico junto a su título y texto (`Group`)

Para que un encabezado no quede en una página/slide y su figura en la siguiente, envuelve
los bloques de una misma idea en un `model.Group`:

```python
blocks.append(model.Group(blocks=[
    model.Heading(text=str(name), level=2),
    model.Figure(make=_figura_perezosa(...), caption="…"),
    model.Markdown(text="explicación…"),
]))
```

El renderer **mide el grupo entero** antes de dibujar nada: si no cabe en lo que queda de
página/slide pero cabe en una entera, lo mueve **completo** a la siguiente; y **encoge la
figura** (vía `height_in`) lo justo para que el título + texto + figura quepan juntos. Si
el grupo es más alto que una página entera, empieza en una nueva y fluye (degradación
honesta, nunca corta). Ejemplo real implementado: `num_distr` envuelve cada columna
(heading + figura histograma/boxplot + nota) en un `Group`.

Recomendado para `agregacion` y cualquier capítulo donde una figura deba ir pegada a su
título/explicación. Coste: si un capítulo inspecciona `chapter.blocks` en sus tests, ahora
encontrará `Group`s — aplana con un helper recursivo (ver `num_distr_test.py::_flatten`).

### 11.3 Zebra striping en tablas (automático)

Todo `DataTable` se renderiza con **filas pares sombreadas** (gris muy suave `#f6f8fa`) y
cabecera con su fondo propio. Es automático en PDF y PPTX; el patrón se mantiene coherente
cuando una tabla larga se parte y repite cabecera (el índice de fila es lógico, no por
página). No hay nada que hacer en los capítulos.

### 11.4 Calidad de render global: DPI alto, tabla ancha → imagen, figura al lado, índice clicable

Cuatro capacidades transversales del motor, **todas automáticas salvo `layout`** (que un
capítulo activa explícitamente). Aplican a PDF y PPTX salvo donde se indique.

**(a) DPI alto (automático).** Toda figura/imagen embebida se rasteriza a **220 dpi**
(constante `_RASTER_DPI` en ambos renderers; en PDF se aplica también al `savefig` de la
página, porque matplotlib re-rasteriza cada `imshow` al escribir la página). Objetivo:
ampliar en el móvil y leer detalle (ejes, celdas) sin pixelar. El texto sigue siendo
vectorial y seleccionable. No hay nada que hacer en los capítulos.

**(b) Tabla ancha → imagen de alta resolución (automático).** Cuando un `DataTable` tiene
**demasiadas columnas para ser legible como texto** en el ancho útil (criterio
`_table_fits_as_text`: ancho mínimo legible por columna × nº de columnas > ancho útil; en
la práctica salta sobre tablas tipo `df.head` con muchas columnas), en vez de comprimir las
columnas hasta hacerlas ilegibles, la tabla se dibuja **entera como una imagen de alta
resolución** (función `render_table_as_figure_py_datascience`: cabecera sombreada + zebra)
escalada para caber completa, de modo que el lector hace **zoom** y la lee sin perder datos.
Si la tabla **sí cabe**, se mantiene como texto seleccionable (PDF) / tabla nativa (PPTX).
Las `KVTable` (2 columnas) caben siempre y se quedan como texto. No hay nada que hacer en
los capítulos.

**(c) Figura al lado de la tabla — `Group(layout="side_by_side")`.** Hint de layout que un
capítulo activa para que su **tabla quede a la izquierda y su figura a la derecha** en la
misma diapositiva, en lugar de apiladas:

```python
model.Group(
    layout="side_by_side",
    blocks=[
        model.Heading(text=str(name), level=2),       # va a ancho completo arriba
        model.DataTable(header=..., rows=...),         # columna IZQUIERDA (~55%)
        model.Figure(make=_grafico_perezoso(...)),     # columna DERECHA (~45%)
        model.Markdown(text="explicación…"),           # va a ancho completo abajo
    ])
```

Contrato exacto del campo:

| Campo | Valor | Efecto |
|---|---|---|
| `layout` | `"stack"` (por defecto) | comportamiento histórico: apilado vertical (keep-together). |
| `layout` | `"side_by_side"` | **PPTX**: la tabla (rasterizada a imagen) ocupa la columna izquierda (~55% del ancho útil) y la figura la derecha (~45%); cualquier otro bloque (heading, markdown) va a ancho completo arriba/abajo. Si no hay un par tabla+figura, o no caben lado a lado en una slide, **cae automáticamente a apilado**. **PDF**: se trata **igual que `stack`** (el ancho A5 móvil no admite dos columnas legibles). Valores desconocidos degradan a `"stack"`. |

Es **retrocompatible**: un `Group` sin `layout` (o `layout="stack"`) se comporta exactamente
como antes. El capítulo `cat_distr` es el consumidor previsto (gráfico a la derecha de la
tabla de categorías en PPT); este motor solo provee el soporte.

**(d) Índice clicable en la portada — `TocEntry`.** La portada emite un `Heading("Índice")`
seguido de un `TocEntry(label, target_id)` por capítulo. El renderer registra la
página/slide de inicio de **cada** capítulo (indexado por `id` **y** por `title`) y cablea
cada `TocEntry` como un salto real a ese inicio: en **PDF** vía
`add_pdf_internal_links_py_datascience` (link GOTO de PyMuPDF), en **PPTX** vía
`pptx_link_run_to_slide_py_datascience` (salto a slide nativo). Como la portada solo conoce
los **títulos** de los capítulos, el `target_id` se hace coincidir contra el `title` (o el
`id`) de destino. Si un destino no resuelve, la entrada se muestra igualmente como texto
(en color de enlace), nunca se corta. Es el mismo mecanismo que los términos clicables del
glosario (§11.1), reutilizado en sentido portada → capítulo.

---

## 10. Integración futura con `profile_table` (siguiente fase)

`profile_table(emit_pdf=True)` usa hoy `render_eda_pdf` (intacto). En la siguiente fase
se añadirá `emit_automatic=True` (o se migrará `emit_pdf`) para que cada EDA emita
**siempre** PDF + PPTX del motor AutomaticEDA desde el mismo profile:

```python
# Bosquejo de la integración aditiva (NO activar si rompe los tests actuales):
if emit_automatic:
    ctx = {"dataset_name": table, "source_origin": db_path, ...}
    render_automatic_eda_pdf(prof,  os.path.join(report_dir, f"aeda_{table}_{ts}.pdf"),
                             {"title": f"EDA — {table}", "ctx": ctx})
    render_automatic_eda_pptx(prof, os.path.join(report_dir, f"aeda_{table}_{ts}.pptx"),
                              {"title": f"EDA — {table}", "ctx": ctx})
```

Hasta entonces los renderers se invocan directamente sobre el `profile` que
`profile_table` ya devuelve.
