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]) => `
${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);