@stll/anonymize-docx 2.2.0 → 2.4.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/dist/index.mjs CHANGED
@@ -1,5 +1,4 @@
1
- import { strToU8, unzipSync, zipSync } from "fflate";
2
- import { SaxesParser } from "saxes";
1
+ import { loadNativeAnonymizeBinding } from "@stll/anonymize";
3
2
  //#region src/types.ts
4
3
  const DOCX_PART_TYPES = {
5
4
  comments: "comments",
@@ -22,7 +21,8 @@ const DOCX_RESTORATION_ERROR_CODES = {
22
21
  invalidPlaceholder: "invalid-placeholder",
23
22
  invalidSession: "invalid-session",
24
23
  restorationLimitExceeded: "restoration-limit-exceeded",
25
- sessionMismatch: "session-mismatch"
24
+ sessionMismatch: "session-mismatch",
25
+ unsupportedDocument: "unsupported-document"
26
26
  };
27
27
  const DOCX_REWRITE_ERROR_CODES = {
28
28
  invalidReplacement: "invalid-replacement",
@@ -45,47 +45,6 @@ const DOCX_ARCHIVE_MAX_BYTES = 64 * 1024 * 1024;
45
45
  const DOCX_ENTRY_MAX_BYTES = 16 * 1024 * 1024;
46
46
  const DOCX_UNCOMPRESSED_MAX_BYTES = 128 * 1024 * 1024;
47
47
  const DOCX_XML_MAX_DEPTH = 256;
48
- const DOCX_MAX_ENTRIES = 4096;
49
- const DOCX_MAX_TEXT_BLOCKS = 1e5;
50
- const DOCX_MAX_TEXT_SEGMENTS = 1e6;
51
- const DOCX_MAX_INLINE_CONTEXT_SCAN_OPS = 2e7;
52
- const CONTENT_TYPES_PATH = "[Content_Types].xml";
53
- const ROOT_RELATIONSHIPS_PATH = "_rels/.rels";
54
- const DOCX_CORE_PROPERTIES_PATH = "docProps/core.xml";
55
- const DOCX_APP_PROPERTIES_PATH = "docProps/app.xml";
56
- const DOCX_CUSTOM_PROPERTIES_PATH = "docProps/custom.xml";
57
- const CUSTOM_XML_DIRECTORY_PREFIX = "customXml/";
58
- const DOCPROPS_DIRECTORY_PREFIX = "docProps/";
59
- const METADATA_CONTENT_TYPES = /* @__PURE__ */ new Set([
60
- "application/vnd.openxmlformats-package.core-properties+xml",
61
- "application/vnd.openxmlformats-officedocument.extended-properties+xml",
62
- "application/vnd.openxmlformats-officedocument.custom-properties+xml"
63
- ]);
64
- const ABSOLUTE_URI_PATTERN = /^[a-z][a-z0-9+.-]*:/iu;
65
- const KNOWN_METADATA_CONTENT_TYPES = {
66
- [DOCX_CORE_PROPERTIES_PATH]: "application/vnd.openxmlformats-package.core-properties+xml",
67
- [DOCX_APP_PROPERTIES_PATH]: "application/vnd.openxmlformats-officedocument.extended-properties+xml",
68
- [DOCX_CUSTOM_PROPERTIES_PATH]: "application/vnd.openxmlformats-officedocument.custom-properties+xml"
69
- };
70
- const GENERIC_XML_CONTENT_TYPE = "application/xml";
71
- const GENERIC_BINARY_CONTENT_TYPE = "application/octet-stream";
72
- const RELATIONSHIPS_CONTENT_TYPE = "application/vnd.openxmlformats-package.relationships+xml";
73
- const PII_RELATIONSHIP_TARGET_SCHEMES = ["mailto:", "tel:"];
74
- const CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types";
75
- const PACKAGE_RELATIONSHIP_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/package/relationships", "http://schemas.openxmlformats.org/package/2006/relationships"]);
76
- const WORDPROCESSING_CONTENT_TYPE_PREFIX = "application/vnd.openxmlformats-officedocument.wordprocessingml.";
77
- const SUPPORTED_CONTENT_TYPE_SUFFIXES = {
78
- "comments+xml": DOCX_PART_TYPES.comments,
79
- "document.main+xml": DOCX_PART_TYPES.mainDocument,
80
- "endnotes+xml": DOCX_PART_TYPES.endnotes,
81
- "footer+xml": DOCX_PART_TYPES.footer,
82
- "footnotes+xml": DOCX_PART_TYPES.footnotes,
83
- "header+xml": DOCX_PART_TYPES.header
84
- };
85
- const WORDPROCESSING_NAMESPACES$1 = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/wordprocessingml/main", "http://schemas.openxmlformats.org/wordprocessingml/2006/main"]);
86
- const RELATIONSHIP_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/officeDocument/relationships", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"]);
87
- const OFFICE_DOCUMENT_RELATIONSHIP_TYPES = new Set([...RELATIONSHIP_NAMESPACES].map((namespace) => `${namespace}/officeDocument`));
88
- const MARKUP_COMPATIBILITY_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/markup-compatibility/main", "http://schemas.openxmlformats.org/markup-compatibility/2006"]);
89
48
  var DocxExtractionError = class extends Error {
90
49
  code;
91
50
  constructor(code, message) {
@@ -94,526 +53,26 @@ var DocxExtractionError = class extends Error {
94
53
  this.code = code;
95
54
  }
96
55
  };
97
- const invalidPackage = (message) => new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidPackage, message);
98
- const assertXmlDepth = (depth) => {
99
- if (depth < 256) return;
100
- throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX XML must not exceed 256 nested elements`);
101
- };
102
- const safeEntryPath = (name) => name.length > 0 && !name.startsWith("/") && !name.includes("\\") && !name.split("/").includes("..") && !name.includes("\0");
103
- const RELATIONSHIPS_ENTRY_PATTERN = /(?:^|\/)_rels\/[^/]+\.rels$/u;
104
- const isRelationshipsEntry = (name) => name === ROOT_RELATIONSHIPS_PATH || RELATIONSHIPS_ENTRY_PATTERN.test(name);
105
- const isCustomXmlEntry = (name) => name.startsWith(CUSTOM_XML_DIRECTORY_PREFIX) && name.endsWith(".xml");
106
- const archiveFilter = ({ budget, file, includeAllEntries }) => {
107
- budget.entryCount += 1;
108
- if (budget.entryCount > DOCX_MAX_ENTRIES) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must contain at most ${DOCX_MAX_ENTRIES} entries`);
109
- if (!safeEntryPath(file.name)) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.unsafeEntryPath, "DOCX archive contains an unsafe entry path");
110
- if (file.originalSize > 16777216) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX entries must not exceed ${DOCX_ENTRY_MAX_BYTES} bytes`);
111
- budget.uncompressedBytes += file.originalSize;
112
- if (budget.uncompressedBytes > 134217728) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} uncompressed bytes`);
113
- return includeAllEntries || file.name === CONTENT_TYPES_PATH || file.name.startsWith("word/") && file.name.endsWith(".xml") || isRelationshipsEntry(file.name) || file.name.startsWith(DOCPROPS_DIRECTORY_PREFIX) || isCustomXmlEntry(file.name);
114
- };
115
- const unzipDocxArchive = (archive, includeAllEntries = false, onSkippedEntry) => {
116
- if (archive.byteLength > 67108864) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.archiveLimitExceeded, `DOCX archives must not exceed ${DOCX_ARCHIVE_MAX_BYTES} bytes`);
117
- const budget = {
118
- entryCount: 0,
119
- uncompressedBytes: 0
120
- };
121
- try {
122
- return unzipSync(archive, { filter: (file) => {
123
- const keep = archiveFilter({
124
- budget,
125
- file,
126
- includeAllEntries
127
- });
128
- if (!keep) onSkippedEntry?.(file.name);
129
- return keep;
130
- } });
131
- } catch (error) {
132
- if (error instanceof DocxExtractionError) throw error;
133
- throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidArchive, "Input is not a valid bounded DOCX ZIP archive");
134
- }
135
- };
136
- const decodeXml = (bytes, path) => {
137
- try {
138
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
139
- } catch {
140
- throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, `DOCX XML part is not valid UTF-8: ${path}`);
141
- }
142
- };
143
- const attributeByLocalName = (tag, localName, namespaces) => {
144
- for (const attribute of Object.values(tag.attributes)) if (attribute.local === localName && (namespaces === void 0 || namespaces.has(attribute.uri))) return attribute.value;
145
- return null;
146
- };
147
- const parseContentTypes = (xml) => {
148
- const parts = [];
149
- const paths = /* @__PURE__ */ new Set();
150
- const parser = new SaxesParser({ xmlns: true });
151
- let parseError = null;
152
- let depth = 0;
153
- parser.on("error", (error) => {
154
- parseError = error;
155
- });
156
- parser.on("doctype", () => {
157
- throw invalidPackage("DOCX XML must not contain a document type declaration");
158
- });
159
- parser.on("opentag", (tag) => {
160
- assertXmlDepth(depth);
161
- depth += 1;
162
- if (tag.local !== "Override" || tag.uri !== CONTENT_TYPES_NAMESPACE) return;
163
- const rawPath = attributeByLocalName(tag, "PartName");
164
- const contentType = attributeByLocalName(tag, "ContentType");
165
- if (rawPath === null || contentType === null) throw invalidPackage("DOCX content-type override is incomplete");
166
- const path = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
167
- if (!safeEntryPath(path)) throw invalidPackage("DOCX content-type override has an unsafe path");
168
- if (paths.has(path)) throw invalidPackage("DOCX content-type overrides must have unique paths");
169
- paths.add(path);
170
- parts.push({
171
- path,
172
- contentType
173
- });
174
- });
175
- parser.on("closetag", () => {
176
- depth -= 1;
177
- });
178
- try {
179
- parser.write(xml).close();
180
- } catch (error) {
181
- if (error instanceof DocxExtractionError) throw error;
182
- parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
183
- }
184
- if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, "DOCX content types are not valid XML");
185
- return parts;
186
- };
187
- const parseMainDocumentTarget = (xml) => {
188
- const targets = [];
189
- const parser = new SaxesParser({ xmlns: true });
190
- let parseError = null;
191
- let depth = 0;
192
- parser.on("error", (error) => {
193
- parseError = error;
194
- });
195
- parser.on("doctype", () => {
196
- throw invalidPackage("DOCX XML must not contain a document type declaration");
197
- });
198
- parser.on("opentag", (tag) => {
199
- assertXmlDepth(depth);
200
- depth += 1;
201
- if (tag.local !== "Relationship" || !PACKAGE_RELATIONSHIP_NAMESPACES.has(tag.uri)) return;
202
- const type = attributeByLocalName(tag, "Type");
203
- if (type === null || !OFFICE_DOCUMENT_RELATIONSHIP_TYPES.has(type)) return;
204
- const targetMode = attributeByLocalName(tag, "TargetMode");
205
- const rawTarget = attributeByLocalName(tag, "Target");
206
- if (targetMode === "External" || rawTarget === null) throw invalidPackage("DOCX main-document relationship must be internal");
207
- const target = rawTarget.startsWith("/") ? rawTarget.slice(1) : rawTarget;
208
- if (!safeEntryPath(target) || target.includes(":")) throw invalidPackage("DOCX main-document relationship has an unsafe target");
209
- targets.push(target);
210
- });
211
- parser.on("closetag", () => {
212
- depth -= 1;
213
- });
214
- try {
215
- parser.write(xml).close();
216
- } catch (error) {
217
- if (error instanceof DocxExtractionError) throw error;
218
- parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
219
- }
220
- if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, "DOCX root relationships are not valid XML");
221
- if (targets.length !== 1) throw invalidPackage("DOCX archive must contain exactly one main-document relationship");
222
- const target = targets.at(0);
223
- if (target === void 0) throw invalidPackage("DOCX main-document relationship is unavailable");
224
- return target;
225
- };
226
- const hasPiiRelationshipTargetScheme = (target) => {
227
- const normalized = target.trim().toLowerCase();
228
- return PII_RELATIONSHIP_TARGET_SCHEMES.some((scheme) => normalized.startsWith(scheme));
229
- };
230
- const isExternalRelationshipTarget = (target, targetMode) => {
231
- const normalized = target.trim();
232
- return targetMode?.trim().toLowerCase() === "external" || ABSOLUTE_URI_PATTERN.test(normalized) || normalized.startsWith("//");
233
- };
234
- const PII_SCHEME_TARGET_REASON = "target uses a PII-bearing external scheme (mailto/tel) that anonymization does not redact";
235
- const EXTERNAL_TARGET_REASON = "target is external and is not examined or redacted by anonymization";
236
- const DANGLING_TARGET_REASON = "target does not resolve to a package part and is not examined or redacted by anonymization";
237
- const relationshipBaseDirectory = (relsPath) => {
238
- const marker = relsPath.lastIndexOf("_rels/");
239
- return marker <= 0 ? "" : relsPath.slice(0, marker);
240
- };
241
- const decodeOpcTarget = (target) => {
242
- try {
243
- return decodeURIComponent(target);
244
- } catch {
245
- return null;
246
- }
247
- };
248
- const normalizeOpcPath = (path) => {
249
- const segments = [];
250
- for (const segment of path.split("/")) {
251
- if (segment === "" || segment === ".") continue;
252
- if (segment === "..") {
253
- if (segments.length === 0) return null;
254
- segments.pop();
255
- continue;
256
- }
257
- segments.push(segment);
258
- }
259
- return segments.length === 0 ? null : segments.join("/");
260
- };
261
- const resolveInternalRelationshipTarget = (target, relsPath) => {
262
- const decoded = decodeOpcTarget(target.trim());
263
- if (decoded === null || decoded === "") return null;
264
- if (decoded.startsWith("/")) return normalizeOpcPath(decoded.slice(1));
265
- return normalizeOpcPath(`${relationshipBaseDirectory(relsPath)}${decoded}`);
266
- };
267
- const parseUncoveredRelationshipTargets = ({ xml, relsPath: path, knownEntryPaths }) => {
268
- const found = [];
269
- const parser = new SaxesParser({ xmlns: true });
270
- let parseError = null;
271
- let depth = 0;
272
- parser.on("error", (error) => {
273
- parseError = error;
274
- });
275
- parser.on("doctype", () => {
276
- throw invalidPackage("DOCX XML must not contain a document type declaration");
277
- });
278
- parser.on("opentag", (tag) => {
279
- assertXmlDepth(depth);
280
- depth += 1;
281
- if (tag.local !== "Relationship" || !PACKAGE_RELATIONSHIP_NAMESPACES.has(tag.uri)) return;
282
- const target = attributeByLocalName(tag, "Target");
283
- if (target === null) return;
284
- const targetMode = attributeByLocalName(tag, "TargetMode");
285
- if (hasPiiRelationshipTargetScheme(target)) {
286
- found.push({
287
- relationshipId: attributeByLocalName(tag, "Id"),
288
- reason: PII_SCHEME_TARGET_REASON
289
- });
290
- return;
291
- }
292
- if (isExternalRelationshipTarget(target, targetMode)) {
293
- found.push({
294
- relationshipId: attributeByLocalName(tag, "Id"),
295
- reason: EXTERNAL_TARGET_REASON
296
- });
297
- return;
298
- }
299
- const resolved = resolveInternalRelationshipTarget(target, path);
300
- if (resolved === null || !knownEntryPaths.has(resolved.toLowerCase())) found.push({
301
- relationshipId: attributeByLocalName(tag, "Id"),
302
- reason: DANGLING_TARGET_REASON
303
- });
304
- });
305
- parser.on("closetag", () => {
306
- depth -= 1;
307
- });
308
- try {
309
- parser.write(xml).close();
310
- } catch (error) {
311
- if (error instanceof DocxExtractionError) throw error;
312
- parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
313
- }
314
- if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, `DOCX relationships are not valid XML: ${path}`);
315
- return found;
316
- };
317
- const classifyPart = ({ contentType, path }) => {
318
- if (!contentType.startsWith(WORDPROCESSING_CONTENT_TYPE_PREFIX)) return null;
319
- const suffix = contentType.slice(63);
320
- const type = SUPPORTED_CONTENT_TYPE_SUFFIXES[suffix];
321
- return type === void 0 ? null : {
322
- type,
323
- path
324
- };
325
- };
326
- const isWordTag = (tag, local) => tag.local === local && WORDPROCESSING_NAMESPACES$1.has(tag.uri);
327
- const frameByLocalName = (stack, local) => {
328
- for (let index = stack.length - 1; index >= 0; index -= 1) {
329
- const frame = stack.at(index);
330
- if (frame !== void 0 && isWordTag(frame.tag, local)) return frame;
331
- }
332
- return null;
333
- };
334
- const blockLocation = (part, blockIndex, paragraphPath, stack) => {
335
- const textBox = frameByLocalName(stack, "txbxContent");
336
- if (textBox !== null) return {
337
- type: "text-box-paragraph",
338
- part,
339
- blockIndex,
340
- xmlPath: paragraphPath,
341
- textBoxPath: textBox.path
342
- };
343
- const cell = frameByLocalName(stack, "tc");
344
- const row = frameByLocalName(stack, "tr");
345
- const table = frameByLocalName(stack, "tbl");
346
- if (cell !== null && row !== null && table !== null) return {
347
- type: "table-cell-paragraph",
348
- part,
349
- blockIndex,
350
- xmlPath: paragraphPath,
351
- tablePath: table.path,
352
- rowPath: row.path,
353
- cellPath: cell.path
354
- };
355
- return {
356
- type: "paragraph",
357
- part,
358
- blockIndex,
359
- xmlPath: paragraphPath
360
- };
361
- };
362
- const revisionForTag = (tag) => {
363
- if (!WORDPROCESSING_NAMESPACES$1.has(tag.uri)) return null;
364
- const revision = {
365
- del: "deletion",
366
- ins: "insertion",
367
- moveFrom: "move-from",
368
- moveTo: "move-to"
369
- }[tag.local];
370
- return revision === void 0 ? null : {
371
- type: "revision",
372
- revision
373
- };
374
- };
375
- const inlineContexts = (stack) => {
376
- const contexts = [];
377
- for (const { tag } of stack) {
378
- if (isWordTag(tag, "hyperlink")) contexts.push({
379
- type: "hyperlink",
380
- relationshipId: attributeByLocalName(tag, "id", RELATIONSHIP_NAMESPACES),
381
- anchor: attributeByLocalName(tag, "anchor", WORDPROCESSING_NAMESPACES$1)
382
- });
383
- const revision = revisionForTag(tag);
384
- if (revision !== null) contexts.push(revision);
385
- }
386
- return contexts;
56
+ const nativeExtractionErrorCode = (message) => {
57
+ if (message.includes("unsafe entry path")) return DOCX_EXTRACTION_ERROR_CODES.unsafeEntryPath;
58
+ if (message.includes("valid bounded DOCX ZIP archive")) return DOCX_EXTRACTION_ERROR_CODES.invalidArchive;
59
+ if (message.includes("valid XML") || message.includes("valid UTF-8")) return DOCX_EXTRACTION_ERROR_CODES.invalidXml;
60
+ if (message.includes(`DOCX archives must not exceed 67108864 bytes`)) return DOCX_EXTRACTION_ERROR_CODES.archiveLimitExceeded;
61
+ if (message.includes("must not exceed") || message.includes("must not contain more than") || message.includes("at most")) return DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded;
62
+ return DOCX_EXTRACTION_ERROR_CODES.invalidPackage;
387
63
  };
388
- const appendSegment = (block, budget, value, source, path, stack) => {
389
- if (value.length === 0) return;
390
- if (budget.segmentCount >= DOCX_MAX_TEXT_SEGMENTS) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must not contain more than ${DOCX_MAX_TEXT_SEGMENTS} text segments`);
391
- budget.segmentCount += 1;
392
- if (budget.inlineContextScanOps + stack.length > DOCX_MAX_INLINE_CONTEXT_SCAN_OPS) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must not require more than ${DOCX_MAX_INLINE_CONTEXT_SCAN_OPS} aggregate inline-context scan operations`);
393
- budget.inlineContextScanOps += stack.length;
394
- const start = block.text.length;
395
- block.text += value;
396
- block.segments.push({
397
- start,
398
- end: block.text.length,
399
- source,
400
- contexts: inlineContexts(stack),
401
- xmlPath: path
402
- });
403
- };
404
- const extractPart = (part, xml, textBudget) => {
405
- const blocks = [];
406
- const stack = [];
407
- const blockStack = [];
408
- let nextBlockIndex = 0;
409
- let currentText = "";
410
- let currentTextPath = null;
411
- let parseError = null;
412
- let unsupportedSymbolCount = 0;
413
- let unsupportedFieldInstructionCount = 0;
414
- let unsupportedAlternateContentCount = 0;
415
- const parser = new SaxesParser({ xmlns: true });
416
- parser.on("error", (error) => {
417
- parseError = error;
418
- });
419
- parser.on("doctype", () => {
420
- throw invalidPackage("DOCX XML must not contain a document type declaration");
421
- });
422
- parser.on("opentag", (tag) => {
423
- assertXmlDepth(stack.length);
424
- const parent = stack.at(-1);
425
- const childIndex = parent?.nextChildIndex ?? 0;
426
- if (parent !== void 0) parent.nextChildIndex += 1;
427
- const path = [...parent?.path ?? [], childIndex];
428
- if (isWordTag(tag, "p")) {
429
- if (nextBlockIndex >= DOCX_MAX_TEXT_BLOCKS) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX parts must not contain more than ${DOCX_MAX_TEXT_BLOCKS} text blocks`);
430
- blockStack.push({
431
- text: "",
432
- segments: [],
433
- location: blockLocation(part, nextBlockIndex, path, stack)
434
- });
435
- nextBlockIndex += 1;
436
- }
437
- stack.push({
438
- tag,
439
- path,
440
- nextChildIndex: 0
441
- });
442
- if (isWordTag(tag, "t") || isWordTag(tag, "delText")) {
443
- currentText = "";
444
- currentTextPath = path;
445
- }
446
- const currentBlock = blockStack.at(-1);
447
- if (currentBlock !== void 0 && isWordTag(tag, "tab")) appendSegment(currentBlock, textBudget, " ", "tab", path, stack);
448
- if (currentBlock !== void 0 && (isWordTag(tag, "br") || isWordTag(tag, "cr"))) appendSegment(currentBlock, textBudget, "\n", "break", path, stack);
449
- if (isWordTag(tag, "sym")) unsupportedSymbolCount += 1;
450
- if (isWordTag(tag, "instrText") || isWordTag(tag, "fldSimple")) unsupportedFieldInstructionCount += 1;
451
- if (tag.local === "AlternateContent" && MARKUP_COMPATIBILITY_NAMESPACES.has(tag.uri)) unsupportedAlternateContentCount += 1;
452
- });
453
- parser.on("text", (text) => {
454
- if (currentTextPath !== null) currentText += text;
455
- });
456
- parser.on("cdata", (text) => {
457
- if (currentTextPath !== null) currentText += text;
458
- });
459
- parser.on("closetag", (tag) => {
460
- const frame = stack.at(-1);
461
- if (frame === void 0 || frame.tag !== tag) throw invalidPackage("DOCX XML element stack is inconsistent");
462
- if (currentTextPath !== null && (isWordTag(tag, "t") || isWordTag(tag, "delText"))) {
463
- const currentBlock = blockStack.at(-1);
464
- if (currentBlock === void 0) {
465
- if (currentText.length > 0) throw invalidPackage("DOCX text is outside a paragraph");
466
- } else appendSegment(currentBlock, textBudget, currentText, "text", currentTextPath, stack);
467
- currentText = "";
468
- currentTextPath = null;
469
- }
470
- if (isWordTag(tag, "p")) {
471
- const completedBlock = blockStack.pop();
472
- if (completedBlock === void 0) throw invalidPackage("DOCX paragraph state is unavailable");
473
- blocks.push(completedBlock);
474
- }
475
- stack.pop();
476
- });
64
+ const extractDocxText = (archive) => {
65
+ const extract = loadNativeAnonymizeBinding().extractDocxTextJson;
66
+ if (extract === void 0) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidPackage, "Native anonymize binding does not expose DOCX extraction");
477
67
  try {
478
- parser.write(xml).close();
68
+ return JSON.parse(extract(archive));
479
69
  } catch (error) {
480
- if (error instanceof DocxExtractionError) throw error;
481
- parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
482
- }
483
- if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, `DOCX part is not valid XML: ${part.path}`);
484
- blocks.sort((left, right) => left.location.blockIndex - right.location.blockIndex);
485
- let hyperlinkTextSegmentCount = 0;
486
- let revisionTextSegmentCount = 0;
487
- for (const { segments } of blocks) for (const { contexts } of segments) {
488
- if (contexts.some((context) => context.type === "hyperlink")) hyperlinkTextSegmentCount += 1;
489
- if (contexts.some((context) => context.type === "revision")) revisionTextSegmentCount += 1;
490
- }
491
- return {
492
- blocks,
493
- hyperlinkTextSegmentCount,
494
- revisionTextSegmentCount,
495
- unsupportedAlternateContentCount,
496
- unsupportedSymbolCount,
497
- unsupportedFieldInstructionCount
498
- };
499
- };
500
- const extractDocxText = (archive) => {
501
- const skippedEntryPaths = [];
502
- const entries = unzipDocxArchive(archive, false, (name) => {
503
- skippedEntryPaths.push(name);
504
- });
505
- const contentTypesBytes = entries[CONTENT_TYPES_PATH];
506
- if (contentTypesBytes === void 0) throw invalidPackage("DOCX archive is missing [Content_Types].xml");
507
- const contentTypes = parseContentTypes(decodeXml(contentTypesBytes, CONTENT_TYPES_PATH));
508
- const rootRelationshipsBytes = entries[ROOT_RELATIONSHIPS_PATH];
509
- if (rootRelationshipsBytes === void 0) throw invalidPackage("DOCX archive is missing _rels/.rels");
510
- const mainDocumentTarget = parseMainDocumentTarget(decodeXml(rootRelationshipsBytes, ROOT_RELATIONSHIPS_PATH));
511
- const supportedParts = contentTypes.map(classifyPart).filter((part) => part !== null);
512
- if (supportedParts.filter((part) => part.type === DOCX_PART_TYPES.mainDocument).length !== 1) throw invalidPackage("DOCX archive must contain exactly one main document");
513
- if (supportedParts.find((part) => part.type === DOCX_PART_TYPES.mainDocument)?.path !== mainDocumentTarget) throw invalidPackage("DOCX main-document relationship and content type do not agree");
514
- const blocks = [];
515
- const coverageParts = [];
516
- let hyperlinkTextSegmentCount = 0;
517
- let revisionTextSegmentCount = 0;
518
- let unsupportedSymbolCount = 0;
519
- let unsupportedFieldInstructionCount = 0;
520
- let unsupportedAlternateContentCount = 0;
521
- const textBudget = {
522
- segmentCount: 0,
523
- inlineContextScanOps: 0
524
- };
525
- for (const part of supportedParts) {
526
- const bytes = entries[part.path];
527
- if (bytes === void 0) throw invalidPackage(`DOCX archive is missing declared part: ${part.path}`);
528
- const extracted = extractPart(part, decodeXml(bytes, part.path), textBudget);
529
- if (blocks.length + extracted.blocks.length > DOCX_MAX_TEXT_BLOCKS) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must not contain more than ${DOCX_MAX_TEXT_BLOCKS} text blocks`);
530
- blocks.push(...extracted.blocks);
531
- coverageParts.push({
532
- status: "extracted",
533
- part,
534
- blockCount: extracted.blocks.length
535
- });
536
- hyperlinkTextSegmentCount += extracted.hyperlinkTextSegmentCount;
537
- revisionTextSegmentCount += extracted.revisionTextSegmentCount;
538
- unsupportedSymbolCount += extracted.unsupportedSymbolCount;
539
- unsupportedFieldInstructionCount += extracted.unsupportedFieldInstructionCount;
540
- unsupportedAlternateContentCount += extracted.unsupportedAlternateContentCount;
541
- }
542
- const knownEntryPaths = new Set([...Object.keys(entries), ...skippedEntryPaths].map((name) => name.toLowerCase()));
543
- for (const [relsPath, relsBytes] of Object.entries(entries)) {
544
- if (!isRelationshipsEntry(relsPath)) continue;
545
- const uncoveredTargets = parseUncoveredRelationshipTargets({
546
- xml: decodeXml(relsBytes, relsPath),
547
- relsPath,
548
- knownEntryPaths
549
- });
550
- for (const uncovered of uncoveredTargets) coverageParts.push({
551
- status: "unsupported",
552
- path: relsPath,
553
- contentType: RELATIONSHIPS_CONTENT_TYPE,
554
- reason: uncovered.relationshipId === null ? `Relationship ${uncovered.reason}` : `Relationship "${uncovered.relationshipId}" ${uncovered.reason}`
555
- });
556
- }
557
- const coveredPaths = /* @__PURE__ */ new Set([CONTENT_TYPES_PATH, ...supportedParts.map((part) => part.path)]);
558
- const overrideContentTypes = new Map(contentTypes.map((entry) => [entry.path, entry.contentType]));
559
- const markUnsupported = (path, contentType, reason) => {
560
- if (coveredPaths.has(path)) return;
561
- coveredPaths.add(path);
562
- coverageParts.push({
563
- status: "unsupported",
564
- path,
565
- contentType,
566
- reason
567
- });
568
- };
569
- for (const path of Object.keys(entries)) if (path.startsWith(DOCPROPS_DIRECTORY_PREFIX)) markUnsupported(path, overrideContentTypes.get(path) ?? KNOWN_METADATA_CONTENT_TYPES[path] ?? GENERIC_XML_CONTENT_TYPE, "Document metadata parts are not extracted or redacted");
570
- else if (isCustomXmlEntry(path)) markUnsupported(path, overrideContentTypes.get(path) ?? GENERIC_XML_CONTENT_TYPE, "Custom XML parts are not extracted or redacted");
571
- for (const { contentType, path } of contentTypes) {
572
- if (contentType === RELATIONSHIPS_CONTENT_TYPE || isRelationshipsEntry(path)) continue;
573
- if (METADATA_CONTENT_TYPES.has(contentType)) {
574
- markUnsupported(path, contentType, "Document metadata parts are not extracted or redacted");
575
- continue;
576
- }
577
- if (contentType.startsWith(WORDPROCESSING_CONTENT_TYPE_PREFIX)) {
578
- markUnsupported(path, contentType, "WordprocessingML part type is not extracted");
579
- continue;
580
- }
581
- markUnsupported(path, contentType, "Package part type is not extracted or redacted");
582
- }
583
- for (const path of [...Object.keys(entries), ...skippedEntryPaths]) {
584
- if (path.endsWith("/") || isRelationshipsEntry(path)) continue;
585
- markUnsupported(path, overrideContentTypes.get(path) ?? (path.endsWith(".xml") ? GENERIC_XML_CONTENT_TYPE : GENERIC_BINARY_CONTENT_TYPE), "Package part is not examined by anonymization");
70
+ const message = error instanceof Error ? error.message : "DOCX extraction failed";
71
+ throw new DocxExtractionError(nativeExtractionErrorCode(message), message);
586
72
  }
587
- return {
588
- contractVersion: 1,
589
- blocks,
590
- coverage: {
591
- parts: coverageParts,
592
- hyperlinkTextSegmentCount,
593
- revisionTextSegmentCount,
594
- unsupportedAlternateContentCount,
595
- unsupportedSymbolCount,
596
- unsupportedFieldInstructionCount
597
- }
598
- };
599
73
  };
600
74
  //#endregion
601
- //#region src/location.ts
602
- const arraysEqual = (left, right) => left.length === right.length && left.every((value, index) => value === right.at(index));
603
- const docxLocationsEqual = (left, right) => {
604
- if (left.type !== right.type || left.part.type !== right.part.type || left.part.path !== right.part.path || left.blockIndex !== right.blockIndex || !arraysEqual(left.xmlPath, right.xmlPath)) return false;
605
- if (left.type === "paragraph" && right.type === "paragraph") return true;
606
- if (left.type === "table-cell-paragraph" && right.type === "table-cell-paragraph") return arraysEqual(left.tablePath, right.tablePath) && arraysEqual(left.rowPath, right.rowPath) && arraysEqual(left.cellPath, right.cellPath);
607
- if (left.type === "text-box-paragraph" && right.type === "text-box-paragraph") return arraysEqual(left.textBoxPath, right.textBoxPath);
608
- return false;
609
- };
610
- const docxLocationKey = ({ blockIndex, part }) => `${part.path}\0${blockIndex}`;
611
- //#endregion
612
75
  //#region src/rewrite.ts
613
- const WORDPROCESSING_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/wordprocessingml/main", "http://schemas.openxmlformats.org/wordprocessingml/2006/main"]);
614
- const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
615
- const DOCX_MAX_REPLACEMENTS = 1e6;
616
- const SIGNATURE_PART_PREFIX = "_xmlsignatures/";
617
76
  var DocxRewriteError = class extends Error {
618
77
  code;
619
78
  constructor(code, message) {
@@ -622,261 +81,110 @@ var DocxRewriteError = class extends Error {
622
81
  this.code = code;
623
82
  }
624
83
  };
625
- const rewriteError = (code, message) => new DocxRewriteError(code, message);
626
- const pathKey = (path) => path.join(".");
627
- const isValidXmlText = (value) => {
628
- for (const character of value) {
629
- const codePoint = character.codePointAt(0);
630
- if (codePoint === void 0 || codePoint !== 9 && codePoint !== 10 && codePoint !== 13 && (codePoint < 32 || codePoint > 55295 && codePoint < 57344 || codePoint > 65533 && codePoint < 65536 || codePoint > 1114111)) return false;
631
- }
632
- return true;
633
- };
634
- const isUtf16Boundary = (value, offset) => {
635
- if (offset === 0 || offset === value.length) return true;
636
- const previous = value.charCodeAt(offset - 1);
637
- const next = value.charCodeAt(offset);
638
- return !(previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343);
639
- };
640
- const escapeXmlText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
641
- const utf8ByteLength = (value) => {
642
- let total = 0;
643
- for (const character of value) {
644
- const codePoint = character.codePointAt(0) ?? 0;
645
- if (codePoint <= 127) total += 1;
646
- else if (codePoint <= 2047) total += 2;
647
- else if (codePoint <= 65535) total += 3;
648
- else total += 4;
649
- }
650
- return total;
651
- };
652
- const XML_SPACE_INSERTION_BYTES = 21;
653
- const escapedXmlTextByteLength = (value) => {
654
- let total = 0;
655
- for (const character of value) {
656
- if (character === "&") {
657
- total += 5;
658
- continue;
84
+ const REWRITE_ERROR_CODES = new Set(Object.values(DOCX_REWRITE_ERROR_CODES));
85
+ const EXTRACTION_ERROR_CODES = new Set(Object.values(DOCX_EXTRACTION_ERROR_CODES));
86
+ const DOCX_REWRITE_MAX_BLOCKS = 1e5;
87
+ const DOCX_REWRITE_MAX_REPLACEMENTS = 1e6;
88
+ const LOCATION_PATH_KEYS = [
89
+ "xmlPath",
90
+ "tablePath",
91
+ "rowPath",
92
+ "cellPath",
93
+ "textBoxPath"
94
+ ];
95
+ const preflightRewritePlan = (rewrites) => {
96
+ const rewriteCount = rewrites.length;
97
+ if (rewriteCount > DOCX_REWRITE_MAX_BLOCKS) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewrites must not contain more than ${DOCX_REWRITE_MAX_BLOCKS} blocks`);
98
+ let replacementCount = 0;
99
+ let estimatedBytes = rewriteCount * 256;
100
+ const serializableRewrites = [];
101
+ for (let rewriteIndex = 0; rewriteIndex < rewriteCount; rewriteIndex += 1) {
102
+ const rewrite = rewrites[rewriteIndex];
103
+ if (rewrite === void 0) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX rewrite plans must not contain sparse blocks");
104
+ if (!Array.isArray(rewrite.replacements)) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX block rewrite replacements must be an array");
105
+ const blockReplacementCount = rewrite.replacements.length;
106
+ replacementCount += blockReplacementCount;
107
+ if (replacementCount > DOCX_REWRITE_MAX_REPLACEMENTS) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewrites must not contain more than ${DOCX_REWRITE_MAX_REPLACEMENTS} replacements`);
108
+ estimatedBytes += (typeof rewrite.expectedText === "string" ? rewrite.expectedText.length * 6 : 0) + blockReplacementCount * 96;
109
+ const serializableReplacements = [];
110
+ for (let replacementIndex = 0; replacementIndex < blockReplacementCount; replacementIndex += 1) {
111
+ const replacement = rewrite.replacements[replacementIndex];
112
+ if (replacement === void 0) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX rewrite plans must not contain sparse replacements");
113
+ const value = replacement.replacement;
114
+ if (typeof value === "string") estimatedBytes += value.length * 6;
115
+ serializableReplacements.push({
116
+ start: typeof replacement.start === "number" ? replacement.start : null,
117
+ end: typeof replacement.end === "number" ? replacement.end : null,
118
+ replacement: typeof value === "string" ? value : null
119
+ });
659
120
  }
660
- if (character === "<" || character === ">") {
661
- total += 4;
662
- continue;
121
+ const location = rewrite.location;
122
+ const part = location["part"];
123
+ for (const value of [
124
+ location["type"],
125
+ part?.["type"],
126
+ part?.["path"]
127
+ ]) if (typeof value === "string") estimatedBytes += value.length * 6;
128
+ const serializableLocation = {
129
+ type: typeof location["type"] === "string" ? location["type"] : null,
130
+ part: {
131
+ type: typeof part?.["type"] === "string" ? part["type"] : null,
132
+ path: typeof part?.["path"] === "string" ? part["path"] : null
133
+ },
134
+ blockIndex: typeof location["blockIndex"] === "number" ? location["blockIndex"] : null
135
+ };
136
+ for (const key of LOCATION_PATH_KEYS) {
137
+ const path = location[key];
138
+ if (Array.isArray(path)) {
139
+ if (path.length > 256) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, `DOCX rewrite location paths must not exceed 256 entries`);
140
+ estimatedBytes += path.length * 24;
141
+ const serializablePath = [];
142
+ for (let pathIndex = 0; pathIndex < path.length; pathIndex += 1) {
143
+ const value = path[pathIndex];
144
+ serializablePath.push(typeof value === "number" ? value : null);
145
+ }
146
+ serializableLocation[key] = serializablePath;
147
+ }
663
148
  }
664
- const codePoint = character.codePointAt(0) ?? 0;
665
- if (codePoint <= 127) total += 1;
666
- else if (codePoint <= 2047) total += 2;
667
- else if (codePoint <= 65535) total += 3;
668
- else total += 4;
669
- }
670
- return total;
671
- };
672
- const replacementByteLength = (replacement) => escapedXmlTextByteLength(replacement.replacement);
673
- const validateReplacement = (replacement, blockText) => {
674
- if (!Number.isSafeInteger(replacement.start) || !Number.isSafeInteger(replacement.end) || replacement.start < 0 || replacement.start >= replacement.end || replacement.end > blockText.length || !isUtf16Boundary(blockText, replacement.start) || !isUtf16Boundary(blockText, replacement.end)) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX replacement spans must be nonempty bounded integer ranges at UTF-16 boundaries");
675
- if (!isValidXmlText(replacement.replacement)) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX replacement text must contain only valid XML characters");
676
- if (replacementByteLength(replacement) > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX replacement text must not exceed ${DOCX_ENTRY_MAX_BYTES} escaped UTF-8 bytes`);
677
- };
678
- const coveredTextSegments = (block, replacement) => {
679
- const segments = block.segments.filter(({ end, start }) => start < replacement.end && end > replacement.start);
680
- let cursor = replacement.start;
681
- for (const segment of segments) {
682
- if (segment.source !== "text" || segment.start > cursor || segment.contexts.some((context) => context.type === "revision")) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacements must stay within contiguous non-revision text segments");
683
- cursor = Math.min(replacement.end, segment.end);
684
- }
685
- if (segments.length === 0 || cursor !== replacement.end) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacements must stay within contiguous non-revision text segments");
686
- return segments;
687
- };
688
- const planBlockUpdates = (block, rewrite) => {
689
- const replacements = [...rewrite.replacements].sort((left, right) => left.start - right.start);
690
- for (const [index, replacement] of replacements.entries()) {
691
- validateReplacement(replacement, block.text);
692
- const previous = index === 0 ? void 0 : replacements.at(index - 1);
693
- if (previous !== void 0 && previous.end > replacement.start) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX replacement spans must not overlap");
694
- }
695
- const values = /* @__PURE__ */ new Map();
696
- const originalValues = /* @__PURE__ */ new Map();
697
- for (const segment of block.segments) {
698
- if (segment.source !== "text") continue;
699
- const original = block.text.slice(segment.start, segment.end);
700
- values.set(pathKey(segment.xmlPath), {
701
- path: segment.xmlPath,
702
- value: original,
703
- originalByteLength: utf8ByteLength(original)
149
+ serializableRewrites.push({
150
+ location: serializableLocation,
151
+ expectedText: typeof rewrite.expectedText === "string" ? rewrite.expectedText : null,
152
+ replacements: serializableReplacements
704
153
  });
705
- originalValues.set(pathKey(segment.xmlPath), original);
154
+ if (estimatedBytes > 134217728) throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewrite plans must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} estimated serialized bytes`);
706
155
  }
707
- for (const replacement of replacements.toReversed()) {
708
- const segments = coveredTextSegments(block, replacement);
709
- const first = segments.at(0);
710
- const last = segments.at(-1);
711
- if (first === void 0 || last === void 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacement text segments are unavailable");
712
- const firstUpdate = values.get(pathKey(first.xmlPath));
713
- const lastUpdate = values.get(pathKey(last.xmlPath));
714
- if (firstUpdate === void 0 || lastUpdate === void 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacement text nodes are unavailable");
715
- const firstStart = replacement.start - first.start;
716
- const lastEnd = replacement.end - last.start;
717
- if (first === last) {
718
- firstUpdate.value = firstUpdate.value.slice(0, firstStart) + replacement.replacement + firstUpdate.value.slice(lastEnd);
719
- continue;
720
- }
721
- firstUpdate.value = firstUpdate.value.slice(0, firstStart) + replacement.replacement;
722
- for (const segment of segments.slice(1, -1)) {
723
- const update = values.get(pathKey(segment.xmlPath));
724
- if (update !== void 0) update.value = "";
725
- }
726
- lastUpdate.value = lastUpdate.value.slice(lastEnd);
727
- }
728
- return [...values.entries()].filter(([key, update]) => update.value !== originalValues.get(key)).map(([, update]) => update);
729
- };
730
- const requiresPreservedSpace = (value) => /^\s|\s$/u.test(value);
731
- const isWordTextTag = (tag) => WORDPROCESSING_NAMESPACES.has(tag.uri) && (tag.local === "t" || tag.local === "delText");
732
- const hasPreservedSpace = (tag) => Object.values(tag.attributes).some((attribute) => attribute.uri === XML_NAMESPACE && attribute.local === "space" && attribute.value === "preserve");
733
- const findClosingTagStart = ({ xml, contentStart, parserPosition }) => {
734
- for (let index = parserPosition - 1; index >= contentStart; index -= 1) if (xml[index] === "<" && xml[index + 1] === "/") return index;
735
- throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX text-node closing tag changed after extraction");
156
+ return serializableRewrites;
736
157
  };
737
- const rewritePartXml = (xml, updates) => {
738
- const updatesByPath = new Map(updates.map((update) => [pathKey(update.path), update]));
739
- const foundPaths = /* @__PURE__ */ new Set();
740
- const patches = [];
741
- const stack = [];
742
- let activeText;
743
- let parseError = null;
744
- const parser = new SaxesParser({ xmlns: true });
745
- parser.on("error", (error) => {
746
- parseError = error;
747
- });
748
- parser.on("opentag", (tag) => {
749
- if (stack.length >= 256) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX XML must not exceed 256 nested elements`);
750
- const parent = stack.at(-1);
751
- const childIndex = parent?.nextChildIndex ?? 0;
752
- if (parent !== void 0) parent.nextChildIndex += 1;
753
- const path = [...parent?.path ?? [], childIndex];
754
- stack.push({
755
- path,
756
- nextChildIndex: 0
757
- });
758
- const key = pathKey(path);
759
- if (isWordTextTag(tag) && updatesByPath.has(key)) {
760
- if (tag.isSelfClosing) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX self-closing text nodes cannot receive replacements");
761
- activeText = {
762
- key,
763
- contentStart: parser.position,
764
- tag
765
- };
766
- }
767
- });
768
- parser.on("closetag", (tag) => {
769
- if (activeText?.tag === tag) {
770
- const update = updatesByPath.get(activeText.key);
771
- if (update !== void 0) {
772
- const contentEnd = findClosingTagStart({
773
- xml,
774
- contentStart: activeText.contentStart,
775
- parserPosition: parser.position
776
- });
777
- patches.push({
778
- start: activeText.contentStart,
779
- end: contentEnd,
780
- value: escapeXmlText(update.value)
781
- });
782
- if (requiresPreservedSpace(update.value) && !hasPreservedSpace(tag)) patches.push({
783
- start: activeText.contentStart - 1,
784
- end: activeText.contentStart - 1,
785
- value: " xml:space=\"preserve\""
786
- });
787
- foundPaths.add(activeText.key);
788
- }
789
- activeText = void 0;
790
- }
791
- stack.pop();
792
- });
158
+ const rewriteDocxText = (archive, rewrites) => {
159
+ const rewrite = loadNativeAnonymizeBinding().rewriteDocxTextNative;
160
+ if (rewrite === void 0) throw new Error("The native anonymize binding does not expose DOCX rewriting");
161
+ let serializableRewrites;
793
162
  try {
794
- parser.write(xml).close();
163
+ serializableRewrites = preflightRewritePlan(rewrites);
795
164
  } catch (error) {
796
165
  if (error instanceof DocxRewriteError) throw error;
797
- parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
166
+ const message = error instanceof Error ? error.message : String(error);
167
+ throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, `DOCX rewrite plan is invalid: ${message}`);
798
168
  }
799
- if (parseError !== null) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX source XML changed after extraction");
800
- if (foundPaths.size !== updatesByPath.size) throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX text-node locations changed after extraction");
801
- let rewritten = xml;
802
- for (const patch of patches.toSorted((left, right) => right.start - left.start)) rewritten = rewritten.slice(0, patch.start) + patch.value + rewritten.slice(patch.end);
803
- return rewritten;
804
- };
805
- const assertArchiveBudgets = (entries) => {
806
- let totalBytes = 0;
807
- for (const bytes of Object.values(entries)) {
808
- if (bytes.byteLength > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX entries must not exceed ${DOCX_ENTRY_MAX_BYTES} bytes`);
809
- totalBytes += bytes.byteLength;
810
- }
811
- if (totalBytes > 134217728) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX archives must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} uncompressed bytes`);
812
- };
813
- const rewriteDocxText = (archive, rewrites) => {
814
- const extraction = extractDocxText(archive);
815
- if (rewrites.length === 0) return {
816
- document: archive.slice(),
817
- rewrittenBlockCount: 0,
818
- appliedReplacementCount: 0
819
- };
820
- const blocksByLocation = new Map(extraction.blocks.map((block) => [docxLocationKey(block.location), block]));
821
- const updatesByPart = /* @__PURE__ */ new Map();
822
- const rewrittenLocations = /* @__PURE__ */ new Set();
823
- const replacementBytesByPart = /* @__PURE__ */ new Map();
824
- let appliedReplacementCount = 0;
825
- let totalReplacementBytes = 0;
826
- for (const rewrite of rewrites) {
827
- const key = docxLocationKey(rewrite.location);
828
- if (rewrittenLocations.has(key)) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "Each DOCX block may appear in a rewrite plan only once");
829
- rewrittenLocations.add(key);
830
- const block = blocksByLocation.get(key);
831
- if (block === void 0 || !docxLocationsEqual(block.location, rewrite.location) || block.text !== rewrite.expectedText) throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX block location or expected text no longer matches");
832
- if (rewrite.replacements.length === 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX block rewrite plans must contain at least one replacement");
833
- if (appliedReplacementCount + rewrite.replacements.length > DOCX_MAX_REPLACEMENTS) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewrites must not contain more than ${DOCX_MAX_REPLACEMENTS} replacements`);
834
- const rewriteReplacementBytes = rewrite.replacements.reduce((total, replacement) => total + replacementByteLength(replacement), 0);
835
- totalReplacementBytes += rewriteReplacementBytes;
836
- if (totalReplacementBytes > 134217728) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewrite replacement text must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} aggregate escaped UTF-8 bytes`);
837
- const partReplacementBytes = (replacementBytesByPart.get(block.location.part.path) ?? 0) + rewriteReplacementBytes;
838
- replacementBytesByPart.set(block.location.part.path, partReplacementBytes);
839
- if (partReplacementBytes > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewrite replacement text for a single part must not exceed ${DOCX_ENTRY_MAX_BYTES} aggregate escaped UTF-8 bytes`);
840
- const partUpdates = updatesByPart.get(block.location.part.path) ?? /* @__PURE__ */ new Map();
841
- for (const update of planBlockUpdates(block, rewrite)) partUpdates.set(pathKey(update.path), update);
842
- updatesByPart.set(block.location.part.path, partUpdates);
843
- appliedReplacementCount += rewrite.replacements.length;
844
- }
845
- let totalUpdatedNodeBytes = 0;
846
- for (const updates of updatesByPart.values()) {
847
- let partUpdatedNodeBytes = 0;
848
- for (const update of updates.values()) partUpdatedNodeBytes += escapedXmlTextByteLength(update.value);
849
- if (partUpdatedNodeBytes > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewritten text nodes for a single part must not exceed ${DOCX_ENTRY_MAX_BYTES} escaped UTF-8 bytes`);
850
- totalUpdatedNodeBytes += partUpdatedNodeBytes;
851
- if (totalUpdatedNodeBytes > 134217728) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX rewritten text nodes must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} aggregate escaped UTF-8 bytes`);
852
- }
853
- const entries = unzipDocxArchive(archive, true);
854
- if (Object.keys(entries).some((path) => path.toLowerCase().startsWith(SIGNATURE_PART_PREFIX))) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "Digitally signed DOCX packages must be re-signed before rewriting");
855
- let projectedTotalBytes = 0;
856
- for (const [partPath, partBytes] of Object.entries(entries)) {
857
- let projected = partBytes.byteLength;
858
- const updates = updatesByPart.get(partPath);
859
- if (updates !== void 0) {
860
- for (const update of updates.values()) projected += escapedXmlTextByteLength(update.value) + XML_SPACE_INSERTION_BYTES - update.originalByteLength;
861
- if (projected > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX parts must not exceed ${DOCX_ENTRY_MAX_BYTES} projected bytes`);
862
- }
863
- projectedTotalBytes += projected;
864
- if (projectedTotalBytes > 134217728) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX archives must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} projected uncompressed bytes`);
169
+ let rewritesJson;
170
+ try {
171
+ rewritesJson = JSON.stringify(serializableRewrites);
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ throw new DocxRewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, `DOCX rewrite plan is not serializable: ${message}`);
865
175
  }
866
- for (const [partPath, updates] of updatesByPart) {
867
- const partBytes = entries[partPath];
868
- if (partBytes === void 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX source part changed after extraction");
869
- const xml = new TextDecoder("utf-8", { fatal: true }).decode(partBytes);
870
- entries[partPath] = strToU8(rewritePartXml(xml, [...updates.values()]));
176
+ try {
177
+ return rewrite(archive, rewritesJson);
178
+ } catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ const separator = message.indexOf(": ");
181
+ const rawCode = message.slice(0, separator);
182
+ const extractionCode = rawCode;
183
+ if (separator > 0 && EXTRACTION_ERROR_CODES.has(extractionCode)) throw new DocxExtractionError(extractionCode, message.slice(separator + 2));
184
+ const code = rawCode;
185
+ if (separator > 0 && REWRITE_ERROR_CODES.has(code)) throw new DocxRewriteError(code, message.slice(separator + 2));
186
+ throw error;
871
187
  }
872
- assertArchiveBudgets(entries);
873
- const document = zipSync(entries);
874
- if (document.byteLength > 67108864) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX archives must not exceed ${DOCX_ARCHIVE_MAX_BYTES} bytes`);
875
- return {
876
- document,
877
- rewrittenBlockCount: rewrites.length,
878
- appliedReplacementCount
879
- };
880
188
  };
881
189
  //#endregion
882
190
  //#region src/coverage.ts
@@ -901,8 +209,6 @@ const docxWorkflowCoverage = (coverage) => {
901
209
  };
902
210
  //#endregion
903
211
  //#region src/restore.ts
904
- const DOCX_RESTORE_MAX_PLACEHOLDER_UTF16 = 512;
905
- const DOCX_RESTORE_MAX_CANDIDATES = 1e6;
906
212
  var DocxRestorationError = class extends Error {
907
213
  code;
908
214
  constructor(code, message) {
@@ -912,53 +218,6 @@ var DocxRestorationError = class extends Error {
912
218
  }
913
219
  };
914
220
  const restorationError = (code, message) => new DocxRestorationError(code, message);
915
- const encodedSessionNamespace = (sessionId) => sessionId.replaceAll("_", "%5F");
916
- const isOwnedPlaceholderCandidate = (value, encodedSessionId) => {
917
- const inner = value.endsWith("]") ? value.slice(0, -1) : value;
918
- const countSeparator = inner.lastIndexOf("_");
919
- if (countSeparator <= 0) return false;
920
- const prefix = inner.slice(0, countSeparator);
921
- const namespaceSeparator = prefix.lastIndexOf("_");
922
- if (namespaceSeparator <= 0) return false;
923
- return prefix.slice(namespaceSeparator + 1) === encodedSessionId;
924
- };
925
- const planBlockRestoration = ({ text, encodedSessionId, restoreCandidate, budget }) => {
926
- const replacements = [];
927
- let start;
928
- for (let cursor = 0; cursor < text.length; cursor += 1) {
929
- const character = text.at(cursor);
930
- if (character === "[") {
931
- if (start !== void 0 && isOwnedPlaceholderCandidate(text.slice(start + 1, cursor), encodedSessionId)) throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX text contains an incomplete placeholder for the expected session");
932
- start = cursor;
933
- continue;
934
- }
935
- if (character !== "]" || start === void 0) continue;
936
- const candidateEnd = cursor + 1;
937
- const candidate = text.slice(start, candidateEnd);
938
- budget.candidateCount += 1;
939
- if (budget.candidateCount > DOCX_RESTORE_MAX_CANDIDATES) throw restorationError(DOCX_RESTORATION_ERROR_CODES.restorationLimitExceeded, `DOCX restoration must not inspect more than ${DOCX_RESTORE_MAX_CANDIDATES} placeholder candidates`);
940
- const isOwned = isOwnedPlaceholderCandidate(candidate.slice(1), encodedSessionId);
941
- if (candidate.length > DOCX_RESTORE_MAX_PLACEHOLDER_UTF16) {
942
- if (isOwned) throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX session placeholder exceeds the maximum length");
943
- start = void 0;
944
- continue;
945
- }
946
- if (!isOwned) {
947
- start = void 0;
948
- continue;
949
- }
950
- const replacement = restoreCandidate(candidate);
951
- if (replacement !== candidate) replacements.push({
952
- start,
953
- end: candidateEnd,
954
- replacement
955
- });
956
- else throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX text contains an unknown placeholder for the expected session");
957
- start = void 0;
958
- }
959
- if (start !== void 0 && isOwnedPlaceholderCandidate(text.slice(start + 1), encodedSessionId)) throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX text contains an incomplete placeholder for the expected session");
960
- return replacements;
961
- };
962
221
  const restoreDocxText = ({ document, session, expectedSessionId, observedAtEpochSeconds }) => {
963
222
  const sessionId = session.sessionId();
964
223
  if (sessionId !== expectedSessionId) throw restorationError(DOCX_RESTORATION_ERROR_CODES.sessionMismatch, "DOCX restoration session does not match the expected session id");
@@ -974,23 +233,40 @@ const restoreDocxText = ({ document, session, expectedSessionId, observedAtEpoch
974
233
  restoredCandidates.set(candidate, restored);
975
234
  return restored;
976
235
  };
977
- const encodedSessionId = encodedSessionNamespace(sessionId);
978
- const extraction = extractDocxText(document);
236
+ const planRestoration = loadNativeAnonymizeBinding().planDocxRestorationJson;
237
+ if (planRestoration === void 0) throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidSession, "Native anonymize binding does not expose DOCX restoration planning");
238
+ let plan;
239
+ try {
240
+ plan = JSON.parse(planRestoration(document, sessionId));
241
+ } catch (error) {
242
+ const message = error instanceof Error ? error.message : String(error);
243
+ const separator = message.indexOf(": ");
244
+ const code = message.slice(0, separator);
245
+ const knownCodes = /* @__PURE__ */ new Set([
246
+ DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder,
247
+ DOCX_RESTORATION_ERROR_CODES.restorationLimitExceeded,
248
+ DOCX_RESTORATION_ERROR_CODES.unsupportedDocument
249
+ ]);
250
+ if (separator > 0 && knownCodes.has(code)) throw restorationError(code, message.slice(separator + 2));
251
+ throw error;
252
+ }
979
253
  const rewrites = [];
980
- const budget = { candidateCount: 0 };
981
254
  let restoredPlaceholderCount = 0;
982
- for (const block of extraction.blocks) {
983
- const replacements = planBlockRestoration({
984
- text: block.text,
985
- encodedSessionId,
986
- restoreCandidate,
987
- budget
255
+ for (const block of plan.blocks) {
256
+ const replacements = block.candidates.map(({ candidate, end, start }) => {
257
+ const replacement = restoreCandidate(candidate);
258
+ if (replacement === candidate) throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX text contains an unknown placeholder for the expected session");
259
+ return {
260
+ start,
261
+ end,
262
+ replacement
263
+ };
988
264
  });
989
265
  if (replacements.length === 0) continue;
990
266
  restoredPlaceholderCount += replacements.length;
991
267
  rewrites.push({
992
268
  location: block.location,
993
- expectedText: block.text,
269
+ expectedText: block.expectedText,
994
270
  replacements
995
271
  });
996
272
  }
@@ -1001,10 +277,21 @@ const restoreDocxText = ({ document, session, expectedSessionId, observedAtEpoch
1001
277
  sessionId,
1002
278
  restoredBlockCount: restored.rewrittenBlockCount,
1003
279
  restoredPlaceholderCount,
1004
- coverage: docxWorkflowCoverage(extraction.coverage)
280
+ coverage: docxWorkflowCoverage(plan.extraction.coverage)
1005
281
  };
1006
282
  };
1007
283
  //#endregion
284
+ //#region src/location.ts
285
+ const arraysEqual = (left, right) => left.length === right.length && left.every((value, index) => value === right.at(index));
286
+ const docxLocationsEqual = (left, right) => {
287
+ if (left.type !== right.type || left.part.type !== right.part.type || left.part.path !== right.part.path || left.blockIndex !== right.blockIndex || !arraysEqual(left.xmlPath, right.xmlPath)) return false;
288
+ if (left.type === "paragraph" && right.type === "paragraph") return true;
289
+ if (left.type === "table-cell-paragraph" && right.type === "table-cell-paragraph") return arraysEqual(left.tablePath, right.tablePath) && arraysEqual(left.rowPath, right.rowPath) && arraysEqual(left.cellPath, right.cellPath);
290
+ if (left.type === "text-box-paragraph" && right.type === "text-box-paragraph") return arraysEqual(left.textBoxPath, right.textBoxPath);
291
+ return false;
292
+ };
293
+ const docxLocationKey = ({ blockIndex, part }) => `${part.path}\0${blockIndex}`;
294
+ //#endregion
1008
295
  //#region src/anonymize.ts
1009
296
  const DOCX_ANONYMIZATION_MAX_CALLER_DETECTIONS = 1e6;
1010
297
  var DocxAnonymizationError = class extends Error {