@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.
- package/CHANGELOG.md +121 -2
- package/README.md +252 -19
- package/dist/doc.d.ts +68 -1
- package/dist/doc.js +447 -62
- package/dist/dom/docx.d.ts +135 -7
- package/dist/dom/docx.js +752 -125
- package/dist/dom/drawingml.d.ts +59 -0
- package/dist/dom/drawingml.js +176 -0
- package/dist/dom/errors.d.ts +35 -0
- package/dist/dom/errors.js +24 -0
- package/dist/dom/index.d.ts +3 -0
- package/dist/dom/index.js +6 -0
- package/dist/dom/office.d.ts +56 -0
- package/dist/dom/office.js +76 -23
- package/dist/dom/opc.d.ts +135 -7
- package/dist/dom/opc.js +407 -14
- package/dist/dom/package-policy.d.ts +137 -0
- package/dist/dom/package-policy.js +355 -0
- package/dist/dom/xlsx.d.ts +64 -6
- package/dist/dom/xlsx.js +75 -20
- package/dist/engine.d.ts +18 -0
- package/dist/engine.js +37 -13
- package/dist/env.d.ts +21 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +10 -5
- package/dist/model-loader.d.ts +61 -6
- package/dist/model-loader.js +122 -17
- package/dist/pipeline.d.ts +87 -8
- package/dist/pipeline.js +249 -50
- package/dist/providers/bardsai.d.ts +8 -1
- package/dist/providers/bardsai.js +22 -2
- package/dist/providers/gliner-base.d.ts +8 -1
- package/dist/providers/gliner-base.js +22 -2
- package/dist/providers/gliner.d.ts +8 -1
- package/dist/providers/gliner.js +22 -2
- package/dist/regex/loader.d.ts +2 -0
- package/dist/regex/loader.js +2 -0
- package/dist/regex/rules.data.js +15 -14
- package/dist/regex/types.d.ts +6 -0
- package/dist/session.d.ts +101 -3
- package/dist/session.js +397 -43
- package/dist/surrogates.js +57 -45
- package/dist/worker-protocol.d.ts +13 -1
- package/dist/worker-protocol.js +164 -12
- package/package.json +1 -1
- package/rules/at.json +1 -1
- package/rules/be.json +1 -1
- package/rules/ch.json +1 -1
- package/rules/de.json +1 -1
- package/rules/dk.json +1 -1
- package/rules/es.json +1 -1
- package/rules/fi.json +1 -1
- package/rules/fr.json +1 -1
- package/rules/it.json +1 -1
- package/rules/no.json +1 -1
- package/rules/pl.json +1 -1
- package/rules/pt.json +1 -1
- package/rules/schema.json +4 -0
- package/rules/se.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
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,
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
for (const hfPath of
|
|
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
|
-
|
|
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:
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
* -
|
|
243
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
|
343
|
-
|
|
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:
|
|
350
|
-
// (
|
|
351
|
-
// and detach the document template
|
|
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
|
|
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
|
|
1056
|
+
// Simple case: replacement is within a single unit
|
|
429
1057
|
const node = affectedNodes[0];
|
|
430
|
-
const currentText = node
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
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
|
|
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
|
|
448
|
-
const lastText = lastNode
|
|
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
|
|
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]
|
|
1083
|
+
setUnitText(affectedNodes[i], '');
|
|
457
1084
|
}
|
|
458
1085
|
// Recalculate flat positions
|
|
459
1086
|
const newFirstLength = (keepBefore + replacement + keepAfter).length;
|