@stll/folio-core 0.64.1 → 0.65.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/ai-edits/headless.js +3 -3
- package/dist/compare/reproducible-package.d.ts +5 -0
- package/dist/compare/reproducible-package.js +10 -23
- package/dist/docx/archiveInflation.d.ts +8 -103
- package/dist/docx/archiveInflation.js +8 -145
- package/dist/docx/canonicalStoryRepack.js +2 -2
- package/dist/docx/ensureParaIds.js +7 -22
- package/dist/docx/metadataPrivacy.js +38 -48
- package/dist/docx/packageParts.d.ts +4 -4
- package/dist/docx/packageParts.js +17 -24
- package/dist/docx/parser.js +4 -5
- package/dist/docx/removeHeaderFooterParts.d.ts +2 -2
- package/dist/docx/removeHeaderFooterParts.js +19 -36
- package/dist/docx/rezip.d.ts +7 -7
- package/dist/docx/rezip.js +143 -531
- package/dist/docx/selectiveSave.js +37 -41
- package/dist/docx/selectiveSaveFlags.d.ts +1 -1
- package/dist/docx/selectiveSaveTripwire.js +5 -11
- package/dist/docx/server/applyDocxXmlPatchProposal.js +20 -41
- package/dist/docx/server/boundedArchive.d.ts +1 -2
- package/dist/docx/server/boundedArchive.js +77 -98
- package/dist/docx/server/inspectDocxPackage.d.ts +1 -1
- package/dist/docx/server/inspectDocxPackage.js +1 -1
- package/dist/docx/unzip.d.ts +2 -3
- package/dist/docx/unzip.js +126 -154
- package/package.json +3 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeText
|
|
1
|
+
import { decodeText } from "../utils/textEncoding.js";
|
|
2
2
|
import { parseCommentsExtended } from "./commentParser.js";
|
|
3
3
|
import { withoutExhaustedComments, withoutOrphanCommentRanges } from "./commentRangeIntegrity.js";
|
|
4
4
|
import { hasUnsynthesizedReplyRanges } from "./commentReplyMarkers.js";
|
|
@@ -18,7 +18,7 @@ import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
|
|
|
18
18
|
import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
|
|
19
19
|
import { buildStructuralDocumentPatch } from "./structuralXmlPatch.js";
|
|
20
20
|
import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
|
|
21
|
-
import {
|
|
21
|
+
import { ZipArchive } from "@stll/docx-core/zip";
|
|
22
22
|
// src/docx/selectiveSave.ts
|
|
23
23
|
/**
|
|
24
24
|
* Splice edited footnote/endnote paragraphs into their parts and return the
|
|
@@ -39,22 +39,22 @@ async function patchNoteParts(zip, doc, candidateIds, updates) {
|
|
|
39
39
|
const remainingIds = new Set(candidateIds);
|
|
40
40
|
if (footnotes.length === 0 && endnotes.length === 0) return remainingIds;
|
|
41
41
|
const findPart = (conventionalLowerPath) => {
|
|
42
|
-
const direct = zip.
|
|
42
|
+
const direct = zip.entry(conventionalLowerPath);
|
|
43
43
|
if (direct) return direct;
|
|
44
|
-
for (const [path, file] of
|
|
44
|
+
for (const [path, file] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) if (path.toLowerCase() === conventionalLowerPath) return file;
|
|
45
45
|
return null;
|
|
46
46
|
};
|
|
47
47
|
const patchPart = async (conventionalLowerPath, serialize) => {
|
|
48
48
|
const file = findPart(conventionalLowerPath);
|
|
49
49
|
if (!file) return true;
|
|
50
|
-
const originalXml = await file.
|
|
50
|
+
const originalXml = await file.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
51
51
|
const originalIds = collectParaIds(originalXml);
|
|
52
52
|
const partIds = /* @__PURE__ */ new Set();
|
|
53
53
|
for (const id of remainingIds) if (originalIds.has(id)) partIds.add(id);
|
|
54
54
|
if (partIds.size === 0) return true;
|
|
55
55
|
const patched = buildPatchedNoteXml(originalXml, serialize(), partIds);
|
|
56
56
|
if (patched === null) return false;
|
|
57
|
-
updates.set(file.
|
|
57
|
+
updates.set(file.path, patched);
|
|
58
58
|
for (const id of partIds) remainingIds.delete(id);
|
|
59
59
|
return true;
|
|
60
60
|
};
|
|
@@ -63,9 +63,9 @@ async function patchNoteParts(zip, doc, candidateIds, updates) {
|
|
|
63
63
|
return remainingIds;
|
|
64
64
|
}
|
|
65
65
|
const findZipEntryCaseInsensitive = (zip, lowerPath) => {
|
|
66
|
-
const direct = zip.
|
|
66
|
+
const direct = zip.entry(lowerPath);
|
|
67
67
|
if (direct) return direct;
|
|
68
|
-
for (const [path, file] of
|
|
68
|
+
for (const [path, file] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) if (path.toLowerCase() === lowerPath) return file;
|
|
69
69
|
return null;
|
|
70
70
|
};
|
|
71
71
|
const commentsExtendedInfoEqual = (a, b) => {
|
|
@@ -94,9 +94,9 @@ async function patchCommentsExtended(zip, plan, updates) {
|
|
|
94
94
|
const desiredXml = serializeCommentsExtended(plan);
|
|
95
95
|
const existing = findZipEntryCaseInsensitive(zip, COMMENTS_EXTENDED_PART_LOWER);
|
|
96
96
|
if (!desiredXml) return existing === null;
|
|
97
|
-
const baselineInfo = existing ? parseCommentsExtended(await existing.
|
|
97
|
+
const baselineInfo = existing ? parseCommentsExtended(await existing.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }))) : /* @__PURE__ */ new Map();
|
|
98
98
|
if (commentsExtendedInfoEqual(parseCommentsExtended(desiredXml), baselineInfo)) return true;
|
|
99
|
-
updates.set(existing?.
|
|
99
|
+
updates.set(existing?.path ?? "word/commentsExtended.xml", desiredXml);
|
|
100
100
|
return ensureCommentsExtendedPackaging(zip, updates);
|
|
101
101
|
}
|
|
102
102
|
/**
|
|
@@ -107,12 +107,12 @@ async function patchCommentsExtended(zip, plan, updates) {
|
|
|
107
107
|
*/
|
|
108
108
|
async function ensureCommentsExtendedPackaging(zip, updates) {
|
|
109
109
|
const ctPath = "[Content_Types].xml";
|
|
110
|
-
const ctXml = updates.get(ctPath) ?? await zip.
|
|
110
|
+
const ctXml = updates.get(ctPath) ?? await zip.entry(ctPath)?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
111
111
|
if (ctXml === void 0) return false;
|
|
112
112
|
const nextCt = addCommentsExtendedOverride(ctXml);
|
|
113
113
|
if (nextCt !== ctXml) updates.set(ctPath, nextCt);
|
|
114
114
|
const relsPath = "word/_rels/document.xml.rels";
|
|
115
|
-
const relsXml = updates.get(relsPath) ?? await zip.
|
|
115
|
+
const relsXml = updates.get(relsPath) ?? await zip.entry(relsPath)?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
116
116
|
if (relsXml === void 0) return false;
|
|
117
117
|
const nextRels = addCommentsExtendedRelationship(relsXml);
|
|
118
118
|
if (nextRels !== relsXml) updates.set(relsPath, nextRels);
|
|
@@ -139,15 +139,15 @@ async function patchNumberingPart(zip, doc, updates) {
|
|
|
139
139
|
const numbering = doc.package.numbering;
|
|
140
140
|
if (!numbering || numbering.abstractNums.length === 0 && numbering.nums.length === 0) return true;
|
|
141
141
|
const conventionalLowerPath = "word/numbering.xml";
|
|
142
|
-
let file = zip.
|
|
142
|
+
let file = zip.entry(conventionalLowerPath);
|
|
143
143
|
if (!file) {
|
|
144
|
-
for (const [path, entry] of
|
|
144
|
+
for (const [path, entry] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) if (path.toLowerCase() === conventionalLowerPath) {
|
|
145
145
|
file = entry;
|
|
146
146
|
break;
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
if (!file) return false;
|
|
150
|
-
const originalXml = await file.
|
|
150
|
+
const originalXml = await file.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
151
151
|
const baselineXml = serializeNumberingXml(parseNumbering(originalXml).definitions);
|
|
152
152
|
const currentXml = serializeNumberingXml(numbering);
|
|
153
153
|
if (currentXml === baselineXml) return true;
|
|
@@ -157,7 +157,7 @@ async function patchNumberingPart(zip, doc, updates) {
|
|
|
157
157
|
currentXml
|
|
158
158
|
});
|
|
159
159
|
if (patched === null) return false;
|
|
160
|
-
if (patched !== originalXml) updates.set(file.
|
|
160
|
+
if (patched !== originalXml) updates.set(file.path, patched);
|
|
161
161
|
return true;
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
@@ -175,10 +175,10 @@ async function patchNumberingPart(zip, doc, updates) {
|
|
|
175
175
|
* same package for the same source.
|
|
176
176
|
*/
|
|
177
177
|
const queueSettingsUpdates = async (zip, updates) => {
|
|
178
|
-
const settingsFile = zip.
|
|
178
|
+
const settingsFile = zip.entry("word/settings.xml");
|
|
179
179
|
if (!settingsFile) return;
|
|
180
|
-
const relsFile = zip.
|
|
181
|
-
const filtered = withoutAttachedTemplate(await settingsFile.
|
|
180
|
+
const relsFile = zip.entry("word/_rels/settings.xml.rels");
|
|
181
|
+
const filtered = withoutAttachedTemplate(await settingsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })), await relsFile?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })));
|
|
182
182
|
if (filtered.settingsXml !== void 0) updates.set("word/settings.xml", filtered.settingsXml);
|
|
183
183
|
if (filtered.relsXml !== void 0) updates.set("word/_rels/settings.xml.rels", filtered.relsXml);
|
|
184
184
|
};
|
|
@@ -206,21 +206,17 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
|
206
206
|
const ownsCommentsPart = hasOwnedCommentsPart(doc);
|
|
207
207
|
const commentPlan = planCommentParts(comments);
|
|
208
208
|
try {
|
|
209
|
-
const zip = await
|
|
209
|
+
const zip = await ZipArchive.read(originalBuffer);
|
|
210
210
|
if (await hasUnmaterializedHyperlinkBindings(doc, zip)) return null;
|
|
211
|
-
for (const
|
|
211
|
+
for (const path of zip.paths()) if (isUnsafePackagePath(path)) return null;
|
|
212
212
|
if (originalBuffer !== doc.originalBuffer) {
|
|
213
213
|
const sourceParts = /* @__PURE__ */ new Map();
|
|
214
|
-
for (const [path, file] of
|
|
214
|
+
for (const [path, file] of zip.entries().map((archiveEntry) => [archiveEntry.path, archiveEntry])) {
|
|
215
215
|
const lowerPath = path.toLowerCase();
|
|
216
|
-
if (
|
|
217
|
-
sourceParts.set(path, await file.
|
|
216
|
+
if (!lowerPath.startsWith("word/") || !lowerPath.endsWith(".xml")) continue;
|
|
217
|
+
sourceParts.set(path, await file.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })));
|
|
218
218
|
}
|
|
219
|
-
for (const [path, xml] of normalizeImportedNumericIds(sourceParts)) if (xml !== sourceParts.get(path))
|
|
220
|
-
zip,
|
|
221
|
-
path,
|
|
222
|
-
data: encodeUtf8(xml)
|
|
223
|
-
});
|
|
219
|
+
for (const [path, xml] of normalizeImportedNumericIds(sourceParts)) if (xml !== sourceParts.get(path)) zip.set(path, xml);
|
|
224
220
|
}
|
|
225
221
|
if (options.bodyAuthority === "canonical") await publishCanonicalImageResources({
|
|
226
222
|
document: doc,
|
|
@@ -229,9 +225,9 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
|
229
225
|
});
|
|
230
226
|
const updates = /* @__PURE__ */ new Map();
|
|
231
227
|
if (changedParaIds.size > 0 || structuralChange) {
|
|
232
|
-
const docXmlFile = zip.
|
|
228
|
+
const docXmlFile = zip.entry("word/document.xml");
|
|
233
229
|
if (!docXmlFile) return null;
|
|
234
|
-
const originalDocXml = await docXmlFile.
|
|
230
|
+
const originalDocXml = await docXmlFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
235
231
|
const serializedDocXml = serializeDocument(doc, readRootNamespaceBindings(originalDocXml), {
|
|
236
232
|
xml: originalDocXml,
|
|
237
233
|
bodyAuthority: options.bodyAuthority,
|
|
@@ -241,7 +237,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
|
241
237
|
const originalBodyParaIds = structuralChange ? collectParaIds(originalDocXml) : void 0;
|
|
242
238
|
if (structuralChange && doc.package.numbering) {
|
|
243
239
|
const numberingFile = findZipEntryCaseInsensitive(zip, "word/numbering.xml");
|
|
244
|
-
const baselineNumbering = numberingFile ? serializeNumberingXml(parseNumbering(await numberingFile.
|
|
240
|
+
const baselineNumbering = numberingFile ? serializeNumberingXml(parseNumbering(await numberingFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }))).definitions) : "";
|
|
245
241
|
const added = collectAddedNumberingDefs(baselineNumbering, serializeNumberingXml(doc.package.numbering));
|
|
246
242
|
if (added.abstractNums.size > 0 || added.nums.size > 0) return null;
|
|
247
243
|
}
|
|
@@ -264,23 +260,23 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
|
264
260
|
}
|
|
265
261
|
}
|
|
266
262
|
const sourceCommentsFile = findZipEntryCaseInsensitive(zip, "word/comments.xml");
|
|
267
|
-
const commentsPartPath = sourceCommentsFile?.
|
|
268
|
-
const sourceCommentsXml = sourceCommentsFile ? await sourceCommentsFile.
|
|
263
|
+
const commentsPartPath = sourceCommentsFile?.path ?? "word/comments.xml";
|
|
264
|
+
const sourceCommentsXml = sourceCommentsFile ? await sourceCommentsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true })) : void 0;
|
|
269
265
|
const commentsUnchanged = sourceCommentsXml !== void 0 && canReplayCommentsPart(doc, sourceCommentsXml);
|
|
270
266
|
if (!commentsUnchanged && (hasComments || ownsCommentsPart || sourceCommentsFile)) {
|
|
271
267
|
const commentsXml = serializeComments(commentPlan, sourceCommentsXml === void 0 ? void 0 : readRootNamespaceBindings(sourceCommentsXml));
|
|
272
268
|
if (commentsXml !== sourceCommentsXml) updates.set(commentsPartPath, commentsXml);
|
|
273
269
|
}
|
|
274
270
|
if (hasComments || ownsCommentsPart) {
|
|
275
|
-
const ctFile = zip.
|
|
271
|
+
const ctFile = zip.entry("[Content_Types].xml");
|
|
276
272
|
if (ctFile) {
|
|
277
|
-
const ctXml = await ctFile.
|
|
273
|
+
const ctXml = await ctFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
278
274
|
if (!ctXml.toLowerCase().includes("/word/comments.xml")) updates.set("[Content_Types].xml", ctXml.replace("</Types>", `<Override PartName="/${commentsPartPath}" ContentType="${COMMENTS_CONTENT_TYPE}"/></Types>`));
|
|
279
275
|
}
|
|
280
276
|
const relsPath = "word/_rels/document.xml.rels";
|
|
281
|
-
const relsFile = zip.
|
|
277
|
+
const relsFile = zip.entry(relsPath);
|
|
282
278
|
if (relsFile) {
|
|
283
|
-
const relsXml = await relsFile.
|
|
279
|
+
const relsXml = await relsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
284
280
|
if (!relsXml.toLowerCase().includes("comments.xml")) {
|
|
285
281
|
const maxId = findMaxRId(relsXml);
|
|
286
282
|
updates.set(relsPath, relsXml.replace("</Relationships>", `<Relationship Id="rId${maxId + 1}" Type="${RELATIONSHIP_TYPES.comments}" Target="${commentsPartPath.slice(5)}"/></Relationships>`));
|
|
@@ -308,16 +304,16 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
|
|
|
308
304
|
onDiagnostic: options.onDiagnostic
|
|
309
305
|
})) updates.set(path, xml);
|
|
310
306
|
await queueSettingsUpdates(zip, updates);
|
|
311
|
-
const corePropsFile = zip.
|
|
307
|
+
const corePropsFile = zip.entry("docProps/core.xml");
|
|
312
308
|
if (corePropsFile) {
|
|
313
|
-
const corePropsXml = await corePropsFile.
|
|
309
|
+
const corePropsXml = await corePropsFile.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
|
|
314
310
|
updates.set("docProps/core.xml", updateCoreProperties(corePropsXml, { updateModifiedDate: true }));
|
|
315
311
|
}
|
|
316
312
|
if ([...updates.keys()].some((path) => {
|
|
317
313
|
const lowerPath = path.toLowerCase();
|
|
318
314
|
return lowerPath.startsWith("word/") && (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels"));
|
|
319
315
|
})) {
|
|
320
|
-
const sourceDocumentXml = await findZipEntryCaseInsensitive(zip, "word/document.xml")?.
|
|
316
|
+
const sourceDocumentXml = await findZipEntryCaseInsensitive(zip, "word/document.xml")?.text();
|
|
321
317
|
if (detectDocxConformanceClass(sourceDocumentXml ?? null) === DOCX_CONFORMANCE_CLASSES.STRICT) return null;
|
|
322
318
|
}
|
|
323
319
|
return await applyUpdatesToZip(zip, updates);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - `selectiveSaveTripwire` — run selective + full and compare bytes for CI
|
|
12
12
|
* observability. Never blocks the user-visible save.
|
|
13
13
|
* - `selectiveSaveMaxBytes` — refuse the selective path for original buffers
|
|
14
|
-
* above this size, since holding them in memory plus the
|
|
14
|
+
* above this size, since holding them in memory plus the archive overhead is
|
|
15
15
|
* the dominant cost.
|
|
16
16
|
*/
|
|
17
17
|
export type FolioSelectiveSaveFlags = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { panic } from "better-result";
|
|
2
|
+
import { ZipArchive } from "@stll/docx-core/zip";
|
|
2
3
|
// src/docx/selectiveSaveTripwire.ts
|
|
3
4
|
/**
|
|
4
5
|
* Selective Save Tripwire
|
|
@@ -16,17 +17,10 @@ import { panic } from "better-result";
|
|
|
16
17
|
*/
|
|
17
18
|
const IGNORED_PATHS = /* @__PURE__ */ new Set(["docProps/core.xml"]);
|
|
18
19
|
async function listEntries(buffer) {
|
|
19
|
-
const zip = await
|
|
20
|
-
const paths = [];
|
|
21
|
-
for (const [path, entry] of Object.entries(zip.files)) {
|
|
22
|
-
if (entry.dir) continue;
|
|
23
|
-
if (IGNORED_PATHS.has(path)) continue;
|
|
24
|
-
paths.push(path);
|
|
25
|
-
}
|
|
26
|
-
paths.sort();
|
|
20
|
+
const zip = await ZipArchive.read(buffer);
|
|
27
21
|
return {
|
|
28
22
|
zip,
|
|
29
|
-
paths
|
|
23
|
+
paths: zip.paths().filter((path) => !IGNORED_PATHS.has(path)).toSorted()
|
|
30
24
|
};
|
|
31
25
|
}
|
|
32
26
|
function bytesEqual(a, b) {
|
|
@@ -35,9 +29,9 @@ function bytesEqual(a, b) {
|
|
|
35
29
|
return true;
|
|
36
30
|
}
|
|
37
31
|
function readEntryBytes(zip, path, side) {
|
|
38
|
-
const entry = zip.
|
|
32
|
+
const entry = zip.entry(path);
|
|
39
33
|
if (!entry) panic(`Missing ${side} zip entry: ${path}`);
|
|
40
|
-
return entry.
|
|
34
|
+
return entry.bytes();
|
|
41
35
|
}
|
|
42
36
|
function readCommonEntryBytes(selectiveZip, fullZip) {
|
|
43
37
|
return Promise.all(selectiveZip.paths.map(async (path) => {
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { encodeUtf8 } from "../../utils/textEncoding.js";
|
|
2
1
|
import { loadDocxArchive } from "./boundedArchive.js";
|
|
3
2
|
import { FOLIO_DOCX_XML_PATCH_PROPOSAL_PROFILE, InvalidFolioDocxXmlPatchProposalError, evaluateDocxXmlPatchProposal, parseFolioDocxXmlPatchProposal } from "./evaluateDocxXmlPatchProposal.js";
|
|
4
3
|
import { FOLIO_DOCX_CONFORMANCE_PROFILE, validateDocxConformance } from "./validateDocxConformance.js";
|
|
5
4
|
import { TaggedError, panic } from "better-result";
|
|
6
5
|
import { createHash } from "node:crypto";
|
|
7
|
-
import
|
|
8
|
-
import { writeZipPart } from "@stll/docx-core/zip";
|
|
6
|
+
import { ZipArchive } from "@stll/docx-core/zip";
|
|
9
7
|
// src/docx/server/applyDocxXmlPatchProposal.ts
|
|
10
8
|
const FOLIO_DOCX_XML_PATCH_APPLICATION_VERSION = 1;
|
|
11
9
|
const FOLIO_DOCX_XML_PATCH_APPLICATION_PROFILE = "folio-xml-patch-application-v1";
|
|
@@ -60,29 +58,26 @@ const verifyArchiveContents = async (bytes, options) => {
|
|
|
60
58
|
cause
|
|
61
59
|
});
|
|
62
60
|
}
|
|
63
|
-
for (const entry of archive.entryMetadata.toSorted(({ path: left }, { path: right }) => left < right ? -1 : Number(left > right))) {
|
|
64
|
-
if (entry.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
cause
|
|
78
|
-
});
|
|
79
|
-
}
|
|
61
|
+
for (const entry of archive.entryMetadata.toSorted(({ path: left }, { path: right }) => left < right ? -1 : Number(left > right))) try {
|
|
62
|
+
if (await archive.readEntryUint8(entry.path) === null) throw new FolioDocxXmlPatchApplicationError({
|
|
63
|
+
message: "An archive entry disappeared during verification.",
|
|
64
|
+
stage: "verify",
|
|
65
|
+
part: entry.path
|
|
66
|
+
});
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
if (cause instanceof FolioDocxXmlPatchApplicationError) throw cause;
|
|
69
|
+
throw new FolioDocxXmlPatchApplicationError({
|
|
70
|
+
message: "An archive entry could not be verified within the configured limits.",
|
|
71
|
+
stage: "verify",
|
|
72
|
+
part: entry.path,
|
|
73
|
+
cause
|
|
74
|
+
});
|
|
80
75
|
}
|
|
81
76
|
};
|
|
82
77
|
const applyReplacements = async ({ bytes, proposal }) => {
|
|
83
78
|
let zip;
|
|
84
79
|
try {
|
|
85
|
-
zip = await
|
|
80
|
+
zip = await ZipArchive.read(bytes);
|
|
86
81
|
} catch (cause) {
|
|
87
82
|
throw new FolioDocxXmlPatchApplicationError({
|
|
88
83
|
message: "The evaluated package could not be loaded for replacement.",
|
|
@@ -91,31 +86,15 @@ const applyReplacements = async ({ bytes, proposal }) => {
|
|
|
91
86
|
});
|
|
92
87
|
}
|
|
93
88
|
for (const replacement of proposal.replacements) {
|
|
94
|
-
const current = zip.
|
|
95
|
-
if (current ===
|
|
89
|
+
const current = zip.entry(replacement.path);
|
|
90
|
+
if (current === void 0) throw new FolioDocxXmlPatchApplicationError({
|
|
96
91
|
message: "An evaluated package part was unavailable during replacement.",
|
|
97
92
|
stage: "replace"
|
|
98
93
|
});
|
|
99
|
-
|
|
100
|
-
zip,
|
|
101
|
-
path: replacement.path,
|
|
102
|
-
data: encodeUtf8(replacement.replacementXml),
|
|
103
|
-
options: {
|
|
104
|
-
date: current.date,
|
|
105
|
-
comment: current.comment,
|
|
106
|
-
unixPermissions: current.unixPermissions,
|
|
107
|
-
dosPermissions: current.dosPermissions,
|
|
108
|
-
compression: "DEFLATE",
|
|
109
|
-
compressionOptions: { level: 6 }
|
|
110
|
-
}
|
|
111
|
-
});
|
|
94
|
+
zip.set(replacement.path, replacement.replacementXml, { modified: current.modified });
|
|
112
95
|
}
|
|
113
96
|
try {
|
|
114
|
-
return await zip.
|
|
115
|
-
type: "uint8array",
|
|
116
|
-
compression: "DEFLATE",
|
|
117
|
-
compressionOptions: { level: 6 }
|
|
118
|
-
});
|
|
97
|
+
return await zip.generate({ level: 6 });
|
|
119
98
|
} catch (cause) {
|
|
120
99
|
throw new FolioDocxXmlPatchApplicationError({
|
|
121
100
|
message: "The replacement package could not be generated.",
|
|
@@ -37,8 +37,7 @@ export type DocxArchiveOptions = {
|
|
|
37
37
|
};
|
|
38
38
|
export type DocxArchiveEntry = {
|
|
39
39
|
readonly path: string;
|
|
40
|
-
readonly
|
|
41
|
-
readonly declaredUncompressedBytes: number | null;
|
|
40
|
+
readonly declaredUncompressedBytes: number;
|
|
42
41
|
};
|
|
43
42
|
export type DocxArchiveReadOptions = {
|
|
44
43
|
maxBytes?: number;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { decodeText } from "../../utils/textEncoding.js";
|
|
2
|
-
import {
|
|
2
|
+
import { DOCX_COMPRESSION_RATIO_GRACE_BYTES, isRatioBoundedPart } from "../archiveInflation.js";
|
|
3
3
|
import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits, createXmlPackageBudget } from "../xmlResourceLimits.js";
|
|
4
|
-
import { TaggedError } from "better-result";
|
|
5
|
-
import
|
|
4
|
+
import { Result, TaggedError } from "better-result";
|
|
5
|
+
import { ZipArchive, ZipError } from "@stll/docx-core/zip";
|
|
6
6
|
// src/docx/server/boundedArchive.ts
|
|
7
7
|
const DOCX_MAX_ENTRY_BYTES = 134217728;
|
|
8
8
|
const DOCX_MAX_TOTAL_BYTES = 268435456;
|
|
@@ -11,27 +11,42 @@ const DOCX_MAX_INPUT_BYTES = 52428800;
|
|
|
11
11
|
const DocxArchiveErrorBase = TaggedError("DocxArchiveError");
|
|
12
12
|
/** Error raised when a DOCX archive cannot be loaded within configured limits. */
|
|
13
13
|
var DocxArchiveError = class extends DocxArchiveErrorBase {};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
/** The archive error for a read the ZIP reader refused. */
|
|
15
|
+
const archiveErrorFor = (error, { maxEntryBytes, maxTotalBytes, maxEntries, maxCompressionRatio }) => {
|
|
16
|
+
const entry = error.path === void 0 ? "" : ` "${error.path}"`;
|
|
17
|
+
switch (error.reason) {
|
|
18
|
+
case "too-many-entries": return new DocxArchiveError({
|
|
19
|
+
message: `DOCX archive holds more than ${maxEntries} entries`,
|
|
20
|
+
reason: "too-many-entries"
|
|
19
21
|
});
|
|
20
|
-
case "
|
|
21
|
-
message: `DOCX entry
|
|
22
|
-
reason: "
|
|
22
|
+
case "entry-too-large": return new DocxArchiveError({
|
|
23
|
+
message: `DOCX entry${entry} declares more than ${maxEntryBytes} bytes`,
|
|
24
|
+
reason: "entry-too-large"
|
|
23
25
|
});
|
|
24
|
-
case "
|
|
25
|
-
message: `DOCX entry
|
|
26
|
+
case "declared-size-exceeded": return new DocxArchiveError({
|
|
27
|
+
message: `DOCX entry${entry} inflated past its declared size`,
|
|
26
28
|
reason: "entry-too-large"
|
|
27
29
|
});
|
|
28
|
-
case "total":
|
|
29
|
-
|
|
30
|
-
message: `DOCX archive exceeded the ${maxTotalBytes}-byte cumulative limit while reading "${path}"`,
|
|
30
|
+
case "total-too-large": return new DocxArchiveError({
|
|
31
|
+
message: `DOCX archive declares more than ${maxTotalBytes} cumulative bytes`,
|
|
31
32
|
reason: "total-too-large"
|
|
32
33
|
});
|
|
34
|
+
case "compression-ratio": return new DocxArchiveError({
|
|
35
|
+
message: error.path === void 0 ? `DOCX archive declares more than ${maxCompressionRatio} bytes per archive byte` : `DOCX entry${entry} declares more than ${maxCompressionRatio} bytes per compressed byte`,
|
|
36
|
+
reason: "compression-ratio-exceeded"
|
|
37
|
+
});
|
|
38
|
+
case "malformed":
|
|
39
|
+
case "unsupported":
|
|
40
|
+
case "invalid-entry-name":
|
|
41
|
+
case "duplicate-entry":
|
|
42
|
+
case "corrupt-entry": return new DocxArchiveError({
|
|
43
|
+
message: "Failed to parse DOCX archive",
|
|
44
|
+
reason: "load-failed",
|
|
45
|
+
cause: error
|
|
46
|
+
});
|
|
33
47
|
}
|
|
34
48
|
};
|
|
49
|
+
const toArchiveError = (cause, limits) => cause instanceof ZipError ? archiveErrorFor(cause, limits) : cause;
|
|
35
50
|
const resolveByteLimit = ({ value, fallback, name }) => {
|
|
36
51
|
const limit = value ?? fallback;
|
|
37
52
|
if (!Number.isSafeInteger(limit) || limit < 0) throw new DocxArchiveError({
|
|
@@ -40,7 +55,6 @@ const resolveByteLimit = ({ value, fallback, name }) => {
|
|
|
40
55
|
});
|
|
41
56
|
return limit;
|
|
42
57
|
};
|
|
43
|
-
const asBytes = (bytes) => bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
44
58
|
const loadDocxArchive = async (bytes, options = {}) => {
|
|
45
59
|
const maxInputBytes = resolveByteLimit({
|
|
46
60
|
value: options.maxInputBytes,
|
|
@@ -71,103 +85,68 @@ const loadDocxArchive = async (bytes, options = {}) => {
|
|
|
71
85
|
message: `DOCX input contains ${bytes.byteLength} bytes (max ${maxInputBytes})`,
|
|
72
86
|
reason: "input-too-large"
|
|
73
87
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
93
|
-
let declaredTotalBytes = 0;
|
|
94
|
-
let declaredTotalKnown = true;
|
|
95
|
-
for (const entry of archiveEntries) {
|
|
96
|
-
if (entry.dir) continue;
|
|
97
|
-
const { compressedBytes, uncompressedBytes } = getZipEntrySizes(entry);
|
|
98
|
-
if (uncompressedBytes === null) {
|
|
99
|
-
declaredTotalKnown = false;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (uncompressedBytes > maxEntryBytes) throw new DocxArchiveError({
|
|
103
|
-
message: `DOCX entry "${entry.name}" declares ${uncompressedBytes} bytes (max ${maxEntryBytes})`,
|
|
104
|
-
reason: "entry-too-large"
|
|
105
|
-
});
|
|
106
|
-
if (exceedsCompressionRatio({
|
|
107
|
-
inflatedBytes: uncompressedBytes,
|
|
108
|
-
compressedBytes,
|
|
109
|
-
maxRatio: compressionRatioLimitFor(entry.name, maxCompressionRatio)
|
|
110
|
-
})) throw new DocxArchiveError({
|
|
111
|
-
message: `DOCX entry "${entry.name}" declares more than ${maxCompressionRatio} bytes per compressed byte`,
|
|
112
|
-
reason: "compression-ratio-exceeded"
|
|
113
|
-
});
|
|
114
|
-
declaredTotalBytes += uncompressedBytes;
|
|
115
|
-
}
|
|
116
|
-
if (declaredTotalKnown && declaredTotalBytes > maxTotalBytes) throw new DocxArchiveError({
|
|
117
|
-
message: `DOCX archive declares ${declaredTotalBytes} cumulative bytes (max ${maxTotalBytes})`,
|
|
118
|
-
reason: "total-too-large"
|
|
119
|
-
});
|
|
120
|
-
if (exceedsCompressionRatio({
|
|
121
|
-
inflatedBytes: declaredTotalBytes,
|
|
122
|
-
compressedBytes: bytes.byteLength,
|
|
123
|
-
maxRatio: maxCompressionRatio
|
|
124
|
-
})) throw new DocxArchiveError({
|
|
125
|
-
message: `DOCX archive declares more than ${maxCompressionRatio} bytes per archive byte`,
|
|
126
|
-
reason: "compression-ratio-exceeded"
|
|
88
|
+
const limits = {
|
|
89
|
+
maxEntryBytes,
|
|
90
|
+
maxTotalBytes,
|
|
91
|
+
maxEntries,
|
|
92
|
+
maxCompressionRatio
|
|
93
|
+
};
|
|
94
|
+
const read = await Result.tryPromise({
|
|
95
|
+
try: async () => await ZipArchive.read(bytes, {
|
|
96
|
+
limits: {
|
|
97
|
+
maxEntries,
|
|
98
|
+
maxEntryBytes,
|
|
99
|
+
maxTotalBytes,
|
|
100
|
+
maxCompressionRatio,
|
|
101
|
+
compressionRatioGraceBytes: DOCX_COMPRESSION_RATIO_GRACE_BYTES
|
|
102
|
+
},
|
|
103
|
+
isCompressionRatioExempt: (path) => !isRatioBoundedPart(path)
|
|
104
|
+
}),
|
|
105
|
+
catch: (cause) => toArchiveError(cause, limits)
|
|
127
106
|
});
|
|
107
|
+
if (Result.isError(read)) throw read.error;
|
|
108
|
+
const zip = read.value;
|
|
109
|
+
const archiveEntries = zip.entries();
|
|
128
110
|
const xmlLimits = {
|
|
129
111
|
...FOLIO_XML_RESOURCE_LIMITS,
|
|
130
112
|
...options.xmlLimits
|
|
131
113
|
};
|
|
132
114
|
const xmlBudget = createXmlPackageBudget();
|
|
133
115
|
const countedParts = /* @__PURE__ */ new Set();
|
|
134
|
-
|
|
135
|
-
let readChain = Promise.resolve();
|
|
116
|
+
let readBytes = 0;
|
|
136
117
|
const readEntry = async (path, readOptions = {}) => {
|
|
137
118
|
const requestedMaxBytes = resolveByteLimit({
|
|
138
119
|
value: readOptions.maxBytes,
|
|
139
120
|
fallback: maxEntryBytes,
|
|
140
121
|
name: "DOCX entry read byte limit"
|
|
141
122
|
});
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return await next;
|
|
123
|
+
const entry = zip.entry(path);
|
|
124
|
+
if (!entry) return null;
|
|
125
|
+
const entryLimit = Math.min(requestedMaxBytes, maxEntryBytes);
|
|
126
|
+
if (entry.size > entryLimit) throw new DocxArchiveError({
|
|
127
|
+
message: `DOCX entry "${path}" exceeded the ${entryLimit}-byte limit`,
|
|
128
|
+
reason: "entry-too-large"
|
|
129
|
+
});
|
|
130
|
+
if (readBytes + entry.size > maxTotalBytes) throw new DocxArchiveError({
|
|
131
|
+
message: `DOCX archive exceeded the ${maxTotalBytes}-byte cumulative limit while reading "${path}"`,
|
|
132
|
+
reason: "total-too-large"
|
|
133
|
+
});
|
|
134
|
+
readBytes += entry.size;
|
|
135
|
+
const content = await Result.tryPromise({
|
|
136
|
+
try: async () => await entry.bytes(),
|
|
137
|
+
catch: (cause) => toArchiveError(cause, limits)
|
|
138
|
+
});
|
|
139
|
+
if (Result.isError(content)) {
|
|
140
|
+
readBytes -= entry.size;
|
|
141
|
+
throw content.error;
|
|
142
|
+
}
|
|
143
|
+
return content.value;
|
|
164
144
|
};
|
|
165
145
|
return {
|
|
166
|
-
entries: Object.freeze(archiveEntries.map(({
|
|
146
|
+
entries: Object.freeze(archiveEntries.map(({ path }) => path)),
|
|
167
147
|
entryMetadata: Object.freeze(archiveEntries.map((entry) => ({
|
|
168
|
-
path: entry.
|
|
169
|
-
|
|
170
|
-
declaredUncompressedBytes: getZipEntrySizes(entry).uncompressedBytes
|
|
148
|
+
path: entry.path,
|
|
149
|
+
declaredUncompressedBytes: entry.size
|
|
171
150
|
}))),
|
|
172
151
|
async readEntryString(path) {
|
|
173
152
|
const content = await readEntry(path);
|
|
@@ -30,7 +30,7 @@ export type FolioDocxPackagePart = {
|
|
|
30
30
|
readonly path: string;
|
|
31
31
|
readonly kind: FolioDocxPackagePartKind;
|
|
32
32
|
readonly contentType: string | null;
|
|
33
|
-
readonly declaredUncompressedBytes: number
|
|
33
|
+
readonly declaredUncompressedBytes: number;
|
|
34
34
|
};
|
|
35
35
|
export type FolioDocxInspectedXmlPart = {
|
|
36
36
|
readonly path: string;
|
|
@@ -200,7 +200,7 @@ const inspectDocxPackage = async (bytes, options = {}) => {
|
|
|
200
200
|
const archive = await loadDocxArchive(bytes, options.archive);
|
|
201
201
|
const contentTypesBytes = await archive.readEntryUint8(CONTENT_TYPES_PATH, { maxBytes: CONTENT_TYPES_READ_LIMIT });
|
|
202
202
|
const declarations = parseContentTypes(contentTypesBytes === null ? null : decodeXml(contentTypesBytes, CONTENT_TYPES_PATH));
|
|
203
|
-
const parts = archive.entryMetadata.
|
|
203
|
+
const parts = archive.entryMetadata.map((entry) => toPackagePart(entry, declarations)).toSorted(({ path: left }, { path: right }) => comparePackagePaths(left, right));
|
|
204
204
|
const partsByPath = new Map(parts.map((part) => [part.path, part]));
|
|
205
205
|
validateRequestedParts({
|
|
206
206
|
requestedPaths,
|
package/dist/docx/unzip.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ZipArchive } from "@stll/docx-core/zip";
|
|
2
2
|
export declare class DocxSecurityError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
@@ -75,7 +75,7 @@ export type RawDocxContent = {
|
|
|
75
75
|
media: Map<string, ArrayBuffer>;
|
|
76
76
|
fonts: Map<string, ArrayBuffer>;
|
|
77
77
|
allXml: Map<string, string>;
|
|
78
|
-
originalZip:
|
|
78
|
+
originalZip: ZipArchive;
|
|
79
79
|
originalBuffer: ArrayBuffer;
|
|
80
80
|
warnings: string[];
|
|
81
81
|
/** True when the input was a password-protected CFB container. */
|
|
@@ -100,7 +100,6 @@ export type UnzipDocxBehavior = {
|
|
|
100
100
|
export declare function unzipDocx(buffer: ArrayBuffer, options?: DocxUnzipOptions, { verifyUnreadEntries }?: UnzipDocxBehavior): Promise<RawDocxContent>;
|
|
101
101
|
/** Keep extracted projections and the source ZIP aligned after parser normalization. */
|
|
102
102
|
export declare const replaceRawDocxXmlParts: (content: RawDocxContent, parts: ReadonlyMap<string, string>) => Promise<void>;
|
|
103
|
-
export declare function getEntryUncompressedSize(file: JSZip.JSZipObject): number | null;
|
|
104
103
|
/**
|
|
105
104
|
* Get a list of all files in the DOCX
|
|
106
105
|
*
|