@doccloak/core 0.10.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 +162 -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 +747 -134
  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 +141 -7
  16. package/dist/dom/opc.js +424 -18
  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 +137 -4
  41. package/dist/session.js +591 -62
  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,73 +8,322 @@
8
8
  */
9
9
  import JSZip from 'jszip';
10
10
  import { normalizeReplacements } from "../docx.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";
11
15
  export { normalizeReplacements };
12
- 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
+ }
13
24
  /**
14
25
  * Element local names (in the w: namespace) that carry document text.
15
26
  * - t: regular run text
16
27
  * - delText: text inside tracked deletions (w:del); still physically present in the file
17
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).
77
+ */
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)
18
133
  */
19
- const TEXT_ELEMENT_NAMES = new Set(['t', 'delText', 'instrText']);
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
+ }
20
182
  /**
21
183
  * Extract all text-bearing elements from an XML document, building a flat text
22
- * 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.
23
193
  */
24
194
  function extractTextFromXml(xmlDoc) {
25
195
  const textNodes = [];
26
196
  const paragraphBreaks = [];
27
197
  let flatText = '';
28
- const body = xmlDoc.getElementsByTagNameNS(W_NS, 'body')[0];
29
- if (!body) {
30
- // For headers/footers/footnotes/comments, process the root element's children
31
- const root = xmlDoc.documentElement;
32
- processParagraphs(root);
33
- }
34
- else {
35
- processParagraphs(body);
36
- }
37
- function processParagraphs(parent) {
38
- const paragraphs = parent.getElementsByTagNameNS(W_NS, 'p');
39
- // Paragraphs can nest (text boxes hold w:p inside an outer w:p run), so the
40
- // same text element is reachable from more than one paragraph. Track visited
41
- // elements to map each one exactly once.
42
- const seen = new Set();
43
- for (let pi = 0; pi < paragraphs.length; pi++) {
44
- const para = paragraphs[pi];
45
- if (pi > 0) {
46
- paragraphBreaks.push({ flatIndex: flatText.length });
47
- 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;
48
218
  }
49
- // Walk all descendants in document order so t/delText/instrText interleave correctly
50
- const descendants = para.getElementsByTagName('*');
51
- for (let di = 0; di < descendants.length; di++) {
52
- const el = descendants[di];
53
- if (el.namespaceURI !== W_NS || !TEXT_ELEMENT_NAMES.has(el.localName))
219
+ else if (isTextElement(child)) {
220
+ const text = child.textContent ?? '';
221
+ if (text.length === 0)
54
222
  continue;
55
- if (seen.has(el))
56
- continue;
57
- seen.add(el);
58
- const text = el.textContent ?? '';
59
- if (text.length > 0) {
60
- textNodes.push({
61
- element: el,
62
- flatStart: flatText.length,
63
- flatEnd: flatText.length + text.length,
64
- });
65
- 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();
66
237
  }
238
+ else {
239
+ flatText += sep;
240
+ }
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);
67
315
  }
68
316
  }
69
317
  }
70
- return { plainText: flatText, textNodes, paragraphBreaks };
318
+ return targets;
71
319
  }
72
320
  /**
73
321
  * Read a .docx file and extract its text content with position mapping.
74
322
  */
75
- export async function readDocx(file) {
323
+ export async function readDocx(file, options = {}) {
76
324
  const arrayBuffer = await file.arrayBuffer();
77
325
  const zip = await JSZip.loadAsync(arrayBuffer);
326
+ assertUnpackedSize(zip, options.maxUnpackedBytes ?? MAX_UNPACKED_BYTES);
78
327
  // Find the main document XML path
79
328
  const documentXmlPath = 'word/document.xml';
80
329
  const docXmlFile = zip.file(documentXmlPath);
@@ -86,11 +335,27 @@ export async function readDocx(file) {
86
335
  const xmlDoc = parser.parseFromString(docXmlStr, 'application/xml');
87
336
  const parseError = xmlDoc.querySelector('parsererror');
88
337
  if (parseError) {
89
- 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
+ }
90
355
  }
91
- const { plainText, textNodes, paragraphBreaks } = extractTextFromXml(xmlDoc);
92
356
  // Also process headers and footers
93
357
  const contentParts = [];
358
+ const warnings = [];
94
359
  let combinedText = plainText;
95
360
  // Add main document as first content part
96
361
  contentParts.push({
@@ -102,28 +367,37 @@ export async function readDocx(file) {
102
367
  flatTextEnd: plainText.length,
103
368
  });
104
369
  // Find all other text-bearing parts: headers, footers, footnotes, endnotes,
105
- // comments, and the glossary (building blocks) subdocument. PII in any of
106
- // these survives in the exported file if it is not extracted and redacted.
107
- const headerFooterPaths = [];
108
- zip.forEach((relativePath) => {
109
- if (relativePath.match(/^word\/(?:(?:header|footer)\d*|footnotes|endnotes|comments)\.xml$/) ||
110
- relativePath === 'word/glossary/document.xml') {
111
- headerFooterPaths.push(relativePath);
112
- }
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);
113
381
  });
114
382
  // Deterministic order so load-time and export-time extractions produce identical offsets
115
- headerFooterPaths.sort();
116
- for (const hfPath of headerFooterPaths) {
383
+ secondaryPaths.sort();
384
+ for (const hfPath of secondaryPaths) {
117
385
  const hfFile = zip.file(hfPath);
118
386
  if (!hfFile)
119
387
  continue;
120
388
  const hfXmlStr = await hfFile.async('string');
121
389
  const hfXmlDoc = parser.parseFromString(hfXmlStr, 'application/xml');
122
- if (hfXmlDoc.querySelector('parsererror'))
390
+ if (hfXmlDoc.querySelector('parsererror')) {
391
+ warnings.push(`${hfPath}: not well-formed XML, skipped by the text extractor`);
123
392
  continue;
124
- const hfExtraction = extractTextFromXml(hfXmlDoc);
393
+ }
394
+ const extract = extractorFor(hfPath);
395
+ const hfExtraction = extract(hfXmlDoc);
125
396
  if (hfExtraction.plainText.length === 0)
126
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 };
127
401
  const offset = combinedText.length + 1; // +1 for separator newline
128
402
  combinedText += '\n' + hfExtraction.plainText;
129
403
  // Adjust text node mappings to the combined text offset
@@ -136,13 +410,20 @@ export async function readDocx(file) {
136
410
  path: hfPath,
137
411
  xmlDoc: hfXmlDoc,
138
412
  textNodes: adjustedNodes,
139
- paragraphBreaks: hfExtraction.paragraphBreaks.map((pb) => ({
140
- flatIndex: pb.flatIndex + offset,
141
- })),
413
+ paragraphBreaks: [
414
+ separatorBreak,
415
+ ...hfExtraction.paragraphBreaks.map((pb) => ({ flatIndex: pb.flatIndex + offset })),
416
+ ],
142
417
  flatTextStart: offset,
143
418
  flatTextEnd: offset + hfExtraction.plainText.length,
144
419
  });
145
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
+ });
146
427
  return {
147
428
  plainText: combinedText,
148
429
  xmlDoc,
@@ -151,20 +432,68 @@ export async function readDocx(file) {
151
432
  zip,
152
433
  documentXmlPath,
153
434
  contentParts,
435
+ empty: combinedText.trim().length === 0,
436
+ unredactable,
437
+ warnings,
154
438
  };
155
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
+ }
156
454
  /**
157
455
  * Apply a list of flat-text replacements to mapped text nodes, handling
158
456
  * replacements that span multiple elements. Ranges are normalized (sorted,
159
457
  * overlaps clamped) and applied end-to-start so earlier positions stay valid.
160
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.
161
466
  */
162
- export function applyTextReplacements(textNodes, replacements) {
467
+ export function applyTextReplacements(textNodes, replacements, breaks = []) {
163
468
  const sorted = normalizeReplacements(replacements);
164
- 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()) {
165
473
  applyReplacement(textNodes, repl.start, repl.end, repl.replacement);
166
474
  }
167
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
+ }
168
497
  /**
169
498
  * Tracked-change elements that are removed outright when accepting changes:
170
499
  * deleted content, move sources, and every *PrChange record (each carries a
@@ -195,7 +524,7 @@ function acceptTrackedChangesInPart(xmlDoc) {
195
524
  for (let i = 0; i < all.length; i++)
196
525
  snapshot.push(all[i]);
197
526
  for (const el of snapshot) {
198
- if (el.namespaceURI !== W_NS)
527
+ if (el.namespaceURI === null || !OOXML_NS.w.has(el.namespaceURI))
199
528
  continue;
200
529
  if (TRACKED_REMOVE.has(el.localName)) {
201
530
  el.parentNode?.removeChild(el);
@@ -216,37 +545,107 @@ function acceptTrackedChangesInPart(xmlDoc) {
216
545
  * and modifies the XML in-place. Also scrubs document metadata and relationship
217
546
  * targets that can leak identities (authors, revision names, hyperlink emails).
218
547
  *
219
- * 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.
220
550
  */
221
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);
222
578
  // Apply flat-text replacements to the mapped text nodes (spanning runs when
223
579
  // needed), from end to start so positions stay valid.
224
- applyTextReplacements(extraction.textNodes, replacements);
580
+ applyTextReplacements(extraction.textNodes, replacements, extraction.paragraphBreaks.map((pb) => pb.flatIndex));
581
+ const renames = renameBookmarks(extraction.contentParts, bookmarkOriginals, valueReplacements);
225
582
  // Serialize all modified XML documents back to the zip
226
583
  const serializer = new XMLSerializer();
584
+ const contentPaths = new Set();
227
585
  for (const part of extraction.contentParts) {
228
586
  sanitizeContentPartAttributes(part.xmlDoc, valueReplacements);
587
+ if (renames.length > 0)
588
+ updateBookmarkReferences(part.xmlDoc, renames);
589
+ scrubAlwaysContentPart(part.xmlDoc);
229
590
  if (options.acceptTrackedChanges) {
230
591
  acceptTrackedChangesInPart(part.xmlDoc);
231
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);
232
597
  const xmlStr = serializer.serializeToString(part.xmlDoc);
233
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);
234
604
  }
235
605
  await sanitizeDocxMetadata(extraction.zip, valueReplacements);
236
- 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);
237
627
  }
238
628
  /**
239
629
  * Scrub identity-bearing attributes inside a content part:
240
630
  * - w:author / w:initials on tracked changes (w:ins, w:del) and comments
241
- * - alt text and object names on drawings (docPr, cNvPr)
242
- * - 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
243
641
  */
244
642
  function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
245
643
  const all = xmlDoc.getElementsByTagName('*');
246
644
  for (let i = 0; i < all.length; i++) {
247
645
  const el = all[i];
248
646
  const attrs = el.attributes;
249
- 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--) {
250
649
  const attr = attrs[a];
251
650
  if (attr.localName === 'author') {
252
651
  attr.value = 'Redacted';
@@ -255,7 +654,13 @@ function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
255
654
  attr.value = 'R';
256
655
  }
257
656
  else if (attr.localName === 'instr') {
258
- attr.value = replaceValues(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);
259
664
  }
260
665
  }
261
666
  if (el.localName === 'docPr' || el.localName === 'cNvPr') {
@@ -268,20 +673,230 @@ function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
268
673
  }
269
674
  }
270
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
+ }
271
840
  /**
272
- * Replace every occurrence of each sensitive value (case-insensitive) in a string.
273
- * Uses a replacer function so '$' sequences in values or placeholders are inert.
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.
274
847
  */
275
- function replaceValues(input, valueReplacements, urlEncode) {
276
- let result = input;
277
- for (const { value, replacement } of valueReplacements) {
278
- if (!value)
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)
279
855
  continue;
280
- const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
281
- const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
282
- result = result.replace(new RegExp(escaped, 'gi'), () => substitute);
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 = '';
283
899
  }
284
- return result;
285
900
  }
286
901
  /**
287
902
  * Scrub metadata parts of the package that survive text replacement untouched:
@@ -304,37 +919,10 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
304
919
  const writeXml = (path, doc) => {
305
920
  zip.file(path, serializer.serializeToString(doc));
306
921
  };
307
- const blankElementsByLocalName = (doc, names) => {
308
- const all = doc.getElementsByTagName('*');
309
- for (let i = 0; i < all.length; i++) {
310
- if (names.has(all[i].localName))
311
- all[i].textContent = '';
312
- }
313
- };
314
- // Core properties: author identities and content-describing fields
315
- const core = await readXml('docProps/core.xml');
316
- if (core) {
317
- blankElementsByLocalName(core, new Set(['creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category']));
318
- writeXml('docProps/core.xml', core);
319
- }
320
- // Extended properties: organization and template/path hints
321
- const app = await readXml('docProps/app.xml');
322
- if (app) {
323
- blankElementsByLocalName(app, new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']));
324
- writeXml('docProps/app.xml', app);
325
- }
326
- // Custom properties: blank all values, keep the property names/structure
327
- const custom = await readXml('docProps/custom.xml');
328
- if (custom) {
329
- const props = custom.getElementsByTagName('*');
330
- for (let i = 0; i < props.length; i++) {
331
- const el = props[i];
332
- if (el.localName !== 'property' && el.childElementCount === 0 && (el.textContent ?? '') !== '') {
333
- el.textContent = '';
334
- }
335
- }
336
- writeXml('docProps/custom.xml', custom);
337
- }
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);
338
926
  // Comment author registry (word/people.xml): names and provider user ids
339
927
  const people = await readXml('word/people.xml');
340
928
  if (people) {
@@ -349,20 +937,22 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
349
937
  }
350
938
  writeXml('word/people.xml', people);
351
939
  }
352
- // 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).
353
943
  const settings = await readXml('word/settings.xml');
354
944
  if (settings) {
355
- for (const name of ['mailMerge', 'rsids', 'attachedTemplate']) {
356
- const els = settings.getElementsByTagNameNS(W_NS, name);
357
- for (let i = els.length - 1; i >= 0; i--) {
358
- 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);
359
948
  }
360
949
  }
361
950
  writeXml('word/settings.xml', settings);
362
951
  }
363
- // Relationship files: scrub sensitive values from external targets
364
- // (hyperlink mailto:/URLs keep the raw value even when display text is redacted)
365
- // 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.
366
956
  const relsPaths = [];
367
957
  zip.forEach((relativePath) => {
368
958
  if (/^word\/(?:glossary\/)?_rels\/[^/]+\.rels$/.test(relativePath)) {
@@ -383,7 +973,7 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
383
973
  }
384
974
  if (rel.getAttribute('TargetMode') === 'External') {
385
975
  const target = rel.getAttribute('Target') ?? '';
386
- const scrubbed = replaceValues(target, valueReplacements, true);
976
+ const scrubbed = replaceSensitiveValues(scrubMailtoTarget(target), valueReplacements, true);
387
977
  if (scrubbed !== target)
388
978
  rel.setAttribute('Target', scrubbed);
389
979
  }
@@ -425,8 +1015,32 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
425
1015
  }
426
1016
  }
427
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
+ }
428
1042
  /**
429
- * Apply a single text replacement across potentially multiple <w:t> elements.
1043
+ * Apply a single text replacement across potentially multiple units.
430
1044
  */
431
1045
  function applyReplacement(textNodes, replStart, replEnd, replacement) {
432
1046
  // Find all text nodes that overlap with this replacement range
@@ -439,35 +1053,34 @@ function applyReplacement(textNodes, replStart, replEnd, replacement) {
439
1053
  if (affectedNodes.length === 0)
440
1054
  return;
441
1055
  if (affectedNodes.length === 1) {
442
- // Simple case: replacement is within a single <w:t> element
1056
+ // Simple case: replacement is within a single unit
443
1057
  const node = affectedNodes[0];
444
- const currentText = node.element.textContent ?? '';
445
- const localStart = replStart - node.flatStart;
446
- 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);
447
1063
  const newText = currentText.slice(0, localStart) + replacement + currentText.slice(localEnd);
448
- node.element.textContent = newText;
449
- // Ensure space preservation
450
- node.element.setAttribute('xml:space', 'preserve');
1064
+ setUnitText(node, newText);
451
1065
  // Update flat positions for this node and all subsequent nodes
452
1066
  const lengthDiff = replacement.length - (replEnd - replStart);
453
1067
  node.flatEnd += lengthDiff;
454
1068
  updateSubsequentNodes(textNodes, node, lengthDiff);
455
1069
  }
456
1070
  else {
457
- // Multi-node case: replacement spans multiple <w:t> elements
1071
+ // Multi-node case: replacement spans multiple units
458
1072
  // Put all replacement text in the first affected node, clear the rest
459
1073
  const firstNode = affectedNodes[0];
460
1074
  const lastNode = affectedNodes[affectedNodes.length - 1];
461
- const firstText = firstNode.element.textContent ?? '';
462
- const lastText = lastNode.element.textContent ?? '';
463
- const keepBefore = firstText.slice(0, replStart - firstNode.flatStart);
464
- 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));
465
1079
  // Set first node to: text-before-replacement + replacement + text-after-replacement-in-last-node
466
- firstNode.element.textContent = keepBefore + replacement + keepAfter;
467
- firstNode.element.setAttribute('xml:space', 'preserve');
1080
+ setUnitText(firstNode, keepBefore + replacement + keepAfter);
468
1081
  // Clear intermediate and last nodes
469
1082
  for (let i = 1; i < affectedNodes.length; i++) {
470
- affectedNodes[i].element.textContent = '';
1083
+ setUnitText(affectedNodes[i], '');
471
1084
  }
472
1085
  // Recalculate flat positions
473
1086
  const newFirstLength = (keepBefore + replacement + keepAfter).length;