Kartikeya Mishra commited on
Commit
3c6b0df
·
1 Parent(s): ca7a3c3

Support generic DOCX label value redaction

Browse files
Files changed (2) hide show
  1. README.md +1 -1
  2. app.js +59 -7
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 a `.docx`, run the workflow, compare uploaded and redacted previews, inspect the **Changed Snippets** before/after evidence panel, and download the generated redacted DOCX.
 
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-]?\d{5}\b/g],
196
- ["dob", /\b(?:Date of Birth|DOB|Birth Date)[:\s-]*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b/gi],
197
- ["address", /\b(?:Registered Office|Address|Mailing address)[:\s-]*[^.]{10,120}(?:\d{3}\s?\d{3}|\d{5})[^.]*/gi],
198
- ["company", /\b[A-Z][A-Za-z&., ]{2,80}\s(?:Limited|Ltd|Private Limited|LLP|Inc|Corporation|Company)\b/g],
199
- ["name", /\b(?:Contact Person|Backup contact|Name|Mr\.|Ms\.|Mrs\.|Dr\.)[:\s-]*[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2}\b/g],
 
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;