@stll/folio-core 0.53.0 → 0.54.1

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 (155) hide show
  1. package/dist/ai-edits/apply.js +415 -166
  2. package/dist/ai-edits/batch-claims.d.ts +21 -6
  3. package/dist/ai-edits/batch-claims.js +47 -16
  4. package/dist/ai-edits/headless.d.ts +20 -0
  5. package/dist/ai-edits/headless.js +140 -11
  6. package/dist/ai-edits/pending-suggestions.js +1 -1
  7. package/dist/ai-edits/read.d.ts +5 -3
  8. package/dist/ai-edits/read.js +97 -47
  9. package/dist/ai-edits/revisionStretches.d.ts +17 -0
  10. package/dist/ai-edits/revisionStretches.js +80 -0
  11. package/dist/ai-edits/snapshot.js +3 -2
  12. package/dist/ai-edits/table-cell-mutations.d.ts +3 -1
  13. package/dist/ai-edits/table-cell-mutations.js +2 -1
  14. package/dist/ai-edits/table-row-column-mutations.d.ts +6 -1
  15. package/dist/ai-edits/table-row-column-mutations.js +27 -10
  16. package/dist/compare/content-alignment.d.ts +5 -1
  17. package/dist/compare/content-alignment.js +39 -32
  18. package/dist/compare/inline-provenance.js +1 -1
  19. package/dist/controller/layoutPipeline.js +3 -2
  20. package/dist/docx/archiveInflation.d.ts +127 -0
  21. package/dist/docx/archiveInflation.js +181 -0
  22. package/dist/docx/metadataPrivacy.js +24 -1
  23. package/dist/docx/noteReferenceMark.d.ts +15 -0
  24. package/dist/docx/noteReferenceMark.js +48 -0
  25. package/dist/docx/paragraphParser.js +5 -3
  26. package/dist/docx/paragraphPropertySource.d.ts +1 -1
  27. package/dist/docx/paragraphPropertySource.js +4 -2
  28. package/dist/docx/parser.js +2 -2
  29. package/dist/docx/rezip.js +4 -2
  30. package/dist/docx/selectiveXmlPatch.d.ts +3 -1
  31. package/dist/docx/selectiveXmlPatch.js +81 -3
  32. package/dist/docx/server/boundedArchive.d.ts +8 -13
  33. package/dist/docx/server/boundedArchive.js +70 -59
  34. package/dist/docx/server/extractDocxText.d.ts +8 -2
  35. package/dist/docx/server/extractDocxText.js +2 -2
  36. package/dist/docx/server/validateDocxConformance.d.ts +1 -1
  37. package/dist/docx/server/validateDocxConformance.js +1 -0
  38. package/dist/docx/unzip.d.ts +19 -2
  39. package/dist/docx/unzip.js +135 -24
  40. package/dist/docx/verticalMergeProjection.d.ts +17 -0
  41. package/dist/docx/verticalMergeProjection.js +120 -0
  42. package/dist/fonts/embeddedFonts.js +1 -1
  43. package/dist/i18n/messages/catalogs.gen.d.ts +34 -34
  44. package/dist/i18n/messages/catalogs.gen.js +34 -34
  45. package/dist/i18n/messages/messages.gen.d.ts +2 -2
  46. package/dist/internal/wholeStoryRevisionResolution.js +95 -25
  47. package/dist/layout-engine/measure/measureParagraph.js +2 -2
  48. package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +12 -1
  49. package/dist/layout-engine/measure/paragraphMeasureShared.js +14 -1
  50. package/dist/markdown/escape.d.ts +2 -2
  51. package/dist/markdown/escape.js +4 -5
  52. package/dist/markdown/renderBlock.js +91 -25
  53. package/dist/markdown/renderRuns.d.ts +10 -1
  54. package/dist/markdown/renderRuns.js +320 -59
  55. package/dist/prosemirror/clearRunColor.d.ts +6 -0
  56. package/dist/prosemirror/clearRunColor.js +46 -0
  57. package/dist/prosemirror/commands/comments.js +62 -22
  58. package/dist/prosemirror/commands/formatPainter.js +1 -1
  59. package/dist/prosemirror/commands/hyperlink.js +2 -1
  60. package/dist/prosemirror/commands/propertyChangeScope.d.ts +3 -2
  61. package/dist/prosemirror/commands/propertyChangeScope.js +28 -4
  62. package/dist/prosemirror/commands/resolveAllTableChanges.js +40 -41
  63. package/dist/prosemirror/commands/resolveParagraphProperties.js +33 -8
  64. package/dist/prosemirror/commands/tableCellMergeResolution.d.ts +7 -1
  65. package/dist/prosemirror/commands/tableCellMergeResolution.js +39 -20
  66. package/dist/prosemirror/commands/tableMergeFoldDecisions.d.ts +61 -0
  67. package/dist/prosemirror/commands/tableMergeFoldDecisions.js +79 -0
  68. package/dist/prosemirror/containerFinalParagraph.d.ts +11 -5
  69. package/dist/prosemirror/containerFinalParagraph.js +7 -6
  70. package/dist/prosemirror/conversion/fromProseDoc.d.ts +2 -13
  71. package/dist/prosemirror/conversion/fromProseDoc.js +12 -590
  72. package/dist/prosemirror/conversion/toProseDoc.d.ts +1 -13
  73. package/dist/prosemirror/conversion/toProseDoc.js +28 -262
  74. package/dist/prosemirror/documentSchema.d.ts +8 -0
  75. package/dist/prosemirror/documentSchema.js +18 -0
  76. package/dist/prosemirror/extensions/StarterKit.js +2 -0
  77. package/dist/prosemirror/extensions/core/ParagraphExtension.js +104 -63
  78. package/dist/prosemirror/extensions/features/BaseKeymapExtension.js +1 -1
  79. package/dist/prosemirror/extensions/features/EmptyParagraphFormatExtension.js +24 -3
  80. package/dist/prosemirror/extensions/features/JoinedRunStyleExtension.d.ts +20 -0
  81. package/dist/prosemirror/extensions/features/JoinedRunStyleExtension.js +43 -0
  82. package/dist/prosemirror/extensions/features/ListExtension.js +10 -9
  83. package/dist/prosemirror/extensions/features/pastedHeadingStyles.js +1 -1
  84. package/dist/prosemirror/extensions/features/pastedHtmlLists.js +2 -4
  85. package/dist/prosemirror/extensions/marks/FootnoteRefExtension.js +14 -8
  86. package/dist/prosemirror/extensions/marks/HyperlinkExtension.js +8 -4
  87. package/dist/prosemirror/extensions/marks/StrikeExtension.js +2 -2
  88. package/dist/prosemirror/extensions/marks/SubscriptExtension.js +2 -2
  89. package/dist/prosemirror/extensions/marks/SuperscriptExtension.js +2 -2
  90. package/dist/prosemirror/extensions/marks/TextColorExtension.js +4 -3
  91. package/dist/prosemirror/extensions/marks/markUtils.d.ts +8 -6
  92. package/dist/prosemirror/extensions/marks/markUtils.js +17 -8
  93. package/dist/prosemirror/extensions/marks/noteReferenceDeletion.d.ts +3 -1
  94. package/dist/prosemirror/extensions/marks/noteReferenceDeletion.js +12 -1
  95. package/dist/prosemirror/extensions/nodes/CommentReferenceExtension.js +2 -2
  96. package/dist/prosemirror/extensions/nodes/HardBreakExtension.js +5 -3
  97. package/dist/prosemirror/extensions/nodes/TableExtension.js +104 -210
  98. package/dist/prosemirror/extensions/types.d.ts +7 -0
  99. package/dist/prosemirror/hyperlinkRemoval.d.ts +9 -0
  100. package/dist/prosemirror/hyperlinkRemoval.js +129 -0
  101. package/dist/prosemirror/index.d.ts +2 -1
  102. package/dist/prosemirror/index.js +2 -1
  103. package/dist/prosemirror/listNumbering.d.ts +3 -21
  104. package/dist/prosemirror/listNumbering.js +24 -39
  105. package/dist/prosemirror/listRendering.d.ts +29 -0
  106. package/dist/prosemirror/listRendering.js +26 -0
  107. package/dist/prosemirror/markupViewNotes.d.ts +8 -0
  108. package/dist/prosemirror/markupViewNotes.js +49 -0
  109. package/dist/prosemirror/markupViewProjection.d.ts +3 -1
  110. package/dist/prosemirror/markupViewProjection.js +7 -2
  111. package/dist/prosemirror/noteReferenceReview.d.ts +77 -0
  112. package/dist/prosemirror/noteReferenceReview.js +332 -0
  113. package/dist/prosemirror/paragraphIndentation.js +5 -2
  114. package/dist/prosemirror/paragraphMarkJoin.d.ts +6 -4
  115. package/dist/prosemirror/paragraphMarkJoin.js +21 -19
  116. package/dist/prosemirror/paragraphPropertyCarry.d.ts +64 -0
  117. package/dist/prosemirror/paragraphPropertyCarry.js +155 -0
  118. package/dist/prosemirror/plugins/documentStyleState.d.ts +26 -0
  119. package/dist/prosemirror/plugins/documentStyleState.js +32 -0
  120. package/dist/prosemirror/plugins/documentStyles.d.ts +2 -21
  121. package/dist/prosemirror/plugins/documentStyles.js +66 -29
  122. package/dist/prosemirror/plugins/index.d.ts +2 -1
  123. package/dist/prosemirror/plugins/index.js +2 -1
  124. package/dist/prosemirror/plugins/paragraphStyleResolution.d.ts +15 -0
  125. package/dist/prosemirror/plugins/paragraphStyleResolution.js +230 -0
  126. package/dist/prosemirror/plugins/suggestionMode.d.ts +12 -2
  127. package/dist/prosemirror/plugins/suggestionMode.js +387 -28
  128. package/dist/prosemirror/plugins/templateDirectives.d.ts +6 -0
  129. package/dist/prosemirror/plugins/templateDirectives.js +21 -7
  130. package/dist/prosemirror/plugins/templatePreviewValues.js +14 -21
  131. package/dist/prosemirror/rebaseParagraphRunFormatting.d.ts +22 -2
  132. package/dist/prosemirror/rebaseParagraphRunFormatting.js +39 -13
  133. package/dist/prosemirror/rebaseParagraphRuns.d.ts +36 -0
  134. package/dist/prosemirror/rebaseParagraphRuns.js +99 -0
  135. package/dist/prosemirror/runFormattingFromMarks.d.ts +18 -0
  136. package/dist/prosemirror/runFormattingFromMarks.js +567 -0
  137. package/dist/prosemirror/runFormattingReconciliation.js +10 -9
  138. package/dist/prosemirror/schema/nodes.d.ts +2 -0
  139. package/dist/prosemirror/styles/paragraphStyleCascade.d.ts +59 -0
  140. package/dist/prosemirror/styles/paragraphStyleCascade.js +145 -0
  141. package/dist/prosemirror/styles/resolvedStyleAttrs.d.ts +21 -2
  142. package/dist/prosemirror/styles/resolvedStyleAttrs.js +38 -1
  143. package/dist/prosemirror/styles/tableStyleRegions.d.ts +31 -0
  144. package/dist/prosemirror/styles/tableStyleRegions.js +55 -0
  145. package/dist/prosemirror/tableCellPaste.d.ts +74 -0
  146. package/dist/prosemirror/tableCellPaste.js +525 -0
  147. package/dist/prosemirror/tableGridMutation.d.ts +53 -14
  148. package/dist/prosemirror/tableGridMutation.js +166 -18
  149. package/dist/prosemirror/tableRunIn.d.ts +51 -0
  150. package/dist/prosemirror/tableRunIn.js +165 -0
  151. package/dist/prosemirror/textInput.js +11 -2
  152. package/dist/prosemirror/trackedRevisionPath.d.ts +17 -0
  153. package/dist/prosemirror/trackedRevisionPath.js +32 -0
  154. package/dist/server.d.ts +2 -2
  155. package/package.json +6 -6
@@ -0,0 +1,127 @@
1
+ import JSZip from "jszip";
2
+ //#region src/docx/archiveInflation.d.ts
3
+ declare module "jszip" {
4
+ interface JSZipObject {
5
+ /**
6
+ * Chunked read of the entry content, missing from the published typings.
7
+ * `nodeStream` is this stream wrapped in a Node.js `Readable`, which
8
+ * browsers and web workers cannot provide; the stream itself is
9
+ * platform-neutral.
10
+ */
11
+ internalStream(type: "uint8array"): JSZip.JSZipStreamHelper<Uint8Array>;
12
+ }
13
+ }
14
+ /**
15
+ * Default ceiling on inflated bytes per compressed byte.
16
+ *
17
+ * Document parts in the repository's fixtures stay under 75:1, and parts over a
18
+ * mebibyte under 15:1. A DEFLATE stream tops out near 1032:1, which is the
19
+ * shape of an entry made of one repeated byte.
20
+ */
21
+ declare const DOCX_MAX_COMPRESSION_RATIO = 200;
22
+ /**
23
+ * Inflated size below which the ratio cap does not apply.
24
+ *
25
+ * A small part can compress extremely well without costing anything to hold,
26
+ * so the ratio only has to bound entries (and packages) large enough to
27
+ * matter. The byte caps still apply below this size.
28
+ */
29
+ declare const DOCX_COMPRESSION_RATIO_GRACE_BYTES: number;
30
+ /**
31
+ * Upper bound on the central-directory records in `bytes`, counted without
32
+ * parsing the archive and stopping once the count passes `stopAfter`.
33
+ *
34
+ * Every record JSZip reads starts with the record signature, so the number of
35
+ * signature occurrences cannot be lower than the number of entries JSZip would
36
+ * build, whatever the end-of-central-directory record claims.
37
+ */
38
+ declare const countCentralDirectoryRecords: (bytes: Uint8Array, stopAfter: number) => number;
39
+ /** Sizes a loaded entry declares in the central directory. */
40
+ type ZipEntrySizes = {
41
+ readonly compressedBytes: number | null;
42
+ readonly uncompressedBytes: number | null;
43
+ };
44
+ /** Declared sizes of an entry JSZip loaded from an archive. */
45
+ declare const getZipEntrySizes: (entry: JSZip.JSZipObject) => ZipEntrySizes;
46
+ /**
47
+ * Whether a loaded entry is stored rather than compressed. A stored entry
48
+ * reads back exactly the bytes the archive carries, so it cannot expand.
49
+ */
50
+ declare const isStoredZipEntry: (entry: JSZip.JSZipObject) => boolean;
51
+ /**
52
+ * Whether the per-entry ratio cap applies to a part. Markup and text parts
53
+ * never compress anywhere near the cap. Binary parts can: an uncompressed
54
+ * bitmap of one colour compresses like a run of one byte. Those stay bounded
55
+ * by the byte caps and the package-wide ratio instead.
56
+ */
57
+ declare const isRatioBoundedPart: (path: string) => boolean;
58
+ /** The per-entry ratio cap for `path`: `maxRatio`, or none for a binary part. */
59
+ declare const compressionRatioLimitFor: (path: string, maxRatio: number) => number;
60
+ type CompressionRatioCheck = {
61
+ inflatedBytes: number;
62
+ compressedBytes: number | null;
63
+ maxRatio: number;
64
+ };
65
+ /**
66
+ * Whether `inflatedBytes` produced from `compressedBytes` passes the ratio cap.
67
+ * Sizes under {@link DOCX_COMPRESSION_RATIO_GRACE_BYTES} never do, and an
68
+ * unknown compressed size is left to the byte caps.
69
+ */
70
+ declare const exceedsCompressionRatio: ({ inflatedBytes, compressedBytes, maxRatio }: CompressionRatioCheck) => boolean;
71
+ /**
72
+ * Inflated bytes charged across one package. `aborted` stops every inflation
73
+ * sharing the budget at its next chunk, once one of them has failed the
74
+ * package.
75
+ */
76
+ type InflationBudget = {
77
+ readonly maxTotalBytes: number;
78
+ inflatedBytes: number;
79
+ aborted: boolean;
80
+ };
81
+ declare const createInflationBudget: (maxTotalBytes: number) => InflationBudget;
82
+ /**
83
+ * The bound an inflation stopped at.
84
+ *
85
+ * - `declared-size` — the entry produced more than its declared size.
86
+ * - `compression-ratio` — the entry passed the ratio cap for its compressed size.
87
+ * - `entry` — the entry passed the per-entry cap the caller set.
88
+ * - `total` — the package passed its cumulative budget.
89
+ * - `aborted` — another inflation sharing the budget already failed.
90
+ */
91
+ type InflationLimit = "declared-size" | "compression-ratio" | "entry" | "total" | "aborted";
92
+ type InflateEntryResult = {
93
+ readonly ok: true;
94
+ readonly bytes: Uint8Array<ArrayBuffer>;
95
+ } | {
96
+ readonly ok: false;
97
+ readonly limit: InflationLimit;
98
+ };
99
+ type InflateEntryOptions = {
100
+ entry: JSZip.JSZipObject;
101
+ /** Most bytes this entry may inflate to. */
102
+ maxEntryBytes: number;
103
+ /** Ratio cap while streaming; see {@link compressionRatioLimitFor}. */
104
+ maxCompressionRatio: number;
105
+ budget: InflationBudget;
106
+ /**
107
+ * `false` inflates the entry only to prove it stays within its bounds; the
108
+ * chunks are dropped and the result carries no bytes. A package part that
109
+ * is never read here is still inflated by a later save, so the bound has
110
+ * to be established for it too.
111
+ */
112
+ retain?: boolean;
113
+ };
114
+ /**
115
+ * Inflate one entry chunk by chunk, checking every bound before a chunk is
116
+ * kept. Pausing the stream abandons the rest of the entry, so a limit bounds
117
+ * memory rather than reporting an overrun afterwards.
118
+ *
119
+ * The budget is charged as chunks arrive, which keeps concurrent inflations
120
+ * of one package honest, and refunded when this entry stops at a limit or
121
+ * fails, since none of its bytes are kept. Stream errors (a corrupt entry)
122
+ * reject; limits resolve with `ok: false` for the caller to map onto its own
123
+ * error or skip policy.
124
+ */
125
+ declare const inflateEntryWithinLimits: ({ entry, maxEntryBytes, maxCompressionRatio, budget, retain }: InflateEntryOptions) => Promise<InflateEntryResult>;
126
+ //#endregion
127
+ export { DOCX_COMPRESSION_RATIO_GRACE_BYTES, DOCX_MAX_COMPRESSION_RATIO, InflateEntryOptions, InflateEntryResult, InflationBudget, InflationLimit, ZipEntrySizes, compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits, isRatioBoundedPart, isStoredZipEntry };
@@ -0,0 +1,181 @@
1
+ //#region src/docx/archiveInflation.ts
2
+ /**
3
+ * Default ceiling on inflated bytes per compressed byte.
4
+ *
5
+ * Document parts in the repository's fixtures stay under 75:1, and parts over a
6
+ * mebibyte under 15:1. A DEFLATE stream tops out near 1032:1, which is the
7
+ * shape of an entry made of one repeated byte.
8
+ */
9
+ const DOCX_MAX_COMPRESSION_RATIO = 200;
10
+ /**
11
+ * Inflated size below which the ratio cap does not apply.
12
+ *
13
+ * A small part can compress extremely well without costing anything to hold,
14
+ * so the ratio only has to bound entries (and packages) large enough to
15
+ * matter. The byte caps still apply below this size.
16
+ */
17
+ const DOCX_COMPRESSION_RATIO_GRACE_BYTES = 4 * 1024 * 1024;
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
+ const RATIO_BOUNDED_EXTENSIONS = /* @__PURE__ */ new Set([
70
+ "xml",
71
+ "rels",
72
+ "vml",
73
+ "txt",
74
+ "htm",
75
+ "html",
76
+ "mht",
77
+ "mhtml",
78
+ "rtf"
79
+ ]);
80
+ /**
81
+ * Whether the per-entry ratio cap applies to a part. Markup and text parts
82
+ * never compress anywhere near the cap. Binary parts can: an uncompressed
83
+ * bitmap of one colour compresses like a run of one byte. Those stay bounded
84
+ * by the byte caps and the package-wide ratio instead.
85
+ */
86
+ 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
+ //#endregion
181
+ export { DOCX_COMPRESSION_RATIO_GRACE_BYTES, DOCX_MAX_COMPRESSION_RATIO, compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits, isRatioBoundedPart, isStoredZipEntry };
@@ -1,3 +1,4 @@
1
+ import { countCentralDirectoryRecords, createInflationBudget, inflateEntryWithinLimits } from "./archiveInflation.js";
1
2
  import { elementToXml, getLocalName, parseXmlDocument } from "./xmlParser.js";
2
3
  import { TaggedError } from "better-result";
3
4
  import JSZip from "jszip";
@@ -63,6 +64,10 @@ const loadPrivacyArchive = async (buffer) => {
63
64
  message: "Document privacy input exceeded the compressed-size limit",
64
65
  reason: "input-too-large"
65
66
  });
67
+ if (countCentralDirectoryRecords(new Uint8Array(buffer), MAX_ARCHIVE_ENTRIES) > MAX_ARCHIVE_ENTRIES) throw new FolioDocumentPrivacyArchiveError({
68
+ message: "Document privacy input exceeded the package-entry limit",
69
+ reason: "too-many-entries"
70
+ });
66
71
  let zip;
67
72
  try {
68
73
  zip = await JSZip.loadAsync(buffer);
@@ -109,6 +114,24 @@ const rewriteCorePropertiesPrivacy = (xml, transforms) => {
109
114
  removedMetadataProperties: FOLIO_DOCUMENT_METADATA_PROPERTIES.filter((property) => removedPropertySet.has(property))
110
115
  };
111
116
  };
117
+ /**
118
+ * Read the core-properties part within its size limit. The declared size was
119
+ * checked when the archive loaded, but only the inflation itself can show that
120
+ * the part is no larger than it claims.
121
+ */
122
+ const readCoreProperties = async (entry) => {
123
+ const result = await inflateEntryWithinLimits({
124
+ entry,
125
+ maxEntryBytes: MAX_CORE_PROPERTIES_BYTES,
126
+ maxCompressionRatio: 200,
127
+ budget: createInflationBudget(MAX_CORE_PROPERTIES_BYTES)
128
+ });
129
+ if (!result.ok) throw new FolioDocumentPrivacyArchiveError({
130
+ message: "Document privacy core properties exceeded the part-size limit",
131
+ reason: "core-properties-too-large"
132
+ });
133
+ return new TextDecoder("utf-8", { ignoreBOM: true }).decode(result.bytes);
134
+ };
112
135
  /** Rewrite selected package metadata fields without changing other package parts. */
113
136
  const rewriteDocxMetadataPrivacy = async (buffer, { transforms }) => {
114
137
  const appliedTransforms = resolveFolioDocumentPrivacyTransforms(transforms);
@@ -121,7 +144,7 @@ const rewriteDocxMetadataPrivacy = async (buffer, { transforms }) => {
121
144
  removedMetadataProperties: []
122
145
  }
123
146
  };
124
- const rewritten = rewriteCorePropertiesPrivacy(await coreProperties.async("text"), appliedTransforms);
147
+ const rewritten = rewriteCorePropertiesPrivacy(await readCoreProperties(coreProperties), appliedTransforms);
125
148
  if (rewritten.removedMetadataProperties.length === 0) return {
126
149
  buffer,
127
150
  privacyReport: {
@@ -0,0 +1,15 @@
1
+ import { document_d_exports } from "../types/document.js";
2
+ //#region src/docx/noteReferenceMark.d.ts
3
+ type NoteKind = "footnote" | "endnote";
4
+ /** The run carrying a note's in-note reference mark, in the note reference style. */
5
+ declare const noteReferenceMarkRun: (kind: NoteKind) => document_d_exports.Run;
6
+ /**
7
+ * A note's content with its reference mark run at the start of its first
8
+ * paragraph, added when the note has none.
9
+ */
10
+ declare const withNoteReferenceMark: (kind: NoteKind, content: readonly document_d_exports.BlockContent[]) => document_d_exports.BlockContent[];
11
+ /** A new note, as an editor adds one: its content led by its own reference mark. */
12
+ declare function createNote(kind: "footnote", id: number, content: readonly document_d_exports.BlockContent[]): document_d_exports.Footnote;
13
+ declare function createNote(kind: "endnote", id: number, content: readonly document_d_exports.BlockContent[]): document_d_exports.Endnote;
14
+ //#endregion
15
+ export { createNote, noteReferenceMarkRun, withNoteReferenceMark };
@@ -0,0 +1,48 @@
1
+ import { cloneParagraphWithPropertySource } from "./paragraphPropertySource.js";
2
+ //#region src/docx/noteReferenceMark.ts
3
+ const MARK_ELEMENT = /<(?:[\w.-]+:)?(?:footnote|endnote)Ref[\s/>]/u;
4
+ /** The run carrying a note's in-note reference mark, in the note reference style. */
5
+ const noteReferenceMarkRun = (kind) => ({
6
+ type: "run",
7
+ formatting: { styleId: kind === "footnote" ? "FootnoteReference" : "EndnoteReference" },
8
+ content: [{
9
+ type: "preservedXml",
10
+ xml: `<w:${kind}Ref/>`,
11
+ text: ""
12
+ }]
13
+ });
14
+ const holdsReferenceMark = (value) => {
15
+ if (Array.isArray(value)) return value.some(holdsReferenceMark);
16
+ if (typeof value !== "object" || value === null) return false;
17
+ const record = value;
18
+ if (record["type"] === "preservedXml" && typeof record["xml"] === "string") return MARK_ELEMENT.test(record["xml"]);
19
+ return Object.values(record).some(holdsReferenceMark);
20
+ };
21
+ /**
22
+ * A note's content with its reference mark run at the start of its first
23
+ * paragraph, added when the note has none.
24
+ */
25
+ const withNoteReferenceMark = (kind, content) => {
26
+ if (holdsReferenceMark(content)) return [...content];
27
+ const index = content.findIndex((block) => block.type === "paragraph");
28
+ const mark = noteReferenceMarkRun(kind);
29
+ if (index === -1) return [{
30
+ type: "paragraph",
31
+ content: [mark]
32
+ }, ...content];
33
+ const paragraph = content[index];
34
+ return content.map((block, position) => position === index ? cloneParagraphWithPropertySource(paragraph, { content: [mark, ...paragraph.content] }) : block);
35
+ };
36
+ function createNote(kind, id, content) {
37
+ return kind === "footnote" ? {
38
+ type: "footnote",
39
+ id,
40
+ content: withNoteReferenceMark(kind, content)
41
+ } : {
42
+ type: "endnote",
43
+ id,
44
+ content: withNoteReferenceMark(kind, content)
45
+ };
46
+ }
47
+ //#endregion
48
+ export { createNote, noteReferenceMarkRun, withNoteReferenceMark };
@@ -2,6 +2,7 @@ import { isValidHexId } from "../utils/hexId.js";
2
2
  import { attributeRemainder } from "./attributeRemainder.js";
3
3
  import { parseBookmarkEnd as parseBookmarkEnd$1, parseBookmarkStart as parseBookmarkStart$1 } from "./bookmarkParser.js";
4
4
  import { CAPTURE, dispatchChildrenWithContext, ownedElsewhere, transitionalNamespaceOf, withPreservedChildren } from "./containerChildren.js";
5
+ import { resolveDefaultParagraphStyle } from "./defaultParagraphStyle.js";
5
6
  import { parseFieldType } from "./fieldParser.js";
6
7
  import { fieldStateOf, parseFieldState } from "./fieldState.js";
7
8
  import { HYPERLINK_CHILD_HANDLERS, parseHyperlink as parseHyperlink$1, parseHyperlinkShell } from "./hyperlinkParser.js";
@@ -1241,7 +1242,8 @@ function parseParagraph(node, styles, theme, numbering, rels = null, media = nul
1241
1242
  }
1242
1243
  if (implicitChildLevelAdvances > 0) listRendering.implicitChildLevelAdvances = implicitChildLevelAdvances;
1243
1244
  paragraph.listRendering = listRendering;
1244
- const chainInd = numPrFromStyle ? styleChainInd(paragraph.formatting?.styleId, styles) : {
1245
+ const styleInd = styleChainInd(paragraph.formatting?.styleId ?? (styles ? resolveDefaultParagraphStyle(styles.values())?.styleId : void 0), styles);
1246
+ const chainInd = numPrFromStyle ? styleInd : {
1245
1247
  left: false,
1246
1248
  firstLine: false
1247
1249
  };
@@ -1250,8 +1252,8 @@ function parseParagraph(node, styles, theme, numbering, rels = null, media = nul
1250
1252
  const directInd = pPr ? findChild(pPr, "w", "ind") : null;
1251
1253
  const hasDirectLeft = hasAttributeAnySpelling(directInd, "CT_Ind @left");
1252
1254
  const hasDirectFirstLineOrHanging = parseNumericAttribute(directInd, "w", "firstLine") !== void 0 || parseNumericAttribute(directInd, "w", "hanging") !== void 0;
1253
- if (!hasDirectLeft && !chainInd.left && level.pPr.indentLeft !== void 0) paragraph.formatting.indentLeft = level.pPr.indentLeft;
1254
- if (!hasDirectFirstLineOrHanging && !chainInd.firstLine && numberingLevelHasMarkerSlot(level)) {
1255
+ if (!hasDirectLeft && !chainInd.left && level.pPr.indentLeft !== void 0 && (level.pPr.indentLeft !== 0 || styleInd.left)) paragraph.formatting.indentLeft = level.pPr.indentLeft;
1256
+ if (!hasDirectFirstLineOrHanging && !chainInd.firstLine && numberingLevelHasMarkerSlot(level) && (level.pPr.indentFirstLine !== 0 || styleInd.firstLine)) {
1255
1257
  if (level.pPr.indentFirstLine !== void 0) paragraph.formatting.indentFirstLine = level.pPr.indentFirstLine;
1256
1258
  if (level.pPr.hangingIndent !== void 0) paragraph.formatting.hangingIndent = level.pPr.hangingIndent;
1257
1259
  }
@@ -136,4 +136,4 @@ declare const linkParagraphPropertySourceCandidate: (target: document_d_exports.
136
136
  declare const getParagraphPropertySourceCandidate: (paragraph: document_d_exports.Paragraph) => document_d_exports.Paragraph | undefined;
137
137
  declare const getParagraphPropertySourceTransferId: (paragraph: document_d_exports.Paragraph) => string | undefined;
138
138
  //#endregion
139
- export { DecodedTableCellParagraphSourcePayload, PARAGRAPH_PROPERTY_SOURCE_VALIDATION_CODES, PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR, PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR, ParagraphPropertySourceValidationCode, ParagraphPropertySourceValidationError, TABLE_CELL_PARAGRAPH_SOURCE_BINDING_ATTR, TABLE_CELL_PARAGRAPH_SOURCE_PAYLOAD_ERROR_CLASSIFICATIONS, TableCellParagraphPropertySourceBinding, TableCellParagraphSourcePayloadErrorClassification, assignDocumentParagraphPropertySourceContract, assignParagraphPropertySource, cloneDocumentWithParagraphPropertySources, cloneParagraphWithPropertySource, cloneParagraphWithoutPropertySource, cloneTableCellsWithParagraphPropertyCaptures, copyDocumentParagraphPropertySourceContract, copyDocumentParagraphPropertySources, copyParagraphPropertyCapture, copyParagraphPropertySource, createProseParagraphWithPropertySource, decodeTableCellParagraphSourcePayload, getDocumentParagraphPropertySourceContract, getExplicitParagraphPropertySourceTransfers, getParagraphPropertySource, getParagraphPropertySourceCandidate, getParagraphPropertySourceToken, getParagraphPropertySourceTransferId, getProseDocumentParagraphPropertySourceContract, getProseParagraphPropertySourceToken, isParagraphPropertySourceToken, joinProseParagraphsWithRightPropertySource, linkParagraphPropertySourceCandidate, linkProseParagraphPropertySource, markParagraphPropertySourceTransfers, paragraphPropertySourceBelongsToDocument, paragraphPropertySourceMatchesEmission, paragraphPropertySourceTokenMatchesContract, proseParagraphAttrsWithoutPropertySource, recreateProseNodeWithDetachedParagraphPropertySource, recreateProseNodeWithParagraphPropertySource, restoreTableCellsWithParagraphPropertySources, setProseParagraphMarkupWithPropertySource, transferProseParagraphPropertySource, transportTableCellsWithParagraphPropertySources, visitDocumentStoryParagraphs, visitTableCellParagraphPropertySourceBindings };
139
+ export { DecodedTableCellParagraphSourcePayload, PARAGRAPH_PROPERTY_SOURCE_VALIDATION_CODES, PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR, PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR, ParagraphPropertySourceTransfer, ParagraphPropertySourceValidationCode, ParagraphPropertySourceValidationError, TABLE_CELL_PARAGRAPH_SOURCE_BINDING_ATTR, TABLE_CELL_PARAGRAPH_SOURCE_PAYLOAD_ERROR_CLASSIFICATIONS, TableCellParagraphPropertySourceBinding, TableCellParagraphSourcePayloadErrorClassification, assignDocumentParagraphPropertySourceContract, assignParagraphPropertySource, cloneDocumentWithParagraphPropertySources, cloneParagraphWithPropertySource, cloneParagraphWithoutPropertySource, cloneTableCellsWithParagraphPropertyCaptures, copyDocumentParagraphPropertySourceContract, copyDocumentParagraphPropertySources, copyParagraphPropertyCapture, copyParagraphPropertySource, createProseParagraphWithPropertySource, decodeTableCellParagraphSourcePayload, getDocumentParagraphPropertySourceContract, getExplicitParagraphPropertySourceTransfers, getParagraphPropertySource, getParagraphPropertySourceCandidate, getParagraphPropertySourceToken, getParagraphPropertySourceTransferId, getProseDocumentParagraphPropertySourceContract, getProseParagraphPropertySourceToken, isParagraphPropertySourceToken, joinProseParagraphsWithRightPropertySource, linkParagraphPropertySourceCandidate, linkProseParagraphPropertySource, markParagraphPropertySourceTransfers, paragraphPropertySourceBelongsToDocument, paragraphPropertySourceMatchesEmission, paragraphPropertySourceTokenMatchesContract, proseParagraphAttrsWithoutPropertySource, recreateProseNodeWithDetachedParagraphPropertySource, recreateProseNodeWithParagraphPropertySource, restoreTableCellsWithParagraphPropertySources, setProseParagraphMarkupWithPropertySource, transferProseParagraphPropertySource, transportTableCellsWithParagraphPropertySources, visitDocumentStoryParagraphs, visitTableCellParagraphPropertySourceBindings };
@@ -542,7 +542,7 @@ const tableCellParagraphPropertySourceBindingForTransport = (paragraph, inspecti
542
542
  };
543
543
  /** Prepare opaque continuation cells for ProseMirror and Yjs transport. */
544
544
  const transportTableCellsWithParagraphPropertySources = (cells) => {
545
- const cloned = structuredClone(cells);
545
+ const cloned = cells.map((cell) => structuredClone(cell));
546
546
  const sources = paragraphsInTableCells(cells);
547
547
  const targets = paragraphsInTableCells(cloned);
548
548
  if (sources.length !== targets.length) panic("The cloned table cells changed paragraph graph ownership.");
@@ -679,7 +679,9 @@ const proseParagraphAttrsWithoutPropertySource = (attrs) => {
679
679
  /** Replace paragraph markup without losing its private parser-owner link. */
680
680
  const setProseParagraphMarkupWithPropertySource = ({ attrs, ownership, pos, transaction }) => {
681
681
  const source = transaction.doc.nodeAt(pos);
682
- transaction.setNodeMarkup(pos, void 0, attrs);
682
+ if (source) {
683
+ for (const [name, value] of Object.entries(attrs)) if (source.attrs[name] !== value) transaction.setNodeAttribute(pos, name, value);
684
+ } else transaction.setNodeMarkup(pos, void 0, attrs);
683
685
  const target = transaction.doc.nodeAt(pos);
684
686
  if (!source || !target) return;
685
687
  if (ownership === "preserve") {
@@ -738,7 +738,7 @@ function fullParseDocx(buffer, onProgress) {
738
738
  * Faster than full parse when you only need variables
739
739
  */
740
740
  async function getDocxVariables(buffer) {
741
- const raw = await unzipDocx(buffer);
741
+ const raw = await unzipDocx(buffer, {}, { verifyUnreadEntries: false });
742
742
  if (!raw.documentXml) return [];
743
743
  return extractAllTemplateVariables(parseDocumentBody(raw.documentXml).content);
744
744
  }
@@ -746,7 +746,7 @@ async function getDocxVariables(buffer) {
746
746
  * Get document summary without full parsing
747
747
  */
748
748
  async function getDocxSummary(buffer) {
749
- const raw = await unzipDocx(buffer);
749
+ const raw = await unzipDocx(buffer, {}, { verifyUnreadEntries: false });
750
750
  const variables = raw.documentXml ? extractAllTemplateVariables(parseDocumentBody(raw.documentXml).content) : [];
751
751
  return {
752
752
  hasDocument: raw.documentXml !== null,
@@ -1339,7 +1339,8 @@ async function serializeHeadersFootersToZip(doc, zip, compressionLevel) {
1339
1339
  }
1340
1340
  async function serializeNotesToZip({ doc, originalZip, newZip, compressionLevel, changedNoteParaIds }) {
1341
1341
  const footnotes = doc.package.footnotes ?? [];
1342
- if (footnotes.length > 0) if (findNotePartEntry(originalZip, "word/footnotes.xml")) await patchNotePartIntoZip({
1342
+ const originalFootnotes = doc.package.footnotes === void 0 ? null : findNotePartEntry(originalZip, "word/footnotes.xml");
1343
+ if (footnotes.length > 0 || originalFootnotes) if (originalFootnotes) await patchNotePartIntoZip({
1343
1344
  conventionalLowerPath: "word/footnotes.xml",
1344
1345
  currentXml: serializeFootnotes(footnotes),
1345
1346
  replacementXml: serializeNewFootnotesPart(footnotes),
@@ -1359,7 +1360,8 @@ async function serializeNotesToZip({ doc, originalZip, newZip, compressionLevel,
1359
1360
  compressionLevel
1360
1361
  });
1361
1362
  const endnotes = doc.package.endnotes ?? [];
1362
- if (endnotes.length > 0) if (findNotePartEntry(originalZip, "word/endnotes.xml")) await patchNotePartIntoZip({
1363
+ const originalEndnotes = doc.package.endnotes === void 0 ? null : findNotePartEntry(originalZip, "word/endnotes.xml");
1364
+ if (endnotes.length > 0 || originalEndnotes) if (originalEndnotes) await patchNotePartIntoZip({
1363
1365
  conventionalLowerPath: "word/endnotes.xml",
1364
1366
  currentXml: serializeEndnotes(endnotes),
1365
1367
  replacementXml: serializeNewEndnotesPart(endnotes),
@@ -165,7 +165,9 @@ type XmlSplice = {
165
165
  * the rest of the part byte-for-byte, so it can write half a comment range:
166
166
  * invalid OOXML that anchors the comment to nothing. Answers null when it
167
167
  * would, leaving the caller to rewrite a wider region — ultimately the whole
168
- * part from the model, which is balanced with itself.
168
+ * part from the model, which is balanced with itself. Overlapping source
169
+ * regions are refused too: replacing an inner region moves the outer region's
170
+ * end, so its original offsets cannot be applied to the resulting string.
169
171
  */
170
172
  declare const spliceXml: (xml: string, splices: readonly XmlSplice[]) => string | null;
171
173
  type NoteElementName = "footnote" | "endnote";
@@ -426,7 +426,11 @@ const routeChangedParagraphs = (originalXml, serializedXml, changedIds) => {
426
426
  });
427
427
  }
428
428
  const candidate = spliceXml(originalXml, splices);
429
- if (candidate === null || !splicesAsCanonical(candidate, PARAGRAPH_SCAN_NAMES)) return {
429
+ if (candidate === null) return {
430
+ type: "refused",
431
+ reason: "unsafe-paragraph-splices"
432
+ };
433
+ if (!splicesAsCanonical(candidate, PARAGRAPH_SCAN_NAMES)) return {
430
434
  type: "refused",
431
435
  reason: "replacement-namespace-conflict"
432
436
  };
@@ -496,11 +500,18 @@ function buildPatchedNoteXml(originalXml, serializedXml, changedIds) {
496
500
  * the rest of the part byte-for-byte, so it can write half a comment range:
497
501
  * invalid OOXML that anchors the comment to nothing. Answers null when it
498
502
  * would, leaving the caller to rewrite a wider region — ultimately the whole
499
- * part from the model, which is balanced with itself.
503
+ * part from the model, which is balanced with itself. Overlapping source
504
+ * regions are refused too: replacing an inner region moves the outer region's
505
+ * end, so its original offsets cannot be applied to the resulting string.
500
506
  */
501
507
  const spliceXml = (xml, splices) => {
502
508
  let result = xml;
503
- for (const { start, end, newXml } of [...splices].toSorted((a, b) => b.start - a.start)) result = result.slice(0, start) + newXml + result.slice(end);
509
+ let unpatchedEnd = xml.length;
510
+ for (const { start, end, newXml } of [...splices].toSorted((a, b) => b.start - a.start)) {
511
+ if (end > unpatchedEnd) return null;
512
+ result = result.slice(0, start) + newXml + result.slice(end);
513
+ unpatchedEnd = start;
514
+ }
504
515
  return patchBreaksCommentRangeBalance(xml, result) ? null : result;
505
516
  };
506
517
  /**
@@ -732,9 +743,25 @@ function buildPatchedNotePartXml({ originalXml, baselineXml, serializedXml, repl
732
743
  const serializedParaIds = collectParaIds(serializedXml);
733
744
  const effectiveChangedParaIds = changedParaIds ?? collectChangedNoteParaIds(baselineXml, serializedXml);
734
745
  const unroutedChangedParaIds = new Set([...effectiveChangedParaIds].filter((paraId) => serializedParaIds.has(paraId)));
746
+ /** Notes the model has and the part does not: new notes, appended to the part. */
747
+ const addedNotes = [];
735
748
  for (const [id, currentSyntaxEntries] of currentElements) {
736
749
  const originalSyntaxEntries = originalElements.get(id);
737
750
  const replacementSyntaxEntries = replacementElements.get(id);
751
+ if (originalSyntaxEntries === void 0) {
752
+ const replacementSyntax = currentSyntaxEntries.length === 1 && replacementSyntaxEntries?.length === 1 ? replacementSyntaxEntries[0] : void 0;
753
+ const replacementNote = replacementSyntax ? extractNoteElement(replacementXml, replacementSyntax, id) : null;
754
+ if (!replacementSyntax || !replacementNote) return {
755
+ type: "refused",
756
+ reason: "unroutable-paragraph"
757
+ };
758
+ for (const paraId of collectParaIds(replacementNote).keys()) unroutedChangedParaIds.delete(paraId);
759
+ addedNotes.push({
760
+ note: replacementNote,
761
+ syntax: replacementSyntax
762
+ });
763
+ continue;
764
+ }
738
765
  if (currentSyntaxEntries.length !== 1 || originalSyntaxEntries?.length !== 1 || replacementSyntaxEntries?.length !== 1) return {
739
766
  type: "refused",
740
767
  reason: "unroutable-paragraph"
@@ -806,10 +833,36 @@ function buildPatchedNotePartXml({ originalXml, baselineXml, serializedXml, repl
806
833
  });
807
834
  }
808
835
  }
836
+ for (const [id, baselineSyntaxEntries] of baselineElements) {
837
+ if (currentElements.has(id) || baselineSyntaxEntries.length !== 1) continue;
838
+ const originalSyntaxEntries = originalElements.get(id);
839
+ const originalSyntax = originalSyntaxEntries?.length === 1 ? originalSyntaxEntries[0] : void 0;
840
+ const originalOffsets = originalSyntax && findNoteElement(originalXml, originalSyntax, id);
841
+ if (!originalOffsets) return {
842
+ type: "refused",
843
+ reason: "unroutable-paragraph"
844
+ };
845
+ const removal = {
846
+ start: originalOffsets.start,
847
+ end: originalOffsets.end,
848
+ newXml: ""
849
+ };
850
+ paragraphSplices.push(removal);
851
+ noteSplices.push(removal);
852
+ }
809
853
  if (unroutedChangedParaIds.size > 0) return {
810
854
  type: "refused",
811
855
  reason: "unroutable-paragraph"
812
856
  };
857
+ if (addedNotes.length > 0) {
858
+ const appended = appendNotesSplice(originalXml, originalElements, addedNotes, { sourceXmlnsDeclarations: replacementXmlnsDeclarations });
859
+ if (!appended) return {
860
+ type: "refused",
861
+ reason: "unroutable-paragraph"
862
+ };
863
+ paragraphSplices.push(appended);
864
+ noteSplices.push(appended);
865
+ }
813
866
  const patched = spliceXml(originalXml, paragraphSplices);
814
867
  if (patched !== null) return {
815
868
  type: "patched",
@@ -825,6 +878,31 @@ function buildPatchedNotePartXml({ originalXml, baselineXml, serializedXml, repl
825
878
  };
826
879
  }
827
880
  /**
881
+ * Insert new notes after the part's last note, spelled with that note's
882
+ * prefixes. Null when the part holds no note to follow.
883
+ */
884
+ const appendNotesSplice = (originalXml, originalElements, addedNotes, { sourceXmlnsDeclarations }) => {
885
+ let last = null;
886
+ for (const [id, entries] of originalElements) for (const syntax of entries) {
887
+ const offsets = findNoteElement(originalXml, syntax, id);
888
+ if (offsets && (!last || offsets.end > last.end)) last = {
889
+ end: offsets.end,
890
+ syntax
891
+ };
892
+ }
893
+ if (!last) return null;
894
+ const target = last.syntax;
895
+ return {
896
+ start: last.end,
897
+ end: last.end,
898
+ newXml: addedNotes.map(({ note, syntax }) => rewriteWordprocessingPrefixes(note, {
899
+ source: syntax,
900
+ target,
901
+ sourceXmlnsDeclarations
902
+ })).join("")
903
+ };
904
+ };
905
+ /**
828
906
  * The full range of the first `<openLiteral …>…</closeTag>` element, or null.
829
907
  * Used to locate an unkeyed sub-element (a level's `mc:AlternateContent`).
830
908
  */