const workflowModel = [
["load", "Load DOCX"],
["preview", "Extract preview"],
["detect", "Detect PII"],
["redact", "Redact XML"],
["package", "Build DOCX"],
["download", "Prepare download"],
];
const piiLabels = ["name", "email", "phone", "company", "address", "ssn", "card", "dob", "ip"];
const fakeNames = ["Aarav Shah", "Arjun Singh", "Priya Menon", "Neha Kapoor", "Kabir Mehta"];
const labelTypes = {
name: ["name", "full name", "customer name", "client name", "applicant name", "candidate name", "employee name", "patient name", "student name", "contact person"],
dob: ["dob", "date of birth", "birth date", "birthdate"],
phone: ["phone", "mobile", "telephone", "tel", "contact number", "contact no", "whatsapp"],
address: ["address", "home address", "office address", "billing address", "shipping address", "residence", "mailing address", "registered office"],
company: ["company", "employer", "organisation", "organization"],
};
const replacements = new Map();
const state = { file: null, steps: new Map() };
const fileInput = document.querySelector("#fileInput");
const dropzone = document.querySelector("#dropzone");
const fileCard = document.querySelector("#fileCard");
const runButton = document.querySelector("#runButton");
const workflow = document.querySelector("#workflow");
const terminal = document.querySelector("#terminal");
const summary = document.querySelector("#summary");
const downloadLink = document.querySelector("#downloadLink");
const uploadedMeta = document.querySelector("#uploadedMeta");
const uploadedPreview = document.querySelector("#uploadedPreview");
const resultMeta = document.querySelector("#resultMeta");
const resultPreview = document.querySelector("#resultPreview");
const changesList = document.querySelector("#changesList");
function timestamp() {
return new Date().toLocaleTimeString();
}
function log(message) {
terminal.textContent += `\n[${timestamp()}] ${message}`;
terminal.scrollTop = terminal.scrollHeight;
}
function setNode(id, status, detail = "") {
state.steps.set(id, { status, detail });
renderWorkflow();
}
function renderWorkflow() {
workflow.innerHTML = "";
for (const [id, label] of workflowModel) {
const step = state.steps.get(id) || { status: "idle", detail: "Waiting" };
const node = document.createElement("article");
node.className = `node ${step.status}`;
node.innerHTML = `
${step.status}
${label}
${step.detail || "Waiting"}
`;
workflow.appendChild(node);
}
}
function previewText(lines, max = 48) {
const visible = lines.filter(Boolean).slice(0, max);
const suffix = lines.length > max ? `\n\n... showing ${max} of ${lines.length} text lines. Download the DOCX for the full document.` : "";
return visible.length ? `${visible.join("\n")}${suffix}` : "No paragraph text was extracted from this DOCX.";
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function compactSnippet(value, max = 260) {
const normalized = String(value).replace(/\s+/g, " ").trim();
if (normalized.length <= max) return normalized;
return `${normalized.slice(0, max - 1)}…`;
}
function renderSummary(counts, changedNodes) {
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const items = [
["Mode", "Browser Rules"],
["Text nodes changed", changedNodes],
["Total redactions", total],
["PII types found", Object.keys(counts).length],
];
for (const key of Object.keys(counts).sort()) {
items.push([key, counts[key]]);
}
summary.innerHTML = items.map(([label, value]) => `
`).join("");
}
function renderChanges(changes, changedNodes) {
if (!changes.length) {
changesList.innerHTML = `
No changed snippets were detected in this run. Try a DOCX containing names, emails, phone numbers,
addresses, SSNs, cards, DOBs, IPs, or company names.
`;
return;
}
const hidden = Math.max(0, changedNodes - changes.length);
changesList.innerHTML = `
Showing ${changes.length} changed text node${changes.length === 1 ? "" : "s"}${hidden ? ` (${hidden} more in the DOCX)` : ""}.
${changes.map((change, index) => `
#${index + 1}
Before
${escapeHtml(compactSnippet(change.before))}
After
${escapeHtml(compactSnippet(change.after))}
`).join("")}
`;
}
function selectFile(file) {
state.file = file || null;
downloadLink.classList.add("hidden");
uploadedMeta.textContent = state.file ? "Selected" : "Waiting";
resultMeta.textContent = "Waiting";
uploadedPreview.textContent = state.file ? "Click Run redaction to extract the uploaded DOCX preview." : "Upload a DOCX to see the original text preview here.";
resultPreview.textContent = "Run redaction to see the redacted output preview here.";
summary.innerHTML = 'No run yet.
';
renderChanges([], 0);
if (!state.file) {
fileCard.textContent = "No file selected";
runButton.disabled = true;
return;
}
fileCard.textContent = `${state.file.name} (${Math.round(state.file.size / 1024).toLocaleString()} KB)`;
runButton.disabled = !state.file.name.toLowerCase().endsWith(".docx");
}
function key(type, value) {
return `${type}:${value.toLowerCase().replace(/\s+/g, " ").trim()}`;
}
function luhn(value) {
const digits = value.replace(/\D/g, "");
let total = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let n = Number(digits[i]);
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
total += n;
alt = !alt;
}
return digits.length >= 13 && total % 10 === 0;
}
function markFound(counts, type, hitTypes) {
counts[type] = (counts[type] || 0) + 1;
if (hitTypes) hitTypes.add(type);
}
function stableHash(value) {
let hash = 2166136261;
for (const char of String(value)) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function labelType(text) {
const normalized = String(text).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
if (!normalized || normalized.length > 45) return null;
for (const [type, labels] of Object.entries(labelTypes)) {
if (labels.includes(normalized)) return type;
}
return null;
}
function escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function seedKnownValues(lines) {
const known = Object.fromEntries(Object.keys(labelTypes).map((type) => [type, new Set()]));
const nameRe = /^(?:Mr\.|Ms\.|Mrs\.|Dr\.)?\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}$/;
const dateRe = /\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}[/-]\d{1,2}|\d{1,2}\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Sept|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{4})\b/i;
const addressHintRe = /\b(?:road|rd\.?|street|st\.?|avenue|ave\.?|lane|ln\.?|drive|dr\.?|boulevard|blvd\.?|suite|apt\.?|apartment|floor|building|tower|office|po\s+box|village|taluka|nagar|mumbai|pune|india|usa|united\s+states)\b/i;
const postalRe = /\b(?:\d{3}\s?\d{3}|\d{5}(?:-\d{4})?)\b/;
for (let i = 1; i < lines.length; i += 1) {
const type = labelType(lines[i - 1]);
const value = String(lines[i] || "").trim();
if (!type || !value || value.length > 180) continue;
if (type === "name" && nameRe.test(value)) known.name.add(value);
if (type === "dob" && dateRe.test(value)) known.dob.add(value.match(dateRe)[0]);
if (type === "phone") {
const digits = value.replace(/\D/g, "");
if (digits.length >= 10 && digits.length <= 15) known.phone.add(value);
}
if (type === "address" && (postalRe.test(value) || addressHintRe.test(value))) known.address.add(value);
if (type === "company" && value.split(/\s+/).length >= 2) known.company.add(value);
}
return known;
}
function replacement(type, value) {
const id = key(type, value);
if (replacements.has(id)) return replacements.get(id);
const ordinal = replacements.size + 1;
const hash = stableHash(id);
let fake = value;
if (type === "email") fake = `contact${String(ordinal).padStart(3, "0")}@example.com`;
if (type === "phone") {
const area = 70000 + (hash % 29999);
const subscriber = 10000 + ((Math.floor(hash / 97) + ordinal * 7919) % 90000);
fake = `+91 ${String(area).padStart(5, "0")} ${String(subscriber).padStart(5, "0")}`;
}
if (type === "ssn") fake = `900-01-${String(1000 + ordinal).slice(-4)}`;
if (type === "card") fake = `4111 1111 1111 ${String(ordinal + 5).padStart(4, "0")}`;
if (type === "ip") fake = `203.0.113.${Math.min(ordinal + 1, 254)}`;
if (type === "dob") fake = `01/01/${1980 + (ordinal % 20)}`;
if (type === "address") fake = `${100 + ordinal} Example Avenue, Sample City - 400001, India`;
if (type === "company") fake = `Example Entity ${String(ordinal).padStart(3, "0")} Limited`;
if (type === "name") fake = fakeNames[(ordinal - 1) % fakeNames.length];
replacements.set(id, fake);
return fake;
}
function detectAndReplace(text, counts, hitTypes = null, knownValues = {}) {
const patterns = [
["email", /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
["ssn", /\b\d{3}-\d{2}-\d{4}\b/g],
["ip", /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g],
["phone", /(?:\+?\d{1,3}[\s.-]?)?(?:\(?\d{2,5}\)?[\s.-]?)?\d{3,5}[\s.-]\d{4,6}\b/g],
["phone", /\b(?:Phone|Mobile|Telephone|Tel|Contact Number|Contact No\.?|WhatsApp)[:#\s-]*(\+?\d[\d\s().-]{8,}\d)\b/gi],
["dob", /\b(?:Date of Birth|DOB|Birth Date|Birthdate)[:\s-]*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}[/-]\d{1,2}|\d{1,2}\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Sept|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{4})\b/gi],
["address", /\b(?:Registered Office|Corporate Office|Home Address|Office Address|Billing Address|Shipping Address|Address|Residence|Mailing address)[:\s-]*[^.]{10,160}(?:\d{3}\s?\d{3}|\d{5}(?:-\d{4})?)[^.]*/gi],
["company", /\b(?:[A-Z][A-Za-z0-9&.'-]*,?\s+){1,8}(?:Private\s+Limited|Public\s+Limited|Pvt\.?\s+Ltd\.?|Limited|Ltd\.?|LLP|L\.L\.P\.|LLC|L\.L\.C\.|Inc\.?|Incorporated|Corporation|Corp\.?|Company|Co\.?|Bank|PLC|GmbH)\b/g],
["name", /\b(?:Contact Person|Full Name|Customer Name|Client Name|Applicant Name|Candidate Name|Employee Name|Patient Name|Student Name|Backup contact|Name|Mr\.|Ms\.|Mrs\.|Dr\.)[:\s-]*[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\b/g],
];
let output = text;
for (const [type, pattern] of patterns) {
output = output.replace(pattern, (match) => {
if (type === "phone" && match.replace(/\D/g, "").length < 10) return match;
markFound(counts, type, hitTypes);
if (type === "dob") return match.replace(/\d{1,2}[/-]\d{1,2}[/-]\d{2,4}/, replacement(type, match));
return replacement(type, match);
});
}
output = output.replace(/\b(?:\d[ -]?){13,19}\b/g, (match) => {
if (!luhn(match)) return match;
markFound(counts, "card", hitTypes);
return replacement("card", match);
});
for (const [type, values] of Object.entries(knownValues)) {
for (const value of [...values].sort((a, b) => b.length - a.length)) {
output = output.replace(new RegExp(escapeRegex(value), "g"), (match) => {
markFound(counts, type, hitTypes);
return replacement(type, match);
});
}
}
return output;
}
async function collectText(zip, xmlFiles) {
const lines = [];
for (const file of xmlFiles) {
const xml = await zip.file(file).async("text");
const doc = new DOMParser().parseFromString(xml, "application/xml");
for (const node of [...doc.getElementsByTagName("w:t")]) {
const text = node.textContent.trim();
if (text) lines.push(text);
}
}
return lines;
}
async function runRedaction() {
if (!state.file) return;
replacements.clear();
state.steps.clear();
renderWorkflow();
terminal.textContent = "Starting browser-side redaction request...";
downloadLink.classList.add("hidden");
runButton.disabled = true;
const counts = {};
let changedNodes = 0;
const changes = [];
try {
setNode("load", "running", "Reading uploaded DOCX archive.");
const buffer = await state.file.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const xmlFiles = Object.keys(zip.files).filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name));
if (!xmlFiles.length) throw new Error("No Word document XML files were found.");
setNode("load", "done", `${xmlFiles.length} Word XML files loaded.`);
setNode("preview", "running", "Extracting uploaded document text.");
const inputLines = await collectText(zip, xmlFiles);
const knownValues = seedKnownValues(inputLines);
uploadedMeta.textContent = `${inputLines.length} lines`;
uploadedPreview.textContent = previewText(inputLines);
setNode("preview", "done", `${inputLines.length} text lines extracted.`);
setNode("detect", "running", "Scanning text nodes for PII patterns.");
for (const file of xmlFiles) {
const xml = await zip.file(file).async("text");
const doc = new DOMParser().parseFromString(xml, "application/xml");
for (const node of [...doc.getElementsByTagName("w:t")]) {
const original = node.textContent;
const hitTypes = new Set();
const replaced = detectAndReplace(original, counts, hitTypes, knownValues);
if (replaced !== original) {
node.textContent = replaced;
changedNodes += 1;
if (changes.length < 50) {
changes.push({
before: original,
after: replaced,
types: [...hitTypes],
});
}
}
}
const serialized = new XMLSerializer().serializeToString(doc);
zip.file(file, serialized);
}
setNode("detect", "done", `${Object.values(counts).reduce((a, b) => a + b, 0)} replacements detected.`);
setNode("redact", "done", `${changedNodes} DOCX text nodes changed.`);
const outputLines = await collectText(zip, xmlFiles);
resultMeta.textContent = `${outputLines.length} lines`;
resultPreview.textContent = previewText(outputLines);
setNode("package", "running", "Generating redacted DOCX download.");
const blob = await zip.generateAsync({ type: "blob" });
const outputName = state.file.name.replace(/\.docx$/i, " - Redacted.docx");
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.download = outputName;
downloadLink.textContent = `Download ${outputName}`;
downloadLink.classList.remove("hidden");
setNode("package", "done", `${Math.round(blob.size / 1024).toLocaleString()} KB DOCX generated.`);
setNode("download", "done", outputName);
renderSummary(counts, changedNodes);
renderChanges(changes, changedNodes);
document.querySelector(".preview-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
log(`Redaction completed with ${Object.values(counts).reduce((a, b) => a + b, 0)} replacements.`);
} catch (error) {
setNode("load", "error", error.message);
log(`ERROR: ${error.message}`);
} finally {
runButton.disabled = !state.file;
}
}
fileInput.addEventListener("change", () => selectFile(fileInput.files[0]));
runButton.addEventListener("click", runRedaction);
for (const eventName of ["dragenter", "dragover"]) {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.remove("dragging");
});
}
dropzone.addEventListener("drop", (event) => {
const [file] = event.dataTransfer.files;
selectFile(file);
});
renderWorkflow();
renderChanges([], 0);