AiAnonymize_v3 / pdf /rendering /redact_method.py
Alessandro Tomassini
deploy(hf): overlay README/Dockerfile da huggingface/, senza docs/binari/model
c42a5a1
Raw
History Blame Contribute Delete
11.7 kB
"""Metodo D — hard redaction: testo fisicamente rimosso dal PDF."""
from __future__ import annotations
import io
from pdf.rendering.base import MethodInfo, RenderKind, RenderMethod, RenderOutput, RenderParams
from pdf.rendering.styles import severity_rgb
# Carattere di mascheramento usato nel PDF (coerente con web.document_helpers,
# che sostituisce il '•' delle regole con '*' perché renderizzabile dalle font).
PDF_MASK_CHAR = "*"
def _int_to_rgb(c: int) -> tuple[float, float, float]:
"""Colore span PyMuPDF (sRGB intero) → terna RGB in [0,1]."""
return ((c >> 16) & 255) / 255, ((c >> 8) & 255) / 255, (c & 255) / 255
def _norm_font(name: str) -> str:
"""Nome font normalizzato (senza prefisso di subset 'ABCDEF+', minuscolo)."""
base = name.split("+", 1)[-1] if "+" in name else name
return base.lower().replace(" ", "")
def _page_spans(page, cache: dict):
"""Span di testo non vuoti della pagina (memoizzati per non rileggere il dict).
Ogni elemento è lo span PyMuPDF (bbox, origin, size, color, font)."""
key = page.number
if key in cache:
return cache[key]
try:
data = page.get_text("dict")
except Exception: # noqa: BLE001 - estrazione best-effort
data = {"blocks": []}
spans = [
s
for block in data.get("blocks", [])
for line in block.get("lines", [])
for s in line.get("spans", [])
if str(s.get("text", "")).strip()
]
cache[key] = spans
return spans
def _original_style(page, rect, spans_cache: dict):
"""(origin, size, color, font_name) per riscrivere il testo mascherato.
Le caratteristiche (font/dimensione/colore) sono prese dal testo della STESSA
RIGA, preferendo lo span che PRECEDE il box (la "frase prima"): è sempre
presente, non viene rimosso dalla redazione e condivide lo stile del testo
oscurato, così anche le frasi riscritte ne ereditano lo stile in modo
affidabile. La x resta quella del box; la baseline è quella della riga.
Preferenza: precede sulla riga → dentro il box → segue → riga sopra → default."""
import fitz
cx0, cx1 = rect.x0, rect.x1
cy = (rect.y0 + rect.y1) / 2.0
before = inside = after = above = None
for s in _page_spans(page, spans_cache):
x0, y0, x1, y1 = s["bbox"]
if y0 <= cy <= y1: # stessa riga del box
if x1 <= cx0 + 1.0: # termina prima → precede
if before is None or x1 > before["bbox"][2]:
before = s
elif x0 >= cx1 - 1.0: # inizia dopo → segue
if after is None or x0 < after["bbox"][0]:
after = s
elif inside is None: # sovrappone il box
inside = s
elif y1 <= cy: # riga sopra il box
if above is None or y1 > above["bbox"][3]:
above = s
ref = before or inside or after
if ref is not None:
baseline_y = ref["origin"][1]
elif above is not None:
ref, baseline_y = above, rect.y1 - rect.height * 0.22
if ref is not None:
return (
fitz.Point(rect.x0, baseline_y),
float(ref["size"]),
_int_to_rgb(int(ref["color"])),
str(ref.get("font", "")),
)
return (
fitz.Point(rect.x0, rect.y1 - rect.height * 0.25),
max(6.0, rect.height * 0.7),
(0.0, 0.0, 0.0),
"",
)
def _embedded_font(doc, page, font_name: str, cache: dict):
"""`fitz.Font` del font EMBEDDED del PDF che corrisponde a `font_name`.
Riusa il programma font REALE del documento (riproduzione esatta): cerca tra i
font della pagina quello col nome corrispondente e ne estrae il buffer. None se
il font non è embedded (es. Base-14 di sistema) o non estraibile."""
import fitz
key = "emb:" + _norm_font(font_name)
if key in cache:
return cache[key]
found = None
for info in page.get_fonts(full=True):
xref, basefont = info[0], info[3]
if _norm_font(basefont) == _norm_font(font_name):
try:
buf = doc.extract_font(xref)[3]
if buf:
found = fitz.Font(fontbuffer=buf)
break
except Exception: # noqa: BLE001 - font non estraibile
continue
cache[key] = found
return found
def _base14_from_name(font_name: str) -> str:
"""Base-14 corrispondente al NOME reale del font (famiglia + peso/corsivo letti
dal nome, non indovinati). Per i font standard (Helvetica/Arial, Times, Courier)
è una riproduzione ESATTA; per gli altri è la famiglia più vicina."""
f = _norm_font(font_name)
bold = "bold" in f or "black" in f or "heavy" in f or "semibold" in f
italic = "italic" in f or "oblique" in f
if "courier" in f or "mono" in f or "consol" in f:
fam = ("cour", "cobo", "coit", "cobi")
elif any(s in f for s in ("times", "georgia", "serif", "roman", "minion",
"garamond", "cambria", "book")):
fam = ("tiro", "tibo", "tiit", "tibi")
else: # Helvetica/Arial/Calibri/sans e sconosciuti
fam = ("helv", "hebo", "heit", "hebi")
return fam[(1 if bold else 0) + (2 if italic else 0)]
def _resolve_font(doc, page, font_name: str, masked: str, cache: dict):
"""Font con cui riscrivere `masked` riproducendo l'originale il più fedelmente.
1) font EMBEDDED del documento, se contiene i glifi del testo mascherato
(riproduzione esatta dell'originale);
2) altrimenti il Base-14 corrispondente al NOME del font (esatto per i font
standard, con tutti i glifi: '*' c'è sempre);
3) helv come ultima rete di sicurezza."""
import fitz
chars = set(masked)
emb = _embedded_font(doc, page, font_name, cache)
if emb is not None and all(emb.has_glyph(ord(c)) for c in chars):
return emb
name = _base14_from_name(font_name)
key = "b14:" + name
if key not in cache:
cache[key] = fitz.Font(name)
fb = cache[key]
if all(fb.has_glyph(ord(c)) for c in chars):
return fb
if "helv" not in cache:
cache["helv"] = fitz.Font("helv")
return cache["helv"]
def _fit_masked(masked: str, font, size: float, max_width: float) -> str:
"""Riduce gli asterischi finché il testo mascherato entra in `max_width`.
Gli asterischi sono più larghi dei caratteri originali e sforerebbero il box.
Si tolgono SOLO asterischi (mai i caratteri reali), dal lato corretto:
- prima lettera visibile ('M****') → taglia in CODA;
- ultime lettere visibili ('****562S') → taglia in TESTA;
- tutto mascherato ('*****') → taglia in coda.
"""
star = PDF_MASK_CHAR
if not masked or font.text_length(masked, fontsize=size) <= max_width:
return masked
if masked[0] != star: # carattere reale in testa (initial) → taglia coda
trim_tail = True
elif masked[-1] != star: # caratteri reali in coda (last_chars) → taglia testa
trim_tail = False
else: # tutto asterischi (full_mask) → taglia coda
trim_tail = True
s = masked
while len(s) > 1 and font.text_length(s, fontsize=size) > max_width:
if trim_tail:
if s[-1] != star:
break # niente più asterischi da togliere in coda
s = s[:-1]
else:
if s[0] != star:
break # niente più asterischi da togliere in testa
s = s[1:]
return s
class RedactPdfMethod(RenderMethod):
info = MethodInfo(
key="redact",
label="D · Hard redaction",
kind=RenderKind.DOWNLOAD,
media_type="application/pdf",
styles=("black", "color"),
default_style="black",
description=(
"PDF con il testo sensibile rimosso fisicamente (irreversibile). "
"Per la consegna finale: il testo non e' recuperabile. Se il box porta "
"un testo mascherato (`masked`), questo viene RISCRITTO al posto "
"dell'originale (es. 'M****')."
),
destructive=True,
)
def render(self, params: RenderParams) -> RenderOutput:
import fitz
# style "black" -> riempimento nero; "color" -> colore per gravita'.
fixed = (0.0, 0.0, 0.0) if params.style == "black" else None
doc = fitz.open(stream=params.pdf_bytes, filetype="pdf")
pages_touched: set[int] = set()
n = 0
font_cache: dict = {}
spans_cache: dict = {} # span per pagina (per leggere lo stile del testo vicino)
# Box mascherati: rimossi a BIANCO ora, poi (dopo le redazioni) ridisegnati
# coi caratteri mascherati nel FONT/colore/dimensione dell'ORIGINALE + barra.
masked_overlays: list[tuple] = []
for b in params.boxes:
page_idx = b.get("page", 0)
page = doc.load_page(page_idx)
rect = fitz.Rect(b["x0"], b["y0"], b["x1"], b["y1"]) + fitz.Rect(-1, -1, 1, 1)
color = fixed if fixed is not None else severity_rgb(b.get("severity", "critica"))
masked = str(b.get("masked") or "")
if masked:
# Stile/font del testo VICINO (la "frase prima" sulla riga) catturati
# PRIMA della redazione, così il testo mascherato è riscritto con lo
# stesso stile del documento. Risolto qui perché dopo la redazione il
# font potrebbe non essere più ispezionabile sulla pagina.
origin, size, txt_color, font_name = _original_style(page, rect, spans_cache)
font = _resolve_font(doc, page, font_name, masked, font_cache)
# Gli asterischi non devono sforare il box originale: riduci finché
# entrano (taglio in coda/testa secondo la regola). Larghezza target
# = box ORIGINALE (rect è espanso di ±1px).
masked = _fit_masked(masked, font, size, (b["x1"] - b["x0"]))
page.add_redact_annot(rect, fill=(1.0, 1.0, 1.0))
masked_overlays.append(
(page_idx, rect, masked, color, origin, size, txt_color, font)
)
else:
page.add_redact_annot(rect, fill=color)
pages_touched.add(page_idx)
n += 1
for page_idx in pages_touched:
doc.load_page(page_idx).apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)
# Dopo le redazioni (così non vengono cancellati): barra bassa nel colore
# dello stile + caratteri mascherati col font/colore/dimensione dell'originale.
for page_idx, rect, masked, bar_color, origin, size, txt_color, font in masked_overlays:
page = doc.load_page(page_idx)
bar_h = max(1.0, rect.height * 0.12)
page.draw_rect(
fitz.Rect(rect.x0, rect.y1 - bar_h, rect.x1, rect.y1),
color=bar_color, fill=bar_color,
)
writer = fitz.TextWriter(page.rect, color=txt_color)
writer.append(origin, masked, font=font, fontsize=size)
writer.write_text(page)
buf = io.BytesIO()
doc.save(buf, garbage=4, deflate=True)
doc.close()
return RenderOutput(
data=buf.getvalue(),
media_type=self.info.media_type,
filename="redatto.pdf",
headers={
"X-Pages-Redacted": str(len(pages_touched)),
"X-Entities-Redacted": str(n),
},
)