@doccloak/core 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +121 -2
  2. package/README.md +252 -19
  3. package/dist/doc.d.ts +68 -1
  4. package/dist/doc.js +447 -62
  5. package/dist/dom/docx.d.ts +135 -7
  6. package/dist/dom/docx.js +752 -125
  7. package/dist/dom/drawingml.d.ts +59 -0
  8. package/dist/dom/drawingml.js +176 -0
  9. package/dist/dom/errors.d.ts +35 -0
  10. package/dist/dom/errors.js +24 -0
  11. package/dist/dom/index.d.ts +3 -0
  12. package/dist/dom/index.js +6 -0
  13. package/dist/dom/office.d.ts +56 -0
  14. package/dist/dom/office.js +76 -23
  15. package/dist/dom/opc.d.ts +135 -7
  16. package/dist/dom/opc.js +407 -14
  17. package/dist/dom/package-policy.d.ts +137 -0
  18. package/dist/dom/package-policy.js +355 -0
  19. package/dist/dom/xlsx.d.ts +64 -6
  20. package/dist/dom/xlsx.js +75 -20
  21. package/dist/engine.d.ts +18 -0
  22. package/dist/engine.js +37 -13
  23. package/dist/env.d.ts +21 -1
  24. package/dist/index.d.ts +8 -6
  25. package/dist/index.js +10 -5
  26. package/dist/model-loader.d.ts +61 -6
  27. package/dist/model-loader.js +122 -17
  28. package/dist/pipeline.d.ts +87 -8
  29. package/dist/pipeline.js +249 -50
  30. package/dist/providers/bardsai.d.ts +8 -1
  31. package/dist/providers/bardsai.js +22 -2
  32. package/dist/providers/gliner-base.d.ts +8 -1
  33. package/dist/providers/gliner-base.js +22 -2
  34. package/dist/providers/gliner.d.ts +8 -1
  35. package/dist/providers/gliner.js +22 -2
  36. package/dist/regex/loader.d.ts +2 -0
  37. package/dist/regex/loader.js +2 -0
  38. package/dist/regex/rules.data.js +15 -14
  39. package/dist/regex/types.d.ts +6 -0
  40. package/dist/session.d.ts +101 -3
  41. package/dist/session.js +397 -43
  42. package/dist/surrogates.js +57 -45
  43. package/dist/worker-protocol.d.ts +13 -1
  44. package/dist/worker-protocol.js +164 -12
  45. package/package.json +1 -1
  46. package/rules/at.json +1 -1
  47. package/rules/be.json +1 -1
  48. package/rules/ch.json +1 -1
  49. package/rules/de.json +1 -1
  50. package/rules/dk.json +1 -1
  51. package/rules/es.json +1 -1
  52. package/rules/fi.json +1 -1
  53. package/rules/fr.json +1 -1
  54. package/rules/it.json +1 -1
  55. package/rules/no.json +1 -1
  56. package/rules/pl.json +1 -1
  57. package/rules/pt.json +1 -1
  58. package/rules/schema.json +4 -0
  59. package/rules/se.json +1 -1
  60. package/rules/universal.json +2 -1
package/dist/dom/docx.js CHANGED
@@ -8,74 +8,322 @@
8
8
  */
9
9
  import JSZip from 'jszip';
10
10
  import { normalizeReplacements } from "../docx.js";
11
- import { replaceSensitiveValues } from "./opc.js";
11
+ import { OOXML_NS, isElementIn, elementsByLocalName, foreignNamespaces, replaceSensitiveValues, scrubMailtoTarget, scrubPackageValues, scrubDocPropsParts, normalizeZipEntries, NORMALISED_DATE, } from "./opc.js";
12
+ import { UnsupportedDocumentError } from "./errors.js";
13
+ import { drawingUnits, leafTextUnits, flattenUnits, stripChartExternalData } from "./drawingml.js";
14
+ import { DOCX_WORD_TEXT_PARTS, DOCX_DRAWING_TEXT_PARTS, DOCX_CUSTOM_XML_TEXT_PARTS, MAX_UNPACKED_BYTES, assertUnpackedSize, collectUnredactableParts, relationshipsOf, } from "./package-policy.js";
12
15
  export { normalizeReplacements };
13
- const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
16
+ /** True for a WordprocessingML element (transitional or strict namespace). */
17
+ export function isWordEl(el, localName) {
18
+ return isElementIn(el, OOXML_NS.w, localName);
19
+ }
20
+ /** True for an Office Math (OMML) element (transitional or strict namespace). */
21
+ export function isMathEl(el, localName) {
22
+ return isElementIn(el, OOXML_NS.m, localName);
23
+ }
14
24
  /**
15
25
  * Element local names (in the w: namespace) that carry document text.
16
26
  * - t: regular run text
17
27
  * - delText: text inside tracked deletions (w:del); still physically present in the file
18
28
  * - instrText: field instructions (e.g. HYPERLINK targets, MERGEFIELD sources)
29
+ * - delInstrText: field instructions inside tracked deletions (M2: leaked
30
+ * whenever tracked changes were not accepted, the web app's default)
31
+ * OMML m:t (formula text) is handled alongside these by isTextElement.
32
+ */
33
+ const TEXT_ELEMENT_NAMES = new Set(['t', 'delText', 'instrText', 'delInstrText']);
34
+ /** True for every element whose text content is document text. */
35
+ function isTextElement(el) {
36
+ if (el.namespaceURI === null)
37
+ return false;
38
+ if (OOXML_NS.w.has(el.namespaceURI))
39
+ return TEXT_ELEMENT_NAMES.has(el.localName);
40
+ if (OOXML_NS.m.has(el.namespaceURI))
41
+ return el.localName === 't';
42
+ return false;
43
+ }
44
+ /**
45
+ * Empty w: elements that separate text (M3). Each becomes one character in
46
+ * the flat text with no TextNodeMapping (zero-width), so a phone number
47
+ * written as "555<noBreakHyphen/>0199" is seen as "555-0199" and a tab or
48
+ * line break never glues two runs into one token.
49
+ */
50
+ const SEPARATOR_CHARS = {
51
+ tab: '\t',
52
+ br: '\n',
53
+ cr: '\n',
54
+ noBreakHyphen: '-',
55
+ softHyphen: '\u00AD',
56
+ };
57
+ /** The w: attribute with the given local name on an element, if present. */
58
+ function wordAttr(el, localName) {
59
+ const attrs = el.attributes;
60
+ for (let i = 0; i < attrs.length; i++) {
61
+ const attr = attrs[i];
62
+ if (attr.localName === localName && attr.namespaceURI !== null && OOXML_NS.w.has(attr.namespaceURI)) {
63
+ return attr;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * The redactable arguments of a field instruction (w:fldSimple@w:instr or
70
+ * the joined w:instrText of a complex field): the content of every quoted
71
+ * string and every bare token that is neither the field keyword (the first
72
+ * token) nor a switch (a token starting with a backslash). Offsets refer to
73
+ * the instruction string. ` HYPERLINK "mailto:a@b" \o "Mail" ` yields
74
+ * "mailto:a@b" and "Mail"; ` REF Bookmark1 \h ` yields "Bookmark1". The
75
+ * keyword is never an argument, so a replacement can never turn a HYPERLINK
76
+ * into something Word cannot evaluate (T174).
19
77
  */
20
- const TEXT_ELEMENT_NAMES = new Set(['t', 'delText', 'instrText']);
78
+ export function fieldInstructionArguments(instr) {
79
+ const args = [];
80
+ const n = instr.length;
81
+ let i = 0;
82
+ let keywordSeen = false;
83
+ const isSpace = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
84
+ while (i < n) {
85
+ const ch = instr[i];
86
+ if (isSpace(ch)) {
87
+ i++;
88
+ continue;
89
+ }
90
+ if (ch === '"') {
91
+ const start = i + 1;
92
+ let j = start;
93
+ while (j < n && instr[j] !== '"') {
94
+ // A backslash escapes the next character inside a quoted argument.
95
+ if (instr[j] === '\\' && j + 1 < n)
96
+ j++;
97
+ j++;
98
+ }
99
+ keywordSeen = true;
100
+ if (j > start)
101
+ args.push({ offset: start, text: instr.slice(start, j) });
102
+ i = j + 1;
103
+ continue;
104
+ }
105
+ const start = i;
106
+ while (i < n && !isSpace(instr[i]) && instr[i] !== '"')
107
+ i++;
108
+ const token = instr.slice(start, i);
109
+ if (!keywordSeen) {
110
+ keywordSeen = true;
111
+ continue;
112
+ }
113
+ if (token.startsWith('\\'))
114
+ continue;
115
+ args.push({ offset: start, text: token });
116
+ }
117
+ return args;
118
+ }
119
+ /**
120
+ * Attribute values that carry user text, in document order (T174, M1):
121
+ * - w:fldSimple@w:instr: quoted and bare arguments (never the keyword)
122
+ * - w:alias@w:val: content control title
123
+ * - wp:docPr@descr and @title: drawing alt text
124
+ * - w:lvlText@w:val: numbering level text (word/numbering.xml); values
125
+ * without a letter ('%1.', '-') are pure format and are skipped (T175)
126
+ * - w:bookmarkStart@w:name: bookmark names (renamed on write, references
127
+ * updated; see renameBookmarks); Word-internal names start with an
128
+ * underscore (_Toc, _GoBack, _Ref, _Hlk) and are skipped (T175) so the
129
+ * editor never shows them. Layer zero and renameBookmarks still cover a
130
+ * known value inside one.
131
+ * - w:frameset / w:frame: w:name@w:val and w:title@w:val of a frames page
132
+ * (word/webSettings.xml, T175)
133
+ */
134
+ function attributeUnits(xmlDoc) {
135
+ const units = [];
136
+ const push = (element, attr, attrOffset = 0, text) => {
137
+ if (!attr)
138
+ return;
139
+ const value = text ?? attr.value;
140
+ if (value.length === 0)
141
+ return;
142
+ units.push({ element, attr, attrOffset, text: value });
143
+ };
144
+ const all = xmlDoc.getElementsByTagName('*');
145
+ for (let i = 0; i < all.length; i++) {
146
+ const el = all[i];
147
+ if (isWordEl(el, 'fldSimple')) {
148
+ const attr = wordAttr(el, 'instr');
149
+ if (!attr)
150
+ continue;
151
+ for (const arg of fieldInstructionArguments(attr.value))
152
+ push(el, attr, arg.offset, arg.text);
153
+ }
154
+ else if (isWordEl(el, 'alias')) {
155
+ push(el, wordAttr(el, 'val'));
156
+ }
157
+ else if (isWordEl(el, 'lvlText')) {
158
+ const attr = wordAttr(el, 'val');
159
+ if (attr && /\p{L}/u.test(attr.value))
160
+ push(el, attr);
161
+ }
162
+ else if (isWordEl(el, 'bookmarkStart')) {
163
+ const attr = wordAttr(el, 'name');
164
+ if (attr && !attr.value.startsWith('_'))
165
+ push(el, attr);
166
+ }
167
+ else if ((isWordEl(el, 'name') || isWordEl(el, 'title')) && isFramesetChild(el)) {
168
+ push(el, wordAttr(el, 'val'));
169
+ }
170
+ else if (isElementIn(el, OOXML_NS.wp, 'docPr')) {
171
+ push(el, el.getAttributeNode('descr'));
172
+ push(el, el.getAttributeNode('title'));
173
+ }
174
+ }
175
+ return units;
176
+ }
177
+ /** True when the element sits directly under a w:frame or w:frameset. */
178
+ function isFramesetChild(el) {
179
+ const parent = el.parentElement;
180
+ return parent !== null && (isWordEl(parent, 'frame') || isWordEl(parent, 'frameset'));
181
+ }
21
182
  /**
22
183
  * Extract all text-bearing elements from an XML document, building a flat text
23
- * and position mapping.
184
+ * and position mapping. Walks the tree in document order (T172), so:
185
+ * - text elements, separators and nested paragraphs interleave correctly;
186
+ * - every text element is mapped exactly once even when paragraphs nest
187
+ * (text boxes hold w:p inside an outer w:p run);
188
+ * - w:txbxContent is bracketed by newlines, so box text is never glued to
189
+ * the paragraph that hosts the drawing (M3);
190
+ * - mc:AlternateContent is extracted in full: both mc:Choice and mc:Fallback
191
+ * hold a copy of a text box, and both copies must be redacted.
192
+ * Every unmapped newline is recorded in paragraphBreaks.
24
193
  */
25
194
  function extractTextFromXml(xmlDoc) {
26
195
  const textNodes = [];
27
196
  const paragraphBreaks = [];
28
197
  let flatText = '';
29
- const body = xmlDoc.getElementsByTagNameNS(W_NS, 'body')[0];
30
- if (!body) {
31
- // For headers/footers/footnotes/comments, process the root element's children
32
- const root = xmlDoc.documentElement;
33
- processParagraphs(root);
34
- }
35
- else {
36
- processParagraphs(body);
37
- }
38
- function processParagraphs(parent) {
39
- const paragraphs = parent.getElementsByTagNameNS(W_NS, 'p');
40
- // Paragraphs can nest (text boxes hold w:p inside an outer w:p run), so the
41
- // same text element is reachable from more than one paragraph. Track visited
42
- // elements to map each one exactly once.
43
- const seen = new Set();
44
- for (let pi = 0; pi < paragraphs.length; pi++) {
45
- const para = paragraphs[pi];
46
- if (pi > 0) {
47
- paragraphBreaks.push({ flatIndex: flatText.length });
48
- flatText += '\n';
198
+ let paragraphCount = 0;
199
+ // A break is emitted lazily, right before the next piece of content, so
200
+ // trailing empty paragraphs add nothing and boundaries never double up.
201
+ let pendingBreak = false;
202
+ const emitBreak = () => {
203
+ paragraphBreaks.push({ flatIndex: flatText.length });
204
+ flatText += '\n';
205
+ pendingBreak = false;
206
+ };
207
+ const flush = () => {
208
+ if (pendingBreak)
209
+ emitBreak();
210
+ };
211
+ const walk = (parent) => {
212
+ for (let child = parent.firstElementChild; child; child = child.nextElementSibling) {
213
+ if (isWordEl(child, 'p')) {
214
+ paragraphCount++;
215
+ flush();
216
+ walk(child);
217
+ pendingBreak = true;
49
218
  }
50
- // Walk all descendants in document order so t/delText/instrText interleave correctly
51
- const descendants = para.getElementsByTagName('*');
52
- for (let di = 0; di < descendants.length; di++) {
53
- const el = descendants[di];
54
- if (el.namespaceURI !== W_NS || !TEXT_ELEMENT_NAMES.has(el.localName))
55
- continue;
56
- if (seen.has(el))
219
+ else if (isTextElement(child)) {
220
+ const text = child.textContent ?? '';
221
+ if (text.length === 0)
57
222
  continue;
58
- seen.add(el);
59
- const text = el.textContent ?? '';
60
- if (text.length > 0) {
61
- textNodes.push({
62
- element: el,
63
- flatStart: flatText.length,
64
- flatEnd: flatText.length + text.length,
65
- });
66
- flatText += text;
223
+ flush();
224
+ textNodes.push({
225
+ element: child,
226
+ flatStart: flatText.length,
227
+ flatEnd: flatText.length + text.length,
228
+ });
229
+ flatText += text;
230
+ }
231
+ else if (child.namespaceURI !== null && OOXML_NS.w.has(child.namespaceURI)
232
+ && Object.prototype.hasOwnProperty.call(SEPARATOR_CHARS, child.localName)) {
233
+ flush();
234
+ const sep = SEPARATOR_CHARS[child.localName];
235
+ if (sep === '\n') {
236
+ emitBreak();
237
+ }
238
+ else {
239
+ flatText += sep;
67
240
  }
68
241
  }
242
+ else if (isWordEl(child, 'txbxContent')) {
243
+ // Newline on entry and on exit: box text is its own block.
244
+ pendingBreak = true;
245
+ flush();
246
+ walk(child);
247
+ pendingBreak = true;
248
+ }
249
+ else {
250
+ walk(child);
251
+ }
252
+ }
253
+ };
254
+ const body = elementsByLocalName(xmlDoc, OOXML_NS.w, 'body')[0];
255
+ // Headers/footers/footnotes/comments have no body: walk the root element.
256
+ walk(body ?? xmlDoc.documentElement);
257
+ // Attribute units (T174) follow the element text, one per line. Each
258
+ // separating newline is a paragraph break, so a replacement never spans
259
+ // two units or a unit and the body text.
260
+ for (const unit of attributeUnits(xmlDoc)) {
261
+ flush();
262
+ textNodes.push({
263
+ element: unit.element,
264
+ attr: unit.attr,
265
+ attrOffset: unit.attrOffset,
266
+ flatStart: flatText.length,
267
+ flatEnd: flatText.length + unit.text.length,
268
+ });
269
+ flatText += unit.text;
270
+ pendingBreak = true;
271
+ }
272
+ return { plainText: flatText, textNodes, paragraphBreaks, paragraphCount };
273
+ }
274
+ /**
275
+ * Which extractor reads a secondary part (T175):
276
+ * - WordprocessingML parts (headers, footers, notes, comments, numbering,
277
+ * webSettings, the glossary subdocument): the paragraph walk plus
278
+ * attribute units;
279
+ * - DrawingML parts (charts, chartEx, SmartArt data and drawing, chart user
280
+ * shapes): drawingUnits;
281
+ * - custom XML data stores: leafTextUnits.
282
+ * Returns null for a part that is not a content part.
283
+ */
284
+ function extractorFor(path) {
285
+ if (DOCX_WORD_TEXT_PARTS.test(path))
286
+ return extractTextFromXml;
287
+ if (DOCX_DRAWING_TEXT_PARTS.test(path))
288
+ return (doc) => flattenUnits(drawingUnits(doc));
289
+ if (DOCX_CUSTOM_XML_TEXT_PARTS.test(path))
290
+ return (doc) => flattenUnits(leafTextUnits(doc));
291
+ return null;
292
+ }
293
+ /**
294
+ * Zip paths of every part referenced by a w:altChunk in the given parts.
295
+ * Word imports the chunk's content (HTML, MHT, RTF, another docx) into the
296
+ * document on open, so the chunk is document text the engine cannot redact:
297
+ * the package policy reports each one as 'html-chunk'.
298
+ */
299
+ async function altChunkTargets(zip, parts) {
300
+ const targets = new Set();
301
+ for (const part of parts) {
302
+ const chunks = elementsByLocalName(part.xmlDoc, OOXML_NS.w, 'altChunk');
303
+ if (chunks.length === 0)
304
+ continue;
305
+ const rels = await relationshipsOf(zip, part.path);
306
+ for (const chunk of chunks) {
307
+ const attrs = chunk.attributes;
308
+ for (let i = 0; i < attrs.length; i++) {
309
+ const attr = attrs[i];
310
+ if (attr.localName !== 'id' || attr.namespaceURI === null || !OOXML_NS.r.has(attr.namespaceURI))
311
+ continue;
312
+ const rel = rels.get(attr.value);
313
+ if (rel?.path && zip.file(rel.path))
314
+ targets.add(rel.path);
315
+ }
69
316
  }
70
317
  }
71
- return { plainText: flatText, textNodes, paragraphBreaks };
318
+ return targets;
72
319
  }
73
320
  /**
74
321
  * Read a .docx file and extract its text content with position mapping.
75
322
  */
76
- export async function readDocx(file) {
323
+ export async function readDocx(file, options = {}) {
77
324
  const arrayBuffer = await file.arrayBuffer();
78
325
  const zip = await JSZip.loadAsync(arrayBuffer);
326
+ assertUnpackedSize(zip, options.maxUnpackedBytes ?? MAX_UNPACKED_BYTES);
79
327
  // Find the main document XML path
80
328
  const documentXmlPath = 'word/document.xml';
81
329
  const docXmlFile = zip.file(documentXmlPath);
@@ -87,11 +335,27 @@ export async function readDocx(file) {
87
335
  const xmlDoc = parser.parseFromString(docXmlStr, 'application/xml');
88
336
  const parseError = xmlDoc.querySelector('parsererror');
89
337
  if (parseError) {
90
- throw new Error('Failed to parse document XML');
338
+ throw new UnsupportedDocumentError('invalid-package', 'Failed to parse document XML', [documentXmlPath]);
339
+ }
340
+ // Fail closed (H1): a main part we cannot interpret is refused, never
341
+ // copied through as a "redacted" file. The body must be WordprocessingML
342
+ // (transitional or strict); if it is, but not one paragraph is, and foreign
343
+ // elements are present, the text lives in a dialect we do not read.
344
+ const rootNs = xmlDoc.documentElement?.namespaceURI ?? '(no namespace)';
345
+ const body = elementsByLocalName(xmlDoc, OOXML_NS.w, 'body')[0];
346
+ if (!body) {
347
+ throw new UnsupportedDocumentError('unrecognized-namespace', `Unsupported .docx: no WordprocessingML body in ${documentXmlPath} (root namespace ${rootNs})`, [rootNs]);
348
+ }
349
+ const { plainText, textNodes, paragraphBreaks, paragraphCount } = extractTextFromXml(xmlDoc);
350
+ if (paragraphCount === 0) {
351
+ const foreign = foreignNamespaces(xmlDoc);
352
+ if (foreign.length > 0) {
353
+ throw new UnsupportedDocumentError('unrecognized-namespace', `Unsupported .docx: no WordprocessingML paragraphs in ${documentXmlPath}, found elements in ${foreign.join(', ')}`, foreign);
354
+ }
91
355
  }
92
- const { plainText, textNodes, paragraphBreaks } = extractTextFromXml(xmlDoc);
93
356
  // Also process headers and footers
94
357
  const contentParts = [];
358
+ const warnings = [];
95
359
  let combinedText = plainText;
96
360
  // Add main document as first content part
97
361
  contentParts.push({
@@ -103,28 +367,37 @@ export async function readDocx(file) {
103
367
  flatTextEnd: plainText.length,
104
368
  });
105
369
  // Find all other text-bearing parts: headers, footers, footnotes, endnotes,
106
- // comments, and the glossary (building blocks) subdocument. PII in any of
107
- // these survives in the exported file if it is not extracted and redacted.
108
- const headerFooterPaths = [];
109
- zip.forEach((relativePath) => {
110
- if (relativePath.match(/^word\/(?:(?:header|footer)\d*|footnotes|endnotes|comments)\.xml$/) ||
111
- relativePath === 'word/glossary/document.xml') {
112
- headerFooterPaths.push(relativePath);
113
- }
370
+ // comments, the glossary (building blocks) subdocument, numbering (level
371
+ // text lives in w:lvlText@w:val, T174), webSettings (frameset names,
372
+ // T175), charts, SmartArt, chart user shapes and custom XML data stores
373
+ // (T175). PII in any of these survives in the exported file if it is not
374
+ // extracted and redacted.
375
+ const secondaryPaths = [];
376
+ zip.forEach((relativePath, entry) => {
377
+ if (entry.dir || relativePath === documentXmlPath)
378
+ return;
379
+ if (extractorFor(relativePath))
380
+ secondaryPaths.push(relativePath);
114
381
  });
115
382
  // Deterministic order so load-time and export-time extractions produce identical offsets
116
- headerFooterPaths.sort();
117
- for (const hfPath of headerFooterPaths) {
383
+ secondaryPaths.sort();
384
+ for (const hfPath of secondaryPaths) {
118
385
  const hfFile = zip.file(hfPath);
119
386
  if (!hfFile)
120
387
  continue;
121
388
  const hfXmlStr = await hfFile.async('string');
122
389
  const hfXmlDoc = parser.parseFromString(hfXmlStr, 'application/xml');
123
- if (hfXmlDoc.querySelector('parsererror'))
390
+ if (hfXmlDoc.querySelector('parsererror')) {
391
+ warnings.push(`${hfPath}: not well-formed XML, skipped by the text extractor`);
124
392
  continue;
125
- const hfExtraction = extractTextFromXml(hfXmlDoc);
393
+ }
394
+ const extract = extractorFor(hfPath);
395
+ const hfExtraction = extract(hfXmlDoc);
126
396
  if (hfExtraction.plainText.length === 0)
127
397
  continue;
398
+ // The separator newline between parts is a break like any other: a
399
+ // replacement must never span two parts.
400
+ const separatorBreak = { flatIndex: combinedText.length };
128
401
  const offset = combinedText.length + 1; // +1 for separator newline
129
402
  combinedText += '\n' + hfExtraction.plainText;
130
403
  // Adjust text node mappings to the combined text offset
@@ -137,13 +410,20 @@ export async function readDocx(file) {
137
410
  path: hfPath,
138
411
  xmlDoc: hfXmlDoc,
139
412
  textNodes: adjustedNodes,
140
- paragraphBreaks: hfExtraction.paragraphBreaks.map((pb) => ({
141
- flatIndex: pb.flatIndex + offset,
142
- })),
413
+ paragraphBreaks: [
414
+ separatorBreak,
415
+ ...hfExtraction.paragraphBreaks.map((pb) => ({ flatIndex: pb.flatIndex + offset })),
416
+ ],
143
417
  flatTextStart: offset,
144
418
  flatTextEnd: offset + hfExtraction.plainText.length,
145
419
  });
146
420
  }
421
+ // Package policy (T177): every part the writer would copy verbatim is
422
+ // reported with a kind and a label; the writer refuses to export them
423
+ // until the host passes allowUnredactable (informed consent).
424
+ const unredactable = await collectUnredactableParts(zip, 'docx', {
425
+ htmlChunkParts: await altChunkTargets(zip, contentParts),
426
+ });
147
427
  return {
148
428
  plainText: combinedText,
149
429
  xmlDoc,
@@ -152,20 +432,68 @@ export async function readDocx(file) {
152
432
  zip,
153
433
  documentXmlPath,
154
434
  contentParts,
435
+ empty: combinedText.trim().length === 0,
436
+ unredactable,
437
+ warnings,
155
438
  };
156
439
  }
440
+ /**
441
+ * Refuse to export while unredactable parts are present and the host did
442
+ * not opt in. Shared by the docx and xlsx writers; runs before any mutation.
443
+ */
444
+ export function assertUnredactableAllowed(unredactable, allowUnredactable) {
445
+ if (unredactable.length === 0 || allowUnredactable === true)
446
+ return;
447
+ const list = unredactable.map((p) => `${p.part} (${p.label})`).join(', ');
448
+ throw new UnsupportedDocumentError('unredactable-parts', `The package holds ${unredactable.length} part(s) DocCloak cannot redact: ${list}. Pass allowUnredactable to copy them verbatim.`, unredactable.map((p) => p.part));
449
+ }
450
+ /** Warning line per unredactable part copied verbatim (allowUnredactable). */
451
+ export function unredactableWarnings(unredactable) {
452
+ return unredactable.map((p) => `${p.part}: ${p.label}, copied verbatim (not redacted)`);
453
+ }
157
454
  /**
158
455
  * Apply a list of flat-text replacements to mapped text nodes, handling
159
456
  * replacements that span multiple elements. Ranges are normalized (sorted,
160
457
  * overlaps clamped) and applied end-to-start so earlier positions stay valid.
161
458
  * Shared by the docx and xlsx writers (T110).
459
+ *
460
+ * `breaks` (T172) lists the flat indices of unmapped newlines (paragraph and
461
+ * text-box boundaries, line breaks, part separators). A replacement never
462
+ * crosses one: a range that contains a break is split at it, the first piece
463
+ * receives the replacement and the following pieces are emptied, so text is
464
+ * never moved from one paragraph (or text box) into another. Restoring the
465
+ * placeholder then yields the original value once, not once per piece.
162
466
  */
163
- export function applyTextReplacements(textNodes, replacements) {
467
+ export function applyTextReplacements(textNodes, replacements, breaks = []) {
164
468
  const sorted = normalizeReplacements(replacements);
165
- for (const repl of [...sorted].reverse()) {
469
+ const pieces = breaks.length === 0
470
+ ? sorted
471
+ : sorted.flatMap((repl) => splitAtBreaks(repl, breaks));
472
+ for (const repl of [...pieces].reverse()) {
166
473
  applyReplacement(textNodes, repl.start, repl.end, repl.replacement);
167
474
  }
168
475
  }
476
+ /** Split one replacement range at every break strictly inside it. */
477
+ function splitAtBreaks(repl, breaks) {
478
+ const inside = breaks.filter((b) => b >= repl.start && b < repl.end).sort((a, b) => a - b);
479
+ if (inside.length === 0)
480
+ return [repl];
481
+ const pieces = [];
482
+ let cursor = repl.start;
483
+ let first = true;
484
+ const push = (start, end) => {
485
+ if (start >= end)
486
+ return;
487
+ pieces.push({ start, end, replacement: first ? repl.replacement : '' });
488
+ first = false;
489
+ };
490
+ for (const b of inside) {
491
+ push(cursor, b);
492
+ cursor = b + 1;
493
+ }
494
+ push(cursor, repl.end);
495
+ return pieces;
496
+ }
169
497
  /**
170
498
  * Tracked-change elements that are removed outright when accepting changes:
171
499
  * deleted content, move sources, and every *PrChange record (each carries a
@@ -196,7 +524,7 @@ function acceptTrackedChangesInPart(xmlDoc) {
196
524
  for (let i = 0; i < all.length; i++)
197
525
  snapshot.push(all[i]);
198
526
  for (const el of snapshot) {
199
- if (el.namespaceURI !== W_NS)
527
+ if (el.namespaceURI === null || !OOXML_NS.w.has(el.namespaceURI))
200
528
  continue;
201
529
  if (TRACKED_REMOVE.has(el.localName)) {
202
530
  el.parentNode?.removeChild(el);
@@ -217,37 +545,107 @@ function acceptTrackedChangesInPart(xmlDoc) {
217
545
  * and modifies the XML in-place. Also scrubs document metadata and relationship
218
546
  * targets that can leak identities (authors, revision names, hyperlink emails).
219
547
  *
220
- * Returns a new .docx file as a Blob.
548
+ * Returns a new .docx file as a Blob. writeAnonymizedDocxWithReport returns
549
+ * the same Blob together with the layer-zero warnings.
221
550
  */
222
551
  export async function writeAnonymizedDocx(extraction, replacements, valueReplacements = [], options = {}) {
552
+ const result = await writeAnonymizedDocxWithReport(extraction, replacements, valueReplacements, options);
553
+ return result.blob;
554
+ }
555
+ /**
556
+ * Same as writeAnonymizedDocx, with the write report. Pipeline (T172, T174):
557
+ * 1. offset replacements in the mapped text and attribute units (never
558
+ * across a break);
559
+ * 2. bookmark rename: a bookmark whose name changed gets a valid identifier,
560
+ * and every reference to it (w:hyperlink@w:anchor, REF / PAGEREF /
561
+ * NOTEREF / HYPERLINK \l instructions) is updated across all parts;
562
+ * 3. per content part: attribute scrub (authors, field arguments, alt
563
+ * text), reference update, always-scrub group (tooltips, content control
564
+ * tags without a data binding), optional tracked-change acceptance,
565
+ * serialize;
566
+ * 4. metadata scrub (docProps, people, settings incl. docVars, rels incl.
567
+ * the mailto rule, thumbnail);
568
+ * 5. layer zero: every known value scrubbed from every other XML/rels/vml
569
+ * part in the package (charts, diagrams, customXml, docVars, glossary,
570
+ * docProps), so a value the extractors never saw cannot leave in a part
571
+ * they do not read;
572
+ * 6. container normalisation (T179): every zip entry dated 1980-01-01 with
573
+ * an empty comment, empty archive comment; then zip generation.
574
+ */
575
+ export async function writeAnonymizedDocxWithReport(extraction, replacements, valueReplacements = [], options = {}) {
576
+ assertUnredactableAllowed(extraction.unredactable, options.allowUnredactable);
577
+ const bookmarkOriginals = snapshotBookmarkNames(extraction.contentParts);
223
578
  // Apply flat-text replacements to the mapped text nodes (spanning runs when
224
579
  // needed), from end to start so positions stay valid.
225
- applyTextReplacements(extraction.textNodes, replacements);
580
+ applyTextReplacements(extraction.textNodes, replacements, extraction.paragraphBreaks.map((pb) => pb.flatIndex));
581
+ const renames = renameBookmarks(extraction.contentParts, bookmarkOriginals, valueReplacements);
226
582
  // Serialize all modified XML documents back to the zip
227
583
  const serializer = new XMLSerializer();
584
+ const contentPaths = new Set();
228
585
  for (const part of extraction.contentParts) {
229
586
  sanitizeContentPartAttributes(part.xmlDoc, valueReplacements);
587
+ if (renames.length > 0)
588
+ updateBookmarkReferences(part.xmlDoc, renames);
589
+ scrubAlwaysContentPart(part.xmlDoc);
230
590
  if (options.acceptTrackedChanges) {
231
591
  acceptTrackedChangesInPart(part.xmlDoc);
232
592
  }
593
+ // Charts built from an embedded workbook: drop the link so Word never
594
+ // opens the (unredacted) workbook to refresh the chart (T175).
595
+ if (CHART_PART.test(part.path))
596
+ stripChartExternalData(part.xmlDoc);
233
597
  const xmlStr = serializer.serializeToString(part.xmlDoc);
234
598
  extraction.zip.file(part.path, xmlStr);
599
+ // Parts the extractor reads only partially (numbering and webSettings:
600
+ // one attribute family; charts, SmartArt, custom XML: the text units)
601
+ // keep their layer-zero pass for everything else in them.
602
+ if (!PARTIAL_EXTRACTION_PART.test(part.path))
603
+ contentPaths.add(part.path);
235
604
  }
236
605
  await sanitizeDocxMetadata(extraction.zip, valueReplacements);
237
- return extraction.zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
606
+ const scrub = await scrubPackageValues(extraction.zip, valueReplacements, { skip: contentPaths });
607
+ normalizeZipEntries(extraction.zip);
608
+ const blob = await extraction.zip.generateAsync({
609
+ type: 'blob',
610
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
611
+ comment: '',
612
+ });
613
+ return { blob, warnings: [...scrub.warnings, ...unredactableWarnings(extraction.unredactable)] };
614
+ }
615
+ /**
616
+ * Content parts whose extracted units do not cover every text-bearing
617
+ * location, so the layer-zero value scrub still visits them: numbering and
618
+ * webSettings (attribute units only), charts (numeric caches, formulas),
619
+ * SmartArt, chart user shapes and custom XML (attribute values).
620
+ */
621
+ const PARTIAL_EXTRACTION_PART = /(?:^|\/)(?:numbering|webSettings)\.xml$|^word\/(?:glossary\/)?(?:charts|diagrams|drawings)\/|^customXml\//;
622
+ /** Chart parts (c:chartSpace), where c:externalData is stripped on write. */
623
+ const CHART_PART = /^word\/(?:glossary\/)?charts\/chart\d*\.xml$/;
624
+ /** True for a w:rsid, w:rsidR, w:rsidRPr, w:rsidP, w:rsidDel, w:rsidTr, w:rsidSect or w:rsidRDefault attribute. */
625
+ function isRsidAttribute(attr) {
626
+ return attr.localName.startsWith('rsid') && attr.namespaceURI !== null && OOXML_NS.w.has(attr.namespaceURI);
238
627
  }
239
628
  /**
240
629
  * Scrub identity-bearing attributes inside a content part:
241
630
  * - w:author / w:initials on tracked changes (w:ins, w:del) and comments
242
- * - alt text and object names on drawings (docPr, cNvPr)
243
- * - field instructions held in attributes (w:fldSimple @w:instr)
631
+ * - w:date on comments, insertions, deletions, moves and every *Change
632
+ * record set to NORMALISED_DATE (T179, M6): the timestamps date the
633
+ * original's editing history even when the author is gone
634
+ * - every w:rsid* attribute removed (T179): revision-save ids link the
635
+ * paragraphs and runs to the editing sessions listed in settings.xml,
636
+ * which the metadata pass drops; the document needs neither
637
+ * - alt text and object names on drawings (docPr, cNvPr); docPr alt text
638
+ * also went through detection (T174), this removal is the second layer
639
+ * - field instructions held in attributes (w:fldSimple @w:instr): known
640
+ * values are removed from the arguments only, never from the keyword
244
641
  */
245
642
  function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
246
643
  const all = xmlDoc.getElementsByTagName('*');
247
644
  for (let i = 0; i < all.length; i++) {
248
645
  const el = all[i];
249
646
  const attrs = el.attributes;
250
- for (let a = 0; a < attrs.length; a++) {
647
+ // Back to front: removing an attribute shifts the live NamedNodeMap.
648
+ for (let a = attrs.length - 1; a >= 0; a--) {
251
649
  const attr = attrs[a];
252
650
  if (attr.localName === 'author') {
253
651
  attr.value = 'Redacted';
@@ -256,7 +654,13 @@ function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
256
654
  attr.value = 'R';
257
655
  }
258
656
  else if (attr.localName === 'instr') {
259
- attr.value = replaceSensitiveValues(attr.value, valueReplacements, false);
657
+ attr.value = scrubInstructionArguments(attr.value, valueReplacements);
658
+ }
659
+ else if (attr.localName === 'date' && attr.namespaceURI !== null && OOXML_NS.w.has(attr.namespaceURI)) {
660
+ attr.value = NORMALISED_DATE;
661
+ }
662
+ else if (isRsidAttribute(attr)) {
663
+ el.removeAttributeNode(attr);
260
664
  }
261
665
  }
262
666
  if (el.localName === 'docPr' || el.localName === 'cNvPr') {
@@ -269,6 +673,231 @@ function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
269
673
  }
270
674
  }
271
675
  }
676
+ /** Known-value scrub restricted to the arguments of a field instruction. */
677
+ function scrubInstructionArguments(instr, valueReplacements) {
678
+ if (valueReplacements.length === 0)
679
+ return instr;
680
+ let result = instr;
681
+ // Back to front so earlier offsets stay valid after a length change.
682
+ for (const arg of fieldInstructionArguments(instr).reverse()) {
683
+ const scrubbed = replaceSensitiveValues(arg.text, valueReplacements, false);
684
+ if (scrubbed !== arg.text) {
685
+ result = result.slice(0, arg.offset) + scrubbed + result.slice(arg.offset + arg.text.length);
686
+ }
687
+ }
688
+ return result;
689
+ }
690
+ /**
691
+ * Always-scrub group inside a content part (T174, M1). No detection and no
692
+ * value list: these attributes hold free text the detector cannot be
693
+ * trusted to catch and the document does not need them to open.
694
+ * - w:hyperlink@w:tooltip is removed
695
+ * - w:sdtPr/w:tag is removed unless the same w:sdtPr has a w:dataBinding
696
+ * (the tag then addresses the bound custom XML node and must survive)
697
+ */
698
+ function scrubAlwaysContentPart(xmlDoc) {
699
+ for (const link of elementsByLocalName(xmlDoc, OOXML_NS.w, 'hyperlink')) {
700
+ const tooltip = wordAttr(link, 'tooltip');
701
+ if (tooltip)
702
+ link.removeAttributeNode(tooltip);
703
+ }
704
+ for (const tag of elementsByLocalName(xmlDoc, OOXML_NS.w, 'tag')) {
705
+ const parent = tag.parentElement;
706
+ if (parent && isWordEl(parent, 'sdtPr')) {
707
+ let bound = false;
708
+ for (let child = parent.firstElementChild; child; child = child.nextElementSibling) {
709
+ if (isWordEl(child, 'dataBinding')) {
710
+ bound = true;
711
+ break;
712
+ }
713
+ }
714
+ if (bound)
715
+ continue;
716
+ }
717
+ tag.parentNode?.removeChild(tag);
718
+ }
719
+ }
720
+ /** Bookmark names before replacements, keyed by the live attribute node. */
721
+ function snapshotBookmarkNames(parts) {
722
+ const originals = new Map();
723
+ for (const part of parts) {
724
+ for (const el of elementsByLocalName(part.xmlDoc, OOXML_NS.w, 'bookmarkStart')) {
725
+ const attr = wordAttr(el, 'name');
726
+ if (attr)
727
+ originals.set(attr, attr.value);
728
+ }
729
+ }
730
+ return originals;
731
+ }
732
+ /**
733
+ * Turn a replacement into a valid Word bookmark identifier: ASCII letters,
734
+ * digits and underscores, starting with a letter, at most 40 characters,
735
+ * unique among the names in `used` (which it extends). '[PERSON_1]' becomes
736
+ * 'PERSON_1'; a replacement that starts with a digit or underscore gets the
737
+ * 'bm_' prefix.
738
+ */
739
+ export function bookmarkIdentifier(replacement, used = new Set()) {
740
+ let name = replacement.replace(/[^A-Za-z0-9_]/g, '');
741
+ if (name.length === 0)
742
+ name = 'bm';
743
+ if (!/^[A-Za-z]/.test(name))
744
+ name = 'bm_' + name;
745
+ name = name.slice(0, 40);
746
+ let candidate = name;
747
+ for (let n = 2; used.has(candidate); n++) {
748
+ const suffix = `_${n}`;
749
+ candidate = name.slice(0, 40 - suffix.length) + suffix;
750
+ }
751
+ used.add(candidate);
752
+ return candidate;
753
+ }
754
+ /**
755
+ * Rewrite every bookmark whose name changed (through an offset replacement
756
+ * of its unit, or through a known value inside it) as a valid identifier.
757
+ * Names that did not change are kept verbatim and reserved so a new name
758
+ * never collides with them.
759
+ */
760
+ function renameBookmarks(parts, originals, valueReplacements) {
761
+ // Same safety rule as the layer-zero attribute scrub: only lettered values
762
+ // may match inside an identifier ('_Toc2024' must not lose its year).
763
+ const lettered = valueReplacements.filter((pair) => /\p{L}/u.test(pair.value));
764
+ const used = new Set();
765
+ const pending = [];
766
+ for (const part of parts) {
767
+ for (const el of elementsByLocalName(part.xmlDoc, OOXML_NS.w, 'bookmarkStart')) {
768
+ const attr = wordAttr(el, 'name');
769
+ if (!attr)
770
+ continue;
771
+ const oldName = originals.get(attr) ?? attr.value;
772
+ const rawValue = lettered.length > 0 ? replaceSensitiveValues(attr.value, lettered, false) : attr.value;
773
+ if (rawValue === oldName) {
774
+ used.add(oldName);
775
+ continue;
776
+ }
777
+ pending.push({ attr, oldName, rawValue });
778
+ }
779
+ }
780
+ const renames = [];
781
+ for (const item of pending) {
782
+ const newName = bookmarkIdentifier(item.rawValue, used);
783
+ item.attr.value = newName;
784
+ renames.push({ oldName: item.oldName, rawValue: item.rawValue, newName });
785
+ }
786
+ return renames;
787
+ }
788
+ /** Field keywords whose first argument is a bookmark name. */
789
+ const BOOKMARK_REFERENCE_KEYWORDS = new Set(['REF', 'PAGEREF', 'NOTEREF']);
790
+ function escapeRegExp(value) {
791
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
792
+ }
793
+ /**
794
+ * Compiled lookup for the reference update: a token equal to a renamed
795
+ * bookmark's old name, or to the raw placeholder that the replacements put
796
+ * in its place (the same entity in a REF argument gets the same placeholder
797
+ * before the rename pass runs), maps to the new identifier. Exact token
798
+ * equality only: a token is delimited by whitespace, quotes or a switch.
799
+ */
800
+ function bookmarkTokenLookup(renames) {
801
+ const map = new Map();
802
+ for (const rename of renames) {
803
+ if (!map.has(rename.oldName))
804
+ map.set(rename.oldName, rename.newName);
805
+ if (rename.rawValue.length > 0 && !map.has(rename.rawValue))
806
+ map.set(rename.rawValue, rename.newName);
807
+ }
808
+ const alternatives = [...map.keys()]
809
+ .sort((a, b) => b.length - a.length)
810
+ .map(escapeRegExp)
811
+ .join('|');
812
+ return { alternatives, map };
813
+ }
814
+ /**
815
+ * Update the bookmark argument of one field instruction. REF / PAGEREF /
816
+ * NOTEREF: every token after the keyword; HYPERLINK: only the token that
817
+ * follows the \l switch. The keyword itself is never touched. Other fields
818
+ * are returned unchanged.
819
+ */
820
+ function updateInstructionReferences(instr, lookup) {
821
+ if (lookup.alternatives.length === 0)
822
+ return instr;
823
+ const keywordMatch = /^\s*([^\s"]+)/.exec(instr);
824
+ if (!keywordMatch)
825
+ return instr;
826
+ const keyword = keywordMatch[1].toUpperCase();
827
+ const keywordEnd = keywordMatch[0].length;
828
+ const swap = (token) => lookup.map.get(token) ?? token;
829
+ if (BOOKMARK_REFERENCE_KEYWORDS.has(keyword)) {
830
+ const pattern = new RegExp(`(^|[\\s"])(${lookup.alternatives})(?=[\\s"\\\\]|$)`, 'g');
831
+ const rest = instr.slice(keywordEnd).replace(pattern, (_m, lead, token) => lead + swap(token));
832
+ return instr.slice(0, keywordEnd) + rest;
833
+ }
834
+ if (keyword === 'HYPERLINK') {
835
+ const pattern = new RegExp(`(\\\\l\\s*"?)(${lookup.alternatives})(?=[\\s"\\\\]|$)`, 'g');
836
+ return instr.replace(pattern, (_m, lead, token) => lead + swap(token));
837
+ }
838
+ return instr;
839
+ }
840
+ /**
841
+ * Point every reference at the renamed bookmarks in one content part:
842
+ * w:hyperlink@w:anchor (exact equality), w:fldSimple@w:instr and the joined
843
+ * w:instrText / w:delInstrText of every complex field (fldChar begin..end,
844
+ * nested fields tracked with a stack). A changed complex instruction is
845
+ * written into its first instrText node and the following ones are emptied,
846
+ * the same way a spanning text replacement lands in the first run.
847
+ */
848
+ function updateBookmarkReferences(xmlDoc, renames) {
849
+ const lookup = bookmarkTokenLookup(renames);
850
+ if (lookup.alternatives.length === 0)
851
+ return;
852
+ for (const link of elementsByLocalName(xmlDoc, OOXML_NS.w, 'hyperlink')) {
853
+ const anchor = wordAttr(link, 'anchor');
854
+ if (!anchor)
855
+ continue;
856
+ const target = lookup.map.get(anchor.value);
857
+ if (target !== undefined)
858
+ anchor.value = target;
859
+ }
860
+ for (const field of elementsByLocalName(xmlDoc, OOXML_NS.w, 'fldSimple')) {
861
+ const instr = wordAttr(field, 'instr');
862
+ if (!instr)
863
+ continue;
864
+ const updated = updateInstructionReferences(instr.value, lookup);
865
+ if (updated !== instr.value)
866
+ instr.value = updated;
867
+ }
868
+ const fields = [];
869
+ const stack = [];
870
+ const all = xmlDoc.getElementsByTagName('*');
871
+ for (let i = 0; i < all.length; i++) {
872
+ const el = all[i];
873
+ if (isWordEl(el, 'fldChar')) {
874
+ const type = wordAttr(el, 'fldCharType')?.value;
875
+ if (type === 'begin') {
876
+ const nodes = [];
877
+ stack.push(nodes);
878
+ fields.push(nodes);
879
+ }
880
+ else if (type === 'end') {
881
+ stack.pop();
882
+ }
883
+ }
884
+ else if ((isWordEl(el, 'instrText') || isWordEl(el, 'delInstrText')) && stack.length > 0) {
885
+ stack[stack.length - 1].push(el);
886
+ }
887
+ }
888
+ for (const nodes of fields) {
889
+ if (nodes.length === 0)
890
+ continue;
891
+ const joined = nodes.map((node) => node.textContent ?? '').join('');
892
+ const updated = updateInstructionReferences(joined, lookup);
893
+ if (updated === joined)
894
+ continue;
895
+ nodes[0].textContent = updated;
896
+ nodes[0].setAttribute('xml:space', 'preserve');
897
+ for (let i = 1; i < nodes.length; i++)
898
+ nodes[i].textContent = '';
899
+ }
900
+ }
272
901
  /**
273
902
  * Scrub metadata parts of the package that survive text replacement untouched:
274
903
  * document properties, comment author registry, revision fingerprints,
@@ -290,37 +919,10 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
290
919
  const writeXml = (path, doc) => {
291
920
  zip.file(path, serializer.serializeToString(doc));
292
921
  };
293
- const blankElementsByLocalName = (doc, names) => {
294
- const all = doc.getElementsByTagName('*');
295
- for (let i = 0; i < all.length; i++) {
296
- if (names.has(all[i].localName))
297
- all[i].textContent = '';
298
- }
299
- };
300
- // Core properties: author identities and content-describing fields
301
- const core = await readXml('docProps/core.xml');
302
- if (core) {
303
- blankElementsByLocalName(core, new Set(['creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category']));
304
- writeXml('docProps/core.xml', core);
305
- }
306
- // Extended properties: organization and template/path hints
307
- const app = await readXml('docProps/app.xml');
308
- if (app) {
309
- blankElementsByLocalName(app, new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']));
310
- writeXml('docProps/app.xml', app);
311
- }
312
- // Custom properties: blank all values, keep the property names/structure
313
- const custom = await readXml('docProps/custom.xml');
314
- if (custom) {
315
- const props = custom.getElementsByTagName('*');
316
- for (let i = 0; i < props.length; i++) {
317
- const el = props[i];
318
- if (el.localName !== 'property' && el.childElementCount === 0 && (el.textContent ?? '') !== '') {
319
- el.textContent = '';
320
- }
321
- }
322
- writeXml('docProps/custom.xml', custom);
323
- }
922
+ // Document properties (core, app, custom): the shared OPC scrub, extended
923
+ // by T179 with content status, identifier, timestamps, revision, statistics
924
+ // and the TitlesOfParts / HeadingPairs vectors.
925
+ await scrubDocPropsParts(zip);
324
926
  // Comment author registry (word/people.xml): names and provider user ids
325
927
  const people = await readXml('word/people.xml');
326
928
  if (people) {
@@ -335,20 +937,22 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
335
937
  }
336
938
  writeXml('word/people.xml', people);
337
939
  }
338
- // Settings: drop mail-merge data sources, revision-save ids, attached template
940
+ // Settings: drop mail-merge data sources, revision-save ids, attached
941
+ // template and document variables (free-form name/value pairs written by
942
+ // macros and add-ins; always removed, T174).
339
943
  const settings = await readXml('word/settings.xml');
340
944
  if (settings) {
341
- for (const name of ['mailMerge', 'rsids', 'attachedTemplate']) {
342
- const els = settings.getElementsByTagNameNS(W_NS, name);
343
- for (let i = els.length - 1; i >= 0; i--) {
344
- els[i].parentNode?.removeChild(els[i]);
945
+ for (const name of ['mailMerge', 'rsids', 'attachedTemplate', 'docVars']) {
946
+ for (const el of elementsByLocalName(settings, OOXML_NS.w, name)) {
947
+ el.parentNode?.removeChild(el);
345
948
  }
346
949
  }
347
950
  writeXml('word/settings.xml', settings);
348
951
  }
349
- // Relationship files: scrub sensitive values from external targets
350
- // (hyperlink mailto:/URLs keep the raw value even when display text is redacted)
351
- // and detach the document template relationship.
952
+ // Relationship files: replace every mailto: target (T174), scrub sensitive
953
+ // values from the other external targets (URLs keep the raw value even
954
+ // when display text is redacted) and detach the document template
955
+ // relationship.
352
956
  const relsPaths = [];
353
957
  zip.forEach((relativePath) => {
354
958
  if (/^word\/(?:glossary\/)?_rels\/[^/]+\.rels$/.test(relativePath)) {
@@ -369,7 +973,7 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
369
973
  }
370
974
  if (rel.getAttribute('TargetMode') === 'External') {
371
975
  const target = rel.getAttribute('Target') ?? '';
372
- const scrubbed = replaceSensitiveValues(target, valueReplacements, true);
976
+ const scrubbed = replaceSensitiveValues(scrubMailtoTarget(target), valueReplacements, true);
373
977
  if (scrubbed !== target)
374
978
  rel.setAttribute('Target', scrubbed);
375
979
  }
@@ -411,8 +1015,32 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
411
1015
  }
412
1016
  }
413
1017
  }
1018
+ /** Current text of a unit: element text, or the mapped slice of the attribute. */
1019
+ function unitText(node) {
1020
+ if (node.attr) {
1021
+ const offset = node.attrOffset ?? 0;
1022
+ return node.attr.value.slice(offset, offset + (node.flatEnd - node.flatStart));
1023
+ }
1024
+ return node.element.textContent ?? '';
1025
+ }
1026
+ /**
1027
+ * Write a unit's text back: element text (with xml:space preserved so
1028
+ * leading/trailing spaces survive) or the mapped slice of the attribute.
1029
+ * Must run before the node's flat positions are updated, since the slice
1030
+ * length is derived from them.
1031
+ */
1032
+ function setUnitText(node, text) {
1033
+ if (node.attr) {
1034
+ const offset = node.attrOffset ?? 0;
1035
+ const value = node.attr.value;
1036
+ node.attr.value = value.slice(0, offset) + text + value.slice(offset + (node.flatEnd - node.flatStart));
1037
+ return;
1038
+ }
1039
+ node.element.textContent = text;
1040
+ node.element.setAttribute('xml:space', 'preserve');
1041
+ }
414
1042
  /**
415
- * Apply a single text replacement across potentially multiple <w:t> elements.
1043
+ * Apply a single text replacement across potentially multiple units.
416
1044
  */
417
1045
  function applyReplacement(textNodes, replStart, replEnd, replacement) {
418
1046
  // Find all text nodes that overlap with this replacement range
@@ -425,35 +1053,34 @@ function applyReplacement(textNodes, replStart, replEnd, replacement) {
425
1053
  if (affectedNodes.length === 0)
426
1054
  return;
427
1055
  if (affectedNodes.length === 1) {
428
- // Simple case: replacement is within a single <w:t> element
1056
+ // Simple case: replacement is within a single unit
429
1057
  const node = affectedNodes[0];
430
- const currentText = node.element.textContent ?? '';
431
- const localStart = replStart - node.flatStart;
432
- const localEnd = replEnd - node.flatStart;
1058
+ const currentText = unitText(node);
1059
+ // A range may begin or end inside a zero-width separator gap (T172):
1060
+ // clamp to the node's own span.
1061
+ const localStart = Math.max(0, replStart - node.flatStart);
1062
+ const localEnd = Math.min(currentText.length, replEnd - node.flatStart);
433
1063
  const newText = currentText.slice(0, localStart) + replacement + currentText.slice(localEnd);
434
- node.element.textContent = newText;
435
- // Ensure space preservation
436
- node.element.setAttribute('xml:space', 'preserve');
1064
+ setUnitText(node, newText);
437
1065
  // Update flat positions for this node and all subsequent nodes
438
1066
  const lengthDiff = replacement.length - (replEnd - replStart);
439
1067
  node.flatEnd += lengthDiff;
440
1068
  updateSubsequentNodes(textNodes, node, lengthDiff);
441
1069
  }
442
1070
  else {
443
- // Multi-node case: replacement spans multiple <w:t> elements
1071
+ // Multi-node case: replacement spans multiple units
444
1072
  // Put all replacement text in the first affected node, clear the rest
445
1073
  const firstNode = affectedNodes[0];
446
1074
  const lastNode = affectedNodes[affectedNodes.length - 1];
447
- const firstText = firstNode.element.textContent ?? '';
448
- const lastText = lastNode.element.textContent ?? '';
449
- const keepBefore = firstText.slice(0, replStart - firstNode.flatStart);
450
- const keepAfter = lastText.slice(replEnd - lastNode.flatStart);
1075
+ const firstText = unitText(firstNode);
1076
+ const lastText = unitText(lastNode);
1077
+ const keepBefore = firstText.slice(0, Math.max(0, replStart - firstNode.flatStart));
1078
+ const keepAfter = lastText.slice(Math.min(lastText.length, replEnd - lastNode.flatStart));
451
1079
  // Set first node to: text-before-replacement + replacement + text-after-replacement-in-last-node
452
- firstNode.element.textContent = keepBefore + replacement + keepAfter;
453
- firstNode.element.setAttribute('xml:space', 'preserve');
1080
+ setUnitText(firstNode, keepBefore + replacement + keepAfter);
454
1081
  // Clear intermediate and last nodes
455
1082
  for (let i = 1; i < affectedNodes.length; i++) {
456
- affectedNodes[i].element.textContent = '';
1083
+ setUnitText(affectedNodes[i], '');
457
1084
  }
458
1085
  // Recalculate flat positions
459
1086
  const newFirstLength = (keepBefore + replacement + keepAfter).length;