---
name: add_pdf_internal_links
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def add_pdf_internal_links(pdf_path: str, links: list) -> dict"
description: "Postprocesa un PDF YA escrito insertando link annotations internos de tipo GOTO ('ir a') con PyMuPDF (import fitz). Pensado para PDFs generados por matplotlib PdfPages, que NO soporta hyperlinks internos: tras escribir el PDF se reabre y, por cada entrada de `links`, se añade una anotacion clicable desde un rectangulo de una pagina origen (src_page + src_rect en puntos top-left) hasta un punto de una pagina destino (dst_page + dst_point). Caso de uso tipico del grupo eda: hacer clicables los terminos de un AutomaticEDA que apuntan a su entrada en el glosario al final del documento. Estilo dict-no-throw: NUNCA lanza; valida cada link y SALTA (n_skipped++) los malformados o fuera de rango en vez de fallar. Guarda de forma segura escribiendo a un temporal en el mismo directorio y haciendo os.replace atomico (evita corromper el original). Devuelve {status:ok,n_links,n_skipped} o {status:error,error}; si pymupdf no esta disponible o el archivo no existe devuelve status error."
tags: [eda, datascience, pdf, links, glossary, pymupdf, fitz, postprocess, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
  - name: pdf_path
    desc: "ruta al PDF existente (str no vacio). Se reescribe IN SITU (in-place) tras añadir los links: se guarda a un temporal `.<base>.tmp_links` en el mismo directorio y se reemplaza atomicamente con os.replace. Si no es str o no existe el archivo -> {status:error}."
  - name: links
    desc: "lista de dicts, uno por link a insertar. Cada dict: src_page (int 0-based de la pagina origen), src_rect ([x0,y0,x1,y1] del rectangulo clicable en PUNTOS PDF 1/72\" con origen ARRIBA-IZQUIERDA), dst_page (int 0-based de la pagina destino), dst_point ([x,y] punto destino, mismos puntos top-left). Las entradas que no son dict, con page fuera de rango [0,page_count), src_rect que no tenga 4 numeros o dst_point que no tenga 2 numeros se SALTAN (n_skipped++), no lanzan. None se trata como lista vacia."
output: "dict (NUNCA lanza): en exito {\"status\":\"ok\",\"n_links\":int,\"n_skipped\":int} con n_links = anotaciones GOTO insertadas y n_skipped = entradas invalidas saltadas. En fallo {\"status\":\"error\",\"error\":str}: pymupdf no disponible, pdf_path no es str / no existe, links no es lista, o cualquier excepcion global (el PDF original queda intacto porque el replace solo ocurre tras un save correcto)."
tested: true
tests: ["test_add_goto_link_basico", "test_links_invalidos_se_saltan", "test_archivo_inexistente_devuelve_error"]
test_file_path: "python/functions/datascience/add_pdf_internal_links_test.py"
file_path: "python/functions/datascience/add_pdf_internal_links.py"
---

## Ejemplo

```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import add_pdf_internal_links

# Tienes un PDF ya escrito por matplotlib PdfPages (sin hyperlinks internos).
# Quieres que el texto "Margen bruto" de la pagina 0 (rectangulo en puntos
# top-left) salte a su entrada del glosario en la ultima pagina (indice 7).
res = add_pdf_internal_links(
    "reports/eda.pdf",
    [
        {"src_page": 0, "src_rect": [72, 120, 180, 134], "dst_page": 7, "dst_point": [72, 200]},
        {"src_page": 0, "src_rect": [72, 140, 180, 154], "dst_page": 7, "dst_point": [72, 260]},
    ],
)
# res == {"status": "ok", "n_links": 2, "n_skipped": 0}
```

## Cuando usarla

Justo DESPUES de escribir un PDF con matplotlib `PdfPages` (o cualquier motor
que no genere hyperlinks internos) cuando necesitas que ciertos terminos o
referencias sean clicables y salten a otra pagina del mismo documento — el caso
canonico es enlazar los terminos de un AutomaticEDA con su entrada de glosario
al final. Es un paso de postproceso: primero generas el PDF y calculas en que
rectangulo quedo cada termino (en puntos PDF), luego pasas esa lista a esta
funcion para inyectar las anotaciones GOTO.

## Gotchas

- **Impura — reescribe el archivo IN SITU.** El PDF en `pdf_path` se reemplaza
  por la version con los links. El guardado es seguro: escribe a un temporal
  `.<base>.tmp_links` en el MISMO directorio y hace `os.replace` atomico tras
  cerrar el documento, asi un fallo a mitad no corrompe el original. Aun asi,
  conserva una copia si el PDF es valioso.
- **Sistema de coordenadas: puntos top-left, igual que matplotlib.** PyMuPDF y
  matplotlib (PdfPages) usan ambos PUNTOS PDF (1/72") con el origen ARRIBA-
  IZQUIERDA, asi que los rectangulos/puntos COINCIDEN: el `src_rect` que calcules
  con la geometria de la figura matplotlib se pasa tal cual, sin invertir el eje
  Y. (Ojo: el espacio de datos de matplotlib SI tiene el origen abajo; lo que
  coincide es el espacio de la PAGINA en puntos.)
- **Indices de pagina 0-based.** `src_page` / `dst_page` son indices base 0
  (la primera pagina es 0). Fuera del rango `[0, page_count)` el link se SALTA
  (cuenta en `n_skipped`), no lanza.
- **dict-no-throw, validacion por-link.** Las entradas malformadas (no dict,
  page fuera de rango, `src_rect` sin 4 numeros, `dst_point` sin 2 numeros) se
  saltan individualmente e incrementan `n_skipped`; el resto de links validos se
  insertan igual. La funcion solo devuelve `{status:error}` ante fallos globales
  (pymupdf ausente, archivo inexistente, `links` no es lista).
- **`error_type: error_go_core` es metadata del registry, no comportamiento.**
  Toda funcion impura debe declararlo y el indexer lo exige, pero el codigo NUNCA
  lanza esa excepcion: degrada al dict de estado.
- **Requiere PyMuPDF (`import fitz`).** Si no esta instalado devuelve
  `{"status":"error","error":"pymupdf no disponible: ..."}`. En el registry el
  venv `python/.venv` ya lo trae.
