Spaces:
Running
Running
Kartikeya Mishra commited on
Commit ·
3c6b0df
1
Parent(s): ca7a3c3
Support generic DOCX label value redaction
Browse files
README.md
CHANGED
|
@@ -7,4 +7,4 @@ license: mit
|
|
| 7 |
|
| 8 |
# PII Redaction Workflow
|
| 9 |
|
| 10 |
-
Static Hugging Face Space for browser-side DOCX PII redaction. Upload
|
|
|
|
| 7 |
|
| 8 |
# PII Redaction Workflow
|
| 9 |
|
| 10 |
+
Static Hugging Face Space for browser-side DOCX PII redaction. Upload any `.docx`, run the workflow, compare uploaded and redacted previews, inspect the **Changed Snippets** before/after evidence panel, and download the generated redacted DOCX. The browser-side detector supports common prose labels and table-style label/value layouts such as `Full Name | Marcus Hill` or `DOB | 7 Jan 1992`.
|
app.js
CHANGED
|
@@ -9,6 +9,13 @@ const workflowModel = [
|
|
| 9 |
|
| 10 |
const piiLabels = ["name", "email", "phone", "company", "address", "ssn", "card", "dob", "ip"];
|
| 11 |
const fakeNames = ["Aarav Shah", "Arjun Singh", "Priya Menon", "Neha Kapoor", "Kabir Mehta"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const replacements = new Map();
|
| 13 |
const state = { file: null, steps: new Map() };
|
| 14 |
|
|
@@ -169,6 +176,41 @@ function markFound(counts, type, hitTypes) {
|
|
| 169 |
if (hitTypes) hitTypes.add(type);
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
function replacement(type, value) {
|
| 173 |
const id = key(type, value);
|
| 174 |
if (replacements.has(id)) return replacements.get(id);
|
|
@@ -187,16 +229,17 @@ function replacement(type, value) {
|
|
| 187 |
return fake;
|
| 188 |
}
|
| 189 |
|
| 190 |
-
function detectAndReplace(text, counts, hitTypes = null) {
|
| 191 |
const patterns = [
|
| 192 |
["email", /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
|
| 193 |
["ssn", /\b\d{3}-\d{2}-\d{4}\b/g],
|
| 194 |
["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],
|
| 195 |
-
["phone", /(?:\+?\d{1,3}[\s-]?)?(?:\(?\d{2,5}\)?[\s-]?)?\d{5}[\s-]
|
| 196 |
-
["
|
| 197 |
-
["
|
| 198 |
-
["
|
| 199 |
-
["
|
|
|
|
| 200 |
];
|
| 201 |
let output = text;
|
| 202 |
for (const [type, pattern] of patterns) {
|
|
@@ -212,6 +255,14 @@ function detectAndReplace(text, counts, hitTypes = null) {
|
|
| 212 |
markFound(counts, "card", hitTypes);
|
| 213 |
return replacement("card", match);
|
| 214 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
return output;
|
| 216 |
}
|
| 217 |
|
|
@@ -250,6 +301,7 @@ async function runRedaction() {
|
|
| 250 |
|
| 251 |
setNode("preview", "running", "Extracting uploaded document text.");
|
| 252 |
const inputLines = await collectText(zip, xmlFiles);
|
|
|
|
| 253 |
uploadedMeta.textContent = `${inputLines.length} lines`;
|
| 254 |
uploadedPreview.textContent = previewText(inputLines);
|
| 255 |
setNode("preview", "done", `${inputLines.length} text lines extracted.`);
|
|
@@ -261,7 +313,7 @@ async function runRedaction() {
|
|
| 261 |
for (const node of [...doc.getElementsByTagName("w:t")]) {
|
| 262 |
const original = node.textContent;
|
| 263 |
const hitTypes = new Set();
|
| 264 |
-
const replaced = detectAndReplace(original, counts, hitTypes);
|
| 265 |
if (replaced !== original) {
|
| 266 |
node.textContent = replaced;
|
| 267 |
changedNodes += 1;
|
|
|
|
| 9 |
|
| 10 |
const piiLabels = ["name", "email", "phone", "company", "address", "ssn", "card", "dob", "ip"];
|
| 11 |
const fakeNames = ["Aarav Shah", "Arjun Singh", "Priya Menon", "Neha Kapoor", "Kabir Mehta"];
|
| 12 |
+
const labelTypes = {
|
| 13 |
+
name: ["name", "full name", "customer name", "client name", "applicant name", "candidate name", "employee name", "patient name", "student name", "contact person"],
|
| 14 |
+
dob: ["dob", "date of birth", "birth date", "birthdate"],
|
| 15 |
+
phone: ["phone", "mobile", "telephone", "tel", "contact number", "contact no", "whatsapp"],
|
| 16 |
+
address: ["address", "home address", "office address", "billing address", "shipping address", "residence", "mailing address", "registered office"],
|
| 17 |
+
company: ["company", "employer", "organisation", "organization"],
|
| 18 |
+
};
|
| 19 |
const replacements = new Map();
|
| 20 |
const state = { file: null, steps: new Map() };
|
| 21 |
|
|
|
|
| 176 |
if (hitTypes) hitTypes.add(type);
|
| 177 |
}
|
| 178 |
|
| 179 |
+
function labelType(text) {
|
| 180 |
+
const normalized = String(text).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
| 181 |
+
if (!normalized || normalized.length > 45) return null;
|
| 182 |
+
for (const [type, labels] of Object.entries(labelTypes)) {
|
| 183 |
+
if (labels.includes(normalized)) return type;
|
| 184 |
+
}
|
| 185 |
+
return null;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function escapeRegex(value) {
|
| 189 |
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function seedKnownValues(lines) {
|
| 193 |
+
const known = Object.fromEntries(Object.keys(labelTypes).map((type) => [type, new Set()]));
|
| 194 |
+
const nameRe = /^(?:Mr\.|Ms\.|Mrs\.|Dr\.)?\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}$/;
|
| 195 |
+
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;
|
| 196 |
+
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;
|
| 197 |
+
const postalRe = /\b(?:\d{3}\s?\d{3}|\d{5}(?:-\d{4})?)\b/;
|
| 198 |
+
for (let i = 1; i < lines.length; i += 1) {
|
| 199 |
+
const type = labelType(lines[i - 1]);
|
| 200 |
+
const value = String(lines[i] || "").trim();
|
| 201 |
+
if (!type || !value || value.length > 180) continue;
|
| 202 |
+
if (type === "name" && nameRe.test(value)) known.name.add(value);
|
| 203 |
+
if (type === "dob" && dateRe.test(value)) known.dob.add(value.match(dateRe)[0]);
|
| 204 |
+
if (type === "phone") {
|
| 205 |
+
const digits = value.replace(/\D/g, "");
|
| 206 |
+
if (digits.length >= 10 && digits.length <= 15) known.phone.add(value);
|
| 207 |
+
}
|
| 208 |
+
if (type === "address" && (postalRe.test(value) || addressHintRe.test(value))) known.address.add(value);
|
| 209 |
+
if (type === "company" && value.split(/\s+/).length >= 2) known.company.add(value);
|
| 210 |
+
}
|
| 211 |
+
return known;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
function replacement(type, value) {
|
| 215 |
const id = key(type, value);
|
| 216 |
if (replacements.has(id)) return replacements.get(id);
|
|
|
|
| 229 |
return fake;
|
| 230 |
}
|
| 231 |
|
| 232 |
+
function detectAndReplace(text, counts, hitTypes = null, knownValues = {}) {
|
| 233 |
const patterns = [
|
| 234 |
["email", /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
|
| 235 |
["ssn", /\b\d{3}-\d{2}-\d{4}\b/g],
|
| 236 |
["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],
|
| 237 |
+
["phone", /(?:\+?\d{1,3}[\s.-]?)?(?:\(?\d{2,5}\)?[\s.-]?)?\d{3,5}[\s.-]\d{4,6}\b/g],
|
| 238 |
+
["phone", /\b(?:Phone|Mobile|Telephone|Tel|Contact Number|Contact No\.?|WhatsApp)[:#\s-]*(\+?\d[\d\s().-]{8,}\d)\b/gi],
|
| 239 |
+
["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],
|
| 240 |
+
["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],
|
| 241 |
+
["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],
|
| 242 |
+
["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],
|
| 243 |
];
|
| 244 |
let output = text;
|
| 245 |
for (const [type, pattern] of patterns) {
|
|
|
|
| 255 |
markFound(counts, "card", hitTypes);
|
| 256 |
return replacement("card", match);
|
| 257 |
});
|
| 258 |
+
for (const [type, values] of Object.entries(knownValues)) {
|
| 259 |
+
for (const value of [...values].sort((a, b) => b.length - a.length)) {
|
| 260 |
+
output = output.replace(new RegExp(escapeRegex(value), "g"), (match) => {
|
| 261 |
+
markFound(counts, type, hitTypes);
|
| 262 |
+
return replacement(type, match);
|
| 263 |
+
});
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
return output;
|
| 267 |
}
|
| 268 |
|
|
|
|
| 301 |
|
| 302 |
setNode("preview", "running", "Extracting uploaded document text.");
|
| 303 |
const inputLines = await collectText(zip, xmlFiles);
|
| 304 |
+
const knownValues = seedKnownValues(inputLines);
|
| 305 |
uploadedMeta.textContent = `${inputLines.length} lines`;
|
| 306 |
uploadedPreview.textContent = previewText(inputLines);
|
| 307 |
setNode("preview", "done", `${inputLines.length} text lines extracted.`);
|
|
|
|
| 313 |
for (const node of [...doc.getElementsByTagName("w:t")]) {
|
| 314 |
const original = node.textContent;
|
| 315 |
const hitTypes = new Set();
|
| 316 |
+
const replaced = detectAndReplace(original, counts, hitTypes, knownValues);
|
| 317 |
if (replaced !== original) {
|
| 318 |
node.textContent = replaced;
|
| 319 |
changedNodes += 1;
|