From ea7266779b493395abb2005c667257e30ef68acb Mon Sep 17 00:00:00 2001 From: BenedictoDieciseis Date: Wed, 7 Oct 2026 20:11:10 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20censura=20PDF=20fail-closed=20con?= =?UTF-8?q?=20verificaci=C3=B3n=20y=20limpieza=20de=20metadatos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- scripts/pdf/censura_core.py | 810 +++++++++++++++++ scripts/pdf/censurar_pdf_por_palabras.py | 293 +++--- tests/test_censura_core.py | 1036 ++++++++++++++++++++++ 3 files changed, 1960 insertions(+), 179 deletions(-) create mode 100644 scripts/pdf/censura_core.py create mode 100644 tests/test_censura_core.py diff --git a/scripts/pdf/censura_core.py b/scripts/pdf/censura_core.py new file mode 100644 index 0000000..d892125 --- /dev/null +++ b/scripts/pdf/censura_core.py @@ -0,0 +1,810 @@ +""" +Núcleo de "Censurar PDF por palabras": lógica pura, sin Tkinter ni UI. + +Principio: FAIL-CLOSED. Un PDF censurado que conserva un término sensible es +peor que no generar ningún archivo. Por eso: + + - Nunca se escribe sobre el nombre final hasta haber VERIFICADO el resultado + reabriendo un temporal (texto, metadatos, outline, anotaciones, campos…). + - Cualquier duda (página escaneada, verificación fallida, importe ilegible, + excepción inesperada) acaba en estado "error" y SIN archivo de salida. + - El PDF original no se modifica nunca. + - Ni los resultados ni los logs contienen términos, texto ni rutas. + +Nota sobre PyMuPDF 1.25.1 (verificado en el entorno del proyecto): + - ``Document.scrub(reset_responses=True)`` lanza AttributeError con cualquier + anotación (bug en ``Annot.delete_responses``) → se usa False. + - ``Document.scrub(attached_files=True)`` lanza TypeError con anotaciones de + adjunto (bug ``update_file(buffer=...)``) → se eliminan antes a mano. + - ``Page.delete_annot`` devuelve un objeto inválido (crash nativo si se usa): + nunca se reutiliza el valor devuelto. +""" + +from __future__ import annotations + +import os +import re +import secrets +import unicodedata +from collections import Counter +from dataclasses import dataclass, field +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from enum import Enum +from pathlib import Path +from typing import Callable, Collection, Iterable, List, Optional, Sequence + +from logger import logger +from scripts.common.filenames import resolve_conflict +from scripts.common.ocr_io import safe_unlink +from ui.exceptions import CancelledByUser + +try: + import fitz # pymupdf +except Exception: # pragma: no cover - depende del entorno + fitz = None + + +# ====================================================== +# CONSTANTES +# ====================================================== + +SUFIJO_SALIDA = "_censurado" +PATRON_CONFLICTO = "_{i:02d}" +PREFIJO_TEMPORAL = ".docflow_censura_" +MARCADOR_OUTLINE = "[CENSURADO]" + + +# ====================================================== +# RESULTADO +# ====================================================== + +class EstadoCensura(str, Enum): + PROCESADO = "procesado" + OMITIDO = "omitido" + ERROR = "error" + + +class MotivoCensura(str, Enum): + OK = "ok" + SIN_COINCIDENCIAS = "sin_coincidencias" + SIN_CAPA_TEXTO = "sin_capa_texto" + VERIFICACION_FALLIDA = "verificacion_fallida" + IMPORTE_NO_INTERPRETABLE = "importe_no_interpretable" + PDF_NO_ABRE = "pdf_no_abre" + PDF_CIFRADO = "pdf_cifrado" + RANGO_INVALIDO = "rango_invalido" + ERROR_INTERNO = "error_interno" + + +# Texto para el resumen al usuario (sin términos ni nombres de archivo). +ETIQUETAS_MOTIVO = { + MotivoCensura.SIN_COINCIDENCIAS: "sin coincidencias", + MotivoCensura.SIN_CAPA_TEXTO: "sin capa de texto: aplica OCR antes", + MotivoCensura.VERIFICACION_FALLIDA: "no superaron la verificación final", + MotivoCensura.IMPORTE_NO_INTERPRETABLE: + "con un importe 'Siendo … lo pagado' no interpretable", + MotivoCensura.PDF_NO_ABRE: "no se pudieron abrir", + MotivoCensura.PDF_CIFRADO: "protegidos con contraseña", + MotivoCensura.RANGO_INVALIDO: "con un rango de páginas inválido", + MotivoCensura.ERROR_INTERNO: "con un error inesperado", +} + + +@dataclass(frozen=True) +class ResultadoCensura: + """Resultado por PDF. No contiene términos ni texto del documento.""" + + estado: EstadoCensura + motivo: MotivoCensura + coincidencias: int = 0 + paginas_afectadas: int = 0 + terminos_sin_coincidencias: int = 0 + paginas_con_imagenes: int = 0 + paginas_solo_vectoriales: int = 0 + paginas_sin_capa_texto: int = 0 + # Categorías de verificación fallidas (p. ej. "texto_normalizado"); nunca términos. + fallos_verificacion: tuple = () + output_path: Optional[Path] = None + + @classmethod + def fallo(cls, motivo: MotivoCensura, **extra) -> "ResultadoCensura": + return cls(estado=EstadoCensura.ERROR, motivo=motivo, **extra) + + +class ImporteNoInterpretable(ValueError): + """Existe el patrón 'Siendo … lo pagado' pero el importe no se pudo interpretar.""" + + +# ====================================================== +# NORMALIZACIÓN Y TÉRMINOS +# ====================================================== + +_RE_ESPACIOS = re.compile(r"\s+") +_RE_GUION_FIN_LINEA = re.compile(r"[-\u2010]\s+") +_CARACTERES_INVISIBLES = {ord(c): None for c in "\u00ad\u200b\u200c\u200d\u2060\ufeff"} + + +def normalizar_texto(texto: Optional[str], *, unir_guiones: bool = True) -> str: + """ + NFKC + sin caracteres invisibles + casefold + (unión de guiones de fin de + línea "-\\s+" → "") + espacios colapsados. + """ + t = unicodedata.normalize("NFKC", texto or "") + t = t.translate(_CARACTERES_INVISIBLES).casefold() + if unir_guiones: + t = _RE_GUION_FIN_LINEA.sub("", t) + return _RE_ESPACIOS.sub(" ", t).strip() + + +def _terminos_normalizados(terminos: Iterable[str]) -> List[str]: + """Formas normalizadas no vacías de los términos (con y sin unión de guiones).""" + resultado: List[str] = [] + for t in terminos: + for unir in (False, True): + n = normalizar_texto(t, unir_guiones=unir) + if n and n not in resultado: + resultado.append(n) + return resultado + + +def _contiene_termino(texto: Optional[str], terminos_norm: Sequence[str]) -> bool: + """ + True si el texto normalizado contiene algún término normalizado. + + Se comprueba con y sin unión de guiones: la unión es necesaria para + "confiden-\\ncial", pero puede ocultar "Pérez- García" (guion legítimo). + Probar ambas variantes solo puede añadir detecciones (fail-closed). + """ + if not texto or not terminos_norm: + return False + variantes = ( + normalizar_texto(texto, unir_guiones=True), + normalizar_texto(texto, unir_guiones=False), + ) + return any(t in v for t in terminos_norm for v in variantes) + + +def preparar_terminos(words: Optional[Iterable[str]]) -> List[str]: + """Limpia, descarta vacíos y deduplica (sin distinguir mayúsculas), conservando orden.""" + vistos = set() + resultado: List[str] = [] + for w in words or []: + w = (w or "").strip() + clave = normalizar_texto(w, unir_guiones=False) + if not clave or clave in vistos: + continue + vistos.add(clave) + resultado.append(w) + return resultado + + +def validar_terminos(words: Optional[Iterable[str]], detectar_importes: bool) -> List[str]: + """ + Devuelve los términos limpios o lanza ValueError. + + Lista vacía solo se admite si la detección de importes está activa. + """ + terminos = preparar_terminos(words) + if not terminos and not detectar_importes: + raise ValueError( + "Introduce al menos una palabra o expresión a censurar, " + "o activa la detección de importes." + ) + return terminos + + +# ====================================================== +# REGLA "SIENDO X € LO PAGADO" (15 % / 85 %) +# ====================================================== + +_RE_SIENDO = re.compile(r"Siendo\s+(.{1,40}?)\s+lo\s+pagado", re.IGNORECASE | re.DOTALL) +_RE_IMPORTE_ES = re.compile(r"^(?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d{1,2})?$") + + +def _parse_euro(valor_str: str) -> Decimal: + """Importe en formato español estricto ('1.234,56 €'); lanza ImporteNoInterpretable.""" + v = _RE_ESPACIOS.sub(" ", (valor_str or "").strip()) + if not v.endswith("€"): + raise ImporteNoInterpretable("importe sin símbolo €") + cifra = v[:-1].strip() + if not _RE_IMPORTE_ES.match(cifra): + raise ImporteNoInterpretable("formato de importe no reconocido") + try: + return Decimal(cifra.replace(".", "").replace(",", ".")) + except InvalidOperation as exc: + raise ImporteNoInterpretable("importe no numérico") from exc + + +def _format_euro(valor: Decimal) -> str: + valor = valor.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + s = f"{valor:,.2f}" + s = s.replace(",", "X").replace(".", ",").replace("X", ".") + return f"{s} €" + + +def detectar_importes_auto(texto: Optional[str]) -> List[str]: + """ + Regla opcional: "Siendo X € lo pagado" → devuelve el 15 % y el 85 % de X + que aparezcan en el texto. + + - Patrón ausente → []. + - Patrón presente pero importe ilegible → ImporteNoInterpretable (nunca [] silencioso). + """ + texto = texto or "" + encontrados: List[str] = [] + texto_norm = normalizar_texto(texto, unir_guiones=False) + + for match in _RE_SIENDO.finditer(texto): + base = _parse_euro(match.group(1)) + for factor in (Decimal("0.15"), Decimal("0.85")): + imp = _format_euro(base * factor) + if imp not in encontrados and normalizar_texto(imp, unir_guiones=False) in texto_norm: + encontrados.append(imp) + + return encontrados + + +# ====================================================== +# UTILIDADES PDF +# ====================================================== + +def _comprobar_cancelacion(is_cancelled: Optional[Callable[[], bool]]) -> None: + if is_cancelled and is_cancelled(): + raise CancelledByUser() + + +def contar_paginas(path: Path) -> int: + """Número de páginas del PDF (lanza si no se puede abrir).""" + if fitz is None: + raise RuntimeError("Falta dependencia 'pymupdf'.") + doc = fitz.open(str(path)) + try: + return int(doc.page_count) + finally: + doc.close() + + +def _resolver_paginas(pages: Optional[Collection[int]], total: int) -> Optional[List[int]]: + """Índices 0-based de las páginas seleccionadas, o None si el rango no es válido.""" + if pages is None: + return list(range(total)) + seleccion = sorted(set(pages)) + if not seleccion or seleccion[0] < 1 or seleccion[-1] > total: + return None + return [p - 1 for p in seleccion] + + +def _pagina_tiene_imagenes(page) -> bool: + """ + Sin umbral de tamaño. Une get_images(full=True) (incluye XObjects de formulario) + y get_image_info() (incluye imágenes inline) para no dejar pasar ninguna. + """ + return bool(page.get_images(full=True)) or bool(page.get_image_info()) + + +@dataclass +class _Analisis: + sin_capa_texto: int = 0 + con_imagenes: int = 0 + solo_vectoriales: int = 0 + + +def _analizar_paginas(doc, indices: Sequence[int], is_cancelled) -> _Analisis: + a = _Analisis() + for idx in indices: + _comprobar_cancelacion(is_cancelled) + page = doc.load_page(idx) + tiene_texto = bool((page.get_text() or "").strip()) + tiene_imagenes = _pagina_tiene_imagenes(page) + + if not tiene_texto: + if tiene_imagenes: + a.sin_capa_texto += 1 + elif page.get_drawings(): + a.solo_vectoriales += 1 + elif tiene_imagenes: + a.con_imagenes += 1 + return a + + +def _texto_documento(doc, is_cancelled) -> str: + partes = [] + for page in doc: + _comprobar_cancelacion(is_cancelled) + partes.append(page.get_text()) + return "".join(partes) + + +def _texto_paginas(doc, indices: Sequence[int]) -> str: + return "\n".join(doc.load_page(i).get_text() for i in indices) + + +# ====================================================== +# REDACCIÓN Y SANEADO +# ====================================================== + +def _buscar(page, term: str) -> list: + """ + search_for con los flags por defecto + search_for sin PRESERVE_LIGATURES. + + Con los flags por defecto, "oficina" (ligadura real) no coincide con + "oficina"; TEXTFLAGS_SEARCH descompone las ligaduras. Se unen ambos + resultados sin duplicar rectángulos. La verificación final sigue siendo + la garantía: esto solo reduce falsos negativos. + """ + rects = list(page.search_for(term)) + vistos = {tuple(round(v, 1) for v in r) for r in rects} + for r in page.search_for(term, flags=fitz.TEXTFLAGS_SEARCH): + clave = tuple(round(v, 1) for v in r) + if clave not in vistos: + vistos.add(clave) + rects.append(r) + return rects + + +def _redactar(doc, indices, terminos, is_cancelled): + """_buscar + add_redact_annot + apply_redactions (defaults de PyMuPDF).""" + coincidencias = 0 + paginas_afectadas = 0 + terminos_con_hit = set() + + for idx in indices: + _comprobar_cancelacion(is_cancelled) + page = doc.load_page(idx) + hits_pagina = 0 + + for k, term in enumerate(terminos): + rects = _buscar(page, term) + for r in rects: + page.add_redact_annot(r, fill=(0, 0, 0)) + if rects: + terminos_con_hit.add(k) + hits_pagina += len(rects) + + if hits_pagina: + page.apply_redactions() + coincidencias += hits_pagina + paginas_afectadas += 1 + + return coincidencias, paginas_afectadas, len(terminos) - len(terminos_con_hit) + + +def _reemplazar_terminos(titulo: str, terminos: Sequence[str]) -> str: + """Sustituye cada término (sin distinguir mayúsculas) por un marcador neutro.""" + nuevo = titulo + for t in terminos: + nuevo = re.sub(re.escape(t), MARCADOR_OUTLINE, nuevo, flags=re.IGNORECASE) + # Si por diferencias de normalización (ligaduras, etc.) aún quedara el término, + # se descarta el título entero. + if _contiene_termino(nuevo, _terminos_normalizados(terminos)): + return MARCADOR_OUTLINE + return nuevo + + +def _sanear_outline(doc, terminos: Sequence[str]) -> int: + """Sustituye términos en los títulos del outline. Devuelve nº de títulos cambiados.""" + toc = doc.get_toc(simple=False) + if not toc: + return 0 + + cambios = 0 + nuevo_toc = [] + for entry in toc: + nivel, titulo, pagina = entry[0], entry[1], entry[2] + destino = entry[3] if len(entry) > 3 else None + nuevo_titulo = _reemplazar_terminos(titulo, terminos) + if nuevo_titulo != titulo: + cambios += 1 + nuevo_toc.append( + [nivel, nuevo_titulo, pagina, destino] if destino else [nivel, nuevo_titulo, pagina] + ) + + if cambios: + doc.set_toc(nuevo_toc) + return cambios + + +def _textos_anotacion(annot) -> List[str]: + info = annot.info or {} + return [info.get("content") or "", info.get("title") or "", info.get("subject") or ""] + + +def _borrar_anotaciones(page, condicion) -> int: + """ + Borra las anotaciones que cumplan la condición. + + No se reutiliza el valor devuelto por Page.delete_annot (inválido en 1.25.1): + tras cada borrado se vuelve a recorrer la página desde cero. + """ + borradas = 0 + limite = sum(1 for _ in page.annots()) + 1 + for _ in range(limite): + objetivo = next((a for a in page.annots() if condicion(a)), None) + if objetivo is None: + return borradas + page.delete_annot(objetivo) + borradas += 1 + raise RuntimeError("No se pudieron eliminar todas las anotaciones previstas.") + + +def _sanear_anotaciones(doc, indices: Sequence[int], terminos: Sequence[str]) -> int: + """Elimina anotaciones (en páginas seleccionadas) cuyo content/title/subject contenga un término.""" + norm = _terminos_normalizados(terminos) + total = 0 + for idx in indices: + page = doc.load_page(idx) + total += _borrar_anotaciones( + page, lambda a: any(_contiene_termino(t, norm) for t in _textos_anotacion(a)) + ) + return total + + +def _eliminar_adjuntos_anotacion(doc) -> int: + """ + Elimina TODAS las anotaciones de fichero adjunto (todas las páginas). + + scrub(attached_files=True) falla con TypeError en PyMuPDF 1.25.1 si existe + alguna, y vaciar el adjunto no es suficiente de todos modos. + """ + total = 0 + for page in doc: + total += _borrar_anotaciones( + page, lambda a: a.type[0] == fitz.PDF_ANNOT_FILE_ATTACHMENT + ) + return total + + +def _aplicar_scrub(doc) -> None: + """scrub() con parámetros explícitos (no se depende de los valores por defecto).""" + doc.scrub( + metadata=True, # Info + xml_metadata=True, # XMP + attached_files=True, + embedded_files=True, + hidden_text=True, + clean_pages=True, + javascript=True, + # URLs y mailto de los enlaces pueden contener datos del cliente. + remove_links=True, + reset_fields=True, + # False por un bug de PyMuPDF 1.25.1: delete_responses lanza AttributeError + # con cualquier anotación. Las respuestas con término ya se eliminan en + # _sanear_anotaciones y la verificación final revisa las anotaciones restantes. + reset_responses=False, + thumbnails=True, + redactions=True, + redact_images=0, + ) + + +# ====================================================== +# VERIFICACIÓN DEL TEMPORAL +# ====================================================== + +def verificar_salida( + path: Path, + terminos: Sequence[str], + indices: Sequence[int], + paginas_originales: int, +) -> List[str]: + """ + Reabre el PDF de salida y devuelve las categorías de fallo (vacía = verificado). + Nunca incluye términos ni texto del documento. + """ + fallos: List[str] = [] + + def marcar(categoria: str) -> None: + if categoria not in fallos: + fallos.append(categoria) + + norm = _terminos_normalizados(terminos) + flags_sin_recorte = fitz.TEXTFLAGS_TEXT & ~fitz.TEXT_MEDIABOX_CLIP + + doc = fitz.open(str(path)) + try: + if doc.page_count != paginas_originales: + marcar("num_paginas") + + for idx in indices: + if idx >= doc.page_count: + marcar("num_paginas") + continue + page = doc.load_page(idx) + + if any(_buscar(page, t) for t in terminos): + marcar("search_for") + if _contiene_termino(page.get_text(), norm): + marcar("texto_normalizado") + # Texto fuera del área visible de la página (MEDIABOX) también cuenta. + if _contiene_termino(page.get_text("text", flags=flags_sin_recorte), norm): + marcar("texto_fuera_de_pagina") + + for annot in page.annots(): + if any(_contiene_termino(t, norm) for t in _textos_anotacion(annot)): + marcar("anotaciones") + + for widget in page.widgets(): + campos = (widget.field_name, widget.field_value, widget.field_label) + if any(_contiene_termino(str(c), norm) for c in campos if c): + marcar("campos_formulario") + + for link in page.get_links(): + if _contiene_termino(str(link.get("uri") or ""), norm): + marcar("enlaces") + + # Metadatos Info: ningún valor (ni estándar ni personalizado). + if any(v for k, v in (doc.metadata or {}).items() if k not in ("format", "encryption")): + marcar("metadatos_info") + info = doc.xref_get_key(-1, "Info") + if info[0] == "xref": + if doc.xref_get_keys(int(info[1].split()[0])): + marcar("metadatos_info") + + if (doc.get_xml_metadata() or "").strip(): + marcar("metadatos_xmp") + + if doc.embfile_count() != 0: + marcar("adjuntos") + for page in doc: + for annot in page.annots(types=[fitz.PDF_ANNOT_FILE_ATTACHMENT]): + marcar("adjuntos") + break + + if any(_contiene_termino(entry[1], norm) for entry in doc.get_toc()): + marcar("outline") + + if any(_contiene_termino(nombre, norm) for nombre in doc.resolve_names().keys()): + marcar("nombres_destino") + finally: + doc.close() + + return fallos + + +# ====================================================== +# TEMPORAL Y PROMOCIÓN (mínimos y propios) +# ====================================================== + +def _crear_ruta_temporal(output_dir: Path) -> Path: + """Ruta oculta con token aleatorio en el MISMO directorio de destino (mismo volumen).""" + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir / f"{PREFIJO_TEMPORAL}{secrets.token_hex(8)}.pdf" + + +def _promover(temp_path: Path, output_dir: Path, stem: str) -> Path: + """ + Nombre final libre (resolve_conflict) + comprobación justo antes + os.replace. + + Nunca sobrescribe: si el nombre aparece ocupado en el último instante se + busca otro. (Queda la ventana, mínima, entre la comprobación y os.replace.) + """ + base = output_dir / f"{stem}{SUFIJO_SALIDA}.pdf" + for _ in range(20): + final = resolve_conflict(base, pattern=PATRON_CONFLICTO) + if os.path.lexists(final): + continue + os.replace(temp_path, final) + if not final.is_file(): + raise OSError("El archivo final no existe tras la promoción.") + return final + raise OSError("No se pudo reservar un nombre de salida libre.") + + +# ====================================================== +# FUNCIÓN PRINCIPAL POR PDF +# ====================================================== + +def censurar_pdf( + input_path: Path, + output_dir: Path, + words: Optional[Iterable[str]], + pages: Optional[Collection[int]] = None, + *, + detectar_importes: bool = True, + is_cancelled: Optional[Callable[[], bool]] = None, +) -> ResultadoCensura: + """ + Censura un PDF (páginas 1-based en ``pages``; None = todas) con política fail-closed. + + Lanza ValueError si no hay términos y la detección de importes está desactivada, + y CancelledByUser si se cancela (sin dejar temporales). Cualquier otro problema + se devuelve como ResultadoCensura con estado "error" u "omitido". + """ + if fitz is None: + raise RuntimeError("Falta dependencia 'pymupdf'.") + + terminos_usuario = validar_terminos(words, detectar_importes) + input_path = Path(input_path) + output_dir = Path(output_dir) + + doc = None + temp_path: Optional[Path] = None + + try: + try: + doc = fitz.open(str(input_path)) + except Exception: + return ResultadoCensura.fallo(MotivoCensura.PDF_NO_ABRE) + + if not doc.is_pdf or doc.page_count == 0: + return ResultadoCensura.fallo(MotivoCensura.PDF_NO_ABRE) + if doc.needs_pass or doc.is_encrypted: + return ResultadoCensura.fallo(MotivoCensura.PDF_CIFRADO) + + total_paginas = doc.page_count + indices = _resolver_paginas(pages, total_paginas) + if indices is None: + return ResultadoCensura.fallo(MotivoCensura.RANGO_INVALIDO) + + _comprobar_cancelacion(is_cancelled) + + # a/b. Capa de texto y avisos por página (antes de tocar nada). + analisis = _analizar_paginas(doc, indices, is_cancelled) + avisos = dict( + paginas_con_imagenes=analisis.con_imagenes, + paginas_solo_vectoriales=analisis.solo_vectoriales, + paginas_sin_capa_texto=analisis.sin_capa_texto, + ) + if analisis.sin_capa_texto: + return ResultadoCensura.fallo(MotivoCensura.SIN_CAPA_TEXTO, **avisos) + + # Regla opcional de importes. + terminos = list(terminos_usuario) + if detectar_importes: + try: + extra = detectar_importes_auto(_texto_documento(doc, is_cancelled)) + except ImporteNoInterpretable: + return ResultadoCensura.fallo(MotivoCensura.IMPORTE_NO_INTERPRETABLE, **avisos) + terminos = preparar_terminos(terminos + extra) + + # c. Redacción. + coincidencias, paginas_afectadas, sin_coincidencia = _redactar( + doc, indices, terminos, is_cancelled + ) if terminos else (0, 0, 0) + + if coincidencias == 0: + # search_for puede fallar (p. ej. ligaduras) aunque el término esté en el texto: + # eso NO es "omitido", es un riesgo de fuga. + if _contiene_termino(_texto_paginas(doc, indices), _terminos_normalizados(terminos)): + return ResultadoCensura.fallo( + MotivoCensura.VERIFICACION_FALLIDA, + fallos_verificacion=("coincidencia_no_localizada",), + **avisos, + ) + return ResultadoCensura( + estado=EstadoCensura.OMITIDO, + motivo=MotivoCensura.SIN_COINCIDENCIAS, + terminos_sin_coincidencias=len(terminos), + paginas_con_imagenes=analisis.con_imagenes, + paginas_solo_vectoriales=analisis.solo_vectoriales, + ) + + # d/e. Saneado y scrub. + _sanear_outline(doc, terminos) + _sanear_anotaciones(doc, indices, terminos) + _eliminar_adjuntos_anotacion(doc) + _aplicar_scrub(doc) + + # f. Guardar a temporal en el destino y verificar reabriéndolo. + _comprobar_cancelacion(is_cancelled) + temp_path = _crear_ruta_temporal(output_dir) + doc.save(str(temp_path), garbage=4, deflate=True) + doc.close() + doc = None + + try: + fallos = verificar_salida(temp_path, terminos, indices, total_paginas) + except Exception: + fallos = ["error_verificacion"] + + if fallos: + return ResultadoCensura.fallo( + MotivoCensura.VERIFICACION_FALLIDA, + coincidencias=coincidencias, + paginas_afectadas=paginas_afectadas, + fallos_verificacion=tuple(fallos), + **avisos, + ) + + # g. Promoción atómica. + _comprobar_cancelacion(is_cancelled) + final = _promover(temp_path, output_dir, input_path.stem) + + return ResultadoCensura( + estado=EstadoCensura.PROCESADO, + motivo=MotivoCensura.OK, + coincidencias=coincidencias, + paginas_afectadas=paginas_afectadas, + terminos_sin_coincidencias=sin_coincidencia, + paginas_con_imagenes=analisis.con_imagenes, + paginas_solo_vectoriales=analisis.solo_vectoriales, + output_path=final, + ) + + except CancelledByUser: + raise + except Exception as exc: + # Solo el tipo de excepción: el mensaje podría incluir rutas o texto. + logger.error(f"[PDF-CENSURA] Error interno ({type(exc).__name__})") + return ResultadoCensura.fallo(MotivoCensura.ERROR_INTERNO) + finally: + # h. Limpieza siempre (éxito, error o cancelación). + if doc is not None: + try: + doc.close() + except Exception: + pass + safe_unlink(temp_path) + + +# ====================================================== +# RESUMEN DE LOTE +# ====================================================== + +@dataclass +class ResumenLote: + """Acumulador de resultados de un lote; genera el mensaje final sin datos sensibles.""" + + total: int + procesados: int = 0 + omitidos: int = 0 + errores: int = 0 + errores_por_motivo: Counter = field(default_factory=Counter) + terminos_sin_coincidencias: int = 0 + paginas_con_imagenes: int = 0 + paginas_solo_vectoriales: int = 0 + archivos: List[Path] = field(default_factory=list) + + def agregar(self, r: ResultadoCensura) -> None: + if r.estado == EstadoCensura.PROCESADO: + self.procesados += 1 + if r.output_path is not None: + self.archivos.append(Path(r.output_path)) + elif r.estado == EstadoCensura.OMITIDO: + self.omitidos += 1 + else: + self.errores += 1 + self.errores_por_motivo[r.motivo] += 1 + return # los avisos de un PDF fallido son parciales: no se suman + + self.terminos_sin_coincidencias += r.terminos_sin_coincidencias + self.paginas_con_imagenes += r.paginas_con_imagenes + self.paginas_solo_vectoriales += r.paginas_solo_vectoriales + + def archivos_existentes(self) -> List[Path]: + return [p for p in self.archivos if Path(p).is_file()] + + def mensaje(self) -> str: + partes = [] + if self.total > 0 and self.procesados == 0: + partes.append("No se censuró ningún PDF.") + + partes.append( + f"{self.procesados} procesado(s), " + f"{self.omitidos} omitido(s) (sin coincidencias), " + f"{self.errores} con error." + ) + + if self.errores_por_motivo: + desglose = "; ".join( + f"{n} {ETIQUETAS_MOTIVO.get(m, m.value)}" + for m, n in sorted(self.errores_por_motivo.items(), key=lambda kv: kv[0].value) + ) + partes.append(f"Errores: {desglose}.") + + if self.terminos_sin_coincidencias: + partes.append(f"Términos sin coincidencias: {self.terminos_sin_coincidencias}.") + + if self.paginas_con_imagenes: + partes.append( + f"Aviso: {self.paginas_con_imagenes} página(s) con imágenes; " + "el texto dentro de imágenes NO se censura." + ) + + if self.paginas_solo_vectoriales: + partes.append( + f"Aviso: {self.paginas_solo_vectoriales} página(s) solo vectoriales " + "sin texto; revísalas manualmente." + ) + + return " ".join(partes) diff --git a/scripts/pdf/censurar_pdf_por_palabras.py b/scripts/pdf/censurar_pdf_por_palabras.py index f50c60a..176628a 100644 --- a/scripts/pdf/censurar_pdf_por_palabras.py +++ b/scripts/pdf/censurar_pdf_por_palabras.py @@ -4,11 +4,9 @@ } import os -import re import tkinter as tk from tkinter import filedialog, messagebox from pathlib import Path -from decimal import Decimal, ROUND_HALF_UP from config import get_ruta from ui.exceptions import CancelledByUser @@ -16,79 +14,16 @@ from ui.window_icon import set_window_icon from logger import logger -from scripts.common.filenames import resolve_conflict from scripts.common.pdf_ranges import parse_ranges, ranges_to_pages_set -from scripts.common.results import build_result, build_cancelled_result - -try: - import fitz # pymupdf -except Exception: - fitz = None - - -# ====================================================== -# UTILIDADES IMPORTES -# ====================================================== - -def _extraer_texto_pdf(path: Path) -> str: - if fitz is None: - raise RuntimeError("Falta dependencia 'pymupdf'.") - - doc = fitz.open(str(path)) - texto = "" - - try: - for page in doc: - texto += page.get_text() - finally: - doc.close() - - return texto - - -def _parse_euro(valor_str: str) -> Decimal: - limpio = valor_str.replace("€", "").strip() - limpio = limpio.replace(".", "").replace(",", ".") - return Decimal(limpio) - - -def _format_euro(valor: Decimal) -> str: - valor = valor.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - s = f"{valor:,.2f}" - s = s.replace(",", "X").replace(".", ",").replace("X", ".") - return f"{s} €" - - -def _extraer_importe_base(texto: str): - match = re.search( - r"Siendo\s+([\d\.,]+\s?€)\s+lo pagado", - texto, - re.IGNORECASE - ) - return match.group(1) if match else None - - -def _detectar_importes_auto(texto: str): - base_str = _extraer_importe_base(texto) - - if not base_str: - return [] - - try: - base = _parse_euro(base_str) - except Exception: - return [] - - imp_15 = _format_euro(base * Decimal("0.15")) - imp_85 = _format_euro(base * Decimal("0.85")) - - encontrados = [] - - for imp in [imp_15, imp_85]: - if imp in texto: - encontrados.append(imp) - - return encontrados +from scripts.common.results import build_result +from scripts.pdf.censura_core import ( + MotivoCensura, + ResultadoCensura, + ResumenLote, + censurar_pdf, + contar_paginas, + validar_terminos, +) # ====================================================== @@ -102,13 +37,14 @@ def __init__(self, parent): set_window_icon(self) self.title("Censurar información en PDF") - self.geometry("680x500") + self.geometry("680x560") self.resizable(False, False) self.resultado = None self.var_todas = tk.BooleanVar(value=True) self.var_rango = tk.StringVar(value="1") + self.var_importes = tk.BooleanVar(value=True) tk.Label( self, @@ -119,6 +55,12 @@ def __init__(self, parent): self.txt_words = tk.Text(self, height=8) self.txt_words.pack(fill="both", padx=12, pady=(0, 8)) + tk.Checkbutton( + self, + text="Detectar importes del 15 % y 85 % (caso 'Siendo X € lo pagado')", + variable=self.var_importes + ).pack(anchor="w", padx=12, pady=(0, 4)) + frame_pages = tk.LabelFrame(self, text="Páginas") frame_pages.pack(fill="x", padx=12, pady=8) @@ -175,6 +117,13 @@ def _toggle_rango(self): def _confirmar(self): words_raw = self.txt_words.get("1.0", "end") words = [w.strip() for w in words_raw.splitlines() if w.strip()] + importes = bool(self.var_importes.get()) + + try: + words = validar_terminos(words, importes) + except ValueError as e: + messagebox.showerror("Faltan términos", str(e), parent=self) + return if self.var_todas.get(): ranges = None @@ -189,6 +138,7 @@ def _confirmar(self): "words": words, "all_pages": self.var_todas.get(), "ranges": ranges, + "detectar_importes": importes, } self.destroy() @@ -199,54 +149,112 @@ def _cancelar(self): # ====================================================== -# LÓGICA PDF +# LOTE (sin diálogos: testable) # ====================================================== -def _censurar_pdf( - input_path: Path, - output_path: Path, - words, - pages_to_process, - is_cancelled=None -): +class CensuraLoteCancelada(CancelledByUser): + """Cancelación que conserva el resultado parcial (ScriptRunner lo entrega a on_cancelled).""" + + def __init__(self, result: dict): + self.result = result + super().__init__("Cancelado") - if fitz is None: - raise RuntimeError("Falta dependencia 'pymupdf'.") - texto = _extraer_texto_pdf(input_path) - auto_words = _detectar_importes_auto(texto) +def _resultado_lote(resumen: ResumenLote, salida_dir: Path, *, cancelado: bool = False) -> dict: + pendientes = max( + resumen.total - resumen.procesados - resumen.omitidos - resumen.errores, 0 + ) + + extra = {f"errores_{m.value}": n for m, n in resumen.errores_por_motivo.items()} - if auto_words: - logger.info(f"[PDF-CENSURA] Importes detectados: {auto_words}") + # Si no se creó la carpeta de salida (nada procesado), se apunta a la de origen. + output_dir = salida_dir if salida_dir.is_dir() else salida_dir.parent - palabras_finales = list(set(words + auto_words)) + return build_result( + message="Cancelado" if cancelado else resumen.mensaje(), + output_dir=output_dir, + total=resumen.total, + procesados=resumen.procesados, + errores=resumen.errores, + omitidos=resumen.omitidos + (pendientes if cancelado else 0), + files=resumen.archivos_existentes(), + terminos_sin_coincidencias=resumen.terminos_sin_coincidencias, + paginas_con_imagenes=resumen.paginas_con_imagenes, + paginas_solo_vectoriales=resumen.paginas_solo_vectoriales, + **extra, + ) + + +def _procesar_un_pdf(pdf_path, salida_dir, cfg, is_cancelled) -> ResultadoCensura: + """Resuelve el rango de páginas y delega en el núcleo.""" + try: + if cfg["all_pages"]: + pages = None + else: + pages = ranges_to_pages_set(cfg["ranges"], contar_paginas(pdf_path)) + except ValueError: + return ResultadoCensura.fallo(MotivoCensura.RANGO_INVALIDO) + except Exception: + return ResultadoCensura.fallo(MotivoCensura.PDF_NO_ABRE) + + return censurar_pdf( + pdf_path, + salida_dir, + cfg["words"], + pages, + detectar_importes=cfg.get("detectar_importes", True), + is_cancelled=is_cancelled, + ) + + +def _ejecutar_lote(pdf_paths, cfg, progress=None, is_cancelled=None) -> dict: + # Defensa en profundidad: lista vacía solo con la casilla de importes activa. + validar_terminos(cfg["words"], cfg.get("detectar_importes", True)) + + pdf_paths = [Path(p) for p in pdf_paths] + salida_dir = pdf_paths[0].parent / "PDF_censurados" + total = len(pdf_paths) + resumen = ResumenLote(total=total) - doc = fitz.open(str(input_path)) + # Nunca se registran términos, texto, nombres de archivo ni rutas. + logger.info(f"[PDF-CENSURA] Procesando {total} PDF(s)") try: - for page_index in range(doc.page_count): + for idx, pdf_path in enumerate(pdf_paths, start=1): if is_cancelled and is_cancelled(): raise CancelledByUser() - page_num = page_index + 1 - if page_num not in pages_to_process: - continue + resultado = _procesar_un_pdf(pdf_path, salida_dir, cfg, is_cancelled) + resumen.agregar(resultado) - page = doc.load_page(page_index) + logger.info( + f"[PDF-CENSURA] archivo {idx} de {total}: " + f"estado={resultado.estado.value} motivo={resultado.motivo.value} " + f"coincidencias={resultado.coincidencias} " + f"paginas_afectadas={resultado.paginas_afectadas} " + f"terminos_sin_coincidencias={resultado.terminos_sin_coincidencias} " + f"paginas_con_imagenes={resultado.paginas_con_imagenes} " + f"paginas_solo_vectoriales={resultado.paginas_solo_vectoriales}" + ) - for term in palabras_finales: - rects = page.search_for(term) - for r in rects: - page.add_redact_annot(r, fill=(0, 0, 0)) + if progress: + progress(idx, total) - page.apply_redactions() + except CancelledByUser: + logger.info( + f"[PDF-CENSURA] Cancelado por usuario. " + f"Procesados: {resumen.procesados}. Omitidos: {resumen.omitidos}. " + f"Errores: {resumen.errores}" + ) + raise CensuraLoteCancelada(_resultado_lote(resumen, salida_dir, cancelado=True)) - output_path.parent.mkdir(parents=True, exist_ok=True) - doc.save(str(output_path), garbage=4, deflate=True) + logger.info( + f"[PDF-CENSURA] Finalizado. Procesados: {resumen.procesados}. " + f"Omitidos: {resumen.omitidos}. Errores: {resumen.errores}" + ) - finally: - doc.close() + return _resultado_lote(resumen, salida_dir) # ====================================================== @@ -281,77 +289,4 @@ def _abrir_dialogo(): if not cfg: raise CancelledByUser() - words = cfg["words"] - - salida_dir = pdf_paths[0].parent / "PDF_censurados" - salida_dir.mkdir(exist_ok=True) - - total = len(pdf_paths) - procesados = 0 - errores = 0 - - logger.info(f"[PDF-CENSURA] Procesando {total} PDF(s)") - - try: - - for idx, pdf_path in enumerate(pdf_paths, start=1): - - if is_cancelled and is_cancelled(): - raise CancelledByUser() - - try: - doc = fitz.open(str(pdf_path)) - try: - max_pages = doc.page_count - finally: - doc.close() - - if cfg["all_pages"]: - pages = set(range(1, max_pages + 1)) - else: - pages = ranges_to_pages_set(cfg["ranges"], max_pages) - - out_path = resolve_conflict( - salida_dir / f"{pdf_path.stem}_censurado.pdf", - pattern="_{i:02d}" - ) - - _censurar_pdf(pdf_path, out_path, words, pages, is_cancelled) - - procesados += 1 - - except CancelledByUser: - raise - - except Exception as e: - errores += 1 - logger.error(f"[PDF-CENSURA] Error en {pdf_path.name}: {e}") - - if progress: - progress(idx, total) - - except CancelledByUser: - logger.info("[PDF-CENSURA] Cancelado por usuario") - return build_cancelled_result( - output_dir=salida_dir, - total=total, - procesados=procesados, - errores=errores, - ) - - logger.info( - f"[PDF-CENSURA] Finalizado. " - f"Procesados: {procesados}. " - f"Errores: {errores}. " - f"Omitidos: {total - procesados - errores}" - ) - - return build_result( - message=f"{procesados} PDF(s) censurado(s)", - output_dir=salida_dir, - total=total, - procesados=procesados, - errores=errores, - ) - - + return _ejecutar_lote(pdf_paths, cfg, progress, is_cancelled) diff --git a/tests/test_censura_core.py b/tests/test_censura_core.py new file mode 100644 index 0000000..c8b560f --- /dev/null +++ b/tests/test_censura_core.py @@ -0,0 +1,1036 @@ +""" +Tests de "Censurar PDF por palabras" (fail-closed): núcleo + lote. + +Todos los PDFs son sintéticos y se generan con PyMuPDF en tmp_path. +""" + +from __future__ import annotations + +import hashlib +import inspect +import logging +import re +import sys +from pathlib import Path + +import fitz +import pytest + +from scripts.metadata import extract_metadata +from scripts.pdf import censura_core as core +from scripts.pdf import censurar_pdf_por_palabras as script +from scripts.pdf.censura_core import ( + EstadoCensura, + MotivoCensura, + ResultadoCensura, + ResumenLote, + censurar_pdf, + detectar_importes_auto, + normalizar_texto, + validar_terminos, +) +from ui.exceptions import CancelledByUser + +TERMINO = "ZORRILLO" + + +# --------------------------------------------------------------------------- +# Generadores de PDFs sintéticos +# --------------------------------------------------------------------------- + + +def _escribir(page, lineas, *, y0=100, fuente="helv", tam=12): + """Escribe líneas de texto Unicode (TextWriter) en la página.""" + if isinstance(lineas, str): + lineas = lineas.split("\n") + tw = fitz.TextWriter(page.rect) + font = fitz.Font(fuente) + for i, linea in enumerate(lineas): + tw.append((72, y0 + i * 16), linea, font=font, fontsize=tam) + tw.write_text(page) + + +def _insertar_imagen_renderizada(doc, texto): + """Página que solo contiene la imagen renderizada de un texto (escaneo simulado).""" + src = fitz.open() + sp = src.new_page() + _escribir(sp, texto, tam=20) + pix = sp.get_pixmap(dpi=72) + src.close() + page = doc.new_page() + page.insert_image(page.rect, pixmap=pix) + return page + + +def _crear_pdf(path: Path, paginas, *, metadata=None, toc=None, xmp=None, adjunto=False): + """paginas: lista de str (texto) o None (página en blanco).""" + doc = fitz.open() + for texto in paginas: + page = doc.new_page() + if texto: + _escribir(page, texto) + if metadata: + doc.set_metadata(metadata) + if toc: + doc.set_toc(toc) + if xmp: + doc.set_xml_metadata(xmp) + if adjunto: + doc.embfile_add("adjunto.txt", f"contenido {TERMINO}".encode(), filename="adjunto.txt") + doc.save(str(path)) + doc.close() + return path + + +def _crear_pdf_solo_imagen(path: Path, texto=f"cliente {TERMINO}"): + doc = fitz.open() + _insertar_imagen_renderizada(doc, texto) + doc.save(str(path)) + doc.close() + return path + + +def _sha(path: Path) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def _archivos(carpeta: Path): + return sorted(p.name for p in carpeta.iterdir()) if carpeta.exists() else [] + + +def _texto_pdf(path: Path) -> str: + doc = fitz.open(str(path)) + try: + return "\n".join(p.get_text() for p in doc) + finally: + doc.close() + + +def _sin_temporales(carpeta: Path) -> bool: + return not list(carpeta.glob(".docflow_censura_*")) if carpeta.exists() else True + + +@pytest.fixture +def log_docflow(caplog): + """El logger de DocFlow no propaga: se engancha el handler de caplog directamente.""" + base = logging.getLogger("DocFlow") + base.addHandler(caplog.handler) + caplog.set_level(logging.DEBUG, logger="DocFlow") + yield caplog + base.removeHandler(caplog.handler) + + +# --------------------------------------------------------------------------- +# Normalización y términos +# --------------------------------------------------------------------------- + + +def test_normalizar_texto_casefold_espacios_y_guiones(): + assert normalizar_texto(" CONFIDEN-\n cial ") == "confidencial" + assert normalizar_texto("Juan\u00a0\n Pérez") == "juan pérez" + assert normalizar_texto("o\ufb01cina") == "oficina" # NFKC: ligadura + assert normalizar_texto("con\u00adfidencial") == "confidencial" # guion blando + assert normalizar_texto(None) == "" + + +def test_normalizar_sin_unir_guiones_conserva_el_guion(): + assert normalizar_texto("pérez- garcía", unir_guiones=False) == "pérez- garcía" + assert normalizar_texto("pérez- garcía", unir_guiones=True) == "pérezgarcía" + + +def test_validar_terminos_lista_vacia_sin_importes_es_error(): + with pytest.raises(ValueError): + validar_terminos([], detectar_importes=False) + with pytest.raises(ValueError): + validar_terminos([" ", "\u200b"], detectar_importes=False) + + +def test_validar_terminos_lista_vacia_con_importes_se_permite(): + assert validar_terminos([], detectar_importes=True) == [] + + +def test_validar_terminos_deduplica_sin_distinguir_mayusculas(): + assert validar_terminos(["Ana", " ana ", "ANA", "Luis"], False) == ["Ana", "Luis"] + + +def test_core_rechaza_lista_vacia_sin_importes(tmp_path): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"cliente {TERMINO}"]) + with pytest.raises(ValueError): + censurar_pdf(pdf, tmp_path / "out", [], detectar_importes=False) + assert _archivos(tmp_path / "out") == [] + + +# --------------------------------------------------------------------------- +# Regla "Siendo X € lo pagado" +# --------------------------------------------------------------------------- + + +def test_importes_calcula_15_y_85(): + texto = "Siendo 1.000,00 € lo pagado.\nHonorarios 150,00 €\nResto 850,00 €\nOtro 12,00 €" + assert detectar_importes_auto(texto) == ["150,00 €", "850,00 €"] + + +def test_importes_sin_patron_devuelve_vacio(): + assert detectar_importes_auto("Documento sin ese patrón. 150,00 €") == [] + + +@pytest.mark.parametrize( + "frase", + [ + "Siendo 1,234.56 € lo pagado", # formato anglosajón: ambiguo, no se adivina + "Siendo abc lo pagado", + "Siendo 1.000,00 lo pagado", # sin símbolo € + "Siendo 12.34.56 € lo pagado", + ], +) +def test_importes_presente_pero_no_interpretable_lanza(frase): + with pytest.raises(core.ImporteNoInterpretable): + detectar_importes_auto(frase) + + +def test_pdf_con_siendo_no_interpretable_es_error_sin_archivo(tmp_path): + pdf = _crear_pdf(tmp_path / "a.pdf", ["Siendo 1,234.56 € lo pagado\nOtra cosa 150,00 €"]) + dest = tmp_path / "out" + r = censurar_pdf(pdf, dest, ["Otra"], detectar_importes=True) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.IMPORTE_NO_INTERPRETABLE + assert _archivos(dest) == [] + + +def test_pdf_con_siendo_no_interpretable_no_falla_si_casilla_desactivada(tmp_path): + pdf = _crear_pdf(tmp_path / "a.pdf", ["Siendo 1,234.56 € lo pagado\nCliente ZORRILLO"]) + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + + +def test_pdf_con_importes_automaticos_se_censura(tmp_path): + pdf = _crear_pdf( + tmp_path / "a.pdf", + ["Siendo 1.000,00 € lo pagado\nHonorarios 150,00 €\nResto 850,00 €\nTotal 999,99 €"], + ) + r = censurar_pdf(pdf, tmp_path / "out", [], detectar_importes=True) + assert r.estado == EstadoCensura.PROCESADO + texto = _texto_pdf(r.output_path) + assert "150,00" not in texto and "850,00" not in texto + assert "999,99" in texto # lo que no es 15 %/85 % se conserva + + +def test_lista_vacia_con_importes_y_sin_patron_es_omitido(tmp_path): + pdf = _crear_pdf(tmp_path / "a.pdf", ["Documento cualquiera"]) + r = censurar_pdf(pdf, tmp_path / "out", [], detectar_importes=True) + assert r.estado == EstadoCensura.OMITIDO + assert r.motivo == MotivoCensura.SIN_COINCIDENCIAS + assert _archivos(tmp_path / "out") == [] + + +# --------------------------------------------------------------------------- +# Caso base: PDF digital +# --------------------------------------------------------------------------- + +XMP = ( + '' + '' + '' + "Autor ZORRILLO" + "" +) + + +def test_pdf_digital_procesado_sin_rastro(tmp_path): + pdf = _crear_pdf( + tmp_path / "expediente.pdf", + [f"Cliente {TERMINO} con DNI 1234\nOtra línea", "Segunda página sin nada"], + metadata={"author": f"Autor {TERMINO}", "title": "Titulo", "keywords": "k"}, + xmp=XMP, + adjunto=True, + ) + dest = tmp_path / "out" + original_hash = _sha(pdf) + + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + assert r.motivo == MotivoCensura.OK + assert r.coincidencias >= 1 + assert r.paginas_afectadas == 1 + assert r.terminos_sin_coincidencias == 0 + assert r.output_path is not None and r.output_path.is_file() + assert r.output_path.parent == dest + assert r.output_path.name == "expediente_censurado.pdf" + assert _sin_temporales(dest) + + salida = fitz.open(str(r.output_path)) + try: + assert salida.page_count == 2 + for page in salida: + assert page.search_for(TERMINO) == [] + assert TERMINO.lower() not in normalizar_texto(_texto_pdf(r.output_path)) + assert not any(v for k, v in salida.metadata.items() if k not in ("format", "encryption")) + assert (salida.get_xml_metadata() or "").strip() == "" + assert salida.embfile_count() == 0 + # Lo que no era sensible se conserva. + assert "Otra línea" in _texto_pdf(r.output_path) + finally: + salida.close() + + assert _sha(pdf) == original_hash + + +def test_original_no_se_modifica_en_ningun_estado(tmp_path): + ok = _crear_pdf(tmp_path / "ok.pdf", [f"x {TERMINO}"]) + vacio = _crear_pdf(tmp_path / "vacio.pdf", ["nada"]) + img = _crear_pdf_solo_imagen(tmp_path / "img.pdf") + hashes = {p: _sha(p) for p in (ok, vacio, img)} + for p in (ok, vacio, img): + censurar_pdf(p, tmp_path / "out", [TERMINO], detectar_importes=False) + assert {p: _sha(p) for p in hashes} == hashes + + +# --------------------------------------------------------------------------- +# Capa de texto, imágenes y vectoriales +# --------------------------------------------------------------------------- + + +def test_pdf_solo_imagen_es_error_sin_capa_texto_y_sin_archivo(tmp_path): + pdf = _crear_pdf_solo_imagen(tmp_path / "escaneo.pdf") + dest = tmp_path / "out" + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.SIN_CAPA_TEXTO + assert r.paginas_sin_capa_texto == 1 + assert r.output_path is None + assert _archivos(dest) == [] + + +def _crear_mixto(path: Path): + doc = fitz.open() + p1 = doc.new_page() + _escribir(p1, f"Página digital {TERMINO}") + p2 = doc.new_page() + _escribir(p2, f"Otra digital {TERMINO}") + _insertar_imagen_renderizada(doc, f"escaneada {TERMINO}") + doc.save(str(path)) + doc.close() + return path + + +def test_mixto_rango_solo_paginas_con_texto_se_procesa(tmp_path): + pdf = _crear_mixto(tmp_path / "mixto.pdf") + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], pages={1, 2}, detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + assert r.paginas_afectadas == 2 + out = fitz.open(str(r.output_path)) + try: + assert out.page_count == 3 + assert out[0].search_for(TERMINO) == [] and out[1].search_for(TERMINO) == [] + finally: + out.close() + + +def test_mixto_rango_que_incluye_pagina_escaneada_es_error(tmp_path): + pdf = _crear_mixto(tmp_path / "mixto.pdf") + dest = tmp_path / "out" + r = censurar_pdf(pdf, dest, [TERMINO], pages={2, 3}, detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.SIN_CAPA_TEXTO + assert _archivos(dest) == [] + + +def test_pagina_con_texto_e_imagen_cuenta_como_pagina_con_imagenes(tmp_path): + doc = fitz.open() + page = doc.new_page() + _escribir(page, f"Cliente {TERMINO}") + pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 20, 20), 0) + pix.set_rect(pix.irect, (200, 0, 0)) + page.insert_image(fitz.Rect(300, 300, 340, 340), pixmap=pix) + pdf = tmp_path / "logo.pdf" + doc.save(str(pdf)) + doc.close() + + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + assert r.paginas_con_imagenes == 1 + assert r.paginas_solo_vectoriales == 0 + + +def test_pagina_solo_vectorial_es_aviso_no_error(tmp_path): + doc = fitz.open() + p1 = doc.new_page() + _escribir(p1, f"Cliente {TERMINO}") + p2 = doc.new_page() + shape = p2.new_shape() + shape.draw_line(fitz.Point(72, 72), fitz.Point(300, 72)) + shape.finish(color=(0, 0, 0), width=1) + shape.commit() + p3 = doc.new_page() # en blanco: válida y sin aviso + pdf = tmp_path / "vec.pdf" + doc.save(str(pdf)) + doc.close() + + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + assert r.paginas_solo_vectoriales == 1 + assert r.paginas_con_imagenes == 0 + + +def test_texto_oculto_tipo_ocr_sobre_imagen_se_censura(tmp_path): + """Capa OCR invisible (render_mode 3) sobre una imagen: el término se elimina.""" + doc = fitz.open() + page = _insertar_imagen_renderizada(doc, "fondo") + page.insert_text((72, 100), f"cliente {TERMINO}", render_mode=3) + pdf = tmp_path / "ocr.pdf" + doc.save(str(pdf)) + doc.close() + + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + assert r.paginas_con_imagenes == 1 + assert TERMINO.lower() not in normalizar_texto(_texto_pdf(r.output_path)) + + +# --------------------------------------------------------------------------- +# Omitido, rangos y PDFs no válidos +# --------------------------------------------------------------------------- + + +def test_cero_coincidencias_es_omitido_sin_archivo(tmp_path): + pdf = _crear_pdf(tmp_path / "a.pdf", ["Texto cualquiera sin nada sensible"]) + dest = tmp_path / "out" + r = censurar_pdf(pdf, dest, ["inexistente", "otra"], detectar_importes=False) + assert r.estado == EstadoCensura.OMITIDO + assert r.motivo == MotivoCensura.SIN_COINCIDENCIAS + assert r.terminos_sin_coincidencias == 2 + assert r.output_path is None + assert _archivos(dest) == [] + + +def test_terminos_sin_coincidencias_se_cuentan_en_procesado(tmp_path): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO, "nadie", "ninguno"], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + assert r.terminos_sin_coincidencias == 2 + + +@pytest.mark.parametrize("pages", [{0}, {5}, set()]) +def test_rango_invalido_es_error(tmp_path, pages): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], pages, detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.RANGO_INVALIDO + assert _archivos(tmp_path / "out") == [] + + +def test_pdf_corrupto_es_error_pdf_no_abre(tmp_path): + malo = tmp_path / "malo.pdf" + malo.write_bytes(b"esto no es un pdf") + r = censurar_pdf(malo, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.PDF_NO_ABRE + + +def test_pdf_cifrado_es_error(tmp_path): + doc = fitz.open() + _escribir(doc.new_page(), f"Cliente {TERMINO}") + pdf = tmp_path / "cifrado.pdf" + doc.save(str(pdf), encryption=fitz.PDF_ENCRYPT_AES_256, user_pw="u", owner_pw="o") + doc.close() + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.PDF_CIFRADO + assert _archivos(tmp_path / "out") == [] + + +# --------------------------------------------------------------------------- +# Falsos negativos de search_for: nunca sale un PDF con el término visible +# --------------------------------------------------------------------------- + + +def test_termino_partido_por_guion_y_mayusculas_no_se_filtra(tmp_path): + pdf = _crear_pdf(tmp_path / "g.pdf", ["Dato CONFIDEN-\ncial del cliente\nNada más"]) + dest = tmp_path / "out" + r = censurar_pdf(pdf, dest, ["confidencial"], detectar_importes=False) + + if r.estado == EstadoCensura.PROCESADO: + # Ni con espacios, saltos ni guiones intermedios puede reconstruirse el término. + compacto = re.sub(r"[\s\-]+", "", normalizar_texto(_texto_pdf(r.output_path))) + assert "confidencial" not in compacto + assert r.coincidencias >= 1 + else: + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA + assert _archivos(dest) == [] + assert _sin_temporales(dest) + + +def test_ligadura_real_se_censura(tmp_path): + """Con ligadura real, search_for por defecto no encuentra 'oficina': se compensa.""" + doc = fitz.open() + page = doc.new_page() + _escribir(page, "La o\ufb01cina del cliente", fuente="tiro") + pdf = tmp_path / "lig.pdf" + doc.save(str(pdf)) + doc.close() + + assert fitz.open(str(pdf))[0].search_for("oficina") == [] # el fallo de PyMuPDF existe + + r = censurar_pdf(pdf, tmp_path / "out", ["oficina"], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + assert "oficina" not in normalizar_texto(_texto_pdf(r.output_path)) + + +def test_search_for_ciego_con_texto_presente_es_error_no_omitido(tmp_path, monkeypatch): + """Si search_for no localiza nada pero el término SÍ está en el texto → error, no 'omitido'.""" + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + monkeypatch.setattr(core, "_buscar", lambda page, term: []) + + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA + assert "coincidencia_no_localizada" in r.fallos_verificacion + assert _archivos(dest) == [] + + +# --------------------------------------------------------------------------- +# Saneado de contenedores +# --------------------------------------------------------------------------- + + +def test_outline_titulo_con_termino_se_sustituye(tmp_path): + pdf = _crear_pdf( + tmp_path / "a.pdf", + [f"Cliente {TERMINO}", "Otra"], + toc=[[1, "Anexo Zorrillo (cliente)", 1], [1, "Capítulo neutro", 2]], + ) + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.PROCESADO + out = fitz.open(str(r.output_path)) + try: + titulos = [t[1] for t in out.get_toc()] + finally: + out.close() + assert titulos[0] == f"Anexo {core.MARCADOR_OUTLINE} (cliente)" + assert titulos[1] == "Capítulo neutro" + assert [t[2] for t in fitz.open(str(r.output_path)).get_toc()] == [1, 2] + + +def test_anotacion_freetext_con_termino_se_elimina_y_el_resto_se_conserva(tmp_path): + doc = fitz.open() + page = doc.new_page() + _escribir(page, f"Cliente {TERMINO}") + a1 = page.add_freetext_annot(fitz.Rect(50, 200, 300, 240), f"Nota sobre {TERMINO}") + a1.set_info(content=f"Nota sobre {TERMINO}") + a1.update() + a2 = page.add_freetext_annot(fitz.Rect(50, 260, 300, 300), "Nota inocua") + a2.set_info(content="Nota inocua") + a2.update() + a3 = page.add_text_annot((400, 400), "x") + a3.set_info(title=f"Autor {TERMINO}", content="neutro") + pdf = tmp_path / "an.pdf" + doc.save(str(pdf)) + doc.close() + + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + out = fitz.open(str(r.output_path)) + try: + contenidos = [a.info["content"] for a in out[0].annots()] + titulos = [a.info["title"] for a in out[0].annots()] + finally: + out.close() + assert contenidos == ["Nota inocua"] # sin crash de scrub con anotaciones restantes + assert not any(TERMINO.lower() in t.lower() for t in contenidos + titulos) + + +def test_adjunto_embebido_y_anotacion_de_adjunto_se_eliminan(tmp_path): + doc = fitz.open() + page = doc.new_page() + _escribir(page, f"Cliente {TERMINO}") + page.add_file_annot((300, 300), f"secreto {TERMINO}".encode(), "adj.txt") + doc.embfile_add("emb.txt", f"secreto {TERMINO}".encode(), filename="emb.txt") + pdf = tmp_path / "adj.pdf" + doc.save(str(pdf)) + doc.close() + + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + out = fitz.open(str(r.output_path)) + try: + assert out.embfile_count() == 0 + assert list(out[0].annots(types=[fitz.PDF_ANNOT_FILE_ATTACHMENT])) == [] + finally: + out.close() + assert TERMINO.encode() not in r.output_path.read_bytes() + + +def test_campo_de_formulario_con_termino_en_el_nombre_es_error(tmp_path): + doc = fitz.open() + page = doc.new_page() + _escribir(page, f"Cliente {TERMINO}") + w = fitz.Widget() + w.rect = fitz.Rect(50, 300, 250, 330) + w.field_name = f"campo_{TERMINO}" + w.field_type = fitz.PDF_WIDGET_TYPE_TEXT + w.field_value = "valor" + page.add_widget(w) + pdf = tmp_path / "form.pdf" + doc.save(str(pdf)) + doc.close() + + dest = tmp_path / "out" + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA + assert "campos_formulario" in r.fallos_verificacion + assert _archivos(dest) == [] + + +def test_nombres_de_destino_con_termino_es_error(tmp_path, monkeypatch): + """resolve_names() con el término → la verificación lo detecta (simulado).""" + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + original = fitz.Document.resolve_names + monkeypatch.setattr( + fitz.Document, "resolve_names", + lambda self: {f"dest_{TERMINO}": {"page": 0}} if self.page_count else original(self), + ) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert "nombres_destino" in r.fallos_verificacion + assert _archivos(dest) == [] + + +# --------------------------------------------------------------------------- +# Fail-closed: verificación fallida, errores inesperados, cancelación +# --------------------------------------------------------------------------- + + +def test_verificacion_fallida_simulada_no_deja_archivo_ni_temporales(tmp_path, monkeypatch): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + vistos = {} + + def falso_verificar(path, terminos, indices, paginas): + vistos["temp_existia"] = Path(path).is_file() + vistos["temp_oculto"] = Path(path).name.startswith(".") + vistos["mismo_dir"] = Path(path).parent == dest + return ["simulado"] + + monkeypatch.setattr(core, "verificar_salida", falso_verificar) + + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert vistos == {"temp_existia": True, "temp_oculto": True, "mismo_dir": True} + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA + assert r.fallos_verificacion == ("simulado",) + assert r.output_path is None + assert _archivos(dest) == [] + + +def test_excepcion_en_verificacion_es_verificacion_fallida(tmp_path, monkeypatch): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + + def explota(*a, **k): + raise RuntimeError("boom") + + monkeypatch.setattr(core, "verificar_salida", explota) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA + assert _archivos(dest) == [] + + +def test_error_inesperado_no_deja_archivo_ni_filtra_en_el_log(tmp_path, monkeypatch, log_docflow): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + + def explota(doc): + raise RuntimeError(f"fallo con {TERMINO} en {pdf}") + + monkeypatch.setattr(core, "_aplicar_scrub", explota) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.ERROR_INTERNO + assert _archivos(dest) == [] + mensajes = "\n".join(rec.getMessage() for rec in log_docflow.records) + assert mensajes # se registró algo + assert TERMINO not in mensajes and str(tmp_path) not in mensajes + + +@pytest.mark.parametrize("k", range(1, 12)) +def test_cancelacion_en_cualquier_punto_no_deja_temporales(tmp_path, k): + """Cancela en la llamada k-ésima a is_cancelled: o termina bien o no deja NADA.""" + pdf = _crear_pdf(tmp_path / "a.pdf", [f"A {TERMINO}", f"B {TERMINO}", f"C {TERMINO}"]) + dest = tmp_path / f"out{k}" + llamadas = {"n": 0} + + def is_cancelled(): + llamadas["n"] += 1 + return llamadas["n"] >= k + + try: + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False, is_cancelled=is_cancelled) + except CancelledByUser: + assert _archivos(dest) == [] + else: + assert r.estado == EstadoCensura.PROCESADO + assert _archivos(dest) == ["a_censurado.pdf"] + assert _sin_temporales(dest) + + +# --------------------------------------------------------------------------- +# Nombre final, colisiones, flags +# --------------------------------------------------------------------------- + + +def test_colision_de_nombre_usa_sufijo_y_nunca_sobrescribe(tmp_path): + pdf = _crear_pdf(tmp_path / "f.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + dest.mkdir() + (dest / "f_censurado.pdf").write_bytes(b"previo-1") + (dest / "f_censurado_02.pdf").write_bytes(b"previo-2") + + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + assert r.output_path.name == "f_censurado_03.pdf" + assert (dest / "f_censurado.pdf").read_bytes() == b"previo-1" + assert (dest / "f_censurado_02.pdf").read_bytes() == b"previo-2" + assert _sin_temporales(dest) + + +def test_nombre_aparece_justo_antes_de_promover_no_se_sobrescribe(tmp_path, monkeypatch): + pdf = _crear_pdf(tmp_path / "f.pdf", [f"Cliente {TERMINO}"]) + dest = tmp_path / "out" + ocupado = dest / "f_censurado.pdf" + llamadas = {"n": 0} + original = core.resolve_conflict + + def carrera(path, pattern="_v{i}"): + resultado = original(path, pattern=pattern) + llamadas["n"] += 1 + if llamadas["n"] == 1: + resultado.write_bytes(b"alguien-me-gano") # aparece tras elegir el nombre + return resultado + + monkeypatch.setattr(core, "resolve_conflict", carrera) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + assert ocupado.read_bytes() == b"alguien-me-gano" + assert r.output_path.name == "f_censurado_02.pdf" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="UF_HIDDEN solo existe en macOS") +def test_archivo_final_no_queda_oculto_en_macos(tmp_path): + import stat as st + + pdf = _crear_pdf(tmp_path / "f.pdf", [f"Cliente {TERMINO}"]) + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + assert not (r.output_path.stat().st_flags & st.UF_HIDDEN) + + +# --------------------------------------------------------------------------- +# ResumenLote +# --------------------------------------------------------------------------- + + +def _res(estado, motivo, **kw): + return ResultadoCensura(estado=estado, motivo=motivo, **kw) + + +def test_resumen_mensaje_con_las_tres_cifras_y_desglose(): + resumen = ResumenLote(total=6) + resumen.agregar(_res(EstadoCensura.PROCESADO, MotivoCensura.OK, + terminos_sin_coincidencias=2, paginas_con_imagenes=3, + paginas_solo_vectoriales=1, output_path=Path("/x/a.pdf"))) + resumen.agregar(_res(EstadoCensura.OMITIDO, MotivoCensura.SIN_COINCIDENCIAS, + terminos_sin_coincidencias=1)) + for _ in range(2): + resumen.agregar(ResultadoCensura.fallo(MotivoCensura.SIN_CAPA_TEXTO, paginas_con_imagenes=9)) + resumen.agregar(ResultadoCensura.fallo(MotivoCensura.VERIFICACION_FALLIDA)) + resumen.agregar(ResultadoCensura.fallo(MotivoCensura.IMPORTE_NO_INTERPRETABLE)) + + msg = resumen.mensaje() + assert (resumen.procesados, resumen.omitidos, resumen.errores) == (1, 1, 4) + assert "1 procesado(s)" in msg and "1 omitido(s)" in msg and "4 con error" in msg + assert "2 sin capa de texto: aplica OCR antes" in msg + assert "1 no superaron la verificación final" in msg + assert "Términos sin coincidencias: 3" in msg + assert "3 página(s) con imágenes" in msg and "NO se censura" in msg # los 9 de errores no suman + assert "1 página(s) solo vectoriales" in msg + assert not msg.startswith("No se censuró") + + +def test_resumen_sin_ningun_procesado_no_presenta_exito(): + resumen = ResumenLote(total=2) + resumen.agregar(_res(EstadoCensura.OMITIDO, MotivoCensura.SIN_COINCIDENCIAS)) + resumen.agregar(ResultadoCensura.fallo(MotivoCensura.SIN_CAPA_TEXTO)) + assert resumen.mensaje().startswith("No se censuró ningún PDF.") + + +def test_resumen_archivos_solo_existentes(tmp_path): + real = tmp_path / "real.pdf" + real.write_bytes(b"x") + resumen = ResumenLote(total=2) + resumen.agregar(_res(EstadoCensura.PROCESADO, MotivoCensura.OK, output_path=real)) + resumen.agregar(_res(EstadoCensura.PROCESADO, MotivoCensura.OK, output_path=tmp_path / "fantasma.pdf")) + assert resumen.archivos_existentes() == [real] + + +# --------------------------------------------------------------------------- +# Lote / run() +# --------------------------------------------------------------------------- + + +def _cfg(words=(TERMINO,), *, importes=False, all_pages=True, ranges=None): + return { + "words": list(words), + "all_pages": all_pages, + "ranges": ranges, + "detectar_importes": importes, + } + + +def test_script_conserva_meta_y_firma_de_run(): + meta = extract_metadata(script) + assert meta["name"] == "Censurar PDF por palabras" + assert meta["category"] == "PDF" + assert list(inspect.signature(script.run).parameters) == ["progress", "is_cancelled"] + + +def test_lote_contabiliza_procesados_omitidos_y_errores(tmp_path): + ok = _crear_pdf(tmp_path / "ok.pdf", [f"Cliente {TERMINO}"]) + vacio = _crear_pdf(tmp_path / "vacio.pdf", ["Nada sensible"]) + img = _crear_pdf_solo_imagen(tmp_path / "img.pdf") + progreso = [] + + res = script._ejecutar_lote( + [ok, vacio, img], _cfg(), progress=lambda i, n: progreso.append((i, n)) + ) + + stats = res["stats"] + assert (stats["total"], stats["procesados"], stats["omitidos"], stats["errores"]) == (3, 1, 1, 1) + assert stats["errores_sin_capa_texto"] == 1 + assert progreso == [(1, 3), (2, 3), (3, 3)] + assert [Path(f).name for f in res["files"]] == ["ok_censurado.pdf"] + assert all(Path(f).is_file() for f in res["files"]) + assert "sin capa de texto: aplica OCR antes" in res["message"] + assert res["output_dir"] == str(tmp_path / "PDF_censurados") + + +def test_lote_todos_fallan_no_presenta_exito(tmp_path): + a = _crear_pdf_solo_imagen(tmp_path / "a.pdf") + b = _crear_pdf_solo_imagen(tmp_path / "b.pdf") + + res = script._ejecutar_lote([a, b], _cfg()) + + assert res["stats"]["procesados"] == 0 + assert res["stats"]["errores"] == 2 + assert res["message"].startswith("No se censuró ningún PDF.") + assert "2 sin capa de texto: aplica OCR antes" in res["message"] + assert res["files"] == [] + assert _archivos(tmp_path / "PDF_censurados") == [] + # Apunta a la carpeta de origen si no se creó la de salida. + assert res["output_dir"] == str(tmp_path) + + +def test_lote_todos_omitidos_no_presenta_exito(tmp_path): + a = _crear_pdf(tmp_path / "a.pdf", ["Nada"]) + res = script._ejecutar_lote([a], _cfg()) + assert res["stats"]["omitidos"] == 1 and res["stats"]["procesados"] == 0 + assert res["message"].startswith("No se censuró ningún PDF.") + + +def test_lote_rango_fuera_de_limites_es_error_rango_invalido(tmp_path): + a = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + res = script._ejecutar_lote( + [a], _cfg(all_pages=False, ranges=[(1, 7)]) + ) + assert res["stats"]["errores"] == 1 + assert res["stats"]["errores_rango_invalido"] == 1 + + +def test_lote_rango_de_paginas_se_respeta(tmp_path): + pdf = _crear_mixto(tmp_path / "m.pdf") + res = script._ejecutar_lote([pdf], _cfg(all_pages=False, ranges=[(1, 2)])) + assert res["stats"]["procesados"] == 1 + res2 = script._ejecutar_lote([pdf], _cfg(all_pages=False, ranges=[(3, 3)])) + assert res2["stats"]["errores_sin_capa_texto"] == 1 + + +def test_lote_lista_vacia_sin_importes_se_rechaza(tmp_path): + a = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + with pytest.raises(ValueError): + script._ejecutar_lote([a], _cfg(words=[], importes=False)) + assert _archivos(tmp_path / "PDF_censurados") == [] + + +def test_lote_cancelacion_entre_archivos_conserva_los_completados(tmp_path): + pdfs = [_crear_pdf(tmp_path / f"{n}.pdf", [f"Cliente {TERMINO}"]) for n in "abc"] + hechos = [] + + with pytest.raises(CancelledByUser) as exc: + script._ejecutar_lote( + pdfs, _cfg(), + progress=lambda i, n: hechos.append(i), + is_cancelled=lambda: len(hechos) >= 1, + ) + + res = exc.value.result + assert res["message"] == "Cancelado" + assert res["stats"]["procesados"] == 1 + assert res["stats"]["omitidos"] == 2 # pendientes + assert [Path(f).name for f in res["files"]] == ["a_censurado.pdf"] + assert _archivos(tmp_path / "PDF_censurados") == ["a_censurado.pdf"] + + +def test_lote_cancelacion_a_mitad_de_un_pdf_no_deja_temporales(tmp_path): + pdfs = [_crear_pdf(tmp_path / f"{n}.pdf", [f"A {TERMINO}", f"B {TERMINO}"]) for n in "ab"] + dest = tmp_path / "PDF_censurados" + estado = {"tras_primero": 0} + + def is_cancelled(): + if (dest / "a_censurado.pdf").exists(): + estado["tras_primero"] += 1 + return estado["tras_primero"] >= 4 # ya dentro del segundo PDF + return False + + with pytest.raises(CancelledByUser) as exc: + script._ejecutar_lote(pdfs, _cfg(), is_cancelled=is_cancelled) + + assert exc.value.result["stats"]["procesados"] == 1 + assert _archivos(dest) == ["a_censurado.pdf"] + assert _sin_temporales(dest) + + +def test_lote_no_registra_terminos_nombres_ni_rutas(tmp_path, log_docflow): + ok = _crear_pdf(tmp_path / "expedienteSECRETO77.pdf", [f"Cliente {TERMINO}"]) + img = _crear_pdf_solo_imagen(tmp_path / "escaneoPRIVADO88.pdf") + vacio = _crear_pdf(tmp_path / "vacioRESERVADO99.pdf", ["Nada"]) + + script._ejecutar_lote([ok, img, vacio], _cfg(words=[TERMINO, "Cliente"])) + + mensajes = "\n".join(r.getMessage() for r in log_docflow.records) + assert "archivo 1 de 3" in mensajes and "archivo 3 de 3" in mensajes + for prohibido in (TERMINO, TERMINO.lower(), "Cliente", "SECRETO77", "PRIVADO88", + "RESERVADO99", str(tmp_path), "PDF_censurados", ".pdf"): + assert prohibido not in mensajes + + +def test_run_usa_dialogo_y_devuelve_resultado(tmp_path, monkeypatch): + pdf = _crear_pdf(tmp_path / "a.pdf", [f"Cliente {TERMINO}"]) + + class DialogoFalso: + def __init__(self, parent): + self.resultado = _cfg() + + monkeypatch.setattr(script.tk, "_get_default_root", lambda *a, **k: None) + monkeypatch.setattr(script, "get_ruta", lambda clave: None) + monkeypatch.setattr(script.filedialog, "askopenfilenames", lambda **kw: [str(pdf)]) + monkeypatch.setattr(script, "CensurarDialog", DialogoFalso) + + res = script.run() + assert res["stats"]["procesados"] == 1 + assert (tmp_path / "PDF_censurados" / "a_censurado.pdf").is_file() + + +def test_run_dialogo_cancelado_lanza_cancelled(tmp_path, monkeypatch): + pdf = _crear_pdf(tmp_path / "a.pdf", ["x"]) + + class DialogoFalso: + def __init__(self, parent): + self.resultado = None + + monkeypatch.setattr(script.tk, "_get_default_root", lambda *a, **k: None) + monkeypatch.setattr(script, "get_ruta", lambda clave: None) + monkeypatch.setattr(script.filedialog, "askopenfilenames", lambda **kw: [str(pdf)]) + monkeypatch.setattr(script, "CensurarDialog", DialogoFalso) + + with pytest.raises(CancelledByUser): + script.run() + + +# --------------------------------------------------------------------------- +# Diálogo (casilla de importes y validación de términos) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dialogo(monkeypatch): + import tkinter as tk + + try: + root = tk.Tk() + root.withdraw() + except tk.TclError as exc: + pytest.skip(f"Tk no disponible en este entorno: {exc}") + + # El diálogo real es modal y bloquea en wait_window()/grab_set(). + monkeypatch.setattr(tk.Toplevel, "wait_window", lambda self, *a, **k: None) + monkeypatch.setattr(tk.Toplevel, "grab_set", lambda self: None) + errores = [] + monkeypatch.setattr( + script.messagebox, "showerror", lambda titulo, msg, **kw: errores.append((titulo, msg)) + ) + + dlg = script.CensurarDialog(root) + dlg.errores = errores + try: + yield dlg + finally: + try: + dlg.destroy() + root.destroy() + except tk.TclError: + pass + + +def _escribir_terminos(dlg, texto): + dlg.txt_words.delete("1.0", "end") + dlg.txt_words.insert("1.0", texto) + + +def test_dialogo_casilla_de_importes_activada_por_defecto(dialogo): + assert dialogo.var_importes.get() is True + + +def test_dialogo_lista_vacia_sin_importes_se_rechaza_con_mensaje(dialogo): + _escribir_terminos(dialogo, " \n \n") + dialogo.var_importes.set(False) + dialogo._confirmar() + assert dialogo.resultado is None + assert len(dialogo.errores) == 1 + assert "al menos una palabra" in dialogo.errores[0][1] + + +def test_dialogo_lista_vacia_con_importes_se_acepta(dialogo): + _escribir_terminos(dialogo, "") + dialogo.var_importes.set(True) + dialogo._confirmar() + assert dialogo.errores == [] + assert dialogo.resultado == { + "words": [], "all_pages": True, "ranges": None, "detectar_importes": True, + } + + +def test_dialogo_con_terminos_y_rango(dialogo): + _escribir_terminos(dialogo, "Ana\nana\nLuis\n") + dialogo.var_importes.set(False) + dialogo.var_todas.set(False) + dialogo.var_rango.set("1-2, 4") + dialogo._confirmar() + assert dialogo.resultado == { + "words": ["Ana", "Luis"], + "all_pages": False, + "ranges": [(1, 2), (4, 4)], + "detectar_importes": False, + } From f4bb269ac6e120a236b9806c5196b0c10a43b5c2 Mon Sep 17 00:00:00 2001 From: BenedictoDieciseis Date: Thu, 8 Oct 2026 20:58:08 +0200 Subject: [PATCH 2/4] feat: censura de formularios y mensajes comprensibles (sprint 9.1) Co-authored-by: Cursor --- scripts/pdf/censura_core.py | 341 +++++++++-- scripts/pdf/censurar_pdf_por_palabras.py | 36 +- tests/test_censura_core.py | 721 +++++++++++++++++++++-- 3 files changed, 1002 insertions(+), 96 deletions(-) diff --git a/scripts/pdf/censura_core.py b/scripts/pdf/censura_core.py index d892125..fb5b235 100644 --- a/scripts/pdf/censura_core.py +++ b/scripts/pdf/censura_core.py @@ -9,7 +9,11 @@ - Cualquier duda (página escaneada, verificación fallida, importe ilegible, excepción inesperada) acaba en estado "error" y SIN archivo de salida. - El PDF original no se modifica nunca. - - Ni los resultados ni los logs contienen términos, texto ni rutas. + - Ni los resultados ni los logs contienen términos, texto ni rutas. Los nombres + base de los PDFs omitidos o fallidos solo aparecen en ResumenLote.mensaje() + (mensaje visible); nunca en logs ni en excepciones. + - Campos de formulario con un término: se aplanan (Document.bake) y se tachan + como texto de página; si no hay término en ningún campo, el formulario se conserva. Nota sobre PyMuPDF 1.25.1 (verificado en el entorno del proyecto): - ``Document.scrub(reset_responses=True)`` lanza AttributeError con cualquier @@ -28,10 +32,11 @@ import unicodedata from collections import Counter from dataclasses import dataclass, field +from dataclasses import fields as dataclass_fields from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from enum import Enum from pathlib import Path -from typing import Callable, Collection, Iterable, List, Optional, Sequence +from typing import Callable, Collection, Iterable, List, Optional, Sequence, Tuple from logger import logger from scripts.common.filenames import resolve_conflict @@ -74,20 +79,77 @@ class MotivoCensura(str, Enum): PDF_CIFRADO = "pdf_cifrado" RANGO_INVALIDO = "rango_invalido" ERROR_INTERNO = "error_interno" - - -# Texto para el resumen al usuario (sin términos ni nombres de archivo). -ETIQUETAS_MOTIVO = { - MotivoCensura.SIN_COINCIDENCIAS: "sin coincidencias", - MotivoCensura.SIN_CAPA_TEXTO: "sin capa de texto: aplica OCR antes", - MotivoCensura.VERIFICACION_FALLIDA: "no superaron la verificación final", + ARCHIVO_NO_ENCONTRADO = "archivo_no_encontrado" + + +# Frases del mensaje al usuario por motivo de error. "{n}" = nº de PDFs. +# Redactadas en español llano; sin términos y sin nombres de archivo (estos los +# añade ResumenLote, solo en el mensaje visible). +FRASES_ERROR = { + MotivoCensura.SIN_CAPA_TEXTO: + "{n} PDF(s) son imágenes escaneadas sin texto: conviértelos antes con " + "«PDF escaneado a PDF OCR»", + MotivoCensura.VERIFICACION_FALLIDA: + "{n} PDF(s) no se han creado por seguridad: tras censurar, el texto " + "seguía apareciendo en {causas}", MotivoCensura.IMPORTE_NO_INTERPRETABLE: - "con un importe 'Siendo … lo pagado' no interpretable", - MotivoCensura.PDF_NO_ABRE: "no se pudieron abrir", - MotivoCensura.PDF_CIFRADO: "protegidos con contraseña", - MotivoCensura.RANGO_INVALIDO: "con un rango de páginas inválido", - MotivoCensura.ERROR_INTERNO: "con un error inesperado", + "{n} PDF(s) tienen la frase «Siendo … lo pagado» con un importe que no " + "se puede interpretar; revisa el formato, p. ej. 1.234,56 €", + MotivoCensura.PDF_NO_ABRE: "{n} PDF(s) no se han podido abrir o están dañados", + MotivoCensura.PDF_CIFRADO: "{n} PDF(s) están protegidos con contraseña", + MotivoCensura.RANGO_INVALIDO: + "{n} PDF(s) no tienen las páginas indicadas en el rango", + MotivoCensura.ERROR_INTERNO: + "{n} PDF(s) han fallado por un error inesperado; consulta el registro", + MotivoCensura.ARCHIVO_NO_ENCONTRADO: + "{n} PDF(s) no se pueden entregar: el archivo de salida no existe", +} + +# Orden fijo de las frases de error en el mensaje. +ORDEN_ERRORES = ( + MotivoCensura.SIN_CAPA_TEXTO, + MotivoCensura.VERIFICACION_FALLIDA, + MotivoCensura.IMPORTE_NO_INTERPRETABLE, + MotivoCensura.PDF_NO_ABRE, + MotivoCensura.PDF_CIFRADO, + MotivoCensura.RANGO_INVALIDO, + MotivoCensura.ERROR_INTERNO, + MotivoCensura.ARCHIVO_NO_ENCONTRADO, +) + +# Categoría interna de verificación → causa legible. +CAUSA_COMPROBACION_INTERNA = "una comprobación interna" +CAUSAS_VERIFICACION = { + "search_for": "el texto de las páginas", + "texto_normalizado": "el texto de las páginas", + "texto_fuera_de_pagina": "el texto de las páginas", + "coincidencia_no_localizada": "el texto de las páginas", + "anotaciones": "comentarios o anotaciones", + "campos_formulario": "campos de formulario", + "enlaces": "enlaces", + "outline": "marcadores", + "nombres_destino": "destinos internos", + "adjuntos": "archivos adjuntos", + "metadatos_info": "metadatos", + "metadatos_xmp": "metadatos", + "num_paginas": CAUSA_COMPROBACION_INTERNA, + "error_verificacion": CAUSA_COMPROBACION_INTERNA, } +# Orden de las causas en el mensaje. +ORDEN_CAUSAS = ( + "el texto de las páginas", + "comentarios o anotaciones", + "campos de formulario", + "enlaces", + "marcadores", + "destinos internos", + "archivos adjuntos", + "metadatos", + CAUSA_COMPROBACION_INTERNA, +) + +# Máximo de nombres de archivo en el mensaje visible. +MAX_NOMBRES_MENSAJE = 5 @dataclass(frozen=True) @@ -102,6 +164,12 @@ class ResultadoCensura: paginas_con_imagenes: int = 0 paginas_solo_vectoriales: int = 0 paginas_sin_capa_texto: int = 0 + # Términos buscados (los del usuario + los importes autodetectados). + terminos_buscados: int = 0 + # Términos añadidos por la regla "Siendo … lo pagado" (15 % / 85 %). + importes_autodetectados: int = 0 + # True si los campos de formulario se convirtieron en contenido fijo (bake). + formularios_aplanados: bool = False # Categorías de verificación fallidas (p. ej. "texto_normalizado"); nunca términos. fallos_verificacion: tuple = () output_path: Optional[Path] = None @@ -409,6 +477,52 @@ def _textos_anotacion(annot) -> List[str]: return [info.get("content") or "", info.get("title") or "", info.get("subject") or ""] +def _textos_widget(widget) -> List[str]: + """ + Todos los textos de un campo de formulario: nombre, valor, etiqueta y + cada elemento de choice_values (lista de opciones de combos y listas). + """ + textos: List[str] = [] + + def anadir(valor) -> None: + if isinstance(valor, (list, tuple)): # opciones como pares [exportado, visible] + for v in valor: + anadir(v) + elif valor: + textos.append(str(valor)) + + anadir(widget.field_name) + anadir(widget.field_value) + anadir(widget.field_label) + anadir(widget.choice_values) + return textos + + +def _formularios_con_termino(doc, indices: Sequence[int], terminos: Sequence[str]) -> bool: + """True si algún campo de formulario de las páginas seleccionadas contiene un término.""" + norm = _terminos_normalizados(terminos) + for idx in indices: + page = doc.load_page(idx) + for widget in page.widgets(): + if any(_contiene_termino(t, norm) for t in _textos_widget(widget)): + return True + return False + + +def _aplanar_formularios(doc) -> None: + """ + Convierte los campos de formulario en contenido fijo de página. + + ATENCIÓN: ``bake`` aplana los campos de TODO el documento (no solo los que + contienen un término) y el PDF deja de ser un formulario. Las anotaciones + (annots=False) y los enlaces se conservan. Tras el bake el valor de cada + campo pasa a ser texto de página, que la redacción normal puede tachar. + Los objetos Page anteriores quedan invalidados: hay que recargarlos con + ``doc.load_page`` (``_redactar`` ya lo hace en cada iteración). + """ + doc.bake(annots=False, widgets=True) + + def _borrar_anotaciones(page, condicion) -> int: """ Borra las anotaciones que cumplan la condición. @@ -524,8 +638,7 @@ def marcar(categoria: str) -> None: marcar("anotaciones") for widget in page.widgets(): - campos = (widget.field_name, widget.field_value, widget.field_label) - if any(_contiene_termino(str(c), norm) for c in campos if c): + if any(_contiene_termino(t, norm) for t in _textos_widget(widget)): marcar("campos_formulario") for link in page.get_links(): @@ -657,7 +770,23 @@ def censurar_pdf( return ResultadoCensura.fallo(MotivoCensura.IMPORTE_NO_INTERPRETABLE, **avisos) terminos = preparar_terminos(terminos + extra) - # c. Redacción. + # Contadores conocidos a partir de aquí (se arrastran a cualquier resultado). + info = dict( + avisos, + terminos_buscados=len(terminos), + # Términos realmente añadidos por la regla (sin duplicar los del usuario). + importes_autodetectados=len(terminos) - len(terminos_usuario), + ) + + # b2. Formularios: si algún campo contiene un término, se aplanan los campos + # (bake) para que su contenido pase a ser texto de página y pueda tacharse. + # Si ningún campo lo contiene, el formulario se conserva intacto. + if terminos and _formularios_con_termino(doc, indices, terminos): + _aplanar_formularios(doc) + info["formularios_aplanados"] = True + + # c. Redacción (recarga cada página con load_page: tras el bake las anteriores + # quedan invalidadas). coincidencias, paginas_afectadas, sin_coincidencia = _redactar( doc, indices, terminos, is_cancelled ) if terminos else (0, 0, 0) @@ -669,14 +798,13 @@ def censurar_pdf( return ResultadoCensura.fallo( MotivoCensura.VERIFICACION_FALLIDA, fallos_verificacion=("coincidencia_no_localizada",), - **avisos, + **info, ) return ResultadoCensura( estado=EstadoCensura.OMITIDO, motivo=MotivoCensura.SIN_COINCIDENCIAS, terminos_sin_coincidencias=len(terminos), - paginas_con_imagenes=analisis.con_imagenes, - paginas_solo_vectoriales=analisis.solo_vectoriales, + **info, ) # d/e. Saneado y scrub. @@ -703,7 +831,7 @@ def censurar_pdf( coincidencias=coincidencias, paginas_afectadas=paginas_afectadas, fallos_verificacion=tuple(fallos), - **avisos, + **info, ) # g. Promoción atómica. @@ -716,9 +844,8 @@ def censurar_pdf( coincidencias=coincidencias, paginas_afectadas=paginas_afectadas, terminos_sin_coincidencias=sin_coincidencia, - paginas_con_imagenes=analisis.con_imagenes, - paginas_solo_vectoriales=analisis.solo_vectoriales, output_path=final, + **info, ) except CancelledByUser: @@ -751,60 +878,186 @@ class ResumenLote: errores: int = 0 errores_por_motivo: Counter = field(default_factory=Counter) terminos_sin_coincidencias: int = 0 + terminos_buscados: int = 0 paginas_con_imagenes: int = 0 paginas_solo_vectoriales: int = 0 + # Solo de PDFs procesados: + coincidencias: int = 0 + paginas_afectadas: int = 0 + importes_autodetectados: int = 0 + pdf_con_formularios_aplanados: int = 0 + # Categorías de fallos_verificacion de los PDFs con verificación fallida. + fallos_verificacion: Counter = field(default_factory=Counter) + # (nombre base, motivo): los nombres solo se usan en el mensaje visible. + omitidos_detalle: List[Tuple[str, MotivoCensura]] = field(default_factory=list) + errores_detalle: List[Tuple[str, MotivoCensura]] = field(default_factory=list) archivos: List[Path] = field(default_factory=list) + # (nombre original, resultado) por PDF: permite recalcular en revalidar_archivos(). + _registros: list = field(default_factory=list, init=False, repr=False) + + def agregar(self, r: ResultadoCensura, nombre: Optional[str] = None) -> None: + self._registros.append((nombre, r)) + base = _nombre_base(nombre) - def agregar(self, r: ResultadoCensura) -> None: if r.estado == EstadoCensura.PROCESADO: self.procesados += 1 if r.output_path is not None: self.archivos.append(Path(r.output_path)) + self.coincidencias += r.coincidencias + self.paginas_afectadas += r.paginas_afectadas + self.importes_autodetectados += r.importes_autodetectados + if r.formularios_aplanados: + self.pdf_con_formularios_aplanados += 1 elif r.estado == EstadoCensura.OMITIDO: self.omitidos += 1 + self.omitidos_detalle.append((base, r.motivo)) else: self.errores += 1 self.errores_por_motivo[r.motivo] += 1 + self.errores_detalle.append((base, r.motivo)) + if r.motivo == MotivoCensura.VERIFICACION_FALLIDA: + # Sin categorías = causa desconocida: se trata como comprobación interna. + for categoria in r.fallos_verificacion or ("error_verificacion",): + self.fallos_verificacion[categoria] += 1 return # los avisos de un PDF fallido son parciales: no se suman self.terminos_sin_coincidencias += r.terminos_sin_coincidencias + self.terminos_buscados += r.terminos_buscados self.paginas_con_imagenes += r.paginas_con_imagenes self.paginas_solo_vectoriales += r.paginas_solo_vectoriales def archivos_existentes(self) -> List[Path]: return [p for p in self.archivos if Path(p).is_file()] + def revalidar_archivos(self) -> int: + """ + Comprueba que cada PDF "procesado" tiene su archivo de salida (existe y es + archivo). Si no, lo reclasifica como error ARCHIVO_NO_ENCONTRADO y recalcula + todos los contadores. Devuelve cuántos reclasificó. + """ + recalculado = ResumenLote(total=self.total) + reclasificados = 0 + for nombre, r in self._registros: + if r.estado == EstadoCensura.PROCESADO and not _es_archivo(r.output_path): + r = ResultadoCensura.fallo(MotivoCensura.ARCHIVO_NO_ENCONTRADO) + reclasificados += 1 + recalculado.agregar(r, nombre) + + if reclasificados: + for f in dataclass_fields(self): + setattr(self, f.name, getattr(recalculado, f.name)) + return reclasificados + + def _causas_verificacion(self) -> str: + causas = { + CAUSAS_VERIFICACION.get(cat, CAUSA_COMPROBACION_INTERNA) + for cat in self.fallos_verificacion + } + return _unir_lista([c for c in ORDEN_CAUSAS if c in causas]) or CAUSA_COMPROBACION_INTERNA + def mensaje(self) -> str: - partes = [] - if self.total > 0 and self.procesados == 0: - partes.append("No se censuró ningún PDF.") - - partes.append( - f"{self.procesados} procesado(s), " - f"{self.omitidos} omitido(s) (sin coincidencias), " - f"{self.errores} con error." - ) + """ + Mensaje visible para el usuario: español llano y UNA sola línea. - if self.errores_por_motivo: - desglose = "; ".join( - f"{n} {ETIQUETAS_MOTIVO.get(m, m.value)}" - for m, n in sorted(self.errores_por_motivo.items(), key=lambda kv: kv[0].value) + Es el único sitio donde aparecen nombres de archivo (solo el nombre base). + Nunca debe enviarse a logs ni a excepciones. Sin términos ni rutas. + """ + partes: List[str] = [] + + if self.procesados: + partes.append( + f"Censura completada en {self.procesados} de {self.total} PDF(s): " + f"{self.coincidencias} coincidencia(s) tachada(s) en " + f"{self.paginas_afectadas} página(s)." ) - partes.append(f"Errores: {desglose}.") + else: + partes.append("No se ha creado ningún archivo.") - if self.terminos_sin_coincidencias: - partes.append(f"Términos sin coincidencias: {self.terminos_sin_coincidencias}.") + if self.omitidos: + nombres = _texto_nombres(n for n, _ in self.omitidos_detalle) + partes.append( + f"{self.omitidos} PDF(s) sin coincidencias; no se ha creado archivo{nombres}." + ) + + motivos = list(ORDEN_ERRORES) + sorted( + (m for m in self.errores_por_motivo if m not in ORDEN_ERRORES), + key=lambda m: m.value, + ) + for motivo in motivos: + n = self.errores_por_motivo.get(motivo, 0) + if not n: + continue + plantilla = FRASES_ERROR.get(motivo, FRASES_ERROR[MotivoCensura.ERROR_INTERNO]) + frase = plantilla.format(n=n, causas=self._causas_verificacion()) + nombres = _texto_nombres(nom for nom, m in self.errores_detalle if m == motivo) + partes.append(f"{frase}{nombres}.") + + if self.total == 1 and self.terminos_sin_coincidencias > 0: + partes.append( + f"{self.terminos_sin_coincidencias} de las {self.terminos_buscados} " + "palabras no se encontraron." + ) if self.paginas_con_imagenes: partes.append( - f"Aviso: {self.paginas_con_imagenes} página(s) con imágenes; " - "el texto dentro de imágenes NO se censura." + f"Aviso: {self.paginas_con_imagenes} página(s) contienen imágenes. " + "Si el texto está dentro de una imagen (por ejemplo, un DNI fotografiado) " + "no se puede censurar automáticamente: revísalas." ) if self.paginas_solo_vectoriales: partes.append( - f"Aviso: {self.paginas_solo_vectoriales} página(s) solo vectoriales " - "sin texto; revísalas manualmente." + f"Aviso: {self.paginas_solo_vectoriales} página(s) sin texto con dibujos; " + "si contienen texto convertido en trazados no se ha podido censurar: " + "revísalas." ) + if self.importes_autodetectados: + partes.append( + f"Se detectaron {self.importes_autodetectados} importe(s) con la regla " + "«Siendo … lo pagado»." + ) + + if self.pdf_con_formularios_aplanados: + partes.append("Los campos de formulario se han convertido en contenido fijo.") + return " ".join(partes) + + +# ====================================================== +# AUXILIARES DEL MENSAJE +# ====================================================== + +def _es_archivo(path: Optional[Path]) -> bool: + try: + return path is not None and Path(path).is_file() + except OSError: + return False + + +def _nombre_base(nombre: Optional[str]) -> str: + """Solo el nombre del archivo (sin carpetas) y en una línea; "" si no hay.""" + if not nombre: + return "" + return " ".join(Path(str(nombre)).name.split()) + + +def _unir_lista(elementos: Sequence[str]) -> str: + """'a', 'a y b', 'a, b y c'.""" + elementos = list(elementos) + if len(elementos) <= 1: + return "".join(elementos) + return ", ".join(elementos[:-1]) + " y " + elementos[-1] + + +def _texto_nombres(nombres: Iterable[str]) -> str: + """' (a.pdf, b.pdf y 3 más)': máximo MAX_NOMBRES_MENSAJE nombres; '' si no hay.""" + nombres = [n for n in nombres if n] + if not nombres: + return "" + visibles = nombres[:MAX_NOMBRES_MENSAJE] + texto = ", ".join(visibles) + resto = len(nombres) - len(visibles) + if resto > 0: + texto += f" y {resto} más" + return f" ({texto})" diff --git a/scripts/pdf/censurar_pdf_por_palabras.py b/scripts/pdf/censurar_pdf_por_palabras.py index 176628a..de06d4d 100644 --- a/scripts/pdf/censurar_pdf_por_palabras.py +++ b/scripts/pdf/censurar_pdf_por_palabras.py @@ -165,8 +165,6 @@ def _resultado_lote(resumen: ResumenLote, salida_dir: Path, *, cancelado: bool = resumen.total - resumen.procesados - resumen.omitidos - resumen.errores, 0 ) - extra = {f"errores_{m.value}": n for m, n in resumen.errores_por_motivo.items()} - # Si no se creó la carpeta de salida (nada procesado), se apunta a la de origen. output_dir = salida_dir if salida_dir.is_dir() else salida_dir.parent @@ -178,10 +176,9 @@ def _resultado_lote(resumen: ResumenLote, salida_dir: Path, *, cancelado: bool = errores=resumen.errores, omitidos=resumen.omitidos + (pendientes if cancelado else 0), files=resumen.archivos_existentes(), - terminos_sin_coincidencias=resumen.terminos_sin_coincidencias, - paginas_con_imagenes=resumen.paginas_con_imagenes, - paginas_solo_vectoriales=resumen.paginas_solo_vectoriales, - **extra, + # El resto de información (avisos, causas de error…) va en el mensaje. + coincidencias_censuradas=resumen.coincidencias, + paginas_censuradas=resumen.paginas_afectadas, ) @@ -207,6 +204,20 @@ def _procesar_un_pdf(pdf_path, salida_dir, cfg, is_cancelled) -> ResultadoCensur ) +def _revalidar_salidas(resumen: ResumenLote) -> None: + """ + Revalidación final: cada PDF "procesado" debe tener su archivo de salida. + Los que no lo tengan pasan a error (ARCHIVO_NO_ENCONTRADO) y los contadores + se recalculan, de modo que el resultado coincide con los archivos físicos. + """ + reclasificados = resumen.revalidar_archivos() + if reclasificados: + logger.warning( + f"[PDF-CENSURA] Revalidación: {reclasificados} PDF(s) procesado(s) " + "sin archivo de salida (reclasificados como error)" + ) + + def _ejecutar_lote(pdf_paths, cfg, progress=None, is_cancelled=None) -> dict: # Defensa en profundidad: lista vacía solo con la casilla de importes activa. validar_terminos(cfg["words"], cfg.get("detectar_importes", True)) @@ -216,7 +227,8 @@ def _ejecutar_lote(pdf_paths, cfg, progress=None, is_cancelled=None) -> dict: total = len(pdf_paths) resumen = ResumenLote(total=total) - # Nunca se registran términos, texto, nombres de archivo ni rutas. + # Nunca se registran términos, texto, nombres de archivo ni rutas. El nombre + # base solo se pasa al resumen, que lo usa únicamente en el mensaje visible. logger.info(f"[PDF-CENSURA] Procesando {total} PDF(s)") try: @@ -226,7 +238,7 @@ def _ejecutar_lote(pdf_paths, cfg, progress=None, is_cancelled=None) -> dict: raise CancelledByUser() resultado = _procesar_un_pdf(pdf_path, salida_dir, cfg, is_cancelled) - resumen.agregar(resultado) + resumen.agregar(resultado, pdf_path.name) logger.info( f"[PDF-CENSURA] archivo {idx} de {total}: " @@ -235,13 +247,17 @@ def _ejecutar_lote(pdf_paths, cfg, progress=None, is_cancelled=None) -> dict: f"paginas_afectadas={resultado.paginas_afectadas} " f"terminos_sin_coincidencias={resultado.terminos_sin_coincidencias} " f"paginas_con_imagenes={resultado.paginas_con_imagenes} " - f"paginas_solo_vectoriales={resultado.paginas_solo_vectoriales}" + f"paginas_solo_vectoriales={resultado.paginas_solo_vectoriales} " + f"fallos={','.join(resultado.fallos_verificacion) or '-'} " + f"importes_autodetectados={resultado.importes_autodetectados} " + f"formularios_aplanados={int(resultado.formularios_aplanados)}" ) if progress: progress(idx, total) except CancelledByUser: + _revalidar_salidas(resumen) logger.info( f"[PDF-CENSURA] Cancelado por usuario. " f"Procesados: {resumen.procesados}. Omitidos: {resumen.omitidos}. " @@ -249,6 +265,8 @@ def _ejecutar_lote(pdf_paths, cfg, progress=None, is_cancelled=None) -> dict: ) raise CensuraLoteCancelada(_resultado_lote(resumen, salida_dir, cancelado=True)) + _revalidar_salidas(resumen) + logger.info( f"[PDF-CENSURA] Finalizado. Procesados: {resumen.procesados}. " f"Omitidos: {resumen.omitidos}. Errores: {resumen.errores}" diff --git a/tests/test_censura_core.py b/tests/test_censura_core.py index c8b560f..831a50d 100644 --- a/tests/test_censura_core.py +++ b/tests/test_censura_core.py @@ -90,6 +90,51 @@ def _crear_pdf_solo_imagen(path: Path, texto=f"cliente {TERMINO}"): return path +def _crear_formulario(path: Path, *, termino_en_campos=True, lineas_extra=(), + solo_en_opciones=False): + """ + PDF con texto de página que contiene el término, un campo de texto, un combo, + una casilla, una anotación Highlight y un enlace URI. + + termino_en_campos: valor del texto, valor+opciones del combo y nombre de la casilla. + solo_en_opciones: el término solo está en la lista de opciones del combo. + """ + doc = fitz.open() + page = doc.new_page() + _escribir(page, [f"Cliente {TERMINO}", *lineas_extra], y0=60) + + en_campos = termino_en_campos and not solo_en_opciones + texto = f"Valor {TERMINO}" if en_campos else "Valor neutro" + opcion = f"Opcion {TERMINO}" if en_campos else "Opcion neutra" + opciones = [opcion, "otra"] + if solo_en_opciones: + opciones = ["Opcion neutra", f"Opcion {TERMINO}"] + casilla = f"casilla_{TERMINO}" if en_campos else "casilla_1" + + for nombre, tipo, valor, y, opts in ( + ("texto_1", fitz.PDF_WIDGET_TYPE_TEXT, texto, 200, None), + ("combo_1", fitz.PDF_WIDGET_TYPE_COMBOBOX, opcion, 250, opciones), + (casilla, fitz.PDF_WIDGET_TYPE_CHECKBOX, True, 300, None), + ): + w = fitz.Widget() + w.field_name = nombre + w.field_type = tipo + w.field_value = valor + if opts: + w.choice_values = opts + w.rect = (fitz.Rect(72, y, 300, y + 25) if tipo != fitz.PDF_WIDGET_TYPE_CHECKBOX + else fitz.Rect(72, y, 92, y + 20)) + page.add_widget(w) + + resaltado = page.add_highlight_annot(fitz.Rect(72, 40, 200, 58)) + resaltado.update() + page.insert_link({"kind": fitz.LINK_URI, "from": fitz.Rect(72, 400, 200, 420), + "uri": "https://example.com/ok"}) + doc.save(str(path)) + doc.close() + return path + + def _sha(path: Path) -> str: return hashlib.sha256(Path(path).read_bytes()).hexdigest() @@ -120,6 +165,37 @@ def log_docflow(caplog): base.removeHandler(caplog.handler) +class _CapturaLog(logging.Handler): + def __init__(self): + super().__init__() + self.mensajes = [] + + def emit(self, record): + self.mensajes.append(record.getMessage()) + + +@pytest.fixture(autouse=True) +def _logs_sin_terminos_nombres_ni_rutas(tmp_path): + """ + En TODOS los tests: nada de lo que se registre en el log de DocFlow puede + contener términos, nombres de archivo ni rutas (política de logs). + """ + base = logging.getLogger("DocFlow") + captura = _CapturaLog() + nivel_previo = base.level + base.addHandler(captura) + base.setLevel(logging.DEBUG) + try: + yield + finally: + base.removeHandler(captura) + base.setLevel(nivel_previo) + + registro = "\n".join(captura.mensajes) + for prohibido in (TERMINO, TERMINO.lower(), "Cliente", str(tmp_path), ".pdf"): + assert prohibido not in registro, f"El log contiene datos prohibidos: {prohibido!r}" + + # --------------------------------------------------------------------------- # Normalización y términos # --------------------------------------------------------------------------- @@ -210,6 +286,9 @@ def test_pdf_con_importes_automaticos_se_censura(tmp_path): ) r = censurar_pdf(pdf, tmp_path / "out", [], detectar_importes=True) assert r.estado == EstadoCensura.PROCESADO + assert r.importes_autodetectados == 2 # el 15 % y el 85 % + assert r.terminos_buscados == 2 + assert r.coincidencias == 2 texto = _texto_pdf(r.output_path) assert "150,00" not in texto and "850,00" not in texto assert "999,99" in texto # lo que no es 15 %/85 % se conserva @@ -410,6 +489,19 @@ def test_terminos_sin_coincidencias_se_cuentan_en_procesado(tmp_path): r = censurar_pdf(pdf, tmp_path / "out", [TERMINO, "nadie", "ninguno"], detectar_importes=False) assert r.estado == EstadoCensura.PROCESADO assert r.terminos_sin_coincidencias == 2 + assert r.terminos_buscados == 3 + assert r.importes_autodetectados == 0 + assert r.formularios_aplanados is False + + +def test_importe_que_coincide_con_un_termino_del_usuario_no_cuenta_dos_veces(tmp_path): + pdf = _crear_pdf( + tmp_path / "a.pdf", ["Siendo 1.000,00 € lo pagado\nHonorarios 150,00 €\nResto 850,00 €"] + ) + r = censurar_pdf(pdf, tmp_path / "out", ["150,00 €"], detectar_importes=True) + assert r.estado == EstadoCensura.PROCESADO + assert r.importes_autodetectados == 1 # solo el 85 % es nuevo + assert r.terminos_buscados == 2 @pytest.mark.parametrize("pages", [{0}, {5}, set()]) @@ -567,26 +659,140 @@ def test_adjunto_embebido_y_anotacion_de_adjunto_se_eliminan(tmp_path): assert TERMINO.encode() not in r.output_path.read_bytes() -def test_campo_de_formulario_con_termino_en_el_nombre_es_error(tmp_path): - doc = fitz.open() - page = doc.new_page() - _escribir(page, f"Cliente {TERMINO}") - w = fitz.Widget() - w.rect = fitz.Rect(50, 300, 250, 330) - w.field_name = f"campo_{TERMINO}" - w.field_type = fitz.PDF_WIDGET_TYPE_TEXT - w.field_value = "valor" - page.add_widget(w) - pdf = tmp_path / "form.pdf" - doc.save(str(pdf)) - doc.close() +# --------------------------------------------------------------------------- +# Formularios (sprint 9.1): el contenido de los campos se censura +# --------------------------------------------------------------------------- + +def test_formulario_con_termino_en_campos_se_aplana_y_se_censura(tmp_path): + pdf = _crear_formulario(tmp_path / "form.pdf") dest = tmp_path / "out" + + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + assert r.motivo == MotivoCensura.OK + assert r.formularios_aplanados is True + assert r.coincidencias >= 3 # texto de página + campo de texto + combo + assert r.terminos_buscados == 1 and r.importes_autodetectados == 0 + + out = fitz.open(str(r.output_path)) + try: + assert not out.is_form_pdf + for page in out: + assert page.search_for(TERMINO) == [] + assert list(page.widgets()) == [] + # Las anotaciones se conservan. + assert [a.type[0] for a in page.annots()] == [fitz.PDF_ANNOT_HIGHLIGHT] + finally: + out.close() + assert TERMINO.lower() not in normalizar_texto(_texto_pdf(r.output_path)) + assert TERMINO.encode() not in r.output_path.read_bytes() + assert _sin_temporales(dest) + + +def test_aplanar_formularios_conserva_anotaciones_y_enlaces(tmp_path): + """bake(annots=False, widgets=True): quita los campos y respeta anotaciones y enlaces.""" + pdf = _crear_formulario(tmp_path / "form.pdf") + doc = fitz.open(str(pdf)) + try: + assert len(list(doc[0].widgets())) == 3 + core._aplanar_formularios(doc) + page = doc.load_page(0) # tras el bake las páginas anteriores quedan invalidadas + assert list(page.widgets()) == [] + assert [a.type[0] for a in page.annots()] == [fitz.PDF_ANNOT_HIGHLIGHT] + assert [link["uri"] for link in page.get_links()] == ["https://example.com/ok"] + finally: + doc.close() + + +def test_formulario_sin_termino_en_campos_se_conserva(tmp_path, monkeypatch): + pdf = _crear_formulario(tmp_path / "form.pdf", termino_en_campos=False) + dest = tmp_path / "out" + + def no_debe_llamarse(doc): + raise AssertionError("bake no debe ejecutarse si ningún campo contiene un término") + + monkeypatch.setattr(core, "_aplanar_formularios", no_debe_llamarse) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + assert r.formularios_aplanados is False + out = fitz.open(str(r.output_path)) + try: + assert out.is_form_pdf + assert [w.field_name for w in out[0].widgets()] == ["texto_1", "combo_1", "casilla_1"] + assert out[0].search_for(TERMINO) == [] + finally: + out.close() + + +def test_termino_solo_en_las_opciones_de_un_combo_aplana_el_formulario(tmp_path): + pdf = _crear_formulario(tmp_path / "form.pdf", solo_en_opciones=True) + r = censurar_pdf(pdf, tmp_path / "out", [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.PROCESADO + assert r.formularios_aplanados is True + out = fitz.open(str(r.output_path)) + try: + assert list(out[0].widgets()) == [] + finally: + out.close() + + +def test_verificar_salida_revisa_las_opciones_de_los_combos(tmp_path): + pdf = _crear_formulario(tmp_path / "form.pdf", solo_en_opciones=True) + # La página contiene el término; se comprueba solo la categoría de formulario. + assert "campos_formulario" in core.verificar_salida(pdf, [TERMINO], [0], 1) + + limpio = _crear_formulario(tmp_path / "limpio.pdf", termino_en_campos=False) + assert "campos_formulario" not in core.verificar_salida(limpio, [TERMINO], [0], 1) + + +def test_bake_ineficaz_hace_que_la_verificacion_falle_y_no_deja_archivo(tmp_path, monkeypatch): + """Si el bake no limpiara los campos, la verificación final es la red de seguridad.""" + pdf = _crear_formulario(tmp_path / "form.pdf") + dest = tmp_path / "out" + monkeypatch.setattr(core, "_aplanar_formularios", lambda doc: None) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) assert r.estado == EstadoCensura.ERROR assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA assert "campos_formulario" in r.fallos_verificacion + assert r.output_path is None + assert _archivos(dest) == [] + + +def test_verificacion_fallida_simulada_tras_el_bake_no_deja_archivo_ni_temporales( + tmp_path, monkeypatch +): + pdf = _crear_formulario(tmp_path / "form.pdf") + dest = tmp_path / "out" + monkeypatch.setattr(core, "verificar_salida", lambda *a, **k: ["campos_formulario"]) + + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.VERIFICACION_FALLIDA + assert r.formularios_aplanados is True + assert r.fallos_verificacion == ("campos_formulario",) + assert _archivos(dest) == [] + assert _sin_temporales(dest) + + +def test_fallo_del_bake_es_error_interno_sin_archivo(tmp_path, monkeypatch): + pdf = _crear_formulario(tmp_path / "form.pdf") + dest = tmp_path / "out" + + def explota(doc): + raise RuntimeError(f"bake roto con {TERMINO}") + + monkeypatch.setattr(core, "_aplanar_formularios", explota) + r = censurar_pdf(pdf, dest, [TERMINO], detectar_importes=False) + + assert r.estado == EstadoCensura.ERROR + assert r.motivo == MotivoCensura.ERROR_INTERNO assert _archivos(dest) == [] @@ -747,34 +953,326 @@ def _res(estado, motivo, **kw): return ResultadoCensura(estado=estado, motivo=motivo, **kw) -def test_resumen_mensaje_con_las_tres_cifras_y_desglose(): - resumen = ResumenLote(total=6) - resumen.agregar(_res(EstadoCensura.PROCESADO, MotivoCensura.OK, - terminos_sin_coincidencias=2, paginas_con_imagenes=3, - paginas_solo_vectoriales=1, output_path=Path("/x/a.pdf"))) - resumen.agregar(_res(EstadoCensura.OMITIDO, MotivoCensura.SIN_COINCIDENCIAS, - terminos_sin_coincidencias=1)) - for _ in range(2): - resumen.agregar(ResultadoCensura.fallo(MotivoCensura.SIN_CAPA_TEXTO, paginas_con_imagenes=9)) - resumen.agregar(ResultadoCensura.fallo(MotivoCensura.VERIFICACION_FALLIDA)) - resumen.agregar(ResultadoCensura.fallo(MotivoCensura.IMPORTE_NO_INTERPRETABLE)) +def _ok(**kw): + kw.setdefault("output_path", Path("/x/salida.pdf")) + return _res(EstadoCensura.PROCESADO, MotivoCensura.OK, **kw) + + +def _omitido(**kw): + return _res(EstadoCensura.OMITIDO, MotivoCensura.SIN_COINCIDENCIAS, **kw) + + +def _error(motivo, **kw): + return ResultadoCensura.fallo(motivo, **kw) + + +AVISO_IMAGENES = ( + "Aviso: {n} página(s) contienen imágenes. Si el texto está dentro de una imagen " + "(por ejemplo, un DNI fotografiado) no se puede censurar automáticamente: revísalas." +) +AVISO_VECTORIALES = ( + "Aviso: {n} página(s) sin texto con dibujos; si contienen texto convertido en " + "trazados no se ha podido censurar: revísalas." +) + + +def test_mensaje_exito(): + resumen = ResumenLote(total=1) + resumen.agregar(_ok(coincidencias=4, paginas_afectadas=2, terminos_buscados=2), "a.pdf") + assert resumen.mensaje() == ( + "Censura completada en 1 de 1 PDF(s): 4 coincidencia(s) tachada(s) en 2 página(s)." + ) + + +def test_mensaje_exito_con_palabras_sin_coincidencias_solo_si_es_un_pdf(): + uno = ResumenLote(total=1) + uno.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3, + terminos_sin_coincidencias=2), "a.pdf") + assert uno.mensaje() == ( + "Censura completada en 1 de 1 PDF(s): 1 coincidencia(s) tachada(s) en 1 página(s). " + "2 de las 3 palabras no se encontraron." + ) + + varios = ResumenLote(total=2) + varios.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3, + terminos_sin_coincidencias=2), "a.pdf") + varios.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3), "b.pdf") + assert "no se encontraron" not in varios.mensaje() + + +def test_mensaje_omitido(): + resumen = ResumenLote(total=1) + resumen.agregar(_omitido(terminos_buscados=2, terminos_sin_coincidencias=2), "vacio.pdf") + assert resumen.mensaje() == ( + "No se ha creado ningún archivo. " + "1 PDF(s) sin coincidencias; no se ha creado archivo (vacio.pdf). " + "2 de las 2 palabras no se encontraron." + ) + + +CASOS_ERROR = [ + (MotivoCensura.SIN_CAPA_TEXTO, {}, + "1 PDF(s) son imágenes escaneadas sin texto: conviértelos antes con " + "«PDF escaneado a PDF OCR» (x.pdf)."), + (MotivoCensura.VERIFICACION_FALLIDA, {"fallos_verificacion": ("texto_normalizado",)}, + "1 PDF(s) no se han creado por seguridad: tras censurar, el texto seguía apareciendo " + "en el texto de las páginas (x.pdf)."), + (MotivoCensura.IMPORTE_NO_INTERPRETABLE, {}, + "1 PDF(s) tienen la frase «Siendo … lo pagado» con un importe que no se puede " + "interpretar; revisa el formato, p. ej. 1.234,56 € (x.pdf)."), + (MotivoCensura.PDF_NO_ABRE, {}, "1 PDF(s) no se han podido abrir o están dañados (x.pdf)."), + (MotivoCensura.PDF_CIFRADO, {}, "1 PDF(s) están protegidos con contraseña (x.pdf)."), + (MotivoCensura.RANGO_INVALIDO, {}, + "1 PDF(s) no tienen las páginas indicadas en el rango (x.pdf)."), + (MotivoCensura.ERROR_INTERNO, {}, + "1 PDF(s) han fallado por un error inesperado; consulta el registro (x.pdf)."), + (MotivoCensura.ARCHIVO_NO_ENCONTRADO, {}, + "1 PDF(s) no se pueden entregar: el archivo de salida no existe (x.pdf)."), +] + + +@pytest.mark.parametrize("motivo,extra,frase", CASOS_ERROR, ids=[c[0].value for c in CASOS_ERROR]) +def test_mensaje_error_por_motivo(motivo, extra, frase): + resumen = ResumenLote(total=1) + resumen.agregar(_error(motivo, **extra), "x.pdf") + assert resumen.mensaje() == "No se ha creado ningún archivo. " + frase + + +def test_todos_los_motivos_de_error_tienen_frase(): + errores = set(MotivoCensura) - {MotivoCensura.OK, MotivoCensura.SIN_COINCIDENCIAS} + assert errores == set(core.FRASES_ERROR) + assert errores == set(core.ORDEN_ERRORES) + + +CASOS_CAUSAS = [ + (("search_for",), "el texto de las páginas"), + (("texto_normalizado",), "el texto de las páginas"), + (("texto_fuera_de_pagina",), "el texto de las páginas"), + (("coincidencia_no_localizada",), "el texto de las páginas"), + (("anotaciones",), "comentarios o anotaciones"), + (("campos_formulario",), "campos de formulario"), + (("enlaces",), "enlaces"), + (("outline",), "marcadores"), + (("nombres_destino",), "destinos internos"), + (("adjuntos",), "archivos adjuntos"), + (("metadatos_info",), "metadatos"), + (("metadatos_xmp",), "metadatos"), + (("num_paginas",), "una comprobación interna"), + (("error_verificacion",), "una comprobación interna"), + (("categoria_desconocida",), "una comprobación interna"), + ((), "una comprobación interna"), + (("metadatos_xmp", "metadatos_info", "search_for", "texto_normalizado"), + "el texto de las páginas y metadatos"), + (("outline", "enlaces", "anotaciones", "metadatos_info"), + "comentarios o anotaciones, enlaces, marcadores y metadatos"), +] + + +@pytest.mark.parametrize("fallos,causa", CASOS_CAUSAS) +def test_mensaje_verificacion_fallida_traduce_las_causas(fallos, causa): + resumen = ResumenLote(total=1) + resumen.agregar( + _error(MotivoCensura.VERIFICACION_FALLIDA, fallos_verificacion=fallos), "x.pdf" + ) + assert resumen.mensaje() == ( + "No se ha creado ningún archivo. 1 PDF(s) no se han creado por seguridad: " + f"tras censurar, el texto seguía apareciendo en {causa} (x.pdf)." + ) + + +def test_mensaje_verificacion_fallida_une_las_causas_de_varios_pdf(): + resumen = ResumenLote(total=3) + resumen.agregar(_error(MotivoCensura.VERIFICACION_FALLIDA, + fallos_verificacion=("campos_formulario",)), "a.pdf") + resumen.agregar(_error(MotivoCensura.VERIFICACION_FALLIDA, + fallos_verificacion=("anotaciones", "campos_formulario")), "b.pdf") + resumen.agregar(_error(MotivoCensura.VERIFICACION_FALLIDA, + fallos_verificacion=("anotaciones",)), "c.pdf") + assert resumen.fallos_verificacion == {"campos_formulario": 2, "anotaciones": 2} + assert resumen.mensaje() == ( + "No se ha creado ningún archivo. 3 PDF(s) no se han creado por seguridad: tras " + "censurar, el texto seguía apareciendo en comentarios o anotaciones y campos de " + "formulario (a.pdf, b.pdf, c.pdf)." + ) + + +def _resumen_mezcla(): + resumen = ResumenLote(total=7) + resumen.agregar(_ok(coincidencias=3, paginas_afectadas=2, importes_autodetectados=2, + formularios_aplanados=True, paginas_con_imagenes=1, + terminos_buscados=4), "uno.pdf") + resumen.agregar(_ok(coincidencias=2, paginas_afectadas=1, paginas_solo_vectoriales=2, + terminos_buscados=3, terminos_sin_coincidencias=1), "dos.pdf") + resumen.agregar(_omitido(terminos_buscados=2, terminos_sin_coincidencias=2), "vacio.pdf") + resumen.agregar(_error(MotivoCensura.SIN_CAPA_TEXTO, paginas_con_imagenes=9), "escaneo.pdf") + resumen.agregar(_error(MotivoCensura.PDF_CIFRADO), "clave.pdf") + resumen.agregar(_error(MotivoCensura.PDF_NO_ABRE), "roto.pdf") + resumen.agregar(_error(MotivoCensura.VERIFICACION_FALLIDA, + fallos_verificacion=("anotaciones", "enlaces"), + coincidencias=50, paginas_afectadas=40), "seg.pdf") + return resumen + + +def test_mensaje_mezcla_de_lote(): + resumen = _resumen_mezcla() + assert (resumen.procesados, resumen.omitidos, resumen.errores) == (2, 1, 4) + # Solo los PDFs procesados suman coincidencias/páginas; los fallidos no suman avisos. + assert (resumen.coincidencias, resumen.paginas_afectadas) == (5, 3) + assert resumen.importes_autodetectados == 2 + assert resumen.pdf_con_formularios_aplanados == 1 + assert resumen.mensaje() == " ".join([ + "Censura completada en 2 de 7 PDF(s): 5 coincidencia(s) tachada(s) en 3 página(s).", + "1 PDF(s) sin coincidencias; no se ha creado archivo (vacio.pdf).", + "1 PDF(s) son imágenes escaneadas sin texto: conviértelos antes con " + "«PDF escaneado a PDF OCR» (escaneo.pdf).", + "1 PDF(s) no se han creado por seguridad: tras censurar, el texto seguía " + "apareciendo en comentarios o anotaciones y enlaces (seg.pdf).", + "1 PDF(s) no se han podido abrir o están dañados (roto.pdf).", + "1 PDF(s) están protegidos con contraseña (clave.pdf).", + AVISO_IMAGENES.format(n=1), + AVISO_VECTORIALES.format(n=2), + "Se detectaron 2 importe(s) con la regla «Siendo … lo pagado».", + "Los campos de formulario se han convertido en contenido fijo.", + ]) + + +@pytest.mark.parametrize("cantidad", [5, 6, 7]) +def test_mensaje_limita_los_nombres_a_cinco(cantidad): + resumen = ResumenLote(total=cantidad) + for i in range(1, cantidad + 1): + resumen.agregar(_omitido(), f"o{i}.pdf") + cinco = ", ".join(f"o{i}.pdf" for i in range(1, 6)) + resto = f" y {cantidad - 5} más" if cantidad > 5 else "" + assert resumen.mensaje() == ( + "No se ha creado ningún archivo. " + f"{cantidad} PDF(s) sin coincidencias; no se ha creado archivo ({cinco}{resto})." + ) + +def test_mensaje_limita_los_nombres_de_errores_por_causa(): + resumen = ResumenLote(total=8) + for i in range(1, 7): + resumen.agregar(_error(MotivoCensura.PDF_CIFRADO), f"c{i}.pdf") + resumen.agregar(_error(MotivoCensura.PDF_NO_ABRE), "roto.pdf") msg = resumen.mensaje() - assert (resumen.procesados, resumen.omitidos, resumen.errores) == (1, 1, 4) - assert "1 procesado(s)" in msg and "1 omitido(s)" in msg and "4 con error" in msg - assert "2 sin capa de texto: aplica OCR antes" in msg - assert "1 no superaron la verificación final" in msg - assert "Términos sin coincidencias: 3" in msg - assert "3 página(s) con imágenes" in msg and "NO se censura" in msg # los 9 de errores no suman - assert "1 página(s) solo vectoriales" in msg - assert not msg.startswith("No se censuró") + assert ("6 PDF(s) están protegidos con contraseña " + "(c1.pdf, c2.pdf, c3.pdf, c4.pdf, c5.pdf y 1 más).") in msg + assert "c6.pdf" not in msg + assert "1 PDF(s) no se han podido abrir o están dañados (roto.pdf)." in msg + + +def test_mensaje_solo_usa_el_nombre_base_y_una_linea(): + resumen = ResumenLote(total=2) + resumen.agregar(_omitido(), "/Users/ana/Expedientes secretos/a.pdf") + resumen.agregar(_omitido(), "uno\ndos.pdf") + msg = resumen.mensaje() + assert "(a.pdf, uno dos.pdf)" in msg + assert "/Users" not in msg and "Expedientes" not in msg + assert "\n" not in msg + + +def test_mensaje_sin_nombres_no_añade_parentesis(): + resumen = ResumenLote(total=1) + resumen.agregar(_omitido()) + assert resumen.mensaje() == ( + "No se ha creado ningún archivo. 1 PDF(s) sin coincidencias; no se ha creado archivo." + ) + + +def _resumenes_variados(): + solo_metadatos = ResumenLote(total=1) + solo_metadatos.agregar(_error(MotivoCensura.VERIFICACION_FALLIDA, + fallos_verificacion=("metadatos_info",)), "x.pdf") + todos_los_errores = ResumenLote(total=len(CASOS_ERROR)) + for motivo, extra, _ in CASOS_ERROR: + todos_los_errores.agregar(_error(motivo, **extra), "x.pdf") + return [_resumen_mezcla(), solo_metadatos, todos_los_errores] + + +def test_mensajes_una_linea_y_sin_palabras_tecnicas(): + for resumen in _resumenes_variados(): + msg = resumen.mensaje() + assert "\n" not in msg and "\r" not in msg + assert TERMINO not in msg and TERMINO.lower() not in msg + assert "título" not in msg.lower() and "autor" not in msg.lower() + # "metadatos" solo aparece como causa de una verificación fallida. + if "metadatos" in msg: + assert "el texto seguía apareciendo en" in msg + assert "metadatos" not in _resumen_mezcla().mensaje() def test_resumen_sin_ningun_procesado_no_presenta_exito(): resumen = ResumenLote(total=2) - resumen.agregar(_res(EstadoCensura.OMITIDO, MotivoCensura.SIN_COINCIDENCIAS)) + resumen.agregar(_omitido()) resumen.agregar(ResultadoCensura.fallo(MotivoCensura.SIN_CAPA_TEXTO)) - assert resumen.mensaje().startswith("No se censuró ningún PDF.") + assert resumen.mensaje().startswith("No se ha creado ningún archivo.") + assert "Censura completada" not in resumen.mensaje() + + +def test_resumen_acumula_importes_y_coincidencias_solo_de_procesados(): + resumen = ResumenLote(total=3) + resumen.agregar(_ok(coincidencias=2, paginas_afectadas=1, importes_autodetectados=1), "a.pdf") + resumen.agregar(_omitido(importes_autodetectados=5), "b.pdf") + resumen.agregar(_error(MotivoCensura.VERIFICACION_FALLIDA, coincidencias=9, + paginas_afectadas=9, importes_autodetectados=7), "c.pdf") + assert resumen.coincidencias == 2 + assert resumen.paginas_afectadas == 1 + assert resumen.importes_autodetectados == 1 + assert resumen.omitidos_detalle == [("b.pdf", MotivoCensura.SIN_COINCIDENCIAS)] + assert resumen.errores_detalle == [("c.pdf", MotivoCensura.VERIFICACION_FALLIDA)] + + +def test_revalidar_reclasifica_los_procesados_sin_archivo(tmp_path): + real = tmp_path / "real_censurado.pdf" + real.write_bytes(b"x") + resumen = ResumenLote(total=3) + resumen.agregar(_ok(coincidencias=2, paginas_afectadas=1, importes_autodetectados=1, + output_path=real), "real.pdf") + resumen.agregar(_ok(coincidencias=7, paginas_afectadas=3, importes_autodetectados=2, + formularios_aplanados=True, + output_path=tmp_path / "fantasma_censurado.pdf"), "fantasma.pdf") + resumen.agregar(_ok(coincidencias=1, paginas_afectadas=1, output_path=None), "sin_ruta.pdf") + + assert resumen.revalidar_archivos() == 2 + + assert (resumen.procesados, resumen.errores, resumen.omitidos) == (1, 2, 0) + assert resumen.errores_por_motivo == {MotivoCensura.ARCHIVO_NO_ENCONTRADO: 2} + assert resumen.errores_detalle == [ + ("fantasma.pdf", MotivoCensura.ARCHIVO_NO_ENCONTRADO), + ("sin_ruta.pdf", MotivoCensura.ARCHIVO_NO_ENCONTRADO), + ] + # Los contadores solo reflejan lo que existe físicamente. + assert (resumen.coincidencias, resumen.paginas_afectadas) == (2, 1) + assert resumen.importes_autodetectados == 1 + assert resumen.pdf_con_formularios_aplanados == 0 + assert resumen.archivos_existentes() == [real] + assert resumen.mensaje() == ( + "Censura completada en 1 de 3 PDF(s): 2 coincidencia(s) tachada(s) en 1 página(s). " + "2 PDF(s) no se pueden entregar: el archivo de salida no existe " + "(fantasma.pdf, sin_ruta.pdf). " + "Se detectaron 1 importe(s) con la regla «Siendo … lo pagado»." + ) + # Idempotente. + assert resumen.revalidar_archivos() == 0 + + +def test_revalidar_sin_cambios_no_toca_nada(tmp_path): + real = tmp_path / "a_censurado.pdf" + real.write_bytes(b"x") + resumen = ResumenLote(total=1) + resumen.agregar(_ok(coincidencias=2, paginas_afectadas=1, output_path=real), "a.pdf") + antes = resumen.mensaje() + assert resumen.revalidar_archivos() == 0 + assert resumen.mensaje() == antes and resumen.procesados == 1 + + +def test_revalidar_trata_una_carpeta_como_archivo_inexistente(tmp_path): + carpeta = tmp_path / "no_es_archivo" + carpeta.mkdir() + resumen = ResumenLote(total=1) + resumen.agregar(_ok(output_path=carpeta), "a.pdf") + assert resumen.revalidar_archivos() == 1 + assert (resumen.procesados, resumen.errores) == (0, 1) def test_resumen_archivos_solo_existentes(tmp_path): @@ -818,15 +1316,33 @@ def test_lote_contabiliza_procesados_omitidos_y_errores(tmp_path): ) stats = res["stats"] - assert (stats["total"], stats["procesados"], stats["omitidos"], stats["errores"]) == (3, 1, 1, 1) - assert stats["errores_sin_capa_texto"] == 1 + assert stats == { + "total": 3, "procesados": 1, "omitidos": 1, "errores": 1, + "coincidencias_censuradas": 1, "paginas_censuradas": 1, + } assert progreso == [(1, 3), (2, 3), (3, 3)] assert [Path(f).name for f in res["files"]] == ["ok_censurado.pdf"] assert all(Path(f).is_file() for f in res["files"]) - assert "sin capa de texto: aplica OCR antes" in res["message"] + assert res["message"] == " ".join([ + "Censura completada en 1 de 3 PDF(s): 1 coincidencia(s) tachada(s) en 1 página(s).", + "1 PDF(s) sin coincidencias; no se ha creado archivo (vacio.pdf).", + "1 PDF(s) son imágenes escaneadas sin texto: conviértelos antes con " + "«PDF escaneado a PDF OCR» (img.pdf).", + ]) + assert "\n" not in res["message"] assert res["output_dir"] == str(tmp_path / "PDF_censurados") +def test_lote_stats_solo_tienen_las_claves_publicas(tmp_path): + ok = _crear_pdf(tmp_path / "ok.pdf", [f"Cliente {TERMINO}"]) + img = _crear_pdf_solo_imagen(tmp_path / "img.pdf") + res = script._ejecutar_lote([ok, img], _cfg()) + assert set(res["stats"]) == { + "total", "procesados", "omitidos", "errores", + "coincidencias_censuradas", "paginas_censuradas", + } + + def test_lote_todos_fallan_no_presenta_exito(tmp_path): a = _crear_pdf_solo_imagen(tmp_path / "a.pdf") b = _crear_pdf_solo_imagen(tmp_path / "b.pdf") @@ -835,8 +1351,11 @@ def test_lote_todos_fallan_no_presenta_exito(tmp_path): assert res["stats"]["procesados"] == 0 assert res["stats"]["errores"] == 2 - assert res["message"].startswith("No se censuró ningún PDF.") - assert "2 sin capa de texto: aplica OCR antes" in res["message"] + assert res["message"] == ( + "No se ha creado ningún archivo. " + "2 PDF(s) son imágenes escaneadas sin texto: conviértelos antes con " + "«PDF escaneado a PDF OCR» (a.pdf, b.pdf)." + ) assert res["files"] == [] assert _archivos(tmp_path / "PDF_censurados") == [] # Apunta a la carpeta de origen si no se creó la de salida. @@ -847,7 +1366,8 @@ def test_lote_todos_omitidos_no_presenta_exito(tmp_path): a = _crear_pdf(tmp_path / "a.pdf", ["Nada"]) res = script._ejecutar_lote([a], _cfg()) assert res["stats"]["omitidos"] == 1 and res["stats"]["procesados"] == 0 - assert res["message"].startswith("No se censuró ningún PDF.") + assert res["message"].startswith("No se ha creado ningún archivo.") + assert "1 PDF(s) sin coincidencias; no se ha creado archivo (a.pdf)." in res["message"] def test_lote_rango_fuera_de_limites_es_error_rango_invalido(tmp_path): @@ -856,7 +1376,7 @@ def test_lote_rango_fuera_de_limites_es_error_rango_invalido(tmp_path): [a], _cfg(all_pages=False, ranges=[(1, 7)]) ) assert res["stats"]["errores"] == 1 - assert res["stats"]["errores_rango_invalido"] == 1 + assert "1 PDF(s) no tienen las páginas indicadas en el rango (a.pdf)." in res["message"] def test_lote_rango_de_paginas_se_respeta(tmp_path): @@ -864,7 +1384,8 @@ def test_lote_rango_de_paginas_se_respeta(tmp_path): res = script._ejecutar_lote([pdf], _cfg(all_pages=False, ranges=[(1, 2)])) assert res["stats"]["procesados"] == 1 res2 = script._ejecutar_lote([pdf], _cfg(all_pages=False, ranges=[(3, 3)])) - assert res2["stats"]["errores_sin_capa_texto"] == 1 + assert res2["stats"]["errores"] == 1 + assert "son imágenes escaneadas sin texto" in res2["message"] def test_lote_lista_vacia_sin_importes_se_rechaza(tmp_path): @@ -917,13 +1438,127 @@ def test_lote_no_registra_terminos_nombres_ni_rutas(tmp_path, log_docflow): img = _crear_pdf_solo_imagen(tmp_path / "escaneoPRIVADO88.pdf") vacio = _crear_pdf(tmp_path / "vacioRESERVADO99.pdf", ["Nada"]) - script._ejecutar_lote([ok, img, vacio], _cfg(words=[TERMINO, "Cliente"])) + res = script._ejecutar_lote([ok, img, vacio], _cfg(words=[TERMINO, "Cliente"])) mensajes = "\n".join(r.getMessage() for r in log_docflow.records) assert "archivo 1 de 3" in mensajes and "archivo 3 de 3" in mensajes for prohibido in (TERMINO, TERMINO.lower(), "Cliente", "SECRETO77", "PRIVADO88", "RESERVADO99", str(tmp_path), "PDF_censurados", ".pdf"): assert prohibido not in mensajes + # Los nombres base sí aparecen en el mensaje visible (y solo ahí). + assert "escaneoPRIVADO88.pdf" in res["message"] + assert "vacioRESERVADO99.pdf" in res["message"] + assert str(tmp_path) not in res["message"] + + +def test_lote_log_por_archivo_incluye_fallos_importes_y_formularios(tmp_path, log_docflow, + monkeypatch): + form = _crear_formulario(tmp_path / "formularioPRIVADO55.pdf") + limpio = _crear_formulario(tmp_path / "limpioPRIVADO56.pdf", termino_en_campos=False) + img = _crear_pdf_solo_imagen(tmp_path / "escaneoPRIVADO57.pdf") + script._ejecutar_lote([form, limpio, img], _cfg()) + + lineas = [r.getMessage() for r in log_docflow.records if "archivo " in r.getMessage()] + assert len(lineas) == 3 + assert re.search(r"estado=procesado motivo=ok .*fallos=- importes_autodetectados=0 " + r"formularios_aplanados=1$", lineas[0]) + assert re.search(r"estado=procesado motivo=ok .*fallos=- importes_autodetectados=0 " + r"formularios_aplanados=0$", lineas[1]) + assert re.search(r"estado=error motivo=sin_capa_texto .*fallos=- " + r"importes_autodetectados=0 formularios_aplanados=0$", lineas[2]) + # Formato anterior conservado. + assert "coincidencias=" in lineas[0] and "paginas_afectadas=" in lineas[0] + assert "terminos_sin_coincidencias=" in lineas[0] + assert "paginas_con_imagenes=" in lineas[0] and "paginas_solo_vectoriales=" in lineas[0] + + monkeypatch.setattr(core, "verificar_salida", + lambda *a, **k: ["campos_formulario", "anotaciones"]) + log_docflow.clear() + script._ejecutar_lote([form], _cfg()) + linea = next(r.getMessage() for r in log_docflow.records if "archivo " in r.getMessage()) + assert "estado=error motivo=verificacion_fallida" in linea + assert "fallos=campos_formulario,anotaciones " in linea + assert linea.endswith("formularios_aplanados=1") + for prohibido in ("PRIVADO55", TERMINO, ".pdf", str(tmp_path)): + assert prohibido not in "\n".join(r.getMessage() for r in log_docflow.records) + + +def test_lote_formulario_e_importes_se_cuentan_y_se_explican_en_el_mensaje(tmp_path): + pdf = _crear_formulario( + tmp_path / "f.pdf", + lineas_extra=["Siendo 1.000,00 € lo pagado", "Honorarios 150,00 €", "Resto 850,00 €"], + ) + res = script._ejecutar_lote([pdf], _cfg(importes=True)) + + stats = res["stats"] + assert stats["procesados"] == 1 and stats["errores"] == 0 + assert stats["coincidencias_censuradas"] >= 5 # término x3, 2 importes + assert stats["paginas_censuradas"] == 1 + msg = res["message"] + assert msg.startswith( + f"Censura completada en 1 de 1 PDF(s): {stats['coincidencias_censuradas']} " + "coincidencia(s) tachada(s) en 1 página(s)." + ) + assert msg.endswith( + "Se detectaron 2 importe(s) con la regla «Siendo … lo pagado». " + "Los campos de formulario se han convertido en contenido fijo." + ) + texto = _texto_pdf(Path(res["files"][0])) + assert "150,00" not in texto and "850,00" not in texto + assert TERMINO.lower() not in normalizar_texto(texto) + + +def test_lote_revalida_que_cada_procesado_tiene_su_archivo(tmp_path, monkeypatch, log_docflow): + a = _crear_pdf(tmp_path / "aaPRIVADO1.pdf", [f"Cliente {TERMINO}"]) + b = _crear_pdf(tmp_path / "bbPRIVADO2.pdf", [f"Cliente {TERMINO}"]) + original = script._procesar_un_pdf + + def procesa_y_borra(pdf_path, *args, **kwargs): + resultado = original(pdf_path, *args, **kwargs) + if Path(pdf_path).name == "aaPRIVADO1.pdf": + resultado.output_path.unlink() # el archivo desaparece tras procesarse + return resultado + + monkeypatch.setattr(script, "_procesar_un_pdf", procesa_y_borra) + res = script._ejecutar_lote([a, b], _cfg()) + + stats = res["stats"] + assert (stats["total"], stats["procesados"], stats["omitidos"], stats["errores"]) == (2, 1, 0, 1) + assert stats["coincidencias_censuradas"] == 1 and stats["paginas_censuradas"] == 1 + assert [Path(f).name for f in res["files"]] == ["bbPRIVADO2_censurado.pdf"] + assert len(res["files"]) == stats["procesados"] == len( + list((tmp_path / "PDF_censurados").glob("*_censurado.pdf")) + ) + assert res["message"] == ( + "Censura completada en 1 de 2 PDF(s): 1 coincidencia(s) tachada(s) en 1 página(s). " + "1 PDF(s) no se pueden entregar: el archivo de salida no existe (aaPRIVADO1.pdf)." + ) + registro = "\n".join(r.getMessage() for r in log_docflow.records) + assert "Revalidación: 1 PDF(s)" in registro + assert "PRIVADO" not in registro and ".pdf" not in registro + assert "Procesados: 1. Omitidos: 0. Errores: 1" in registro # contadores ya corregidos + + +def test_lote_cancelado_tambien_revalida_los_archivos(tmp_path, monkeypatch): + pdfs = [_crear_pdf(tmp_path / f"{n}.pdf", [f"Cliente {TERMINO}"]) for n in "ab"] + original = script._procesar_un_pdf + + def procesa_y_borra(pdf_path, *args, **kwargs): + resultado = original(pdf_path, *args, **kwargs) + resultado.output_path.unlink() + return resultado + + monkeypatch.setattr(script, "_procesar_un_pdf", procesa_y_borra) + hechos = [] + with pytest.raises(CancelledByUser) as exc: + script._ejecutar_lote( + pdfs, _cfg(), + progress=lambda i, n: hechos.append(i), + is_cancelled=lambda: len(hechos) >= 1, + ) + stats = exc.value.result["stats"] + assert stats["procesados"] == 0 and stats["errores"] == 1 + assert exc.value.result["files"] == [] def test_run_usa_dialogo_y_devuelve_resultado(tmp_path, monkeypatch): From 1ee012ba686f1cd6996f9d2a5ede5e2bc8791658 Mon Sep 17 00:00:00 2001 From: BenedictoDieciseis Date: Thu, 8 Oct 2026 21:19:34 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20conservar=20valores=20de=20formulari?= =?UTF-8?q?o=20sin=20t=C3=A9rminos=20y=20singular=20en=20mensaje?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- scripts/pdf/censura_core.py | 20 ++++++++++++---- tests/test_censura_core.py | 46 +++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/scripts/pdf/censura_core.py b/scripts/pdf/censura_core.py index fb5b235..5882767 100644 --- a/scripts/pdf/censura_core.py +++ b/scripts/pdf/censura_core.py @@ -580,7 +580,10 @@ def _aplicar_scrub(doc) -> None: javascript=True, # URLs y mailto de los enlaces pueden contener datos del cliente. remove_links=True, - reset_fields=True, + # False: si ningún campo contiene un término, sus valores deben conservarse. + # verificar_salida ya revisa nombre, valor, etiqueta y opciones de cada campo; + # los campos con término se aplanan con bake antes de redactar. + reset_fields=False, # False por un bug de PyMuPDF 1.25.1: delete_responses lanza AttributeError # con cualquier anotación. Las respuestas con término ya se eliminan en # _sanear_anotaciones y la verificación final revisa las anotaciones restantes. @@ -993,10 +996,17 @@ def mensaje(self) -> str: partes.append(f"{frase}{nombres}.") if self.total == 1 and self.terminos_sin_coincidencias > 0: - partes.append( - f"{self.terminos_sin_coincidencias} de las {self.terminos_buscados} " - "palabras no se encontraron." - ) + if self.terminos_buscados == 1: + partes.append("La palabra no se encontró.") + elif self.terminos_sin_coincidencias == 1: + partes.append( + f"1 de las {self.terminos_buscados} palabras no se encontró." + ) + else: + partes.append( + f"{self.terminos_sin_coincidencias} de las {self.terminos_buscados} " + "palabras no se encontraron." + ) if self.paginas_con_imagenes: partes.append( diff --git a/tests/test_censura_core.py b/tests/test_censura_core.py index 831a50d..8c4e95c 100644 --- a/tests/test_censura_core.py +++ b/tests/test_censura_core.py @@ -706,9 +706,21 @@ def test_aplanar_formularios_conserva_anotaciones_y_enlaces(tmp_path): doc.close() +def _valores_widgets(path: Path) -> dict: + doc = fitz.open(str(path)) + try: + return { + w.field_name: w.field_value + for w in doc[0].widgets() + } + finally: + doc.close() + + def test_formulario_sin_termino_en_campos_se_conserva(tmp_path, monkeypatch): pdf = _crear_formulario(tmp_path / "form.pdf", termino_en_campos=False) dest = tmp_path / "out" + valores_originales = _valores_widgets(pdf) def no_debe_llamarse(doc): raise AssertionError("bake no debe ejecutarse si ningún campo contiene un término") @@ -722,6 +734,7 @@ def no_debe_llamarse(doc): try: assert out.is_form_pdf assert [w.field_name for w in out[0].widgets()] == ["texto_1", "combo_1", "casilla_1"] + assert _valores_widgets(r.output_path) == valores_originales assert out[0].search_for(TERMINO) == [] finally: out.close() @@ -984,19 +997,42 @@ def test_mensaje_exito(): ) -def test_mensaje_exito_con_palabras_sin_coincidencias_solo_si_es_un_pdf(): - uno = ResumenLote(total=1) - uno.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3, - terminos_sin_coincidencias=2), "a.pdf") - assert uno.mensaje() == ( +def test_mensaje_una_sola_palabra_sin_coincidencias(): + resumen = ResumenLote(total=1) + resumen.agregar(_omitido(terminos_buscados=1, terminos_sin_coincidencias=1), "vacio.pdf") + assert resumen.mensaje() == ( + "No se ha creado ningún archivo. " + "1 PDF(s) sin coincidencias; no se ha creado archivo (vacio.pdf). " + "La palabra no se encontró." + ) + + +def test_mensaje_una_de_varias_palabras_sin_coincidencias(): + resumen = ResumenLote(total=1) + resumen.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3, + terminos_sin_coincidencias=1), "a.pdf") + assert resumen.mensaje() == ( + "Censura completada en 1 de 1 PDF(s): 1 coincidencia(s) tachada(s) en 1 página(s). " + "1 de las 3 palabras no se encontró." + ) + + +def test_mensaje_varias_palabras_sin_coincidencias(): + resumen = ResumenLote(total=1) + resumen.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3, + terminos_sin_coincidencias=2), "a.pdf") + assert resumen.mensaje() == ( "Censura completada en 1 de 1 PDF(s): 1 coincidencia(s) tachada(s) en 1 página(s). " "2 de las 3 palabras no se encontraron." ) + +def test_mensaje_palabras_sin_coincidencias_solo_si_es_un_pdf(): varios = ResumenLote(total=2) varios.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3, terminos_sin_coincidencias=2), "a.pdf") varios.agregar(_ok(coincidencias=1, paginas_afectadas=1, terminos_buscados=3), "b.pdf") + assert "no se encontró" not in varios.mensaje() assert "no se encontraron" not in varios.mensaje() From 52937caba56084568d71f8f78e4c0f97c199b46f Mon Sep 17 00:00:00 2001 From: BenedictoDieciseis Date: Thu, 8 Oct 2026 21:28:02 +0200 Subject: [PATCH 4/4] docs: cerrar sprint 9 y ADR-001 Co-authored-by: Cursor --- DECISIONS.md | 7 +++++++ ROADMAP.md | 57 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 43177b5..d4dcb2b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -8,3 +8,10 @@ Formato por entrada: - Decisión: - Alternativas descartadas: - Consecuencias: + +## ADR-001 — Censura de PDF fail-closed (2026-10-08) + +- Contexto: la censura por palabras fallaba en silencio (PDFs sin capa de texto, cero coincidencias contadas como procesado, metadatos y formularios intactos, sin verificación). +- Decisión: política fail-closed. Un PDF solo se escribe si el resultado, reabierto desde un temporal, supera la verificación; cualquier duda produce error sin archivo. Estados procesado/omitido/error. Lógica en `censura_core` sin UI. Formularios con término: aplanado y redacción. Revalidación de archivos al final del lote. Importes automáticos como opción visible y estricta. Los nombres de archivo solo aparecen en el mensaje visible, nunca en logs. +- Alternativas descartadas: censura sin verificación (fuga silenciosa); OCR automático antes de censurar (diferido: requiere diseño de cadena); umbral de tamaño para imágenes (un DNI pegado es pequeño); vaciar valores de campos (no fiable en PyMuPDF 1.25.1) o resetear todos los campos (pierde datos no sensibles); modificar `ocr_io` para reutilizar helpers (riesgo sobre código validado). +- Consecuencias: más PDFs terminan en error o aviso en lugar de salir «censurados»; el usuario debe aplicar OCR a los escaneados; quedan límites documentados (texto en imágenes, vectores, texto oculto, enlaces eliminados, formularios aplanados); la verificación añade coste de CPU; dependencia de PyMuPDF 1.25.1 con tres bugs sorteados (`scrub` `reset_responses`, `scrub` `attached_files`, `delete_annot`) que deben revisarse al actualizar la librería. diff --git a/ROADMAP.md b/ROADMAP.md index 65c816a..3aeba72 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,7 +346,7 @@ Estado: Cerrada en desarrollo y validada en macOS. * MBOX y EML no aparecen como pestañas. * Las cinco herramientas de CONVERSIÓN están disponibles. * Herramientas MBOX/EML probadas manualmente. -* Suite actual: 395 tests superados y 1 omitido. +* Suite actual: 528 passed, 1 skipped. * Validación visual realizada en macOS. #### Pendiente multiplataforma @@ -448,9 +448,14 @@ Estado: Parcialmente implementada. * Promoción atómica. * Eliminación controlada del flag UF_HIDDEN en macOS. * Conservación de otros flags y atributos extendidos. +* Censura fail-closed con verificación del resultado. +* Limpieza de metadatos en censura. ### Pendiente +* EML→PDF: HTML no confiable (bloqueo de red y JS, timeout, cancelación). +* Censura: texto dentro de imágenes (OCR por página y censura encadenada). +* Censura: importes con variantes de formato (1000,00 / 1.000,00). * Confirmación antes de modificar originales. * Normalización común de entradas. * Gestión completamente centralizada de colisiones. @@ -467,7 +472,7 @@ Estado: En progreso. ### Implementado -* Suite general con 395 tests superados y 1 omitido. +* Suite general con 528 passed, 1 skipped. * Tests del núcleo OCR. * Tests de integración OCR. * Tests de selección múltiple. @@ -631,6 +636,8 @@ Largo plazo. Estado de ubicación: pendiente de decisión de producto. +La censura por palabras ya está disponible (Sprint 9). La anonimización por categorías (DNI, IBAN, teléfonos, direcciones, correos) se apoyaría en el mismo núcleo (`censura_core`) y en su verificación fail-closed. + #### Posibles ubicaciones * Nueva pestaña específica si incorpora revisión visual, reglas y múltiples herramientas. @@ -662,19 +669,20 @@ Largo plazo. ### Alta -1. Validación y empaquetado OCR en Windows. -2. PDF → Markdown. -3. Drag & Drop. +1. Sprint 10 — EML→PDF seguro. +2. Sprint 11 — OCR en Windows. ### Media -1. Añadir iconos a RENOMBRADO y CONVERSIÓN. -2. Agrupar y unir PDFs por patrón. -3. Mejoras del renombrado. -4. Componentes reutilizables. -5. Extracción inteligente de datos. -6. Auditoría de licencias y dependencias. -7. Firma y notarización de macOS. +1. PDF → Markdown. +2. Drag & Drop. +3. Añadir iconos a RENOMBRADO y CONVERSIÓN. +4. Agrupar y unir PDFs por patrón. +5. Mejoras del renombrado. +6. Componentes reutilizables. +7. Extracción inteligente de datos. +8. Auditoría de licencias y dependencias. +9. Firma y notarización de macOS. ### Baja @@ -745,7 +753,7 @@ Estado: Cerrado en desarrollo y validado en macOS. * Tests de presencia de las herramientas migradas. * Tests de unicidad del registro. * Tests de executor correcto. -* Suite completa: 395 passed, 1 skipped. +* Suite completa: 528 passed, 1 skipped. * Imports validados. * Ausencia de referencias UI obsoletas verificada. * Arranque correcto. @@ -776,8 +784,27 @@ sin pérdida funcional, con las herramientas MBOX y EML integradas en CONVERSIÓ * Validación de la nueva navegación en Windows. * Añadir iconos a RENOMBRADO y CONVERSIÓN. +## SPRINT 9 — CENSURA FAIL-CLOSED ✅ + +Estado: Cerrado en desarrollo y validado en macOS. + +* Núcleo `scripts/pdf/censura_core.py` (sin Tkinter); el script queda como diálogo, `run` y `build_result`. +* Estados por PDF: procesado / omitido (sin coincidencias, sin archivo) / error (sin archivo). +* Página seleccionada sin texto y con imágenes: error «imágenes escaneadas sin texto» (aplicar OCR antes). +* Verificación del resultado reabriendo un temporal antes de promoverlo con `os.replace`: texto de página (`search_for` y `get_text` normalizado NFKC/casefold/guiones), metadatos Info/XMP, adjuntos, outline, anotaciones, campos de formulario (nombre, valor, etiqueta y opciones), enlaces y destinos con nombre. +* Saneado de outline y anotaciones; `scrub` con parámetros explícitos. +* Campos de formulario: si algún campo de las páginas seleccionadas contiene un término, se aplanan los campos del documento (`bake`) y se tacha el valor como texto de página; si no, el formulario se conserva con sus valores. +* Revalidación final del lote: un procesado sin archivo físico pasa a error. +* Regla «Siendo X € lo pagado» (15 %/85 %) como casilla visible, activada por defecto; importe ilegible = error. +* Mensajes en lenguaje llano con recuento de coincidencias y páginas, causas de error traducidas y nombres de PDFs fallidos (solo en el mensaje visible; los logs no contienen términos, nombres ni rutas). +* Tests: suite 528 passed, 1 skipped. +* Límites conocidos: no se censura texto dentro de imágenes (el resumen avisa del nº de páginas con imágenes); texto convertido en trazados vectoriales solo avisa; texto oculto sin término no se garantiza eliminado; los enlaces se eliminan, también los internos; el aplanado de formularios afecta a todo el documento; un término presente solo en el nombre u opciones de un campo y no en texto visible produce «omitido». + ## SIGUIENTE SPRINT RECOMENDADO -### SPRINT 9 — OCR EN WINDOWS +1. **Sprint 10 — EML→PDF seguro** (HTML no confiable, bloqueo de red y JS, timeout, cancelación). +2. **Sprint 11 — OCR en Windows** (antes planificado como Sprint 9). +3. **Sprint 12 — Separación UI/lógica en herramientas pesadas** (extraer, rotar, optimizar, unir, limpiar numeración, renombrar PDFs por índice; no `ocr_pdf` ni MBOX). +4. **Sprint 13+** — release gate (auditoría de licencias, incluida PyMuPDF AGPL frente a la licencia propietaria; firma y notarización), componentes comunes, Drag & Drop, PDF→Markdown, iconos. -Lo mantendría como siguiente sprint porque es actualmente el mayor bloqueo técnico para que DocFlow pueda avanzar hacia una distribución verdaderamente multiplataforma. Los iconos de RENOMBRADO y CONVERSIÓN los trataría como un ajuste UX pequeño, posiblemente antes o después del Sprint 9, pero no retrasaría Windows por ellos. +Los fallos de privacidad se corrigen antes de ampliar la distribución.