Spaces:
Running
Running
Alessandro Tomassini
deploy(hf): overlay README/Dockerfile da huggingface/, senza docs/binari/model
c42a5a1 | """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), | |
| }, | |
| ) | |