@stll/folio-core 0.64.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/ai-edits/apply.js +31 -9
  2. package/dist/ai-edits/headless.js +3 -3
  3. package/dist/compare/reproducible-package.d.ts +5 -0
  4. package/dist/compare/reproducible-package.js +10 -23
  5. package/dist/docx/archiveInflation.d.ts +8 -103
  6. package/dist/docx/archiveInflation.js +8 -145
  7. package/dist/docx/attributeRemainder.js +1 -1
  8. package/dist/docx/canonicalStoryRepack.js +2 -2
  9. package/dist/docx/documentSectionFacts.js +1 -1
  10. package/dist/docx/ensureParaIds.js +9 -24
  11. package/dist/docx/importedIdCollisions.js +1 -1
  12. package/dist/docx/mathToMathml.js +1 -1
  13. package/dist/docx/metadataPrivacy.js +38 -48
  14. package/dist/docx/numericIdNormalization.js +2 -2
  15. package/dist/docx/packageParts.d.ts +4 -4
  16. package/dist/docx/packageParts.js +17 -24
  17. package/dist/docx/parser.js +5 -6
  18. package/dist/docx/removeHeaderFooterParts.d.ts +2 -2
  19. package/dist/docx/removeHeaderFooterParts.js +19 -36
  20. package/dist/docx/revisionIdNormalization.js +2 -2
  21. package/dist/docx/rezip.d.ts +7 -7
  22. package/dist/docx/rezip.js +144 -532
  23. package/dist/docx/sdtProperties.js +1 -2
  24. package/dist/docx/selectedXmlIdentityScan.d.ts +1 -1
  25. package/dist/docx/selectedXmlIdentityScan.js +1 -1
  26. package/dist/docx/selectiveSave.js +37 -41
  27. package/dist/docx/selectiveSaveFlags.d.ts +1 -1
  28. package/dist/docx/selectiveSaveTripwire.js +5 -11
  29. package/dist/docx/selectiveXmlPatch.js +2 -2
  30. package/dist/docx/server/applyDocxXmlPatchProposal.js +20 -41
  31. package/dist/docx/server/boundedArchive.d.ts +2 -3
  32. package/dist/docx/server/boundedArchive.js +78 -99
  33. package/dist/docx/server/inspectDocxPackage.d.ts +1 -1
  34. package/dist/docx/server/inspectDocxPackage.js +1 -1
  35. package/dist/docx/server/validateDocxConformance.js +1 -1
  36. package/dist/docx/shapeAlternateContent.js +1 -1
  37. package/dist/docx/storyBlockReplay.js +1 -1
  38. package/dist/docx/unzip.d.ts +2 -3
  39. package/dist/docx/unzip.js +127 -155
  40. package/dist/docx/verbatimCapture.js +1 -1
  41. package/dist/docx/xmlParser.d.ts +5 -36
  42. package/dist/docx/xmlParser.js +14 -78
  43. package/dist/docx/xmlSafety.js +5 -5
  44. package/dist/layout-painter/renderPage.js +4 -2
  45. package/dist/markdown/countParagraphList.d.ts +18 -0
  46. package/dist/markdown/countParagraphList.js +90 -0
  47. package/dist/markdown/renderParagraph.js +2 -62
  48. package/dist/markdown/renderRuns.js +4 -1
  49. package/dist/prosemirror/conversion/fromProseDoc.js +5 -3
  50. package/dist/prosemirror/conversion/toProseDoc.js +4 -1
  51. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +4 -1
  52. package/dist/prosemirror/replacedAnnotations.d.ts +6 -0
  53. package/dist/prosemirror/replacedAnnotations.js +30 -8
  54. package/dist/prosemirror/schema/nodes.d.ts +7 -0
  55. package/dist/prosemirror/textBoxAnchorAttrs.js +9 -1
  56. package/package.json +3 -5
  57. package/dist/docx/streamingXmlParser.d.ts +0 -60
  58. package/dist/docx/streamingXmlParser.js +0 -406
  59. package/dist/docx/xmlNamespaceContext.d.ts +0 -6
  60. package/dist/docx/xmlNamespaceContext.js +0 -53
  61. package/dist/docx/xmlResourceLimits.d.ts +0 -107
  62. package/dist/docx/xmlResourceLimits.js +0 -194
@@ -26,7 +26,7 @@ import { getDocumentNumbering, getDocumentNumberingInstanceIds } from "../prosem
26
26
  import { getDocumentStyleDefinitions, getDocumentStyleResolver } from "../prosemirror/plugins/documentStyleState.js";
27
27
  import { claimRevisionIds, nextRevisionId, nextRevisionIdRange, reserveRevisionIds, seedRevisionIdsFromDoc } from "../prosemirror/plugins/revisionIds.js";
28
28
  import { rebaseParagraphRuns } from "../prosemirror/rebaseParagraphRuns.js";
29
- import { NON_INCLUSIVE_MARK_DISPOSITION, addCarriedMarks, annotatedReplacement, commentsAcrossBlockBoundary, commentsForTrackedInsertion, hasReplacedAnnotations, inheritedReplacementMarks, joinCommentStretches, placeTrackedInsertion, surveyReplacedAnnotations } from "../prosemirror/replacedAnnotations.js";
29
+ import { NON_INCLUSIVE_MARK_DISPOSITION, addCarriedMarks, annotatedReplacement, commentsAcrossBlockBoundary, commentsForTrackedInsertion, hasReplacedAnnotations, inheritedReplacementMarks, joinCommentStretches, placeTrackedInsertion, surveyAcceptedReplacedAnnotations, surveyReplacedAnnotations } from "../prosemirror/replacedAnnotations.js";
30
30
  import { applyMarksToRunFormattingRepresentation, runFormattingInlineControlNodeName, selectRunFormattingCarrierRepresentations } from "../prosemirror/runFormattingInlineCarriers.js";
31
31
  import { RUN_FORMATTING_MARK_NAMES } from "../prosemirror/runFormattingMarkNames.js";
32
32
  import { readAuthoredRunFormatting, readAuthoredRunPropertySnapshot, reconcileRunFormattingMarks } from "../prosemirror/runFormattingReconciliation.js";
@@ -1395,6 +1395,17 @@ const holdsOnlyInsertedContent = (paragraph, revisions) => {
1395
1395
  });
1396
1396
  return whollyInserted;
1397
1397
  };
1398
+ /** Whether every inline of `paragraph` is an ordinary insertion one of `revisionIds` made. */
1399
+ const holdsOnlyContentInsertedBy = (paragraph, revisionIds) => {
1400
+ let whollyInserted = true;
1401
+ paragraph.descendants((node) => {
1402
+ if (!whollyInserted) return false;
1403
+ if (!node.isInline || isZeroWidthAnchor(node)) return true;
1404
+ whollyInserted = node.marks.some((mark) => mark.type.name === "insertion" && mark.attrs["moveKind"] !== "moveTo" && revisionIds.has(mark.attrs["revisionId"]));
1405
+ return false;
1406
+ });
1407
+ return whollyInserted;
1408
+ };
1398
1409
  /**
1399
1410
  * Remove the paragraph between `from` and `to`, its content with it but not
1400
1411
  * the range boundaries it holds: those stay where it was, as rejecting a
@@ -2072,7 +2083,7 @@ const withRotatedAddedFinalBreaks = ({ tr, batchRevisionIds, revisionSeed, autho
2072
2083
  const trackedInsertion = insertedRevisionId !== null && batchRevisionIds.has(insertedRevisionId);
2073
2084
  const suggestedInsertion = isSuggestedParagraphInsert(node);
2074
2085
  const deletedBreakRevisionId = addedOrDeletedBreakRevisionId(deletedMark);
2075
- if (isPlainDeletedPPrMark(deletedMark) && deletedBreakRevisionId !== null && !batchRevisionIds.has(deletedBreakRevisionId) && !holdsOnlyDeletedContent(previous.node) && (trackedInsertion || suggestedInsertion) && holdsOnlyInsertedContent(node, "ordinary")) {
2086
+ if (isPlainDeletedPPrMark(deletedMark) && deletedBreakRevisionId !== null && !batchRevisionIds.has(deletedBreakRevisionId) && !holdsOnlyDeletedContent(previous.node) && (trackedInsertion && holdsOnlyContentInsertedBy(node, batchRevisionIds) || suggestedInsertion && holdsOnlyInsertedContent(node, "ordinary"))) {
2076
2087
  supersededDeletedBreaks.push({
2077
2088
  preceding: previous.position,
2078
2089
  inserted: position,
@@ -2094,6 +2105,7 @@ const withRotatedAddedFinalBreaks = ({ tr, batchRevisionIds, revisionSeed, autho
2094
2105
  });
2095
2106
  };
2096
2107
  collectSupersededDeletedBreaks(tr.doc, 0);
2108
+ const passedThrough = new Set(supersededDeletedBreaks.map(({ preceding }) => preceding));
2097
2109
  for (const { preceding, inserted, deletedMark, precedingMark, precedingFormatting } of supersededDeletedBreaks) {
2098
2110
  const insertionRevisionId = addedBreakRevisionId(precedingMark);
2099
2111
  if (insertionRevisionId !== null) {
@@ -2118,6 +2130,7 @@ const withRotatedAddedFinalBreaks = ({ tr, batchRevisionIds, revisionSeed, autho
2118
2130
  tr.setNodeAttribute(preceding, "pPrMark", precedingMark);
2119
2131
  tr.setNodeAttribute(inserted, "pPrMark", deletedMark);
2120
2132
  if (insertionRevisionId === null) continue;
2133
+ if (passedThrough.has(inserted)) continue;
2121
2134
  const source = tr.doc.nodeAt(inserted);
2122
2135
  const survivor = paragraphLeftAfter({
2123
2136
  doc: tr.doc,
@@ -2903,7 +2916,12 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations: reques
2903
2916
  }
2904
2917
  const nodes = [];
2905
2918
  for (const group of nodeGroups.toReversed()) for (const node of group) nodes.push(node);
2906
- tr = tr.insert(item.from, nodes);
2919
+ tr = insertBlocksInsideComments({
2920
+ tr,
2921
+ at: item.from,
2922
+ blocks: nodes,
2923
+ ...item.boundaryComments !== void 0 && { carriedComments: item.boundaryComments }
2924
+ });
2907
2925
  applied.push(...runApplied);
2908
2926
  continue;
2909
2927
  }
@@ -3288,7 +3306,8 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations: reques
3288
3306
  tr = insertBlocksInsideComments({
3289
3307
  tr,
3290
3308
  at: item.from,
3291
- blocks: built.nodes
3309
+ blocks: built.nodes,
3310
+ ...item.boundaryComments !== void 0 && { carriedComments: item.boundaryComments }
3292
3311
  });
3293
3312
  break;
3294
3313
  }
@@ -3713,7 +3732,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations: reques
3713
3732
  const carriedComments = tableAfterDeletedSplit.get(item.blockFrom);
3714
3733
  const usesRetractedSplit = carriedComments !== void 0 && item.from === item.blockTo;
3715
3734
  let at = usesRetractedSplit ? tr.mapping.map(item.blockFrom, -1) : item.from;
3716
- let insertionComments = carriedComments;
3735
+ let insertionComments = carriedComments ?? item.boundaryComments;
3717
3736
  if (!usesRetractedSplit) {
3718
3737
  const boundary = tr.doc.resolve(at);
3719
3738
  const before = boundary.nodeBefore;
@@ -3903,6 +3922,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations: reques
3903
3922
  if (mode === "direct") {
3904
3923
  if (item.to > item.from) tr = tr.delete(item.from, item.to);
3905
3924
  tr = tr.split(item.from);
3925
+ tr = tr.setNodeAttribute(item.blockFrom, "pPrMark", null);
3906
3926
  if (splitsFromItsTextBoxes) gatherTextBoxesAfterParagraph(tr, item.blockFrom);
3907
3927
  } else {
3908
3928
  const revisionIdMark = operationRevisionSeed++;
@@ -4754,7 +4774,7 @@ const directReplacementMarks = ({ doc, plan, replacementBackground }) => {
4754
4774
  const first = pieces.at(0);
4755
4775
  const last = pieces.at(-1);
4756
4776
  if (first === void 0 || last === void 0) panic("A planned replacement lost the characters it removes");
4757
- marks = addCarriedMarks(inheritedReplacementMarks(doc, first.from, first.to), surveyReplacedAnnotations(doc, first.from, last.to).carried);
4777
+ marks = addCarriedMarks(inheritedReplacementMarks(doc, first.from, first.to), surveyAcceptedReplacedAnnotations(doc, first.from, last.to).carried);
4758
4778
  }
4759
4779
  const written = clearsBackground ? withoutBackground(marks) : marks;
4760
4780
  for (let offset = 0; offset < change.text.length; offset++) allocated.push(written);
@@ -5020,7 +5040,7 @@ const applyMinimalTrackedReplacement = ({ tr, item, noteReferences, replacement,
5020
5040
  if (first === void 0 || last === void 0 || styleSource === null) panic("A planned replacement lost the characters it removes");
5021
5041
  at = last.to;
5022
5042
  removedFrom = first.from;
5023
- marks = addCarriedMarks(inheritedReplacementMarks(doc, styleSource.from, styleSource.to), surveyReplacedAnnotations(doc, first.from, last.to).carried);
5043
+ marks = addCarriedMarks(inheritedReplacementMarks(doc, styleSource.from, styleSource.to), surveyAcceptedReplacedAnnotations(doc, first.from, last.to).carried);
5024
5044
  for (const piece of change.pieces) {
5025
5045
  addTrackedDeletionMark({
5026
5046
  insertionPolicy: "preserve-pending",
@@ -5285,7 +5305,8 @@ const resolveOperationTarget = ({ snapshot, operation, liveBlocks, liveBlocksByP
5285
5305
  blockFrom,
5286
5306
  blockTo,
5287
5307
  blockNode,
5288
- insertTexts
5308
+ insertTexts,
5309
+ boundaryComments: commentsAcrossBlockBoundary(doc, insertFrom)
5289
5310
  }
5290
5311
  };
5291
5312
  }
@@ -5541,7 +5562,8 @@ const resolveOperationTarget = ({ snapshot, operation, liveBlocks, liveBlocksByP
5541
5562
  to: insertFrom,
5542
5563
  blockFrom,
5543
5564
  blockTo,
5544
- blockNode
5565
+ blockNode,
5566
+ boundaryComments: commentsAcrossBlockBoundary(doc, insertFrom)
5545
5567
  }
5546
5568
  };
5547
5569
  }
@@ -56,7 +56,7 @@ import { TaggedError, panic } from "better-result";
56
56
  import { REVIEW_CARRIERS, finalizeParagraphFormatting, numberingLevelEntries, setDocumentBodyComments } from "@stll/docx-core/model";
57
57
  import { reconcileDocumentPartRelationships } from "@stll/docx-core/ops";
58
58
  import { EditorState } from "prosemirror-state";
59
- import JSZip from "jszip";
59
+ import { ZipArchive } from "@stll/docx-core/zip";
60
60
  // src/ai-edits/headless.ts
61
61
  /**
62
62
  * Headless `.docx` review path: buffer -> apply AI edits -> buffer, with
@@ -1990,8 +1990,8 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1990
1990
  if (xml === null) return null;
1991
1991
  const extension = media.path.split(".").at(-1)?.toLowerCase();
1992
1992
  if (!extension || !/^[a-z0-9]+$/u.test(extension)) return null;
1993
- const originalZip = await JSZip.loadAsync(this.originalBuffer);
1994
- const existingPaths = new Set(Object.keys(originalZip.files).map((path) => path.toLowerCase()));
1993
+ const originalZip = await ZipArchive.read(this.originalBuffer);
1994
+ const existingPaths = new Set(originalZip.paths().map((path) => path.toLowerCase()));
1995
1995
  let suffix = 1;
1996
1996
  let path = `word/media/folio-import-${suffix}.${extension}`;
1997
1997
  while (existingPaths.has(path.toLowerCase()) || this.baseDocument.package.media?.has(path) || this.importedMedia.has(path)) path = `word/media/folio-import-${++suffix}.${extension}`;
@@ -1 +1,6 @@
1
+ /**
2
+ * Date comparison output from the comparison's timestamp. Imported entries
3
+ * retain source dates, and saves can stamp `dcterms:modified` from the wall
4
+ * clock; restamping both makes repeated comparisons reproducible.
5
+ */
1
6
  export declare const withFixedPackageDates: (buffer: ArrayBuffer, date: Date) => Promise<ArrayBuffer>;
@@ -1,13 +1,12 @@
1
- import { decodeText, encodeUtf8 } from "../utils/textEncoding.js";
2
- import JSZip from "jszip";
3
- import { writeZipPart } from "@stll/docx-core/zip";
1
+ import { decodeText } from "../utils/textEncoding.js";
2
+ import { ZipArchive } from "@stll/docx-core/zip";
4
3
  // src/compare/reproducible-package.ts
5
4
  /**
6
5
  * Date comparison output from the comparison's timestamp. Imported entries
7
6
  * retain source dates, and saves can stamp `dcterms:modified` from the wall
8
7
  * clock; restamping both makes repeated comparisons reproducible.
9
8
  */
10
- /** JSZip deflate level `repackDocx` writes DOCX parts at. */
9
+ /** ZipArchive deflate level `repackDocx` writes DOCX parts at. */
11
10
  const DOCX_COMPRESSION_LEVEL = 6;
12
11
  const CORE_PROPERTIES_PATH = "docProps/core.xml";
13
12
  const MODIFIED_ELEMENT = /<dcterms:modified[^<>]*>[^<]*<\/dcterms:modified>/u;
@@ -18,24 +17,12 @@ const MODIFIED_ELEMENT = /<dcterms:modified[^<>]*>[^<]*<\/dcterms:modified>/u;
18
17
  */
19
18
  const withFixedModifiedDate = (corePropsXml, date) => corePropsXml.replace(MODIFIED_ELEMENT, `<dcterms:modified xsi:type="dcterms:W3CDTF">${date.toISOString()}</dcterms:modified>`);
20
19
  const withFixedPackageDates = async (buffer, date) => {
21
- const zip = await JSZip.loadAsync(buffer);
22
- const coreProps = zip.file(CORE_PROPERTIES_PATH);
23
- if (coreProps) writeZipPart({
24
- zip,
25
- path: CORE_PROPERTIES_PATH,
26
- data: encodeUtf8(withFixedModifiedDate(await coreProps.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true })), date)),
27
- options: {
28
- compression: "DEFLATE",
29
- compressionOptions: { level: DOCX_COMPRESSION_LEVEL }
30
- }
31
- });
32
- zip.forEach((_path, file) => {
33
- file.date = date;
34
- });
35
- return await zip.generateAsync({
36
- type: "arraybuffer",
37
- compression: "DEFLATE",
38
- compressionOptions: { level: DOCX_COMPRESSION_LEVEL }
39
- });
20
+ const zip = await ZipArchive.read(buffer);
21
+ const coreProps = zip.entry(CORE_PROPERTIES_PATH);
22
+ if (coreProps) zip.set(CORE_PROPERTIES_PATH, withFixedModifiedDate(decodeText(await coreProps.bytes(), { ignoreBOM: true }), date));
23
+ return (await zip.generate({
24
+ level: DOCX_COMPRESSION_LEVEL,
25
+ modified: date
26
+ })).buffer;
40
27
  };
41
28
  export { withFixedPackageDates };
@@ -1,15 +1,10 @@
1
- import JSZip from "jszip";
2
- declare module "jszip" {
3
- interface JSZipObject {
4
- /**
5
- * Chunked read of the entry content, missing from the published typings.
6
- * `nodeStream` is this stream wrapped in a Node.js `Readable`, which
7
- * browsers and web workers cannot provide; the stream itself is
8
- * platform-neutral.
9
- */
10
- internalStream(type: "uint8array"): JSZip.JSZipStreamHelper<Uint8Array>;
11
- }
12
- }
1
+ /**
2
+ * Compression bounds for DOCX packages.
3
+ *
4
+ * The archive reader (`@stll/docx-core/zip`) checks every size an archive
5
+ * declares before anything inflates, and stops each inflation at its entry's
6
+ * declared size. The values here are the DOCX policy it is given.
7
+ */
13
8
  /**
14
9
  * Default ceiling on inflated bytes per compressed byte.
15
10
  *
@@ -26,100 +21,10 @@ export declare const DOCX_MAX_COMPRESSION_RATIO = 200;
26
21
  * matter. The byte caps still apply below this size.
27
22
  */
28
23
  export declare const DOCX_COMPRESSION_RATIO_GRACE_BYTES: number;
29
- /**
30
- * Upper bound on the central-directory records in `bytes`, counted without
31
- * parsing the archive and stopping once the count passes `stopAfter`.
32
- *
33
- * Every record JSZip reads starts with the record signature, so the number of
34
- * signature occurrences cannot be lower than the number of entries JSZip would
35
- * build, whatever the end-of-central-directory record claims.
36
- */
37
- export declare const countCentralDirectoryRecords: (bytes: Uint8Array, stopAfter: number) => number;
38
- /** Sizes a loaded entry declares in the central directory. */
39
- export type ZipEntrySizes = {
40
- readonly compressedBytes: number | null;
41
- readonly uncompressedBytes: number | null;
42
- };
43
- /** Declared sizes of an entry JSZip loaded from an archive. */
44
- export declare const getZipEntrySizes: (entry: JSZip.JSZipObject) => ZipEntrySizes;
45
- /**
46
- * Whether a loaded entry is stored rather than compressed. A stored entry
47
- * reads back exactly the bytes the archive carries, so it cannot expand.
48
- */
49
- export declare const isStoredZipEntry: (entry: JSZip.JSZipObject) => boolean;
50
24
  /**
51
25
  * Whether the per-entry ratio cap applies to a part. Markup and text parts
52
26
  * never compress anywhere near the cap. Binary parts can: an uncompressed
53
27
  * bitmap of one colour compresses like a run of one byte. Those stay bounded
54
28
  * by the byte caps and the package-wide ratio instead.
55
29
  */
56
- export declare const isRatioBoundedPart: (path: string) => boolean;
57
- /** The per-entry ratio cap for `path`: `maxRatio`, or none for a binary part. */
58
- export declare const compressionRatioLimitFor: (path: string, maxRatio: number) => number;
59
- type CompressionRatioCheck = {
60
- inflatedBytes: number;
61
- compressedBytes: number | null;
62
- maxRatio: number;
63
- };
64
- /**
65
- * Whether `inflatedBytes` produced from `compressedBytes` passes the ratio cap.
66
- * Sizes under {@link DOCX_COMPRESSION_RATIO_GRACE_BYTES} never do, and an
67
- * unknown compressed size is left to the byte caps.
68
- */
69
- export declare const exceedsCompressionRatio: ({ inflatedBytes, compressedBytes, maxRatio }: CompressionRatioCheck) => boolean;
70
- /**
71
- * Inflated bytes charged across one package. `aborted` stops every inflation
72
- * sharing the budget at its next chunk, once one of them has failed the
73
- * package.
74
- */
75
- export type InflationBudget = {
76
- readonly maxTotalBytes: number;
77
- inflatedBytes: number;
78
- aborted: boolean;
79
- };
80
- export declare const createInflationBudget: (maxTotalBytes: number) => InflationBudget;
81
- /**
82
- * The bound an inflation stopped at.
83
- *
84
- * - `declared-size` — the entry produced more than its declared size.
85
- * - `compression-ratio` — the entry passed the ratio cap for its compressed size.
86
- * - `entry` — the entry passed the per-entry cap the caller set.
87
- * - `total` — the package passed its cumulative budget.
88
- * - `aborted` — another inflation sharing the budget already failed.
89
- */
90
- export type InflationLimit = "declared-size" | "compression-ratio" | "entry" | "total" | "aborted";
91
- export type InflateEntryResult = {
92
- readonly ok: true;
93
- readonly bytes: Uint8Array<ArrayBuffer>;
94
- } | {
95
- readonly ok: false;
96
- readonly limit: InflationLimit;
97
- };
98
- export type InflateEntryOptions = {
99
- entry: JSZip.JSZipObject;
100
- /** Most bytes this entry may inflate to. */
101
- maxEntryBytes: number;
102
- /** Ratio cap while streaming; see {@link compressionRatioLimitFor}. */
103
- maxCompressionRatio: number;
104
- budget: InflationBudget;
105
- /**
106
- * `false` inflates the entry only to prove it stays within its bounds; the
107
- * chunks are dropped and the result carries no bytes. A package part that
108
- * is never read here is still inflated by a later save, so the bound has
109
- * to be established for it too.
110
- */
111
- retain?: boolean;
112
- };
113
- /**
114
- * Inflate one entry chunk by chunk, checking every bound before a chunk is
115
- * kept. Pausing the stream abandons the rest of the entry, so a limit bounds
116
- * memory rather than reporting an overrun afterwards.
117
- *
118
- * The budget is charged as chunks arrive, which keeps concurrent inflations
119
- * of one package honest, and refunded when this entry stops at a limit or
120
- * fails, since none of its bytes are kept. Stream errors (a corrupt entry)
121
- * reject; limits resolve with `ok: false` for the caller to map onto its own
122
- * error or skip policy.
123
- */
124
- export declare const inflateEntryWithinLimits: ({ entry, maxEntryBytes, maxCompressionRatio, budget, retain }: InflateEntryOptions) => Promise<InflateEntryResult>;
125
- export {};
30
+ export declare const isRatioBoundedPart: (path: string) => boolean;
@@ -1,5 +1,12 @@
1
1
  // src/docx/archiveInflation.ts
2
2
  /**
3
+ * Compression bounds for DOCX packages.
4
+ *
5
+ * The archive reader (`@stll/docx-core/zip`) checks every size an archive
6
+ * declares before anything inflates, and stops each inflation at its entry's
7
+ * declared size. The values here are the DOCX policy it is given.
8
+ */
9
+ /**
3
10
  * Default ceiling on inflated bytes per compressed byte.
4
11
  *
5
12
  * Document parts in the repository's fixtures stay under 75:1, and parts over a
@@ -15,57 +22,6 @@ const DOCX_MAX_COMPRESSION_RATIO = 200;
15
22
  * matter. The byte caps still apply below this size.
16
23
  */
17
24
  const DOCX_COMPRESSION_RATIO_GRACE_BYTES = 4194304;
18
- const CENTRAL_DIRECTORY_RECORD_SIGNATURE = [
19
- 80,
20
- 75,
21
- 1,
22
- 2
23
- ];
24
- /**
25
- * Upper bound on the central-directory records in `bytes`, counted without
26
- * parsing the archive and stopping once the count passes `stopAfter`.
27
- *
28
- * Every record JSZip reads starts with the record signature, so the number of
29
- * signature occurrences cannot be lower than the number of entries JSZip would
30
- * build, whatever the end-of-central-directory record claims.
31
- */
32
- const countCentralDirectoryRecords = (bytes, stopAfter) => {
33
- const [first, second, third, fourth] = CENTRAL_DIRECTORY_RECORD_SIGNATURE;
34
- let count = 0;
35
- let offset = bytes.indexOf(first);
36
- while (offset !== -1 && offset + 3 < bytes.length) {
37
- if (bytes[offset + 1] === second && bytes[offset + 2] === third && bytes[offset + 3] === fourth) {
38
- count += 1;
39
- if (count > stopAfter) return count;
40
- }
41
- offset = bytes.indexOf(first, offset + 1);
42
- }
43
- return count;
44
- };
45
- const finiteSize = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
46
- /** Declared sizes of an entry JSZip loaded from an archive. */
47
- const getZipEntrySizes = (entry) => {
48
- const data = "_data" in entry ? entry._data : void 0;
49
- if (typeof data !== "object" || data === null) return {
50
- compressedBytes: null,
51
- uncompressedBytes: null
52
- };
53
- return {
54
- compressedBytes: "compressedSize" in data ? finiteSize(data.compressedSize) : null,
55
- uncompressedBytes: "uncompressedSize" in data ? finiteSize(data.uncompressedSize) : null
56
- };
57
- };
58
- const STORED_COMPRESSION_MAGIC = "\0\0";
59
- /**
60
- * Whether a loaded entry is stored rather than compressed. A stored entry
61
- * reads back exactly the bytes the archive carries, so it cannot expand.
62
- */
63
- const isStoredZipEntry = (entry) => {
64
- const data = "_data" in entry ? entry._data : void 0;
65
- if (typeof data !== "object" || data === null || !("compression" in data)) return false;
66
- const { compression } = data;
67
- return typeof compression === "object" && compression !== null && "magic" in compression && compression.magic === STORED_COMPRESSION_MAGIC;
68
- };
69
25
  const RATIO_BOUNDED_EXTENSIONS = /* @__PURE__ */ new Set([
70
26
  "xml",
71
27
  "rels",
@@ -84,97 +40,4 @@ const RATIO_BOUNDED_EXTENSIONS = /* @__PURE__ */ new Set([
84
40
  * by the byte caps and the package-wide ratio instead.
85
41
  */
86
42
  const isRatioBoundedPart = (path) => RATIO_BOUNDED_EXTENSIONS.has(path.slice(path.lastIndexOf(".") + 1).toLowerCase());
87
- /** The per-entry ratio cap for `path`: `maxRatio`, or none for a binary part. */
88
- const compressionRatioLimitFor = (path, maxRatio) => isRatioBoundedPart(path) ? maxRatio : Number.POSITIVE_INFINITY;
89
- /**
90
- * Whether `inflatedBytes` produced from `compressedBytes` passes the ratio cap.
91
- * Sizes under {@link DOCX_COMPRESSION_RATIO_GRACE_BYTES} never do, and an
92
- * unknown compressed size is left to the byte caps.
93
- */
94
- const exceedsCompressionRatio = ({ inflatedBytes, compressedBytes, maxRatio }) => compressedBytes !== null && inflatedBytes > 4194304 && inflatedBytes > compressedBytes * maxRatio;
95
- const createInflationBudget = (maxTotalBytes) => ({
96
- maxTotalBytes,
97
- inflatedBytes: 0,
98
- aborted: false
99
- });
100
- const EMPTY_BYTES = /* @__PURE__ */ new Uint8Array(0);
101
- const concatChunks = (chunks, totalBytes) => {
102
- const merged = new Uint8Array(totalBytes);
103
- let offset = 0;
104
- for (const chunk of chunks) {
105
- merged.set(chunk, offset);
106
- offset += chunk.length;
107
- }
108
- return merged;
109
- };
110
- /**
111
- * Inflate one entry chunk by chunk, checking every bound before a chunk is
112
- * kept. Pausing the stream abandons the rest of the entry, so a limit bounds
113
- * memory rather than reporting an overrun afterwards.
114
- *
115
- * The budget is charged as chunks arrive, which keeps concurrent inflations
116
- * of one package honest, and refunded when this entry stops at a limit or
117
- * fails, since none of its bytes are kept. Stream errors (a corrupt entry)
118
- * reject; limits resolve with `ok: false` for the caller to map onto its own
119
- * error or skip policy.
120
- */
121
- const inflateEntryWithinLimits = async ({ entry, maxEntryBytes, maxCompressionRatio, budget, retain = true }) => {
122
- const { compressedBytes, uncompressedBytes } = getZipEntrySizes(entry);
123
- return await new Promise((resolve, reject) => {
124
- const stream = entry.internalStream("uint8array");
125
- const chunks = [];
126
- let entryBytes = 0;
127
- let settled = false;
128
- const settle = () => {
129
- if (settled) return false;
130
- settled = true;
131
- return true;
132
- };
133
- const refund = () => {
134
- budget.inflatedBytes -= entryBytes;
135
- };
136
- const stop = (limit) => {
137
- if (!settle()) return;
138
- stream.pause();
139
- refund();
140
- resolve({
141
- ok: false,
142
- limit
143
- });
144
- };
145
- const limitFor = () => {
146
- if (budget.aborted) return "aborted";
147
- if (uncompressedBytes !== null && entryBytes > uncompressedBytes) return "declared-size";
148
- if (exceedsCompressionRatio({
149
- inflatedBytes: entryBytes,
150
- compressedBytes,
151
- maxRatio: maxCompressionRatio
152
- })) return "compression-ratio";
153
- if (entryBytes > maxEntryBytes) return "entry";
154
- if (budget.inflatedBytes > budget.maxTotalBytes) return "total";
155
- return null;
156
- };
157
- stream.on("data", (chunk) => {
158
- if (settled) return;
159
- entryBytes += chunk.length;
160
- budget.inflatedBytes += chunk.length;
161
- const limit = limitFor();
162
- if (limit !== null) {
163
- stop(limit);
164
- return;
165
- }
166
- if (retain) chunks.push(chunk);
167
- }).on("end", () => {
168
- if (!settle()) return;
169
- resolve({
170
- ok: true,
171
- bytes: retain ? concatChunks(chunks, entryBytes) : EMPTY_BYTES
172
- });
173
- }).on("error", (error) => {
174
- if (!settle()) return;
175
- refund();
176
- reject(error);
177
- }).resume();
178
- });
179
- };
180
- export { DOCX_COMPRESSION_RATIO_GRACE_BYTES, DOCX_MAX_COMPRESSION_RATIO, compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits, isRatioBoundedPart, isStoredZipEntry };
43
+ export { DOCX_COMPRESSION_RATIO_GRACE_BYTES, DOCX_MAX_COMPRESSION_RATIO, isRatioBoundedPart };
@@ -1,9 +1,9 @@
1
1
  import { modelledAttributeNames } from "./propertyElementAttributes.js";
2
2
  import { OOXML_NAMESPACES } from "./serializer/partNamespaces.js";
3
- import { XML_NAMESPACE_URI } from "./xmlNamespaceContext.js";
4
3
  import { getLocalName, resolveAttributeNamespaceUri } from "./xmlParser.js";
5
4
  import { TaggedError } from "better-result";
6
5
  import { escapeXmlAttribute } from "@stll/docx-core";
6
+ import { XML_NAMESPACE_URI } from "@stll/docx-core/xml/namespaces";
7
7
  // src/docx/attributeRemainder.ts
8
8
  /**
9
9
  * The attribute remainder: what an element carried and its parser has no field for.
@@ -5,7 +5,7 @@ import { readDocumentSectionFacts } from "./documentSectionFacts.js";
5
5
  import { parseDocx } from "./parser.js";
6
6
  import { assertSectionCarriersMatchModel } from "./rezip.js";
7
7
  import { serializeDocument } from "./serializer/documentSerializer.js";
8
- import JSZip from "jszip";
8
+ import { ZipArchive } from "@stll/docx-core/zip";
9
9
  // src/docx/canonicalStoryRepack.ts
10
10
  /** Canonical snapshots own explicit section references; the PM projection does not. */
11
11
  const referenceKey = ({ element, type, rId }) => `${element}:${type}:${rId}`;
@@ -19,7 +19,7 @@ const xmlFingerprint = async (xml) => {
19
19
  const repackWithCanonicalStoryRemovals = async ({ document, repack }) => {
20
20
  const originalBuffer = document.originalBuffer;
21
21
  if (!originalBuffer) return repack();
22
- const xml = await (await JSZip.loadAsync(originalBuffer)).file("word/document.xml")?.async("uint8array").then((bytes) => decodeText(bytes, { ignoreBOM: true }));
22
+ const xml = await (await ZipArchive.read(originalBuffer)).entry("word/document.xml")?.bytes().then((bytes) => decodeText(bytes, { ignoreBOM: true }));
23
23
  if (!xml) return repack();
24
24
  const before = readDocumentSectionFacts(xml);
25
25
  const serializedXml = serializeDocument(document);
@@ -1,6 +1,6 @@
1
1
  import { parseHeaderFooterType } from "./headerFooterRefParser.js";
2
2
  import { OFFICE_RELATIONSHIP_NAMESPACE_URIS, WORDPROCESSINGML_NAMESPACE_URIS, getAttributeByNamespaceUri, getChildElements, getLocalName, getNamespaceUri, parseXml } from "./xmlParser.js";
3
- import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
3
+ import { assertXmlResourceLimits } from "@stll/docx-core/xml/limits";
4
4
  // src/docx/documentSectionFacts.ts
5
5
  const isWordprocessingElement = (element, localName) => getLocalName(element.name) === localName && WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "");
6
6
  /** Both facts from one walk of a parsed document tree. */
@@ -1,14 +1,12 @@
1
1
  import { deterministicHexId } from "../utils/hexId.js";
2
- import { encodeUtf8 } from "../utils/textEncoding.js";
3
2
  import { resolvePackageRelationshipTarget } from "./packageParts.js";
4
3
  import { spliceXml } from "./selectiveXmlPatch.js";
5
4
  import { loadDocxArchive } from "./server/boundedArchive.js";
6
- import { scanStreamingXmlElements } from "./streamingXmlParser.js";
7
- import { resolveNamespaceUri } from "./xmlNamespaceContext.js";
8
5
  import { OFFICE_RELATIONSHIP_NAMESPACE_URIS, WORDPROCESSINGML_NAMESPACE_URIS, getAttribute, getChildElements, getLocalName, getNamespaceUri, parseXmlDocument, resolveAttributeNamespaceUri } from "./xmlParser.js";
9
6
  import { TaggedError } from "better-result";
10
- import JSZip from "jszip";
11
- import { writeZipPart } from "@stll/docx-core/zip";
7
+ import { scanStreamingXmlElements } from "@stll/docx-core/xml";
8
+ import { resolveNamespaceUri } from "@stll/docx-core/xml/namespaces";
9
+ import { ZipArchive } from "@stll/docx-core/zip";
12
10
  // src/docx/ensureParaIds.ts
13
11
  /**
14
12
  * Headless `w14:paraId` normalization for a `.docx` buffer.
@@ -321,7 +319,7 @@ const mainDocumentPart = (rels, entries) => {
321
319
  /** OPC part names are case-insensitive; compare lowercased. */
322
320
  const isTargetPart = (path) => TARGET_PART_PATTERNS.some((pattern) => pattern.test(path.toLowerCase()));
323
321
  const toUint8Array = (docx) => docx instanceof Uint8Array ? docx : new Uint8Array(docx);
324
- const hasDigitalSignatureParts = (zip) => Object.values(zip.files).some(({ dir, name }) => !dir && name.toLowerCase().startsWith(DIGITAL_SIGNATURE_PART_PREFIX));
322
+ const hasDigitalSignatureParts = (zip) => zip.entries().some(({ path }) => path.toLowerCase().startsWith(DIGITAL_SIGNATURE_PART_PREFIX));
325
323
  /**
326
324
  * Backfill `w14:paraId` on every paragraph of a `.docx` buffer. See the
327
325
  * module doc for the exact contract. Throws {@link EnsureParaIdsError} when
@@ -381,31 +379,18 @@ const ensureParaIdsInternal = async (docx, options) => {
381
379
  alreadyComplete: true,
382
380
  mintedParaIds: []
383
381
  };
384
- const zip = await JSZip.loadAsync(docx);
382
+ const zip = await ZipArchive.read(docx);
385
383
  if (hasDigitalSignatureParts(zip) && options.allowSignedPackageMutation !== true) throw new EnsureParaIdsError({
386
384
  reason: ENSURE_PARA_IDS_REASONS.SIGNED_PACKAGE,
387
385
  message: "Refusing to normalize a digitally signed package because rewriting OOXML invalidates its signatures. Warn the user and pass allowSignedPackageMutation only if invalidation is acceptable."
388
386
  });
389
387
  for (const [partPath, content] of updates) {
390
- const sourceEntry = zip.file(partPath);
391
- if (sourceEntry === null) throw createEnsureParaIdsError(`Package part disappeared during normalization: ${partPath}`);
392
- writeZipPart({
393
- zip,
394
- path: partPath,
395
- data: encodeUtf8(content),
396
- options: {
397
- compression: "DEFLATE",
398
- compressionOptions: { level: 6 },
399
- date: sourceEntry.date
400
- }
401
- });
388
+ const sourceEntry = zip.entry(partPath);
389
+ if (sourceEntry === void 0) throw createEnsureParaIdsError(`Package part disappeared during normalization: ${partPath}`);
390
+ zip.set(partPath, content, { modified: sourceEntry.modified });
402
391
  }
403
392
  return {
404
- docx: await zip.generateAsync({
405
- type: "uint8array",
406
- compression: "DEFLATE",
407
- compressionOptions: { level: 6 }
408
- }),
393
+ docx: await zip.generate({ level: 6 }),
409
394
  assigned,
410
395
  deduplicated,
411
396
  alreadyComplete: false,
@@ -3,10 +3,10 @@ import { REVISION_ELEMENT_NAMES } from "./revisionIdNormalization.js";
3
3
  import { scanSelectedStreamingXmlElements } from "./selectedXmlIdentityScan.js";
4
4
  import { OOXML_NAMESPACES } from "./serializer/partNamespaces.js";
5
5
  import { toTransitionalNamespaceUri } from "./transitionalSpelling.js";
6
- import { resolveNamespaceUri } from "./xmlNamespaceContext.js";
7
6
  import { NAMESPACES, getLocalName, getNamespaceUri } from "./xmlParser.js";
8
7
  import { panic } from "better-result";
9
8
  import { IMPORTED_ID_COLLISION_KINDS, PARSE_WARNING_CODES } from "@stll/docx-core/model";
9
+ import { resolveNamespaceUri } from "@stll/docx-core/xml/namespaces";
10
10
  // src/docx/importedIdCollisions.ts
11
11
  const WORD_2010_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml";
12
12
  const PACKAGE_RELATIONSHIP_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships";
@@ -1,6 +1,6 @@
1
1
  import { WORDPROCESSINGML_NAMESPACE_URIS, findChildByLocalName, findChildByNamespaceUri, getAttributeByNamespaceUri, getLocalName, getNamespaceUri, parseXmlDocument } from "./xmlParser.js";
2
- import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
3
2
  import { escapeXmlText } from "@stll/docx-core";
3
+ import { assertXmlResourceLimits } from "@stll/docx-core/xml/limits";
4
4
  // src/docx/mathToMathml.ts
5
5
  /**
6
6
  * OMML → MathML conversion.