from io import BytesIO, StringIO from html import escape import csv import re 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", "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", "GENERIC_PII": "VERTROUWELIJK", } 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", } # 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", } 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 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() writer = csv.DictWriter( output, fieldnames=["entity_type", "detected_text", "placeholder", "score"], ) writer.writeheader() for row in report_rows: writer.writerow(row) return output.getvalue().encode("utf-8")