--- 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 `..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 `..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.