@stll/anonymize-docx 0.0.1-placeholder.0 → 2.0.2
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/ATTRIBUTION.md +8 -0
- package/LICENSE +202 -0
- package/README.md +146 -2
- package/dist/index.d.mts +239 -0
- package/dist/index.mjs +868 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -7
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { strToU8, unzipSync, zipSync } from "fflate";
|
|
2
|
+
import { SaxesParser } from "saxes";
|
|
3
|
+
//#region src/types.ts
|
|
4
|
+
const DOCX_PART_TYPES = {
|
|
5
|
+
comments: "comments",
|
|
6
|
+
endnotes: "endnotes",
|
|
7
|
+
footer: "footer",
|
|
8
|
+
footnotes: "footnotes",
|
|
9
|
+
header: "header",
|
|
10
|
+
mainDocument: "main-document"
|
|
11
|
+
};
|
|
12
|
+
const DOCX_COVERAGE_MODES = {
|
|
13
|
+
allowPartial: "allow-partial",
|
|
14
|
+
requireFull: "require-full"
|
|
15
|
+
};
|
|
16
|
+
const DOCX_ANONYMIZATION_ERROR_CODES = {
|
|
17
|
+
incompleteCoverage: "incomplete-coverage",
|
|
18
|
+
invalidCallerDetections: "invalid-caller-detections",
|
|
19
|
+
sessionMismatch: "session-mismatch"
|
|
20
|
+
};
|
|
21
|
+
const DOCX_RESTORATION_ERROR_CODES = {
|
|
22
|
+
invalidPlaceholder: "invalid-placeholder",
|
|
23
|
+
invalidSession: "invalid-session",
|
|
24
|
+
restorationLimitExceeded: "restoration-limit-exceeded",
|
|
25
|
+
sessionMismatch: "session-mismatch"
|
|
26
|
+
};
|
|
27
|
+
const DOCX_REWRITE_ERROR_CODES = {
|
|
28
|
+
invalidReplacement: "invalid-replacement",
|
|
29
|
+
rewriteLimitExceeded: "rewrite-limit-exceeded",
|
|
30
|
+
staleExtraction: "stale-extraction",
|
|
31
|
+
unsupportedReplacement: "unsupported-replacement"
|
|
32
|
+
};
|
|
33
|
+
const DOCX_EXTRACTION_ERROR_CODES = {
|
|
34
|
+
archiveLimitExceeded: "archive-limit-exceeded",
|
|
35
|
+
invalidArchive: "invalid-archive",
|
|
36
|
+
invalidPackage: "invalid-package",
|
|
37
|
+
invalidXml: "invalid-xml",
|
|
38
|
+
unsafeEntryPath: "unsafe-entry-path",
|
|
39
|
+
uncompressedLimitExceeded: "uncompressed-limit-exceeded"
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/extract.ts
|
|
43
|
+
const DOCX_EXTRACTION_CONTRACT_VERSION = 1;
|
|
44
|
+
const DOCX_ARCHIVE_MAX_BYTES = 64 * 1024 * 1024;
|
|
45
|
+
const DOCX_ENTRY_MAX_BYTES = 16 * 1024 * 1024;
|
|
46
|
+
const DOCX_UNCOMPRESSED_MAX_BYTES = 128 * 1024 * 1024;
|
|
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 CONTENT_TYPES_PATH = "[Content_Types].xml";
|
|
52
|
+
const ROOT_RELATIONSHIPS_PATH = "_rels/.rels";
|
|
53
|
+
const CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types";
|
|
54
|
+
const PACKAGE_RELATIONSHIP_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/package/relationships", "http://schemas.openxmlformats.org/package/2006/relationships"]);
|
|
55
|
+
const WORDPROCESSING_CONTENT_TYPE_PREFIX = "application/vnd.openxmlformats-officedocument.wordprocessingml.";
|
|
56
|
+
const SUPPORTED_CONTENT_TYPE_SUFFIXES = {
|
|
57
|
+
"comments+xml": DOCX_PART_TYPES.comments,
|
|
58
|
+
"document.main+xml": DOCX_PART_TYPES.mainDocument,
|
|
59
|
+
"endnotes+xml": DOCX_PART_TYPES.endnotes,
|
|
60
|
+
"footer+xml": DOCX_PART_TYPES.footer,
|
|
61
|
+
"footnotes+xml": DOCX_PART_TYPES.footnotes,
|
|
62
|
+
"header+xml": DOCX_PART_TYPES.header
|
|
63
|
+
};
|
|
64
|
+
const WORDPROCESSING_NAMESPACES$1 = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/wordprocessingml/main", "http://schemas.openxmlformats.org/wordprocessingml/2006/main"]);
|
|
65
|
+
const RELATIONSHIP_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/officeDocument/relationships", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"]);
|
|
66
|
+
const OFFICE_DOCUMENT_RELATIONSHIP_TYPES = new Set([...RELATIONSHIP_NAMESPACES].map((namespace) => `${namespace}/officeDocument`));
|
|
67
|
+
const MARKUP_COMPATIBILITY_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/markup-compatibility/main", "http://schemas.openxmlformats.org/markup-compatibility/2006"]);
|
|
68
|
+
var DocxExtractionError = class extends Error {
|
|
69
|
+
code;
|
|
70
|
+
constructor(code, message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "DocxExtractionError";
|
|
73
|
+
this.code = code;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const invalidPackage = (message) => new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidPackage, message);
|
|
77
|
+
const assertXmlDepth = (depth) => {
|
|
78
|
+
if (depth < 256) return;
|
|
79
|
+
throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX XML must not exceed 256 nested elements`);
|
|
80
|
+
};
|
|
81
|
+
const safeEntryPath = (name) => name.length > 0 && !name.startsWith("/") && !name.includes("\\") && !name.split("/").includes("..") && !name.includes("\0");
|
|
82
|
+
const archiveFilter = ({ budget, file, includeAllEntries }) => {
|
|
83
|
+
budget.entryCount += 1;
|
|
84
|
+
if (budget.entryCount > DOCX_MAX_ENTRIES) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must contain at most ${DOCX_MAX_ENTRIES} entries`);
|
|
85
|
+
if (!safeEntryPath(file.name)) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.unsafeEntryPath, "DOCX archive contains an unsafe entry path");
|
|
86
|
+
if (file.originalSize > 16777216) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX entries must not exceed ${DOCX_ENTRY_MAX_BYTES} bytes`);
|
|
87
|
+
budget.uncompressedBytes += file.originalSize;
|
|
88
|
+
if (budget.uncompressedBytes > 134217728) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX archives must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} uncompressed bytes`);
|
|
89
|
+
return includeAllEntries || file.name === CONTENT_TYPES_PATH || file.name === ROOT_RELATIONSHIPS_PATH || file.name.startsWith("word/") && file.name.endsWith(".xml");
|
|
90
|
+
};
|
|
91
|
+
const unzipDocxArchive = (archive, includeAllEntries = false) => {
|
|
92
|
+
if (archive.byteLength > 67108864) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.archiveLimitExceeded, `DOCX archives must not exceed ${DOCX_ARCHIVE_MAX_BYTES} bytes`);
|
|
93
|
+
const budget = {
|
|
94
|
+
entryCount: 0,
|
|
95
|
+
uncompressedBytes: 0
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
return unzipSync(archive, { filter: (file) => archiveFilter({
|
|
99
|
+
budget,
|
|
100
|
+
file,
|
|
101
|
+
includeAllEntries
|
|
102
|
+
}) });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof DocxExtractionError) throw error;
|
|
105
|
+
throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidArchive, "Input is not a valid bounded DOCX ZIP archive");
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const decodeXml = (bytes, path) => {
|
|
109
|
+
try {
|
|
110
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, `DOCX XML part is not valid UTF-8: ${path}`);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const attributeByLocalName = (tag, localName, namespaces) => {
|
|
116
|
+
for (const attribute of Object.values(tag.attributes)) if (attribute.local === localName && (namespaces === void 0 || namespaces.has(attribute.uri))) return attribute.value;
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
const parseContentTypes = (xml) => {
|
|
120
|
+
const parts = [];
|
|
121
|
+
const paths = /* @__PURE__ */ new Set();
|
|
122
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
123
|
+
let parseError = null;
|
|
124
|
+
let depth = 0;
|
|
125
|
+
parser.on("error", (error) => {
|
|
126
|
+
parseError = error;
|
|
127
|
+
});
|
|
128
|
+
parser.on("doctype", () => {
|
|
129
|
+
throw invalidPackage("DOCX XML must not contain a document type declaration");
|
|
130
|
+
});
|
|
131
|
+
parser.on("opentag", (tag) => {
|
|
132
|
+
assertXmlDepth(depth);
|
|
133
|
+
depth += 1;
|
|
134
|
+
if (tag.local !== "Override" || tag.uri !== CONTENT_TYPES_NAMESPACE) return;
|
|
135
|
+
const rawPath = attributeByLocalName(tag, "PartName");
|
|
136
|
+
const contentType = attributeByLocalName(tag, "ContentType");
|
|
137
|
+
if (rawPath === null || contentType === null) throw invalidPackage("DOCX content-type override is incomplete");
|
|
138
|
+
const path = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
|
|
139
|
+
if (!safeEntryPath(path)) throw invalidPackage("DOCX content-type override has an unsafe path");
|
|
140
|
+
if (paths.has(path)) throw invalidPackage("DOCX content-type overrides must have unique paths");
|
|
141
|
+
paths.add(path);
|
|
142
|
+
parts.push({
|
|
143
|
+
path,
|
|
144
|
+
contentType
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
parser.on("closetag", () => {
|
|
148
|
+
depth -= 1;
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
parser.write(xml).close();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof DocxExtractionError) throw error;
|
|
154
|
+
parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
|
|
155
|
+
}
|
|
156
|
+
if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, "DOCX content types are not valid XML");
|
|
157
|
+
return parts;
|
|
158
|
+
};
|
|
159
|
+
const parseMainDocumentTarget = (xml) => {
|
|
160
|
+
const targets = [];
|
|
161
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
162
|
+
let parseError = null;
|
|
163
|
+
let depth = 0;
|
|
164
|
+
parser.on("error", (error) => {
|
|
165
|
+
parseError = error;
|
|
166
|
+
});
|
|
167
|
+
parser.on("doctype", () => {
|
|
168
|
+
throw invalidPackage("DOCX XML must not contain a document type declaration");
|
|
169
|
+
});
|
|
170
|
+
parser.on("opentag", (tag) => {
|
|
171
|
+
assertXmlDepth(depth);
|
|
172
|
+
depth += 1;
|
|
173
|
+
if (tag.local !== "Relationship" || !PACKAGE_RELATIONSHIP_NAMESPACES.has(tag.uri)) return;
|
|
174
|
+
const type = attributeByLocalName(tag, "Type");
|
|
175
|
+
if (type === null || !OFFICE_DOCUMENT_RELATIONSHIP_TYPES.has(type)) return;
|
|
176
|
+
const targetMode = attributeByLocalName(tag, "TargetMode");
|
|
177
|
+
const rawTarget = attributeByLocalName(tag, "Target");
|
|
178
|
+
if (targetMode === "External" || rawTarget === null) throw invalidPackage("DOCX main-document relationship must be internal");
|
|
179
|
+
const target = rawTarget.startsWith("/") ? rawTarget.slice(1) : rawTarget;
|
|
180
|
+
if (!safeEntryPath(target) || target.includes(":")) throw invalidPackage("DOCX main-document relationship has an unsafe target");
|
|
181
|
+
targets.push(target);
|
|
182
|
+
});
|
|
183
|
+
parser.on("closetag", () => {
|
|
184
|
+
depth -= 1;
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
parser.write(xml).close();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof DocxExtractionError) throw error;
|
|
190
|
+
parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
|
|
191
|
+
}
|
|
192
|
+
if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, "DOCX root relationships are not valid XML");
|
|
193
|
+
if (targets.length !== 1) throw invalidPackage("DOCX archive must contain exactly one main-document relationship");
|
|
194
|
+
const target = targets.at(0);
|
|
195
|
+
if (target === void 0) throw invalidPackage("DOCX main-document relationship is unavailable");
|
|
196
|
+
return target;
|
|
197
|
+
};
|
|
198
|
+
const classifyPart = ({ contentType, path }) => {
|
|
199
|
+
if (!contentType.startsWith(WORDPROCESSING_CONTENT_TYPE_PREFIX)) return null;
|
|
200
|
+
const suffix = contentType.slice(63);
|
|
201
|
+
const type = SUPPORTED_CONTENT_TYPE_SUFFIXES[suffix];
|
|
202
|
+
return type === void 0 ? null : {
|
|
203
|
+
type,
|
|
204
|
+
path
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const isWordTag = (tag, local) => tag.local === local && WORDPROCESSING_NAMESPACES$1.has(tag.uri);
|
|
208
|
+
const frameByLocalName = (stack, local) => {
|
|
209
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
210
|
+
const frame = stack.at(index);
|
|
211
|
+
if (frame !== void 0 && isWordTag(frame.tag, local)) return frame;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
};
|
|
215
|
+
const blockLocation = (part, blockIndex, paragraphPath, stack) => {
|
|
216
|
+
const textBox = frameByLocalName(stack, "txbxContent");
|
|
217
|
+
if (textBox !== null) return {
|
|
218
|
+
type: "text-box-paragraph",
|
|
219
|
+
part,
|
|
220
|
+
blockIndex,
|
|
221
|
+
xmlPath: paragraphPath,
|
|
222
|
+
textBoxPath: textBox.path
|
|
223
|
+
};
|
|
224
|
+
const cell = frameByLocalName(stack, "tc");
|
|
225
|
+
const row = frameByLocalName(stack, "tr");
|
|
226
|
+
const table = frameByLocalName(stack, "tbl");
|
|
227
|
+
if (cell !== null && row !== null && table !== null) return {
|
|
228
|
+
type: "table-cell-paragraph",
|
|
229
|
+
part,
|
|
230
|
+
blockIndex,
|
|
231
|
+
xmlPath: paragraphPath,
|
|
232
|
+
tablePath: table.path,
|
|
233
|
+
rowPath: row.path,
|
|
234
|
+
cellPath: cell.path
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
type: "paragraph",
|
|
238
|
+
part,
|
|
239
|
+
blockIndex,
|
|
240
|
+
xmlPath: paragraphPath
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
const revisionForTag = (tag) => {
|
|
244
|
+
if (!WORDPROCESSING_NAMESPACES$1.has(tag.uri)) return null;
|
|
245
|
+
const revision = {
|
|
246
|
+
del: "deletion",
|
|
247
|
+
ins: "insertion",
|
|
248
|
+
moveFrom: "move-from",
|
|
249
|
+
moveTo: "move-to"
|
|
250
|
+
}[tag.local];
|
|
251
|
+
return revision === void 0 ? null : {
|
|
252
|
+
type: "revision",
|
|
253
|
+
revision
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
const inlineContexts = (stack) => {
|
|
257
|
+
const contexts = [];
|
|
258
|
+
for (const { tag } of stack) {
|
|
259
|
+
if (isWordTag(tag, "hyperlink")) contexts.push({
|
|
260
|
+
type: "hyperlink",
|
|
261
|
+
relationshipId: attributeByLocalName(tag, "id", RELATIONSHIP_NAMESPACES),
|
|
262
|
+
anchor: attributeByLocalName(tag, "anchor", WORDPROCESSING_NAMESPACES$1)
|
|
263
|
+
});
|
|
264
|
+
const revision = revisionForTag(tag);
|
|
265
|
+
if (revision !== null) contexts.push(revision);
|
|
266
|
+
}
|
|
267
|
+
return contexts;
|
|
268
|
+
};
|
|
269
|
+
const appendSegment = (block, budget, value, source, path, stack) => {
|
|
270
|
+
if (value.length === 0) return;
|
|
271
|
+
if (budget.segmentCount >= DOCX_MAX_TEXT_SEGMENTS) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.uncompressedLimitExceeded, `DOCX parts must not contain more than ${DOCX_MAX_TEXT_SEGMENTS} text segments`);
|
|
272
|
+
budget.segmentCount += 1;
|
|
273
|
+
const start = block.text.length;
|
|
274
|
+
block.text += value;
|
|
275
|
+
block.segments.push({
|
|
276
|
+
start,
|
|
277
|
+
end: block.text.length,
|
|
278
|
+
source,
|
|
279
|
+
contexts: inlineContexts(stack),
|
|
280
|
+
xmlPath: path
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
const extractPart = (part, xml) => {
|
|
284
|
+
const blocks = [];
|
|
285
|
+
const stack = [];
|
|
286
|
+
const blockStack = [];
|
|
287
|
+
let nextBlockIndex = 0;
|
|
288
|
+
let currentText = "";
|
|
289
|
+
let currentTextPath = null;
|
|
290
|
+
let parseError = null;
|
|
291
|
+
let unsupportedSymbolCount = 0;
|
|
292
|
+
let unsupportedFieldInstructionCount = 0;
|
|
293
|
+
let unsupportedAlternateContentCount = 0;
|
|
294
|
+
const textBudget = { segmentCount: 0 };
|
|
295
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
296
|
+
parser.on("error", (error) => {
|
|
297
|
+
parseError = error;
|
|
298
|
+
});
|
|
299
|
+
parser.on("doctype", () => {
|
|
300
|
+
throw invalidPackage("DOCX XML must not contain a document type declaration");
|
|
301
|
+
});
|
|
302
|
+
parser.on("opentag", (tag) => {
|
|
303
|
+
assertXmlDepth(stack.length);
|
|
304
|
+
const parent = stack.at(-1);
|
|
305
|
+
const childIndex = parent?.nextChildIndex ?? 0;
|
|
306
|
+
if (parent !== void 0) parent.nextChildIndex += 1;
|
|
307
|
+
const path = [...parent?.path ?? [], childIndex];
|
|
308
|
+
if (isWordTag(tag, "p")) {
|
|
309
|
+
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`);
|
|
310
|
+
blockStack.push({
|
|
311
|
+
text: "",
|
|
312
|
+
segments: [],
|
|
313
|
+
location: blockLocation(part, nextBlockIndex, path, stack)
|
|
314
|
+
});
|
|
315
|
+
nextBlockIndex += 1;
|
|
316
|
+
}
|
|
317
|
+
stack.push({
|
|
318
|
+
tag,
|
|
319
|
+
path,
|
|
320
|
+
nextChildIndex: 0
|
|
321
|
+
});
|
|
322
|
+
if (isWordTag(tag, "t") || isWordTag(tag, "delText")) {
|
|
323
|
+
currentText = "";
|
|
324
|
+
currentTextPath = path;
|
|
325
|
+
}
|
|
326
|
+
const currentBlock = blockStack.at(-1);
|
|
327
|
+
if (currentBlock !== void 0 && isWordTag(tag, "tab")) appendSegment(currentBlock, textBudget, " ", "tab", path, stack);
|
|
328
|
+
if (currentBlock !== void 0 && (isWordTag(tag, "br") || isWordTag(tag, "cr"))) appendSegment(currentBlock, textBudget, "\n", "break", path, stack);
|
|
329
|
+
if (isWordTag(tag, "sym")) unsupportedSymbolCount += 1;
|
|
330
|
+
if (isWordTag(tag, "instrText") || isWordTag(tag, "fldSimple")) unsupportedFieldInstructionCount += 1;
|
|
331
|
+
if (tag.local === "AlternateContent" && MARKUP_COMPATIBILITY_NAMESPACES.has(tag.uri)) unsupportedAlternateContentCount += 1;
|
|
332
|
+
});
|
|
333
|
+
parser.on("text", (text) => {
|
|
334
|
+
if (currentTextPath !== null) currentText += text;
|
|
335
|
+
});
|
|
336
|
+
parser.on("cdata", (text) => {
|
|
337
|
+
if (currentTextPath !== null) currentText += text;
|
|
338
|
+
});
|
|
339
|
+
parser.on("closetag", (tag) => {
|
|
340
|
+
const frame = stack.at(-1);
|
|
341
|
+
if (frame === void 0 || frame.tag !== tag) throw invalidPackage("DOCX XML element stack is inconsistent");
|
|
342
|
+
if (currentTextPath !== null && (isWordTag(tag, "t") || isWordTag(tag, "delText"))) {
|
|
343
|
+
const currentBlock = blockStack.at(-1);
|
|
344
|
+
if (currentBlock === void 0) {
|
|
345
|
+
if (currentText.length > 0) throw invalidPackage("DOCX text is outside a paragraph");
|
|
346
|
+
} else appendSegment(currentBlock, textBudget, currentText, "text", currentTextPath, stack);
|
|
347
|
+
currentText = "";
|
|
348
|
+
currentTextPath = null;
|
|
349
|
+
}
|
|
350
|
+
if (isWordTag(tag, "p")) {
|
|
351
|
+
const completedBlock = blockStack.pop();
|
|
352
|
+
if (completedBlock === void 0) throw invalidPackage("DOCX paragraph state is unavailable");
|
|
353
|
+
blocks.push(completedBlock);
|
|
354
|
+
}
|
|
355
|
+
stack.pop();
|
|
356
|
+
});
|
|
357
|
+
try {
|
|
358
|
+
parser.write(xml).close();
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error instanceof DocxExtractionError) throw error;
|
|
361
|
+
parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
|
|
362
|
+
}
|
|
363
|
+
if (parseError !== null) throw new DocxExtractionError(DOCX_EXTRACTION_ERROR_CODES.invalidXml, `DOCX part is not valid XML: ${part.path}`);
|
|
364
|
+
blocks.sort((left, right) => left.location.blockIndex - right.location.blockIndex);
|
|
365
|
+
let hyperlinkTextSegmentCount = 0;
|
|
366
|
+
let revisionTextSegmentCount = 0;
|
|
367
|
+
for (const { segments } of blocks) for (const { contexts } of segments) {
|
|
368
|
+
if (contexts.some((context) => context.type === "hyperlink")) hyperlinkTextSegmentCount += 1;
|
|
369
|
+
if (contexts.some((context) => context.type === "revision")) revisionTextSegmentCount += 1;
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
blocks,
|
|
373
|
+
hyperlinkTextSegmentCount,
|
|
374
|
+
revisionTextSegmentCount,
|
|
375
|
+
unsupportedAlternateContentCount,
|
|
376
|
+
unsupportedSymbolCount,
|
|
377
|
+
unsupportedFieldInstructionCount
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
const extractDocxText = (archive) => {
|
|
381
|
+
const entries = unzipDocxArchive(archive);
|
|
382
|
+
const contentTypesBytes = entries[CONTENT_TYPES_PATH];
|
|
383
|
+
if (contentTypesBytes === void 0) throw invalidPackage("DOCX archive is missing [Content_Types].xml");
|
|
384
|
+
const contentTypes = parseContentTypes(decodeXml(contentTypesBytes, CONTENT_TYPES_PATH));
|
|
385
|
+
const rootRelationshipsBytes = entries[ROOT_RELATIONSHIPS_PATH];
|
|
386
|
+
if (rootRelationshipsBytes === void 0) throw invalidPackage("DOCX archive is missing _rels/.rels");
|
|
387
|
+
const mainDocumentTarget = parseMainDocumentTarget(decodeXml(rootRelationshipsBytes, ROOT_RELATIONSHIPS_PATH));
|
|
388
|
+
const supportedParts = contentTypes.map(classifyPart).filter((part) => part !== null);
|
|
389
|
+
if (supportedParts.filter((part) => part.type === DOCX_PART_TYPES.mainDocument).length !== 1) throw invalidPackage("DOCX archive must contain exactly one main document");
|
|
390
|
+
if (supportedParts.find((part) => part.type === DOCX_PART_TYPES.mainDocument)?.path !== mainDocumentTarget) throw invalidPackage("DOCX main-document relationship and content type do not agree");
|
|
391
|
+
const blocks = [];
|
|
392
|
+
const coverageParts = [];
|
|
393
|
+
let hyperlinkTextSegmentCount = 0;
|
|
394
|
+
let revisionTextSegmentCount = 0;
|
|
395
|
+
let unsupportedSymbolCount = 0;
|
|
396
|
+
let unsupportedFieldInstructionCount = 0;
|
|
397
|
+
let unsupportedAlternateContentCount = 0;
|
|
398
|
+
let textSegmentCount = 0;
|
|
399
|
+
for (const part of supportedParts) {
|
|
400
|
+
const bytes = entries[part.path];
|
|
401
|
+
if (bytes === void 0) throw invalidPackage(`DOCX archive is missing declared part: ${part.path}`);
|
|
402
|
+
const extracted = extractPart(part, decodeXml(bytes, part.path));
|
|
403
|
+
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`);
|
|
404
|
+
const extractedSegmentCount = extracted.blocks.reduce((count, block) => count + block.segments.length, 0);
|
|
405
|
+
if (textSegmentCount + extractedSegmentCount > 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`);
|
|
406
|
+
textSegmentCount += extractedSegmentCount;
|
|
407
|
+
blocks.push(...extracted.blocks);
|
|
408
|
+
coverageParts.push({
|
|
409
|
+
status: "extracted",
|
|
410
|
+
part,
|
|
411
|
+
blockCount: extracted.blocks.length
|
|
412
|
+
});
|
|
413
|
+
hyperlinkTextSegmentCount += extracted.hyperlinkTextSegmentCount;
|
|
414
|
+
revisionTextSegmentCount += extracted.revisionTextSegmentCount;
|
|
415
|
+
unsupportedSymbolCount += extracted.unsupportedSymbolCount;
|
|
416
|
+
unsupportedFieldInstructionCount += extracted.unsupportedFieldInstructionCount;
|
|
417
|
+
unsupportedAlternateContentCount += extracted.unsupportedAlternateContentCount;
|
|
418
|
+
}
|
|
419
|
+
for (const { contentType, path } of contentTypes) if (contentType.startsWith(WORDPROCESSING_CONTENT_TYPE_PREFIX) && classifyPart({
|
|
420
|
+
contentType,
|
|
421
|
+
path
|
|
422
|
+
}) === null) coverageParts.push({
|
|
423
|
+
status: "unsupported",
|
|
424
|
+
path,
|
|
425
|
+
contentType,
|
|
426
|
+
reason: "WordprocessingML part type is not extracted"
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
contractVersion: 1,
|
|
430
|
+
blocks,
|
|
431
|
+
coverage: {
|
|
432
|
+
parts: coverageParts,
|
|
433
|
+
hyperlinkTextSegmentCount,
|
|
434
|
+
revisionTextSegmentCount,
|
|
435
|
+
unsupportedAlternateContentCount,
|
|
436
|
+
unsupportedSymbolCount,
|
|
437
|
+
unsupportedFieldInstructionCount
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/location.ts
|
|
443
|
+
const arraysEqual = (left, right) => left.length === right.length && left.every((value, index) => value === right.at(index));
|
|
444
|
+
const docxLocationsEqual = (left, right) => {
|
|
445
|
+
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;
|
|
446
|
+
if (left.type === "paragraph" && right.type === "paragraph") return true;
|
|
447
|
+
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);
|
|
448
|
+
if (left.type === "text-box-paragraph" && right.type === "text-box-paragraph") return arraysEqual(left.textBoxPath, right.textBoxPath);
|
|
449
|
+
return false;
|
|
450
|
+
};
|
|
451
|
+
const docxLocationKey = ({ blockIndex, part }) => `${part.path}\0${blockIndex}`;
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/rewrite.ts
|
|
454
|
+
const WORDPROCESSING_NAMESPACES = /* @__PURE__ */ new Set(["http://purl.oclc.org/ooxml/wordprocessingml/main", "http://schemas.openxmlformats.org/wordprocessingml/2006/main"]);
|
|
455
|
+
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
|
456
|
+
const DOCX_MAX_REPLACEMENTS = 1e6;
|
|
457
|
+
const SIGNATURE_PART_PREFIX = "_xmlsignatures/";
|
|
458
|
+
var DocxRewriteError = class extends Error {
|
|
459
|
+
code;
|
|
460
|
+
constructor(code, message) {
|
|
461
|
+
super(message);
|
|
462
|
+
this.name = "DocxRewriteError";
|
|
463
|
+
this.code = code;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
const rewriteError = (code, message) => new DocxRewriteError(code, message);
|
|
467
|
+
const pathKey = (path) => path.join(".");
|
|
468
|
+
const isValidXmlText = (value) => {
|
|
469
|
+
for (const character of value) {
|
|
470
|
+
const codePoint = character.codePointAt(0);
|
|
471
|
+
if (codePoint === void 0 || codePoint !== 9 && codePoint !== 10 && codePoint !== 13 && (codePoint < 32 || codePoint > 55295 && codePoint < 57344 || codePoint > 65533 && codePoint < 65536 || codePoint > 1114111)) return false;
|
|
472
|
+
}
|
|
473
|
+
return true;
|
|
474
|
+
};
|
|
475
|
+
const isUtf16Boundary = (value, offset) => {
|
|
476
|
+
if (offset === 0 || offset === value.length) return true;
|
|
477
|
+
const previous = value.charCodeAt(offset - 1);
|
|
478
|
+
const next = value.charCodeAt(offset);
|
|
479
|
+
return !(previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343);
|
|
480
|
+
};
|
|
481
|
+
const validateReplacement = (replacement, blockText) => {
|
|
482
|
+
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");
|
|
483
|
+
if (!isValidXmlText(replacement.replacement)) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX replacement text must contain only valid XML characters");
|
|
484
|
+
if (strToU8(replacement.replacement).byteLength > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX replacement text must not exceed ${DOCX_ENTRY_MAX_BYTES} UTF-8 bytes`);
|
|
485
|
+
};
|
|
486
|
+
const coveredTextSegments = (block, replacement) => {
|
|
487
|
+
const segments = block.segments.filter(({ end, start }) => start < replacement.end && end > replacement.start);
|
|
488
|
+
let cursor = replacement.start;
|
|
489
|
+
for (const segment of segments) {
|
|
490
|
+
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");
|
|
491
|
+
cursor = Math.min(replacement.end, segment.end);
|
|
492
|
+
}
|
|
493
|
+
if (segments.length === 0 || cursor !== replacement.end) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacements must stay within contiguous non-revision text segments");
|
|
494
|
+
return segments;
|
|
495
|
+
};
|
|
496
|
+
const planBlockUpdates = (block, rewrite) => {
|
|
497
|
+
const replacements = [...rewrite.replacements].sort((left, right) => left.start - right.start);
|
|
498
|
+
for (const [index, replacement] of replacements.entries()) {
|
|
499
|
+
validateReplacement(replacement, block.text);
|
|
500
|
+
const previous = index === 0 ? void 0 : replacements.at(index - 1);
|
|
501
|
+
if (previous !== void 0 && previous.end > replacement.start) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX replacement spans must not overlap");
|
|
502
|
+
}
|
|
503
|
+
const values = /* @__PURE__ */ new Map();
|
|
504
|
+
const originalValues = /* @__PURE__ */ new Map();
|
|
505
|
+
for (const segment of block.segments) {
|
|
506
|
+
if (segment.source !== "text") continue;
|
|
507
|
+
values.set(pathKey(segment.xmlPath), {
|
|
508
|
+
path: segment.xmlPath,
|
|
509
|
+
value: block.text.slice(segment.start, segment.end)
|
|
510
|
+
});
|
|
511
|
+
originalValues.set(pathKey(segment.xmlPath), block.text.slice(segment.start, segment.end));
|
|
512
|
+
}
|
|
513
|
+
for (const replacement of replacements.toReversed()) {
|
|
514
|
+
const segments = coveredTextSegments(block, replacement);
|
|
515
|
+
const first = segments.at(0);
|
|
516
|
+
const last = segments.at(-1);
|
|
517
|
+
if (first === void 0 || last === void 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacement text segments are unavailable");
|
|
518
|
+
const firstUpdate = values.get(pathKey(first.xmlPath));
|
|
519
|
+
const lastUpdate = values.get(pathKey(last.xmlPath));
|
|
520
|
+
if (firstUpdate === void 0 || lastUpdate === void 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX replacement text nodes are unavailable");
|
|
521
|
+
const firstStart = replacement.start - first.start;
|
|
522
|
+
const lastEnd = replacement.end - last.start;
|
|
523
|
+
if (first === last) {
|
|
524
|
+
firstUpdate.value = firstUpdate.value.slice(0, firstStart) + replacement.replacement + firstUpdate.value.slice(lastEnd);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
firstUpdate.value = firstUpdate.value.slice(0, firstStart) + replacement.replacement;
|
|
528
|
+
for (const segment of segments.slice(1, -1)) {
|
|
529
|
+
const update = values.get(pathKey(segment.xmlPath));
|
|
530
|
+
if (update !== void 0) update.value = "";
|
|
531
|
+
}
|
|
532
|
+
lastUpdate.value = lastUpdate.value.slice(lastEnd);
|
|
533
|
+
}
|
|
534
|
+
return [...values.entries()].filter(([key, update]) => update.value !== originalValues.get(key)).map(([, update]) => update);
|
|
535
|
+
};
|
|
536
|
+
const escapeXmlText = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
537
|
+
const requiresPreservedSpace = (value) => /^\s|\s$/u.test(value);
|
|
538
|
+
const isWordTextTag = (tag) => WORDPROCESSING_NAMESPACES.has(tag.uri) && (tag.local === "t" || tag.local === "delText");
|
|
539
|
+
const hasPreservedSpace = (tag) => Object.values(tag.attributes).some((attribute) => attribute.uri === XML_NAMESPACE && attribute.local === "space" && attribute.value === "preserve");
|
|
540
|
+
const findClosingTagStart = ({ xml, contentStart, parserPosition }) => {
|
|
541
|
+
for (let index = parserPosition - 1; index >= contentStart; index -= 1) if (xml[index] === "<" && xml[index + 1] === "/") return index;
|
|
542
|
+
throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX text-node closing tag changed after extraction");
|
|
543
|
+
};
|
|
544
|
+
const rewritePartXml = (xml, updates) => {
|
|
545
|
+
const updatesByPath = new Map(updates.map((update) => [pathKey(update.path), update]));
|
|
546
|
+
const foundPaths = /* @__PURE__ */ new Set();
|
|
547
|
+
const patches = [];
|
|
548
|
+
const stack = [];
|
|
549
|
+
let activeText;
|
|
550
|
+
let parseError = null;
|
|
551
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
552
|
+
parser.on("error", (error) => {
|
|
553
|
+
parseError = error;
|
|
554
|
+
});
|
|
555
|
+
parser.on("opentag", (tag) => {
|
|
556
|
+
if (stack.length >= 256) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `DOCX XML must not exceed 256 nested elements`);
|
|
557
|
+
const parent = stack.at(-1);
|
|
558
|
+
const childIndex = parent?.nextChildIndex ?? 0;
|
|
559
|
+
if (parent !== void 0) parent.nextChildIndex += 1;
|
|
560
|
+
const path = [...parent?.path ?? [], childIndex];
|
|
561
|
+
stack.push({
|
|
562
|
+
path,
|
|
563
|
+
nextChildIndex: 0
|
|
564
|
+
});
|
|
565
|
+
const key = pathKey(path);
|
|
566
|
+
if (isWordTextTag(tag) && updatesByPath.has(key)) {
|
|
567
|
+
if (tag.isSelfClosing) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX self-closing text nodes cannot receive replacements");
|
|
568
|
+
activeText = {
|
|
569
|
+
key,
|
|
570
|
+
contentStart: parser.position,
|
|
571
|
+
tag
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
parser.on("closetag", (tag) => {
|
|
576
|
+
if (activeText?.tag === tag) {
|
|
577
|
+
const update = updatesByPath.get(activeText.key);
|
|
578
|
+
if (update !== void 0) {
|
|
579
|
+
const contentEnd = findClosingTagStart({
|
|
580
|
+
xml,
|
|
581
|
+
contentStart: activeText.contentStart,
|
|
582
|
+
parserPosition: parser.position
|
|
583
|
+
});
|
|
584
|
+
patches.push({
|
|
585
|
+
start: activeText.contentStart,
|
|
586
|
+
end: contentEnd,
|
|
587
|
+
value: escapeXmlText(update.value)
|
|
588
|
+
});
|
|
589
|
+
if (requiresPreservedSpace(update.value) && !hasPreservedSpace(tag)) patches.push({
|
|
590
|
+
start: activeText.contentStart - 1,
|
|
591
|
+
end: activeText.contentStart - 1,
|
|
592
|
+
value: " xml:space=\"preserve\""
|
|
593
|
+
});
|
|
594
|
+
foundPaths.add(activeText.key);
|
|
595
|
+
}
|
|
596
|
+
activeText = void 0;
|
|
597
|
+
}
|
|
598
|
+
stack.pop();
|
|
599
|
+
});
|
|
600
|
+
try {
|
|
601
|
+
parser.write(xml).close();
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error instanceof DocxRewriteError) throw error;
|
|
604
|
+
parseError = error instanceof Error ? error : /* @__PURE__ */ new Error("invalid XML");
|
|
605
|
+
}
|
|
606
|
+
if (parseError !== null) throw rewriteError(DOCX_REWRITE_ERROR_CODES.unsupportedReplacement, "DOCX source XML changed after extraction");
|
|
607
|
+
if (foundPaths.size !== updatesByPath.size) throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX text-node locations changed after extraction");
|
|
608
|
+
let rewritten = xml;
|
|
609
|
+
for (const patch of patches.toSorted((left, right) => right.start - left.start)) rewritten = rewritten.slice(0, patch.start) + patch.value + rewritten.slice(patch.end);
|
|
610
|
+
return rewritten;
|
|
611
|
+
};
|
|
612
|
+
const assertArchiveBudgets = (entries) => {
|
|
613
|
+
let totalBytes = 0;
|
|
614
|
+
for (const bytes of Object.values(entries)) {
|
|
615
|
+
if (bytes.byteLength > 16777216) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX entries must not exceed ${DOCX_ENTRY_MAX_BYTES} bytes`);
|
|
616
|
+
totalBytes += bytes.byteLength;
|
|
617
|
+
}
|
|
618
|
+
if (totalBytes > 134217728) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX archives must not exceed ${DOCX_UNCOMPRESSED_MAX_BYTES} uncompressed bytes`);
|
|
619
|
+
};
|
|
620
|
+
const rewriteDocxText = (archive, rewrites) => {
|
|
621
|
+
const extraction = extractDocxText(archive);
|
|
622
|
+
if (rewrites.length === 0) return {
|
|
623
|
+
document: archive.slice(),
|
|
624
|
+
rewrittenBlockCount: 0,
|
|
625
|
+
appliedReplacementCount: 0
|
|
626
|
+
};
|
|
627
|
+
const blocksByLocation = new Map(extraction.blocks.map((block) => [docxLocationKey(block.location), block]));
|
|
628
|
+
const updatesByPart = /* @__PURE__ */ new Map();
|
|
629
|
+
const rewrittenLocations = /* @__PURE__ */ new Set();
|
|
630
|
+
let appliedReplacementCount = 0;
|
|
631
|
+
for (const rewrite of rewrites) {
|
|
632
|
+
const key = docxLocationKey(rewrite.location);
|
|
633
|
+
if (rewrittenLocations.has(key)) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "Each DOCX block may appear in a rewrite plan only once");
|
|
634
|
+
rewrittenLocations.add(key);
|
|
635
|
+
const block = blocksByLocation.get(key);
|
|
636
|
+
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");
|
|
637
|
+
if (rewrite.replacements.length === 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.invalidReplacement, "DOCX block rewrite plans must contain at least one replacement");
|
|
638
|
+
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`);
|
|
639
|
+
const partUpdates = updatesByPart.get(block.location.part.path) ?? /* @__PURE__ */ new Map();
|
|
640
|
+
for (const update of planBlockUpdates(block, rewrite)) partUpdates.set(pathKey(update.path), update);
|
|
641
|
+
updatesByPart.set(block.location.part.path, partUpdates);
|
|
642
|
+
appliedReplacementCount += rewrite.replacements.length;
|
|
643
|
+
}
|
|
644
|
+
const entries = unzipDocxArchive(archive, true);
|
|
645
|
+
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");
|
|
646
|
+
for (const [partPath, updates] of updatesByPart) {
|
|
647
|
+
const partBytes = entries[partPath];
|
|
648
|
+
if (partBytes === void 0) throw rewriteError(DOCX_REWRITE_ERROR_CODES.staleExtraction, "DOCX source part changed after extraction");
|
|
649
|
+
const xml = new TextDecoder("utf-8", { fatal: true }).decode(partBytes);
|
|
650
|
+
entries[partPath] = strToU8(rewritePartXml(xml, [...updates.values()]));
|
|
651
|
+
}
|
|
652
|
+
assertArchiveBudgets(entries);
|
|
653
|
+
const document = zipSync(entries);
|
|
654
|
+
if (document.byteLength > 67108864) throw rewriteError(DOCX_REWRITE_ERROR_CODES.rewriteLimitExceeded, `Rewritten DOCX archives must not exceed ${DOCX_ARCHIVE_MAX_BYTES} bytes`);
|
|
655
|
+
return {
|
|
656
|
+
document,
|
|
657
|
+
rewrittenBlockCount: rewrites.length,
|
|
658
|
+
appliedReplacementCount
|
|
659
|
+
};
|
|
660
|
+
};
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/coverage.ts
|
|
663
|
+
const hasPartialCoverage = (coverage) => coverage.parts.some(({ status }) => status === "unsupported") || coverage.hyperlinkTextSegmentCount > 0 || coverage.revisionTextSegmentCount > 0 || coverage.unsupportedAlternateContentCount > 0 || coverage.unsupportedSymbolCount > 0 || coverage.unsupportedFieldInstructionCount > 0;
|
|
664
|
+
const docxWorkflowCoverage = (coverage) => {
|
|
665
|
+
const counts = {
|
|
666
|
+
extractedPartCount: coverage.parts.filter(({ status }) => status === "extracted").length,
|
|
667
|
+
unsupportedPartCount: coverage.parts.filter(({ status }) => status === "unsupported").length,
|
|
668
|
+
hyperlinkTextSegmentCount: coverage.hyperlinkTextSegmentCount,
|
|
669
|
+
revisionTextSegmentCount: coverage.revisionTextSegmentCount,
|
|
670
|
+
unsupportedAlternateContentCount: coverage.unsupportedAlternateContentCount,
|
|
671
|
+
unsupportedSymbolCount: coverage.unsupportedSymbolCount,
|
|
672
|
+
unsupportedFieldInstructionCount: coverage.unsupportedFieldInstructionCount
|
|
673
|
+
};
|
|
674
|
+
return hasPartialCoverage(coverage) ? {
|
|
675
|
+
status: "partial",
|
|
676
|
+
counts
|
|
677
|
+
} : {
|
|
678
|
+
status: "full",
|
|
679
|
+
counts
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/restore.ts
|
|
684
|
+
const DOCX_RESTORE_MAX_PLACEHOLDER_UTF16 = 512;
|
|
685
|
+
const DOCX_RESTORE_MAX_CANDIDATES = 1e6;
|
|
686
|
+
var DocxRestorationError = class extends Error {
|
|
687
|
+
code;
|
|
688
|
+
constructor(code, message) {
|
|
689
|
+
super(message);
|
|
690
|
+
this.name = "DocxRestorationError";
|
|
691
|
+
this.code = code;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const restorationError = (code, message) => new DocxRestorationError(code, message);
|
|
695
|
+
const encodedSessionNamespace = (sessionId) => sessionId.replaceAll("_", "%5F");
|
|
696
|
+
const isOwnedPlaceholderCandidate = (value, encodedSessionId) => {
|
|
697
|
+
const inner = value.endsWith("]") ? value.slice(0, -1) : value;
|
|
698
|
+
const countSeparator = inner.lastIndexOf("_");
|
|
699
|
+
if (countSeparator <= 0) return false;
|
|
700
|
+
const prefix = inner.slice(0, countSeparator);
|
|
701
|
+
const namespaceSeparator = prefix.lastIndexOf("_");
|
|
702
|
+
if (namespaceSeparator <= 0) return false;
|
|
703
|
+
return prefix.slice(namespaceSeparator + 1) === encodedSessionId;
|
|
704
|
+
};
|
|
705
|
+
const planBlockRestoration = ({ text, encodedSessionId, restoreCandidate, budget }) => {
|
|
706
|
+
const replacements = [];
|
|
707
|
+
let start;
|
|
708
|
+
for (let cursor = 0; cursor < text.length; cursor += 1) {
|
|
709
|
+
const character = text.at(cursor);
|
|
710
|
+
if (character === "[") {
|
|
711
|
+
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");
|
|
712
|
+
start = cursor;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (character !== "]" || start === void 0) continue;
|
|
716
|
+
const candidateEnd = cursor + 1;
|
|
717
|
+
const candidate = text.slice(start, candidateEnd);
|
|
718
|
+
budget.candidateCount += 1;
|
|
719
|
+
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`);
|
|
720
|
+
const isOwned = isOwnedPlaceholderCandidate(candidate.slice(1), encodedSessionId);
|
|
721
|
+
if (candidate.length > DOCX_RESTORE_MAX_PLACEHOLDER_UTF16) {
|
|
722
|
+
if (isOwned) throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX session placeholder exceeds the maximum length");
|
|
723
|
+
start = void 0;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (!isOwned) {
|
|
727
|
+
start = void 0;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const replacement = restoreCandidate(candidate);
|
|
731
|
+
if (replacement !== candidate) replacements.push({
|
|
732
|
+
start,
|
|
733
|
+
end: candidateEnd,
|
|
734
|
+
replacement
|
|
735
|
+
});
|
|
736
|
+
else throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidPlaceholder, "DOCX text contains an unknown placeholder for the expected session");
|
|
737
|
+
start = void 0;
|
|
738
|
+
}
|
|
739
|
+
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");
|
|
740
|
+
return replacements;
|
|
741
|
+
};
|
|
742
|
+
const restoreDocxText = ({ document, session, expectedSessionId, observedAtEpochSeconds }) => {
|
|
743
|
+
const sessionId = session.sessionId();
|
|
744
|
+
if (sessionId !== expectedSessionId) throw restorationError(DOCX_RESTORATION_ERROR_CODES.sessionMismatch, "DOCX restoration session does not match the expected session id");
|
|
745
|
+
const assertSessionAvailable = () => {
|
|
746
|
+
if (session.restoreText("", observedAtEpochSeconds) !== "") throw restorationError(DOCX_RESTORATION_ERROR_CODES.invalidSession, "DOCX restoration session must preserve text without placeholders");
|
|
747
|
+
};
|
|
748
|
+
assertSessionAvailable();
|
|
749
|
+
const restoredCandidates = /* @__PURE__ */ new Map();
|
|
750
|
+
const restoreCandidate = (candidate) => {
|
|
751
|
+
const cached = restoredCandidates.get(candidate);
|
|
752
|
+
if (cached !== void 0) return cached;
|
|
753
|
+
const restored = session.restoreText(candidate, observedAtEpochSeconds);
|
|
754
|
+
restoredCandidates.set(candidate, restored);
|
|
755
|
+
return restored;
|
|
756
|
+
};
|
|
757
|
+
const encodedSessionId = encodedSessionNamespace(sessionId);
|
|
758
|
+
const extraction = extractDocxText(document);
|
|
759
|
+
const rewrites = [];
|
|
760
|
+
const budget = { candidateCount: 0 };
|
|
761
|
+
let restoredPlaceholderCount = 0;
|
|
762
|
+
for (const block of extraction.blocks) {
|
|
763
|
+
const replacements = planBlockRestoration({
|
|
764
|
+
text: block.text,
|
|
765
|
+
encodedSessionId,
|
|
766
|
+
restoreCandidate,
|
|
767
|
+
budget
|
|
768
|
+
});
|
|
769
|
+
if (replacements.length === 0) continue;
|
|
770
|
+
restoredPlaceholderCount += replacements.length;
|
|
771
|
+
rewrites.push({
|
|
772
|
+
location: block.location,
|
|
773
|
+
expectedText: block.text,
|
|
774
|
+
replacements
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
assertSessionAvailable();
|
|
778
|
+
const restored = rewriteDocxText(document, rewrites);
|
|
779
|
+
return {
|
|
780
|
+
document: restored.document,
|
|
781
|
+
sessionId,
|
|
782
|
+
restoredBlockCount: restored.rewrittenBlockCount,
|
|
783
|
+
restoredPlaceholderCount,
|
|
784
|
+
coverage: docxWorkflowCoverage(extraction.coverage)
|
|
785
|
+
};
|
|
786
|
+
};
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/anonymize.ts
|
|
789
|
+
const DOCX_ANONYMIZATION_MAX_CALLER_DETECTIONS = 1e6;
|
|
790
|
+
var DocxAnonymizationError = class extends Error {
|
|
791
|
+
code;
|
|
792
|
+
constructor(code, message) {
|
|
793
|
+
super(message);
|
|
794
|
+
this.name = "DocxAnonymizationError";
|
|
795
|
+
this.code = code;
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
const anonymizationError = (code, message) => new DocxAnonymizationError(code, message);
|
|
799
|
+
const planCallerDetections = (extractionBlocks, inputs) => {
|
|
800
|
+
const blocksByLocation = new Map(extractionBlocks.map((block) => [docxLocationKey(block.location), block]));
|
|
801
|
+
const detectionsByLocation = /* @__PURE__ */ new Map();
|
|
802
|
+
let callerDetectionCount = 0;
|
|
803
|
+
for (const input of inputs) {
|
|
804
|
+
const key = docxLocationKey(input.location);
|
|
805
|
+
if (detectionsByLocation.has(key)) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.invalidCallerDetections, "Each DOCX block may have only one caller-detection input");
|
|
806
|
+
const block = blocksByLocation.get(key);
|
|
807
|
+
if (block === void 0 || !docxLocationsEqual(block.location, input.location) || block.text !== input.expectedText) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.invalidCallerDetections, "DOCX caller-detection location or expected text no longer matches");
|
|
808
|
+
if (input.detections.length > 1e6 - callerDetectionCount) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.invalidCallerDetections, `DOCX workflows must not contain more than ${DOCX_ANONYMIZATION_MAX_CALLER_DETECTIONS} caller detections`);
|
|
809
|
+
detectionsByLocation.set(key, input);
|
|
810
|
+
callerDetectionCount += input.detections.length;
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
detectionsByLocation,
|
|
814
|
+
callerDetectionCount
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
const anonymizeDocx = ({ document, session, expectedSessionId, policy, callerDetections = [], observedAtEpochSeconds }) => {
|
|
818
|
+
const sessionId = session.sessionId();
|
|
819
|
+
if (sessionId !== expectedSessionId) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.sessionMismatch, "DOCX anonymization session does not match the expected session");
|
|
820
|
+
const extraction = extractDocxText(document);
|
|
821
|
+
const coverage = docxWorkflowCoverage(extraction.coverage);
|
|
822
|
+
if (coverage.status === "partial" && policy.coverage.mode === DOCX_COVERAGE_MODES.requireFull) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.incompleteCoverage, "DOCX contains content outside the fully supported anonymization coverage");
|
|
823
|
+
const { detectionsByLocation, callerDetectionCount } = planCallerDetections(extraction.blocks, callerDetections);
|
|
824
|
+
const plan = session.planTextBatchWithCallerDetections({
|
|
825
|
+
inputs: extraction.blocks.map((block) => ({
|
|
826
|
+
fullText: block.text,
|
|
827
|
+
detections: detectionsByLocation.get(docxLocationKey(block.location))?.detections ?? []
|
|
828
|
+
})),
|
|
829
|
+
...policy.operators === void 0 ? {} : { operators: policy.operators },
|
|
830
|
+
...observedAtEpochSeconds === void 0 ? {} : { observedAtEpochSeconds }
|
|
831
|
+
});
|
|
832
|
+
if (plan.blocks.length !== extraction.blocks.length) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.invalidCallerDetections, "DOCX session redaction plan does not match the extracted block count");
|
|
833
|
+
const rewrites = [];
|
|
834
|
+
let entityCount = 0;
|
|
835
|
+
let retainedCallerDetectionCount = 0;
|
|
836
|
+
for (const [index, block] of extraction.blocks.entries()) {
|
|
837
|
+
const blockPlan = plan.blocks.at(index);
|
|
838
|
+
if (blockPlan === void 0) throw anonymizationError(DOCX_ANONYMIZATION_ERROR_CODES.invalidCallerDetections, "DOCX session redaction plan is missing an extracted block");
|
|
839
|
+
entityCount += blockPlan.entityCount;
|
|
840
|
+
retainedCallerDetectionCount += blockPlan.callerEntityCount;
|
|
841
|
+
if (blockPlan.replacements.length === 0) continue;
|
|
842
|
+
rewrites.push({
|
|
843
|
+
location: block.location,
|
|
844
|
+
expectedText: block.text,
|
|
845
|
+
replacements: blockPlan.replacements
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
const rewritten = rewriteDocxText(document, rewrites);
|
|
849
|
+
plan.commit();
|
|
850
|
+
return {
|
|
851
|
+
document: rewritten.document,
|
|
852
|
+
summary: {
|
|
853
|
+
contractVersion: 1,
|
|
854
|
+
sessionId,
|
|
855
|
+
blockCount: extraction.blocks.length,
|
|
856
|
+
rewrittenBlockCount: rewritten.rewrittenBlockCount,
|
|
857
|
+
appliedReplacementCount: rewritten.appliedReplacementCount,
|
|
858
|
+
entityCount,
|
|
859
|
+
callerDetectionCount,
|
|
860
|
+
retainedCallerDetectionCount,
|
|
861
|
+
coverage
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
};
|
|
865
|
+
//#endregion
|
|
866
|
+
export { DOCX_ANONYMIZATION_ERROR_CODES, DOCX_ANONYMIZATION_MAX_CALLER_DETECTIONS, DOCX_ARCHIVE_MAX_BYTES, DOCX_COVERAGE_MODES, DOCX_ENTRY_MAX_BYTES, DOCX_EXTRACTION_CONTRACT_VERSION, DOCX_EXTRACTION_ERROR_CODES, DOCX_PART_TYPES, DOCX_RESTORATION_ERROR_CODES, DOCX_REWRITE_ERROR_CODES, DOCX_UNCOMPRESSED_MAX_BYTES, DOCX_XML_MAX_DEPTH, DocxAnonymizationError, DocxExtractionError, DocxRestorationError, DocxRewriteError, anonymizeDocx, extractDocxText, restoreDocxText, rewriteDocxText };
|
|
867
|
+
|
|
868
|
+
//# sourceMappingURL=index.mjs.map
|