#!/usr/bin/env python3
"""
Anonimizador local de documentos jurídicos (Colombia) — v7
==========================================================
Detección automática de datos personales + revisión antes de guardar.
El dato real nunca sale de tu máquina.
A) Interfaz gráfica: python anonimizador.py
B) Línea de comandos: python anonimizador.py doc1.docx --salida C:/Salida/
ENTRADA: .docx · .txt · .md · .pdf (texto seleccionable, no escaneados)
SALIDA: mismo formato + _ANONIMIZADO y _EQUIVALENCIAS.csv (no subir)
"""
import csv
import json
import os
import re
import shutil
import sys
import tempfile
import zipfile
from copy import deepcopy
from pathlib import Path
from xml.sax.saxutils import escape, unescape
from docx.oxml.ns import qn
from motor_anonimizacion import Hallazgo, MotorAnonimizacion
from procesar_pdf import extraer_parrafos_pdf, extraer_texto_pdf, procesar_pdf
from rutas_app import cargar_preferencias, guardar_preferencias, ruta_reemplazos
EXTENSIONES_SOPORTADAS = {".docx", ".txt", ".md", ".pdf"}
FILTRO_ARCHIVOS = [
("Documentos", "*.docx *.txt *.md *.pdf"),
("Word", "*.docx"),
("PDF", "*.pdf"),
("Texto", "*.txt *.md"),
("Todos", "*.*"),
]
ARCHIVO_REEMPLAZOS = ruta_reemplazos()
REEMPLAZOS_POR_DEFECTO: dict = {}
ADVERTENCIA = (
"Revisa el documento anonimizado antes de subirlo. Aunque el sistema detecta "
"automáticamente nombres, cédulas y empresas, la revisión final es criterio del abogado."
)
def cargar_reemplazos() -> dict:
if ARCHIVO_REEMPLAZOS.exists():
try:
return json.loads(ARCHIVO_REEMPLAZOS.read_text(encoding="utf-8"))
except Exception:
pass
return dict(REEMPLAZOS_POR_DEFECTO)
def guardar_reemplazos(reemplazos: dict):
ARCHIVO_REEMPLAZOS.write_text(
json.dumps(reemplazos, ensure_ascii=False, indent=2), encoding="utf-8")
def _extraer_texto_contenedor(contenedor) -> list[str]:
partes = []
for p in contenedor.paragraphs:
if p.text.strip():
partes.append(p.text)
for tabla in contenedor.tables:
for fila in tabla.rows:
for celda in fila.cells:
partes.extend(_extraer_texto_contenedor(celda))
return partes
def extraer_texto_docx(ruta: Path) -> str:
from docx import Document
doc = Document(ruta)
partes = _extraer_texto_contenedor(doc)
for seccion in doc.sections:
for parte in (seccion.header, seccion.footer,
seccion.first_page_header, seccion.first_page_footer,
seccion.even_page_header, seccion.even_page_footer):
if parte is not None:
partes.extend(_extraer_texto_contenedor(parte))
return "\n".join(partes)
def es_formato_soportado(ruta: Path) -> bool:
return ruta.suffix.lower() in EXTENSIONES_SOPORTADAS
def recolectar_rutas(entradas: list[str]) -> list[Path]:
rutas: list[Path] = []
for entrada in entradas:
p = Path(entrada)
if not p.exists():
raise FileNotFoundError(f"No existe: {p}")
if p.is_dir():
for f in sorted(p.iterdir()):
if f.is_file() and es_formato_soportado(f):
rutas.append(f.resolve())
elif es_formato_soportado(p):
rutas.append(p.resolve())
else:
raise ValueError(f"Formato no soportado: {p.name}")
return rutas
def extraer_parrafos(ruta: Path) -> list[str]:
suf = ruta.suffix.lower()
if suf == ".docx":
from docx import Document
doc = Document(ruta)
return [p.text for p in doc.paragraphs if p.text.strip()]
if suf == ".pdf":
return extraer_parrafos_pdf(ruta)
return [ln for ln in ruta.read_text(encoding="utf-8", errors="ignore").splitlines() if ln.strip()]
def extraer_texto(ruta: Path) -> str:
suf = ruta.suffix.lower()
if suf == ".docx":
return extraer_texto_docx(ruta)
if suf == ".pdf":
return extraer_texto_pdf(ruta)
return ruta.read_text(encoding="utf-8", errors="ignore")
def _copiar_rpr(run) -> object | None:
rpr = run._element.find(qn("w:rPr"))
return deepcopy(rpr) if rpr is not None else None
def _aplicar_rpr(run, rpr) -> None:
if rpr is None:
return
existente = run._element.find(qn("w:rPr"))
if existente is not None:
run._element.remove(existente)
run._element.insert(0, deepcopy(rpr))
def _mapear_caracteres_runs(p) -> list[int]:
"""Para cada carácter del párrafo, índice del run que lo contiene."""
mapa: list[int] = []
for i, run in enumerate(p.runs):
mapa.extend([i] * len(run.text))
return mapa
def _segmentos_con_formato(texto: str, mapa: list[int],
spans: list[tuple[int, int, str, str]]) -> list[tuple[str, int]]:
if not spans:
return [(texto, mapa[0] if mapa else 0)]
segmentos: list[tuple[str, int]] = []
pos = 0
for inicio, fin, reemplazo, _ in sorted(spans, key=lambda s: s[0]):
if pos < inicio:
fmt = mapa[pos] if pos < len(mapa) else (mapa[-1] if mapa else 0)
segmentos.append((texto[pos:inicio], fmt))
fmt_rep = mapa[inicio] if inicio < len(mapa) else (mapa[-1] if mapa else 0)
segmentos.append((reemplazo, fmt_rep))
pos = fin
if pos < len(texto):
fmt = mapa[pos] if pos < len(mapa) else (mapa[-1] if mapa else 0)
segmentos.append((texto[pos:], fmt))
return segmentos
def _fusionar_segmentos(segmentos: list[tuple[str, int]]) -> list[tuple[str, int]]:
if not segmentos:
return []
fusionados = [segmentos[0]]
for texto, fmt in segmentos[1:]:
if not texto:
continue
if fmt == fusionados[-1][1]:
fusionados[-1] = (fusionados[-1][0] + texto, fmt)
else:
fusionados.append((texto, fmt))
return fusionados
def _eliminar_runs(p) -> None:
for run in list(p.runs):
run._element.getparent().remove(run._element)
def _aplicar_a_parrafo(p, motor: MotorAnonimizacion, hallazgos: list[Hallazgo],
registro: dict):
"""Aplica reemplazos conservando el formato (subrayado, negrita, etc.) de cada run."""
texto_original = p.text
if not texto_original:
return
spans = motor._recolectar_spans(texto_original, hallazgos)
if not spans:
return
mapa = _mapear_caracteres_runs(p)
rpr_copias = [_copiar_rpr(run) for run in p.runs]
segmentos = _fusionar_segmentos(_segmentos_con_formato(texto_original, mapa, spans))
_eliminar_runs(p)
for texto, fmt_idx in segmentos:
if not texto:
continue
nuevo_run = p.add_run(texto)
if rpr_copias and fmt_idx < len(rpr_copias):
_aplicar_rpr(nuevo_run, rpr_copias[fmt_idx])
for _, _, reemplazo, original in spans:
registro[original] = reemplazo
def _procesar_contenedor(contenedor, motor: MotorAnonimizacion,
hallazgos: list[Hallazgo], registro: dict):
for p in contenedor.paragraphs:
_aplicar_a_parrafo(p, motor, hallazgos, registro)
for tabla in contenedor.tables:
for fila in tabla.rows:
for celda in fila.cells:
_procesar_contenedor(celda, motor, hallazgos, registro)
def _anonimizar_xml_wt(xml_texto: str, motor: MotorAnonimizacion,
hallazgos: list[Hallazgo], registro: dict) -> str:
def _sub(m):
contenido = unescape(m.group(2))
nuevo = motor.aplicar(contenido, hallazgos, registro)
return m.group(1) + escape(nuevo) + m.group(3)
return re.sub(r"(]*>)(.*?)()", _sub, xml_texto, flags=re.DOTALL)
def _postprocesar_zip(ruta_docx: Path, motor: MotorAnonimizacion,
hallazgos: list[Hallazgo], registro: dict):
objetivos = {"word/footnotes.xml", "word/endnotes.xml", "word/comments.xml"}
fd, tmp_nombre = tempfile.mkstemp(suffix=".docx")
tmp = Path(tmp_nombre)
os.close(fd)
with zipfile.ZipFile(ruta_docx, "r") as zin, \
zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
datos = zin.read(item.filename)
if item.filename in objetivos:
xml = datos.decode("utf-8")
datos = _anonimizar_xml_wt(xml, motor, hallazgos, registro).encode("utf-8")
zout.writestr(item, datos)
shutil.move(str(tmp), str(ruta_docx))
def _carpeta_destino(ruta: Path, carpeta_salida: Path | None) -> Path:
destino = carpeta_salida if carpeta_salida else ruta.parent
destino.mkdir(parents=True, exist_ok=True)
return destino
def procesar_docx(ruta: Path, motor: MotorAnonimizacion,
hallazgos: list[Hallazgo], registro: dict,
carpeta_salida: Path | None = None) -> Path:
from docx import Document
doc = Document(ruta)
_procesar_contenedor(doc, motor, hallazgos, registro)
for seccion in doc.sections:
for parte in (seccion.header, seccion.footer,
seccion.first_page_header, seccion.first_page_footer,
seccion.even_page_header, seccion.even_page_footer):
if parte is not None:
_procesar_contenedor(parte, motor, hallazgos, registro)
cp = doc.core_properties
cp.author = cp.last_modified_by = cp.title = cp.subject = ""
cp.comments = cp.keywords = cp.category = ""
salida = _carpeta_destino(ruta, carpeta_salida) / f"{ruta.stem}_ANONIMIZADO.docx"
doc.save(salida)
_postprocesar_zip(salida, motor, hallazgos, registro)
return salida
def procesar_txt(ruta: Path, motor: MotorAnonimizacion,
hallazgos: list[Hallazgo], registro: dict,
carpeta_salida: Path | None = None) -> Path:
texto = ruta.read_text(encoding="utf-8", errors="ignore")
salida = _carpeta_destino(ruta, carpeta_salida) / f"{ruta.stem}_ANONIMIZADO{ruta.suffix}"
salida.write_text(motor.aplicar(texto, hallazgos, registro), encoding="utf-8")
return salida
def guardar_equivalencias(ruta: Path, registro: dict,
carpeta_salida: Path | None = None) -> Path:
tabla = _carpeta_destino(ruta, carpeta_salida) / f"{ruta.stem}_EQUIVALENCIAS.csv"
with open(tabla, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["Dato real", "Reemplazo", "Tipo"])
for real, rol in sorted(registro.items(), key=lambda x: x[0].lower()):
w.writerow([real, rol, ""])
return tabla
def procesar(ruta: Path, hallazgos: list[Hallazgo],
carpeta_salida: Path | None = None):
motor = MotorAnonimizacion()
registro: dict[str, str] = {}
suf = ruta.suffix.lower()
if suf == ".docx":
salida = procesar_docx(ruta, motor, hallazgos, registro, carpeta_salida)
elif suf in (".txt", ".md"):
salida = procesar_txt(ruta, motor, hallazgos, registro, carpeta_salida)
elif suf == ".pdf":
salida = procesar_pdf(ruta, motor, hallazgos, registro, carpeta_salida)
else:
raise ValueError("Formato no soportado. Usa .docx, .txt, .md o .pdf")
tabla = guardar_equivalencias(ruta, registro, carpeta_salida)
parrafos_salida = extraer_parrafos(salida)
fugas = motor.verificar_resultado(parrafos_salida, hallazgos)
return salida, tabla, len(registro), fugas
def _parsear_manual(texto_caja: str) -> dict:
nuevos = {}
for linea in texto_caja.splitlines():
linea = linea.strip()
if not linea or linea.startswith("#"):
continue
if "=>" in linea:
real, rol = linea.split("=>", 1)
if real.strip() and rol.strip():
nuevos[real.strip()] = rol.strip()
return nuevos
def _crear_tabla_revision(padre, items: list[Hallazgo], titulo_cols: tuple,
valores_defecto: bool):
import tkinter as tk
from tkinter import ttk
marco = tk.Frame(padre, bg="#111111")
marco.pack(fill="both", expand=True)
canvas = tk.Canvas(marco, bg="#1d1d1d", highlightthickness=0)
scroll = ttk.Scrollbar(marco, orient="vertical", command=canvas.yview)
interior = tk.Frame(canvas, bg="#1d1d1d")
interior.bind("",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=interior, anchor="nw")
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
vars_check: list[tk.BooleanVar] = []
colores_tipo = {
"PERSONA": "#7ec8ff", "EMPRESA": "#ffb347", "LUGAR": "#b19cd9",
"CÉDULA": "#ff6b6b", "NIT": "#ff6b6b", "CORREO": "#ff6b6b",
"TELÉFONO": "#ff6b6b", "DIRECCIÓN": "#ff6b6b", "RADICADO": "#aaaaaa",
"MANUAL": "#9be29b", "REVISAR": "#ffcc66",
}
encabezado = tk.Frame(interior, bg="#2a2a2a")
encabezado.pack(fill="x", pady=(0, 4))
for i, (txt, w) in enumerate(titulo_cols):
tk.Label(encabezado, text=txt, bg="#2a2a2a", fg="#cccccc",
font=("Segoe UI", 9, "bold"), width=w, anchor="w"
).grid(row=0, column=i, padx=4, pady=4, sticky="w")
for h in items:
fila = tk.Frame(interior, bg="#1d1d1d")
fila.pack(fill="x", pady=1)
var = tk.BooleanVar(value=valores_defecto if h.tipo == "REVISAR" else h.activo)
vars_check.append(var)
color = colores_tipo.get(h.tipo, "#f5f5f5")
tk.Checkbutton(fila, variable=var, bg="#1d1d1d", activebackground="#1d1d1d",
selectcolor="#333333").grid(row=0, column=0, padx=4)
tk.Label(fila, text=h.texto[:45] + ("…" if len(h.texto) > 45 else ""),
bg="#1d1d1d", fg="#f5f5f5", font=("Consolas", 9),
width=30, anchor="w").grid(row=0, column=1, sticky="w")
tk.Label(fila, text=h.reemplazo, bg="#1d1d1d", fg="#9be29b",
font=("Consolas", 9), width=18, anchor="w").grid(row=0, column=2, sticky="w")
tk.Label(fila, text=h.tipo, bg="#1d1d1d", fg=color,
font=("Segoe UI", 8), width=10, anchor="w").grid(row=0, column=3, sticky="w")
tk.Label(fila, text=h.fuente, bg="#1d1d1d", fg="#888888",
font=("Segoe UI", 8), width=10, anchor="w").grid(row=0, column=4, sticky="w")
conf_color = {"alta": "#9be29b", "media": "#ffcc66", "baja": "#ff6b6b"}.get(h.confianza, "#888")
tk.Label(fila, text=h.confianza, bg="#1d1d1d", fg=conf_color,
font=("Segoe UI", 8), width=8, anchor="w").grid(row=0, column=5, sticky="w")
return vars_check
def mostrar_revision(raiz, hallazgos: list[Hallazgo], texto: str,
motor: MotorAnonimizacion,
parrafos: list[str] | None = None) -> list[Hallazgo] | None:
import tkinter as tk
from tkinter import ttk
resultado: list[Hallazgo] | None = None
bloques = parrafos if parrafos else texto.split("\n")
pendientes = motor.detectar_pendientes(texto, hallazgos, parrafos=bloques)
ventana = tk.Toplevel(raiz)
ventana.title("Revisión — confirma qué anonimizar")
ventana.geometry("780x560")
ventana.configure(bg="#111111")
ventana.transient(raiz)
ventana.grab_set()
tk.Label(
ventana,
text=(f"Detectados: {len(hallazgos)} · Pendientes por revisar: {len(pendientes)}. "
"Revisa la pestaña «Pendientes» antes de confirmar."),
bg="#111111", fg="#f5f5f5", font=("Segoe UI", 10), wraplength=720,
).pack(pady=(12, 8), padx=16)
notebook = ttk.Notebook(ventana)
notebook.pack(fill="both", expand=True, padx=16, pady=4)
tab_detectados = tk.Frame(notebook, bg="#111111")
tab_pendientes = tk.Frame(notebook, bg="#111111")
notebook.add(tab_detectados, text=f"Detectados ({len(hallazgos)})")
notebook.add(tab_pendientes, text=f"Pendientes ({len(pendientes)})")
cols = [("✓", 3), ("Dato", 30), ("Reemplazo", 18), ("Tipo", 10), ("Fuente", 10), ("Conf.", 8)]
vars_hallazgos = _crear_tabla_revision(tab_detectados, hallazgos, cols, True)
vars_pendientes = _crear_tabla_revision(tab_pendientes, pendientes, cols, False) if pendientes else []
if not pendientes:
tk.Label(tab_pendientes, text="No quedaron números ni nombres sospechosos sin anonimizar.",
bg="#111111", fg="#9be29b", font=("Segoe UI", 10)).pack(pady=40)
def confirmar():
nonlocal resultado
for h, var in zip(hallazgos, vars_hallazgos):
h.activo = var.get()
extras: list[Hallazgo] = []
for h, var in zip(pendientes, vars_pendientes):
if var.get():
h.activo = True
h.tipo = "MANUAL"
h.fuente = "revisión"
h.patron = re.compile(r"(? list[dict] | None:
import tkinter as tk
from tkinter import ttk
resultado: list[dict] | None = None
total_h = sum(len(d["hallazgos"]) for d in documentos)
total_p = sum(
len(d["motor"].detectar_pendientes(d["texto"], d["hallazgos"], parrafos=d["parrafos"]))
for d in documentos
)
ventana = tk.Toplevel(raiz)
ventana.title("Revisión por documento")
ventana.geometry("820x600")
ventana.configure(bg="#111111")
ventana.transient(raiz)
ventana.grab_set()
tk.Label(
ventana,
text=(f"{len(documentos)} documento(s) · {total_h} detectados · "
f"{total_p} pendientes. Revisa cada pestaña antes de confirmar."),
bg="#111111", fg="#f5f5f5", font=("Segoe UI", 10), wraplength=760,
).pack(pady=(12, 8), padx=16)
notebook = ttk.Notebook(ventana)
notebook.pack(fill="both", expand=True, padx=16, pady=4)
estados: list[tuple] = []
for doc in documentos:
pendientes = doc["motor"].detectar_pendientes(
doc["texto"], doc["hallazgos"], parrafos=doc["parrafos"])
tab = tk.Frame(notebook, bg="#111111")
nombre = doc["ruta"].name
if len(nombre) > 28:
nombre = nombre[:25] + "…"
notebook.add(tab, text=f"{nombre} ({len(doc['hallazgos'])})")
vars_h, vars_p, pend = _pestaña_revision_doc(tab, doc["hallazgos"], pendientes)
estados.append((doc, vars_h, vars_p, pend))
def confirmar():
nonlocal resultado
salida: list[dict] = []
for doc, vars_h, vars_p, pendientes in estados:
hallazgos = doc["hallazgos"]
for h, var in zip(hallazgos, vars_h):
h.activo = var.get()
extras: list[Hallazgo] = []
for h, var in zip(pendientes, vars_p):
if var.get():
h.activo = True
h.tipo = "MANUAL"
h.fuente = "revisión"
h.patron = re.compile(
r"(? Path:
prefs = cargar_preferencias()
if prefs.get("modo_salida") == "carpeta":
guardada = prefs.get("carpeta_salida", "").strip()
if guardada:
p = Path(guardada)
if p.is_dir():
return p.resolve()
if archivos:
padres = {p.parent.resolve() for p in archivos}
if len(padres) == 1:
return next(iter(padres))
return archivos[0].parent.resolve()
return Path.home() / "Documents"
def mostrar_dialogo_carpeta_salida(raiz, archivos: list[Path]) -> tuple[bool, Path | None]:
"""(True, None) junto al original · (True, Path) carpeta única · (False, None) cancelado."""
import tkinter as tk
from tkinter import filedialog, messagebox
prefs = cargar_preferencias()
modo_inicial = prefs.get("modo_salida", "junto")
carpeta_inicial = _carpeta_sugerida(archivos)
ventana = tk.Toplevel(raiz)
ventana.title("¿Dónde guardar los archivos?")
ventana.geometry("560x280")
ventana.configure(bg="#111111")
ventana.transient(raiz)
ventana.grab_set()
resultado: dict = {"ok": False, "carpeta": None}
tk.Label(
ventana,
text="Elige la carpeta de destino antes de generar los documentos anonimizados.",
bg="#111111", fg="#f5f5f5", font=("Segoe UI", 10), wraplength=500,
).pack(pady=(16, 12), padx=20)
marco = tk.Frame(ventana, bg="#111111")
marco.pack(fill="x", padx=20)
var_modo = tk.StringVar(value=modo_inicial)
tk.Radiobutton(
marco, text="Junto a cada archivo original", variable=var_modo, value="junto",
bg="#111111", fg="#f5f5f5", selectcolor="#333333", activebackground="#111111",
font=("Segoe UI", 10),
).pack(anchor="w", pady=2)
tk.Radiobutton(
marco, text="Carpeta única para todos:", variable=var_modo, value="carpeta",
bg="#111111", fg="#f5f5f5", selectcolor="#333333", activebackground="#111111",
font=("Segoe UI", 10),
).pack(anchor="w", pady=(8, 4))
fila_ruta = tk.Frame(marco, bg="#111111")
fila_ruta.pack(fill="x", pady=4)
entrada = tk.Entry(fila_ruta, font=("Consolas", 9), bg="#1d1d1d", fg="#f5f5f5",
insertbackground="#ffffff", relief="flat")
entrada.insert(0, str(carpeta_inicial))
entrada.pack(side="left", fill="x", expand=True, ipady=4)
def examinar():
elegida = filedialog.askdirectory(
title="Carpeta de salida",
initialdir=entrada.get().strip() or str(Path.home()),
)
if elegida:
entrada.delete(0, "end")
entrada.insert(0, elegida)
tk.Button(fila_ruta, text="Examinar…", command=examinar,
bg="#333333", fg="#f5f5f5", font=("Segoe UI", 9),
relief="flat", padx=10, pady=4).pack(side="left", padx=(8, 0))
var_recordar = tk.BooleanVar(value=bool(prefs.get("modo_salida")))
tk.Checkbutton(
ventana, text="Usar esta opción como predeterminada",
variable=var_recordar, bg="#111111", fg="#aaaaaa", selectcolor="#333333",
activebackground="#111111", font=("Segoe UI", 9),
).pack(anchor="w", padx=20, pady=(12, 0))
def confirmar():
if var_modo.get() == "carpeta":
texto = entrada.get().strip()
if not texto:
messagebox.showwarning("Falta carpeta", "Indica o selecciona una carpeta.")
return
destino = Path(texto)
try:
destino.mkdir(parents=True, exist_ok=True)
except OSError as e:
messagebox.showerror("Carpeta no válida", str(e))
return
if not destino.is_dir():
messagebox.showwarning("Carpeta no válida", "La ruta no es una carpeta.")
return
resultado["carpeta"] = destino.resolve()
else:
resultado["carpeta"] = None
if var_recordar.get():
if var_modo.get() == "carpeta":
guardar_preferencias({
"modo_salida": "carpeta",
"carpeta_salida": str(resultado["carpeta"]),
})
else:
guardar_preferencias({"modo_salida": "junto", "carpeta_salida": ""})
resultado["ok"] = True
ventana.destroy()
def cancelar():
ventana.destroy()
marco_btn = tk.Frame(ventana, bg="#111111")
marco_btn.pack(pady=20)
tk.Button(marco_btn, text="Continuar y anonimizar", command=confirmar,
bg="#ffffff", fg="#111111", font=("Segoe UI", 10, "bold"),
relief="flat", padx=16, pady=6).pack(side="left", padx=8)
tk.Button(marco_btn, text="Cancelar", command=cancelar,
bg="#333333", fg="#f5f5f5", font=("Segoe UI", 10),
relief="flat", padx=16, pady=6).pack(side="left", padx=8)
ventana.wait_window()
return resultado["ok"], resultado["carpeta"]
def _texto_salida_predeterminada() -> str:
prefs = cargar_preferencias()
if prefs.get("modo_salida") == "carpeta":
carpeta = prefs.get("carpeta_salida", "").strip()
if carpeta:
return f"Salida predeterminada: {carpeta}"
return "Salida predeterminada: junto a cada archivo original"
def lanzar_gui():
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext
reemplazos = cargar_reemplazos()
archivos_sel: list[Path] = []
analisis_docs: list[dict] = []
raiz = tk.Tk()
raiz.title("Anonimizador de Expedientes — Trifuerza v7")
raiz.geometry("720x660")
raiz.configure(bg="#111111")
estilo = {"bg": "#111111", "fg": "#f5f5f5", "font": ("Segoe UI", 10)}
tk.Label(raiz, text="ANONIMIZADOR DE EXPEDIENTES",
font=("Segoe UI", 14, "bold"), bg="#111111", fg="#ffffff"
).pack(pady=(16, 2))
tk.Label(raiz, text="Varios archivos · .docx .txt .md .pdf · Revisión local",
**estilo).pack(pady=(0, 12))
marco_archivo = tk.Frame(raiz, bg="#111111")
marco_archivo.pack(fill="x", padx=20)
etiqueta_archivo = tk.Label(marco_archivo, text="Ningún archivo seleccionado",
anchor="w", **estilo)
def _actualizar_etiqueta():
if not archivos_sel:
etiqueta_archivo.config(text="Ningún archivo seleccionado")
elif len(archivos_sel) == 1:
etiqueta_archivo.config(text=archivos_sel[0].name)
else:
nombres = ", ".join(p.name for p in archivos_sel[:3])
if len(archivos_sel) > 3:
nombres += f" … (+{len(archivos_sel) - 3})"
etiqueta_archivo.config(text=f"{len(archivos_sel)} archivos: {nombres}")
def seleccionar():
rutas = filedialog.askopenfilenames(
title="Selecciona uno o varios documentos",
filetypes=FILTRO_ARCHIVOS)
if rutas:
archivos_sel.clear()
analisis_docs.clear()
for r in rutas:
p = Path(r)
if es_formato_soportado(p):
archivos_sel.append(p)
_actualizar_etiqueta()
log(f"{len(archivos_sel)} archivo(s) seleccionado(s). Pulsa ANALIZAR.")
def seleccionar_carpeta():
carpeta = filedialog.askdirectory(title="Selecciona una carpeta con documentos")
if carpeta:
try:
encontrados = recolectar_rutas([carpeta])
except ValueError as e:
messagebox.showwarning("Sin archivos válidos", str(e))
return
if not encontrados:
messagebox.showwarning("Carpeta vacía",
"No hay .docx, .txt, .md ni .pdf en esa carpeta.")
return
archivos_sel.clear()
analisis_docs.clear()
archivos_sel.extend(encontrados)
_actualizar_etiqueta()
log(f"{len(archivos_sel)} archivo(s) en carpeta. Pulsa ANALIZAR.")
tk.Button(marco_archivo, text="Seleccionar archivos…", command=seleccionar,
bg="#ffffff", fg="#111111", font=("Segoe UI", 10, "bold"),
relief="flat", padx=12, pady=4).pack(side="left")
tk.Button(marco_archivo, text="Carpeta…", command=seleccionar_carpeta,
bg="#333333", fg="#f5f5f5", font=("Segoe UI", 10),
relief="flat", padx=10, pady=4).pack(side="left", padx=(6, 0))
etiqueta_archivo.pack(side="left", padx=12)
etiqueta_salida = tk.Label(raiz, text=_texto_salida_predeterminada(),
anchor="w", fg="#888888", bg="#111111",
font=("Segoe UI", 9))
etiqueta_salida.pack(fill="x", padx=20, pady=(4, 0))
tk.Label(raiz, text="Reemplazos adicionales (opcional, formato: Nombre => rol):",
anchor="w", **estilo).pack(fill="x", padx=20, pady=(12, 2))
tk.Label(raiz,
text="Cédulas, NIT, nombres y empresas se detectan solos. "
"Cortes, leyes y juzgados NO se anonimizan.",
anchor="w", fg="#aaaaaa", bg="#111111", font=("Segoe UI", 9)
).pack(fill="x", padx=20, pady=(0, 4))
caja = scrolledtext.ScrolledText(raiz, height=5, font=("Consolas", 10),
bg="#1d1d1d", fg="#f5f5f5",
insertbackground="#ffffff", relief="flat")
caja.pack(fill="both", expand=False, padx=20)
if reemplazos:
caja.insert("1.0", "\n".join(f"{k} => {v}" for k, v in reemplazos.items()))
salida_log = scrolledtext.ScrolledText(raiz, height=10, font=("Consolas", 9),
bg="#1d1d1d", fg="#9be29b",
relief="flat", state="disabled")
def log(msg):
salida_log.config(state="normal")
salida_log.insert("end", msg + "\n")
salida_log.config(state="disabled")
salida_log.see("end")
def analizar():
if not archivos_sel:
messagebox.showwarning("Faltan archivos", "Primero selecciona documento(s) o carpeta.")
return
manual = _parsear_manual(caja.get("1.0", "end"))
guardar_reemplazos(manual)
analisis_docs.clear()
errores: list[str] = []
try:
for ruta in archivos_sel:
try:
texto = extraer_texto(ruta)
parrafos = extraer_parrafos(ruta)
motor = MotorAnonimizacion(reemplazos_manual=manual)
hallazgos = motor.analizar(texto)
analisis_docs.append({
"ruta": ruta,
"texto": texto,
"parrafos": parrafos,
"motor": motor,
"hallazgos": hallazgos,
})
log(f"✔ {ruta.name}: {len(hallazgos)} hallazgo(s)")
except Exception as e:
errores.append(f"{ruta.name}: {e}")
log(f"✗ {ruta.name}: {e}")
if not analisis_docs:
messagebox.showerror("Error", "\n".join(errores) or "No se pudo analizar.")
return
total = sum(len(d["hallazgos"]) for d in analisis_docs)
log(f"Análisis listo: {len(analisis_docs)} doc(s), {total} hallazgo(s) en total.")
if errores:
log(f"⚠ {len(errores)} archivo(s) con error (omitidos).")
log("Pulsa ANONIMIZAR para revisar y confirmar.")
except ImportError as e:
messagebox.showerror("Falta una librería", str(e))
def anonimizar():
if not archivos_sel:
messagebox.showwarning("Faltan archivos", "Primero selecciona documento(s) o carpeta.")
return
if not analisis_docs:
analizar()
if not analisis_docs:
return
if len(analisis_docs) == 1:
doc = analisis_docs[0]
confirmados = mostrar_revision(
raiz, list(doc["hallazgos"]), doc["texto"], doc["motor"],
parrafos=doc["parrafos"])
docs_ok = [{**doc, "hallazgos": confirmados}] if confirmados else None
else:
docs_ok = mostrar_revision_multiples(raiz, analisis_docs)
if not docs_ok:
log("Anonimización cancelada.")
return
ok_salida, carpeta_dest = mostrar_dialogo_carpeta_salida(
raiz, [doc["ruta"] for doc in docs_ok])
if not ok_salida:
log("Anonimización cancelada (sin carpeta de salida).")
return
etiqueta_salida.config(text=_texto_salida_predeterminada())
if carpeta_dest:
log(f"Guardando en: {carpeta_dest}")
else:
log("Guardando junto a cada archivo original.")
procesados = 0
total_fugas = 0
carpeta_mostrar = carpeta_dest
try:
for doc in docs_ok:
activos = [h for h in doc["hallazgos"] if h.activo]
if not activos:
log(f"⊘ {doc['ruta'].name}: sin datos marcados, omitido.")
continue
salida, tabla, n, fugas = procesar(
doc["ruta"], doc["hallazgos"], carpeta_salida=carpeta_dest)
procesados += 1
total_fugas += len(fugas)
if carpeta_mostrar is None:
carpeta_mostrar = salida.parent
log(f"✔ {salida} ({n} reemplazos)")
if fugas:
for f in fugas[:3]:
log(f" ⚠ fuga: {f.texto}")
if procesados == 0:
messagebox.showwarning("Sin cambios", "Ningún documento tenía datos marcados.")
return
log(f"⚠ {ADVERTENCIA}")
msg = f"{procesados} documento(s) anonimizado(s)."
if carpeta_dest:
msg += f"\n\nCarpeta:\n{carpeta_dest}"
if total_fugas:
msg += f"\n\n⚠ Quedaron {total_fugas} dato(s) visibles en total."
messagebox.showinfo("Listo", msg)
except Exception as e:
messagebox.showerror("Error", str(e))
marco_btn = tk.Frame(raiz, bg="#111111")
marco_btn.pack(pady=10)
tk.Button(marco_btn, text="ANALIZAR", command=analizar,
bg="#333333", fg="#f5f5f5", font=("Segoe UI", 10, "bold"),
relief="flat", padx=20, pady=6).pack(side="left", padx=6)
tk.Button(marco_btn, text="ANONIMIZAR", command=anonimizar,
bg="#ffffff", fg="#111111", font=("Segoe UI", 11, "bold"),
relief="flat", padx=24, pady=8).pack(side="left", padx=6)
salida_log.pack(fill="both", expand=True, padx=20, pady=(0, 16))
log("Selecciona archivos o una carpeta y pulsa ANALIZAR.")
raiz.mainloop()
def _parsear_args_cli(argv: list[str]) -> tuple[list[str], Path | None]:
args = list(argv)
carpeta: Path | None = None
i = 0
while i < len(args):
if args[i] == "--salida":
if i + 1 >= len(args):
print("Error: --salida requiere una ruta de carpeta.")
sys.exit(1)
carpeta = Path(args[i + 1])
del args[i:i + 2]
continue
i += 1
return args, carpeta
def _resolver_carpeta_salida_cli(carpeta_arg: Path | None) -> Path | None:
if carpeta_arg is not None:
carpeta_arg.mkdir(parents=True, exist_ok=True)
if not carpeta_arg.is_dir():
print(f"Error: --salida no es una carpeta válida: {carpeta_arg}")
sys.exit(1)
return carpeta_arg.resolve()
prefs = cargar_preferencias()
if prefs.get("modo_salida") == "carpeta":
guardada = prefs.get("carpeta_salida", "").strip()
if guardada:
p = Path(guardada)
if p.is_dir():
return p.resolve()
return None
def main():
if len(sys.argv) >= 2:
entradas, carpeta_arg = _parsear_args_cli(sys.argv[1:])
if not entradas:
print("Uso: python anonimizador.py archivo.docx [más archivos o carpeta/] [--salida carpeta/]")
sys.exit(1)
try:
rutas = recolectar_rutas(entradas)
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
if not rutas:
print("No hay archivos .docx, .txt, .md ni .pdf para procesar.")
sys.exit(1)
carpeta_salida = _resolver_carpeta_salida_cli(carpeta_arg)
manual = cargar_reemplazos()
errores = 0
for ruta in rutas:
try:
texto = extraer_texto(ruta)
motor = MotorAnonimizacion(reemplazos_manual=manual)
hallazgos = motor.analizar(texto)
salida, tabla, n, fugas = procesar(ruta, hallazgos, carpeta_salida=carpeta_salida)
print(f"✔ {ruta.name} → {salida.name}")
print(f" Equivalencias: {tabla.name} (NUNCA subas este archivo)")
print(f" Reemplazos: {n}")
if fugas:
print(f" ⚠ Quedaron {len(fugas)} dato(s) sin anonimizar")
except Exception as e:
errores += 1
print(f"✗ {ruta.name}: {e}")
if errores:
sys.exit(1)
print(f"⚠ {ADVERTENCIA}")
else:
lanzar_gui()
if __name__ == "__main__":
main()