"""Postprocesa un PDF existente insertando link annotations internos (GOTO). Motor: PyMuPDF (``import fitz``). Pensado para PDFs generados por matplotlib ``PdfPages``, que no soporta hyperlinks internos: tras escribir el PDF, esta funcion lo reabre y le añade anotaciones "ir a" (GOTO) desde un rectangulo de una pagina origen hasta un punto de una pagina destino. Util para hacer clicables terminos que apuntan a su entrada en un glosario al final del documento. Estilo dict-no-throw del grupo `eda`: NUNCA lanza; devuelve un dict de estado. """ import os def add_pdf_internal_links(pdf_path: str, links: list) -> dict: """Añade link annotations internos (GOTO) a un PDF ya escrito. Postprocesa un PDF (p.ej. generado por matplotlib PdfPages, que NO soporta hyperlinks internos) insertando, por cada entrada de ``links``, una anotacion de tipo "ir a" desde un rectangulo de una pagina origen hasta un punto de una pagina destino. Sirve para hacer clicables terminos que apuntan a su entrada en un glosario al final del documento. Args: pdf_path: ruta al PDF existente (se reescribe in situ). links: lista de dicts, cada uno: { "src_page": int, # indice 0-based de la pagina origen "src_rect": [x0,y0,x1,y1], # rectangulo clicable, en PUNTOS PDF # (1/72") con origen ARRIBA-IZQUIERDA "dst_page": int, # indice 0-based de la pagina destino "dst_point": [x, y], # punto destino, mismos puntos top-left } Returns: dict (NUNCA lanza): {"status":"ok","n_links":int,"n_skipped":int} o {"status":"error","error":str}. Si pymupdf no esta disponible o el archivo no existe -> {"status":"error", ...}. """ try: try: import fitz # PyMuPDF except Exception as exc: # ImportError u otro fallo de carga return {"status": "error", "error": f"pymupdf no disponible: {exc}"} if not isinstance(pdf_path, str) or not pdf_path: return {"status": "error", "error": "pdf_path debe ser una ruta no vacia"} if not os.path.isfile(pdf_path): return {"status": "error", "error": f"el archivo no existe: {pdf_path}"} if links is None: links = [] if not isinstance(links, (list, tuple)): return {"status": "error", "error": "links debe ser una lista de dicts"} doc = fitz.open(pdf_path) try: n_pages = doc.page_count n_ok = 0 n_skipped = 0 for link in links: if not isinstance(link, dict): n_skipped += 1 continue src_page = link.get("src_page") dst_page = link.get("dst_page") src_rect = link.get("src_rect") dst_point = link.get("dst_point") # src_page / dst_page: enteros 0-based en rango. if not _is_int(src_page) or not _is_int(dst_page): n_skipped += 1 continue if not (0 <= src_page < n_pages) or not (0 <= dst_page < n_pages): n_skipped += 1 continue # src_rect: 4 numeros. if not _is_num_seq(src_rect, 4): n_skipped += 1 continue # dst_point: 2 numeros. if not _is_num_seq(dst_point, 2): n_skipped += 1 continue try: doc[int(src_page)].insert_link( { "kind": fitz.LINK_GOTO, "from": fitz.Rect(*[float(v) for v in src_rect]), "page": int(dst_page), "to": fitz.Point(*[float(v) for v in dst_point]), } ) n_ok += 1 except Exception: n_skipped += 1 continue # Guardado seguro: escribir a temporal en el mismo directorio y # reemplazar atomicamente (evita corromper el PDF original). directory = os.path.dirname(os.path.abspath(pdf_path)) or "." base = os.path.basename(pdf_path) tmp_path = os.path.join(directory, f".{base}.tmp_links") doc.save(tmp_path) finally: doc.close() os.replace(tmp_path, pdf_path) return {"status": "ok", "n_links": n_ok, "n_skipped": n_skipped} except Exception as exc: # degrada cualquier fallo a dict de error return {"status": "error", "error": str(exc)} def _is_int(value) -> bool: """True si value es un entero (no bool).""" return isinstance(value, int) and not isinstance(value, bool) def _is_num_seq(value, length: int) -> bool: """True si value es una secuencia de `length` numeros (int/float, no bool).""" if not isinstance(value, (list, tuple)) or len(value) != length: return False for v in value: if isinstance(v, bool) or not isinstance(v, (int, float)): return False return True