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