d1a3d58a6b
Mejoras transversales del motor de render (no del contenido de capítulos): 1. Fix negrita pisa texto (PDF): _place_rich_lines mide el ancho REAL de cada span con las métricas de fuente del renderer (peso correcto) en vez del grid de ancho medio; negrita y normal en la misma línea ya no se solapan. 2. Zebra striping: filas pares sombreadas (#f6f8fa) en DataTable (PDF + PPTX), coherente al partir tablas largas (índice de fila lógico, no por página). 3. Keep-together: bloque Group nuevo; el renderer mide el grupo entero y lo mueve completo a la página/slide siguiente si no cabe, y encoge la figura (height_in) para dejar sitio a su título y texto. num_distr lo usa. 4. Caption siempre visible en toda figura PPTX (fallback al heading); la figura reserva el alto de su caption para que ambos quepan en el mismo slide. 5. Portada construida al final (con resumen agregado del análisis vía ctx['document_summary']) pero colocada primera por build_document. 6. Glosario: capítulo nuevo (último) + GlossaryCollector en ctx; los capítulos registran términos y marcan apariciones con [[term:key]]...[[/term]]. Links clicables reales: PDF (PyMuPDF, link GOTO) y PPTX (slide-jump nativo). Enganchado "entropía" en cat_distr como ejemplo end-to-end. Funciones reutilizables delegadas a fn-constructor (tag eda): - add_pdf_internal_links_py_datascience (PyMuPDF) - pptx_link_run_to_slide_py_datascience (slide-jump) Contrato docs/automatic_eda_contract.md actualizado (§1/§3/§5 + §11 nueva) con la API de glosario, keep-together y zebra para la siguiente fase. PyMuPDF declarado en pyproject. Suite verde (90 tests); golden titanic verificado. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
5.2 KiB
Python
133 lines
5.2 KiB
Python
"""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
|