ghostwriter-api / src /redactors /docx_handler.py
ayush678's picture
Upload 8 files
14c6c4f verified
Raw History Blame Contribute Delete
5.69 kB
from docx import Document
from src.detectors.hybrid_detector import HybridDetector
from src.redactors.mapper import StatefulMapper
class DocxHandler:
"""
Handles reading, redacting, and saving DOCX files safely without destroying
document structure, tables, headers, or footers.
"""
def __init__(self, detector: HybridDetector, mapper: StatefulMapper):
self.detector = detector
self.mapper = mapper
def process_document(self, input_path: str, output_path: str):
"""
Loads a document, redacts PII across all sections, and saves it.
"""
doc = Document(input_path)
# 1. Process Core Paragraphs
self._process_paragraphs(doc.paragraphs)
# 2. Process Tables
for table in doc.tables:
for row_idx, row in enumerate(table.rows):
for cell_idx, cell in enumerate(row.cells):
is_address_cell = False
if row_idx > 0:
try:
above_cell = table.cell(row_idx - 1, cell_idx)
if above_cell.text.strip().upper() in ["REGISTERED OFFICE", "CORPORATE OFFICE"]:
is_address_cell = True
except Exception:
pass
self._process_paragraphs(cell.paragraphs, is_address_cell=is_address_cell)
# 3. Process Headers and Footers
for section in doc.sections:
self._process_paragraphs(section.header.paragraphs)
self._process_paragraphs(section.footer.paragraphs)
doc.save(output_path)
print(f"Redacted document successfully saved to {output_path}")
def _process_paragraphs(self, paragraphs, is_address_cell=False):
"""
Iterates over a list of paragraphs and redacts PII within them.
"""
for paragraph in paragraphs:
runs_text = "".join(r.text for r in paragraph.runs)
if not runs_text.strip():
continue
if is_address_cell:
entities = [{"entity_type": "ADDRESS", "start": 0, "end": len(runs_text), "text": runs_text}]
else:
# Detect entities in the run-reconstructed paragraph text
entities = self.detector.analyze(runs_text)
if not entities:
continue
# We process entities in reverse order (right to left) so that modifying
# the text of a run does not invalidate the start/end character indices
# of entities that appear earlier in the paragraph.
entities.sort(key=lambda x: x['start'], reverse=True)
self._redact_runs(paragraph, entities)
def _redact_runs(self, paragraph, entities):
"""
Replaces detected PII across Word 'Runs' safely.
A single paragraph is composed of multiple runs (sections of text with consistent formatting).
An entity might span across multiple runs, so we must map character indices to runs.
"""
for entity in entities:
e_start = entity['start']
e_end = entity['end']
e_type = entity['entity_type']
e_text = entity['text']
fake_text = self.mapper.get_replacement(e_type, e_text)
# Re-calculate run indices dynamically just to be safe,
# since we are modifying runs in-place.
# Because we process right-to-left, the indices to the left remain intact.
current_len = 0
runs_info = []
for idx, run in enumerate(paragraph.runs):
runs_info.append({
"index": idx,
"run": run,
"start": current_len,
"end": current_len + len(run.text),
"text": run.text
})
current_len += len(run.text)
# Find which runs the entity overlaps with
overlapping_runs = []
for r_info in runs_info:
# Overlap condition:
# Entity starts before run ends AND Entity ends after run starts
if e_start < r_info['end'] and e_end > r_info['start']:
overlapping_runs.append(r_info)
if not overlapping_runs:
continue
# If the entity is entirely contained in one run
if len(overlapping_runs) == 1:
r_info = overlapping_runs[0]
run = r_info['run']
local_start = e_start - r_info['start']
local_end = e_end - r_info['start']
# Replace the text inside the run
run.text = run.text[:local_start] + fake_text + run.text[local_end:]
# If the entity spans multiple runs
else:
first_r = overlapping_runs[0]
last_r = overlapping_runs[-1]
local_start = e_start - first_r['start']
local_end = e_end - last_r['start']
# Place the replacement text in the first run, keep the text before the entity
first_r['run'].text = first_r['run'].text[:local_start] + fake_text
# Clear all intermediate runs completely
for r_info in overlapping_runs[1:-1]:
r_info['run'].text = ""
# Keep the text after the entity in the last run
last_r['run'].text = last_r['run'].text[local_end:]