"""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), }, )