from io import BytesIO, StringIO from html import escape import csv import re try: from legal_reference_taxonomy import REFERENCE_ENTITY_TYPES except Exception: REFERENCE_ENTITY_TYPES = [] import fitz # PyMuPDF from docx import Document from reportlab.lib.pagesizes import A4 from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.lib.styles import getSampleStyleSheet PLACEHOLDER_LABELS = { "PERSON": "PERSOON", "LOCATION": "LOCATIE", "ORGANIZATION": "ORGANISATIE", "EMAIL_ADDRESS": "EMAIL", "PHONE_NUMBER": "TELEFOON", "IBAN_CODE": "IBAN", "CREDIT_CARD": "BETAALKAART", "DATE_TIME": "DATUM", "URL": "URL", "IP_ADDRESS": "IP_ADRES", "GENERIC_PII": "VERTROUWELIJK", # Dutch/EU entities "NL_BSN": "BSN", "NL_POSTCODE": "POSTCODE", "NL_IBAN": "IBAN", "NL_KVK_NUMBER": "KVK_NUMMER", "NL_VAT_NUMBER": "BTW_NUMMER", "NL_PHONE_NUMBER": "TELEFOON", "NL_LICENSE_PLATE": "KENTEKEN", "NL_DRIVER_LICENSE": "RIJBEWIJS", "NL_BIG_NUMBER": "BIG_NUMMER", "NL_ADDRESS": "ADRES", "NL_DATE_OF_BIRTH": "GEBOORTEDATUM", # Dutch legal entities "NL_ECLI": "ECLI", "NL_LEGAL_CASE_NUMBER": "ZAAKNUMMER", "NL_ROLNUMMER": "ROLNUMMER", "NL_REKESTNUMMER": "REKESTNUMMER", "NL_PARKETNUMMER": "PARKETNUMMER", "NL_DOSSIER_NUMBER": "DOSSIERNUMMER", "NL_CLIENT_NUMBER": "CLIENTNUMMER", "NL_CJIB_NUMBER": "CJIB_NUMMER", "NL_POLICE_REPORT_NUMBER": "PV_NUMMER", "NL_INSURANCE_CLAIM_NUMBER": "SCHADE_OF_POLISNUMMER", "NL_INCIDENT_NUMBER": "INCIDENTNUMMER", "NL_CLAIM_NUMBER": "CLAIMNUMMER", "NL_OTHER_REFERENCE": "OVERIGE_REFERENTIE", "NL_LEGAL_PARTY_NAME": "PROCESPARTIJ", "NL_COURT_OR_AUTHORITY": "INSTANTIE", # Dutch legal reference taxonomy entities "NL_CLIENT_REFERENCE": "CLIENT_REFERENTIE", "NL_CASE_REFERENCE": "ZAAKREFERENTIE", "NL_INTERNAL_REFERENCE": "INTERNE_REFERENTIE", "NL_CONTEXTUAL_REFERENCE": "REFERENTIE", "NL_INVOICE_NUMBER": "FACTUURNUMMER", "NL_ORDER_OR_CONTRACT_NUMBER": "CONTRACT_OF_ORDERNUMMER", "NL_SCHOOL_REFERENCE": "SCHOOLREFERENTIE", "NL_CHILD_PROTECTION_REFERENCE": "JEUGD_OF_BESCHERMINGSREFERENTIE", "NL_EMPLOYMENT_REFERENCE": "ARBEIDSREFERENTIE", "NL_INSURANCE_REFERENCE": "VERZEKERINGSREFERENTIE", "NL_HEALTHCARE_REFERENCE": "ZORGREFERENTIE", "NL_POLICE_REFERENCE": "POLITIE_OF_OM_REFERENTIE", "NL_IMMIGRATION_REFERENCE": "VREEMDELINGENREFERENTIE", "NL_MUNICIPAL_REFERENCE": "BESTUURSREFERENTIE", "NL_REAL_ESTATE_REFERENCE": "VASTGOEDREFERENTIE", "NL_VEHICLE_REFERENCE": "VOERTUIG_OF_KENTEKENREFERENTIE", "NL_OBJECT_REFERENCE": "OBJECTREFERENTIE", "NL_SUSPICIOUS_REFERENCE_CANDIDATE": "MOGELIJKE_REFERENTIE", "NL_POSSIBLE_LICENSE_PLATE": "MOGELIJK_KENTEKEN", } STRUCTURED_ENTITY_TYPES = { "EMAIL_ADDRESS", "PHONE_NUMBER", "IBAN_CODE", "CREDIT_CARD", "DATE_TIME", "URL", "IP_ADDRESS", "NL_BSN", "NL_POSTCODE", "NL_IBAN", "NL_KVK_NUMBER", "NL_VAT_NUMBER", "NL_PHONE_NUMBER", "NL_LICENSE_PLATE", "NL_DRIVER_LICENSE", "NL_BIG_NUMBER", "NL_ADDRESS", "NL_DATE_OF_BIRTH", "NL_ECLI", "NL_LEGAL_CASE_NUMBER", "NL_ROLNUMMER", "NL_REKESTNUMMER", "NL_PARKETNUMMER", "NL_DOSSIER_NUMBER", "NL_CLIENT_NUMBER", "NL_CJIB_NUMBER", "NL_POLICE_REPORT_NUMBER", "NL_INSURANCE_CLAIM_NUMBER", "NL_LEGAL_PARTY_NAME", "NL_COURT_OR_AUTHORITY", *REFERENCE_ENTITY_TYPES, } # Common headings and document-structure words that should not become global replacements. DOCUMENT_WORD_DENYLIST = { "chapter", "section", "article", "paragraph", "appendix", "annex", "schedule", "table", "figure", "introduction", "background", "summary", "conclusion", "scope", "purpose", "definitions", "agreement", "contract", "party", "parties", "client", "supplier", "service", "services", "project", "document", "version", "draft", "review", "confidential", "hoofdstuk", "paragraaf", "artikel", "bijlage", "inleiding", "samenvatting", "conclusie", "doel", "definities", "overeenkomst", "contract", "partij", "partijen", "klant", "leverancier", "dienst", "diensten", "project", "document", "versie", "concept", "vertrouwelijk", "rechtbank", "gerechtshof", "advocaat", "rechter", "griffier", } SENSITIVE_LEGAL_CONTEXT = { "strafzaak", "verdachte", "slachtoffer", "tenlastelegging", "veroordeling", "echtscheiding", "alimentatie", "omgangsregeling", "minderjarige", "jeugdzorg", "ontslag", "arbeidsongeschikt", "ziekte", "medisch", "asiel", "verblijfsvergunning", "faillissement", "curator", } LEGAL_ENTITY_TYPES = { "NL_ECLI", "NL_LEGAL_CASE_NUMBER", "NL_ROLNUMMER", "NL_REKESTNUMMER", "NL_PARKETNUMMER", "NL_DOSSIER_NUMBER", "NL_CLIENT_NUMBER", "NL_CJIB_NUMBER", "NL_POLICE_REPORT_NUMBER", "NL_INSURANCE_CLAIM_NUMBER", "NL_LEGAL_PARTY_NAME", "NL_COURT_OR_AUTHORITY", *REFERENCE_ENTITY_TYPES, } def uploaded_file_to_text(uploaded_file): """Convert uploaded .txt, .docx, or text-based .pdf to plain text.""" filename = uploaded_file.name.lower() data = uploaded_file.getvalue() if filename.endswith(".txt"): return data.decode("utf-8", errors="ignore"), "txt" if filename.endswith(".docx"): doc = Document(BytesIO(data)) return extract_docx_text(doc), "docx" if filename.endswith(".pdf"): return extract_pdf_text(data), "pdf" raise ValueError("Unsupported file type. Please upload .txt, .docx, or .pdf.") def extract_docx_text(doc): parts = [] for paragraph in iter_docx_paragraphs(doc): text = paragraph.text.strip() if text: parts.append(text) return "\n".join(parts) def extract_pdf_text(pdf_bytes): parts = [] with fitz.open(stream=pdf_bytes, filetype="pdf") as doc: for page in doc: text = page.get_text("text", sort=True) if text.strip(): parts.append(text) return "\n\n".join(parts) def iter_docx_paragraphs(doc): """Yield paragraphs from body, tables, headers and footers.""" for paragraph in doc.paragraphs: yield paragraph for table in doc.tables: yield from iter_table_paragraphs(table) for section in doc.sections: header_footer_parts = [ section.header, section.footer, section.first_page_header, section.first_page_footer, section.even_page_header, section.even_page_footer, ] for part in header_footer_parts: for paragraph in part.paragraphs: yield paragraph for table in part.tables: yield from iter_table_paragraphs(table) def iter_table_paragraphs(table): for row in table.rows: for cell in row.cells: for paragraph in cell.paragraphs: yield paragraph for nested_table in cell.tables: yield from iter_table_paragraphs(nested_table) def normalize_detected_text(value: str) -> str: return re.sub(r"\s+", " ", (value or "").strip()) def should_skip_detection(original: str, entity_type: str, score: float) -> bool: """Reduce false-positive global replacements in exported documents.""" if not original: return True original_clean = normalize_detected_text(original) original_lower = original_clean.lower() if original_lower in DOCUMENT_WORD_DENYLIST: return True if not any(char.isalnum() for char in original_clean): return True if original_clean.isdigit() and entity_type not in STRUCTURED_ENTITY_TYPES: return True if len(original_clean) < 4 and entity_type not in STRUCTURED_ENTITY_TYPES: return True # Organization detection is useful, but often false-positives on headings. if entity_type == "ORGANIZATION": if score < 0.85: return True if len(original_clean.split()) == 1 and "." not in original_clean: return True if entity_type == "LOCATION" and score < 0.60: return True # Legal identifiers are usually safer to include once our recognizers find them. if entity_type in LEGAL_ENTITY_TYPES: return False return False def placeholder_for_entity(entity_type: str, count: int) -> str: label = PLACEHOLDER_LABELS.get(entity_type, entity_type) return f"[{label}_{count:02d}]" def build_placeholder_replacements(text, analyze_results): """Build stable placeholder suggestions from Presidio results.""" counters = {} replacements = {} report_rows = [] sorted_results = sorted(analyze_results, key=lambda r: (r.start, r.end)) for result in sorted_results: original = normalize_detected_text(text[result.start : result.end]) entity_type = result.entity_type score = float(getattr(result, "score", 0.0) or 0.0) if should_skip_detection(original, entity_type, score): continue if original in replacements: continue counters[entity_type] = counters.get(entity_type, 0) + 1 placeholder = placeholder_for_entity(entity_type, counters[entity_type]) replacements[original] = placeholder report_rows.append( { "entity_type": entity_type, "detected_text": original, "placeholder": placeholder, "score": round(score, 3), } ) return replacements, report_rows def apply_replacements_to_text(text, replacements): """Apply longest replacements first to avoid partial replacement problems.""" output = text for original, placeholder in sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True): output = output.replace(original, placeholder) return output def anonymized_docx_from_original(uploaded_file, replacements): """Replace detected text inside the original .docx, preserving most formatting.""" doc = Document(BytesIO(uploaded_file.getvalue())) for paragraph in iter_docx_paragraphs(doc): replace_in_paragraph_runs(paragraph, replacements) output = BytesIO() doc.save(output) output.seek(0) return output.getvalue() def replace_in_paragraph_runs(paragraph, replacements): for original, placeholder in sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True): while replace_once_in_runs(paragraph, original, placeholder): pass def replace_once_in_runs(paragraph, old_text, new_text): """Replace one occurrence across Word runs.""" if not paragraph.runs: return False full_text = "".join(run.text for run in paragraph.runs) start = full_text.find(old_text) if start == -1: return False end = start + len(old_text) current_pos = 0 replacement_done = False for run in paragraph.runs: run_text = run.text run_start = current_pos run_end = current_pos + len(run_text) current_pos = run_end if run_end <= start or run_start >= end: continue local_start = max(start - run_start, 0) local_end = min(end - run_start, len(run_text)) before = run_text[:local_start] after = run_text[local_end:] if not replacement_done: if end <= run_end: run.text = before + new_text + after else: run.text = before + new_text replacement_done = True else: if end <= run_end: run.text = after else: run.text = "" return True def docx_from_text(text): doc = Document() for line in text.splitlines(): doc.add_paragraph(line) output = BytesIO() doc.save(output) output.seek(0) return output.getvalue() def pdf_from_text(text): output = BytesIO() pdf = SimpleDocTemplate(output, pagesize=A4) styles = getSampleStyleSheet() normal = styles["Normal"] story = [] for block in text.splitlines(): safe_block = escape(block) if block.strip() else " " story.append(Paragraph(safe_block, normal)) story.append(Spacer(1, 6)) pdf.build(story) output.seek(0) return output.getvalue() def replacement_report_csv(report_rows): output = StringIO() fieldnames = ["entity_type", "detected_text", "placeholder", "score", "source", "reason"] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for row in report_rows: writer.writerow(row) return output.getvalue().encode("utf-8") def scrub_report_txt(report_rows, profile: str, source_filename: str | None = None): """Build a simple local scrub report for legal review/admin evidence.""" counts = {} legal_count = 0 for row in report_rows: entity = str(row.get("entity_type", "UNKNOWN")) or "UNKNOWN" counts[entity] = counts.get(entity, 0) + 1 if entity in LEGAL_ENTITY_TYPES: legal_count += 1 lines = [ "SolidPrivacy Scrub report", "==========================", "", f"Recognition profile: {profile}", f"Source file: {source_filename or 'text input / not specified'}", "Processing location: local app process / no external API call required by this recognizer pack", "Cloud/LLM use: none required for Dutch recognizer pack", "", "Detected entity counts:", ] if counts: for entity, count in sorted(counts.items()): lines.append(f"- {entity}: {count}") else: lines.append("- No entities included in the final replacement table.") lines.extend( [ "", "Legal review note:", ( "Manual review recommended: yes. Legal identifiers or personal identifiers may still be present " "if they were not detected or were unticked in the review table." ), "", "Scope warning:", "This is a scrubbing/pseudonymisation aid, not a guarantee of irreversible anonymisation.", ] ) if legal_count: lines.insert(-4, f"Legal/matter identifiers included: {legal_count}") return "\n".join(lines).encode("utf-8")