@stll/folio-core 0.32.2 → 0.33.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 (222) hide show
  1. package/dist/ai-edits/__fixtures__/paragraphs.d.ts +17 -0
  2. package/dist/ai-edits/__fixtures__/paragraphs.js +33 -0
  3. package/dist/ai-edits/apply.d.ts +49 -3
  4. package/dist/ai-edits/apply.js +649 -85
  5. package/dist/ai-edits/blockRange.d.ts +7 -3
  6. package/dist/ai-edits/blockRange.js +14 -6
  7. package/dist/ai-edits/headless.d.ts +39 -5
  8. package/dist/ai-edits/headless.js +76 -17
  9. package/dist/ai-edits/index.d.ts +6 -6
  10. package/dist/ai-edits/index.js +3 -3
  11. package/dist/ai-edits/read.d.ts +4 -0
  12. package/dist/ai-edits/read.js +30 -6
  13. package/dist/ai-edits/scoped-reading.js +4 -3
  14. package/dist/ai-edits/snapshot.d.ts +42 -2
  15. package/dist/ai-edits/snapshot.js +127 -62
  16. package/dist/ai-edits/table-row-column-mutations.d.ts +24 -1
  17. package/dist/ai-edits/table-row-column-mutations.js +98 -6
  18. package/dist/ai-edits/table-targets.d.ts +12 -3
  19. package/dist/ai-edits/table-targets.js +15 -5
  20. package/dist/ai-edits/types.d.ts +219 -15
  21. package/dist/ai-edits/word-diff.d.ts +54 -11
  22. package/dist/ai-edits/word-diff.js +221 -61
  23. package/dist/compare/__fixtures__/body-sequence.d.ts +33 -0
  24. package/dist/compare/__fixtures__/body-sequence.js +67 -0
  25. package/dist/compare/__fixtures__/nested-table.d.ts +16 -0
  26. package/dist/compare/__fixtures__/nested-table.js +50 -0
  27. package/dist/compare/__fixtures__/numbered-list.d.ts +34 -0
  28. package/dist/compare/__fixtures__/numbered-list.js +85 -0
  29. package/dist/compare/column-alignment.d.ts +27 -0
  30. package/dist/compare/column-alignment.js +162 -0
  31. package/dist/compare/compare.d.ts +107 -0
  32. package/dist/compare/compare.js +406 -0
  33. package/dist/compare/formatting.d.ts +29 -0
  34. package/dist/compare/formatting.js +84 -0
  35. package/dist/compare/plan.d.ts +22 -0
  36. package/dist/compare/plan.js +1059 -0
  37. package/dist/compare/reproducible-package.d.ts +17 -0
  38. package/dist/compare/reproducible-package.js +30 -0
  39. package/dist/compare/scenario.d.ts +77 -0
  40. package/dist/compare/scenario.js +263 -0
  41. package/dist/compare/types.d.ts +282 -0
  42. package/dist/compare/types.js +32 -0
  43. package/dist/compare/verification.d.ts +55 -0
  44. package/dist/compare/verification.js +146 -0
  45. package/dist/compat/eigenpal.d.ts +9 -6
  46. package/dist/compat/eigenpal.js +6 -3
  47. package/dist/controller/layoutPipeline.d.ts +13 -0
  48. package/dist/controller/layoutPipeline.js +15 -1
  49. package/dist/display-list/build/buildContext.d.ts +39 -0
  50. package/dist/display-list/build/buildContext.js +31 -0
  51. package/dist/display-list/build/buildDisplayList.d.ts +79 -0
  52. package/dist/display-list/build/buildDisplayList.js +283 -0
  53. package/dist/display-list/build/colors.d.ts +20 -0
  54. package/dist/display-list/build/colors.js +136 -0
  55. package/dist/display-list/build/floatingImages.d.ts +24 -0
  56. package/dist/display-list/build/floatingImages.js +41 -0
  57. package/dist/display-list/build/fontTable.d.ts +31 -0
  58. package/dist/display-list/build/fontTable.js +97 -0
  59. package/dist/display-list/build/furniture.d.ts +86 -0
  60. package/dist/display-list/build/furniture.js +98 -0
  61. package/dist/display-list/build/glyphs.d.ts +55 -0
  62. package/dist/display-list/build/glyphs.js +103 -0
  63. package/dist/display-list/build/headerFooterPrimitives.d.ts +17 -0
  64. package/dist/display-list/build/headerFooterPrimitives.js +40 -0
  65. package/dist/display-list/build/imagePrimitives.d.ts +35 -0
  66. package/dist/display-list/build/imagePrimitives.js +264 -0
  67. package/dist/display-list/build/pageBorderPrimitives.d.ts +23 -0
  68. package/dist/display-list/build/pageBorderPrimitives.js +111 -0
  69. package/dist/display-list/build/pageFurniture.d.ts +35 -0
  70. package/dist/display-list/build/pageFurniture.js +141 -0
  71. package/dist/display-list/build/paragraphPrimitives.d.ts +19 -0
  72. package/dist/display-list/build/paragraphPrimitives.js +819 -0
  73. package/dist/display-list/build/regions.d.ts +46 -0
  74. package/dist/display-list/build/regions.js +56 -0
  75. package/dist/display-list/build/storyPrimitives.d.ts +29 -0
  76. package/dist/display-list/build/storyPrimitives.js +279 -0
  77. package/dist/display-list/build/strokes.d.ts +19 -0
  78. package/dist/display-list/build/strokes.js +78 -0
  79. package/dist/display-list/build/tablePrimitives.d.ts +38 -0
  80. package/dist/display-list/build/tablePrimitives.js +409 -0
  81. package/dist/display-list/build/textBoxPrimitives.d.ts +14 -0
  82. package/dist/display-list/build/textBoxPrimitives.js +129 -0
  83. package/dist/display-list/build/textDecorations.d.ts +29 -0
  84. package/dist/display-list/build/textDecorations.js +29 -0
  85. package/dist/display-list/build/unsupported.d.ts +57 -0
  86. package/dist/display-list/build/unsupported.js +0 -0
  87. package/dist/display-list/build/watermarkPrimitives.d.ts +15 -0
  88. package/dist/display-list/build/watermarkPrimitives.js +128 -0
  89. package/dist/display-list/dom/renderDisplayListToDom.d.ts +26 -0
  90. package/dist/display-list/dom/renderDisplayListToDom.js +620 -0
  91. package/dist/display-list/editor/displayListPagePainter.d.ts +22 -0
  92. package/dist/display-list/editor/displayListPagePainter.js +63 -0
  93. package/dist/display-list/editor/pageRenderer.d.ts +24 -0
  94. package/dist/display-list/editor/pageRenderer.js +36 -0
  95. package/dist/display-list/primitives.d.ts +95 -0
  96. package/dist/display-list/primitives.js +127 -0
  97. package/dist/display-list/types.d.ts +472 -0
  98. package/dist/display-list/types.js +0 -0
  99. package/dist/document-operations.d.ts +39 -8
  100. package/dist/document-operations.js +169 -16
  101. package/dist/document-stories.d.ts +16 -0
  102. package/dist/document-stories.js +48 -4
  103. package/dist/docx/packageParts.d.ts +35 -0
  104. package/dist/docx/packageParts.js +120 -0
  105. package/dist/docx/paragraphParser.js +22 -22
  106. package/dist/docx/paragraphTraversal.js +1 -1
  107. package/dist/docx/renderedPageBreakNormalization.js +3 -1
  108. package/dist/docx/revisionIdNormalization.d.ts +13 -0
  109. package/dist/docx/revisionIdNormalization.js +103 -0
  110. package/dist/docx/rezip.js +26 -17
  111. package/dist/docx/selectiveSave.js +2 -2
  112. package/dist/docx/serializer/commentSerializer.js +6 -1
  113. package/dist/docx/serializer/paragraphSerializer.js +33 -4
  114. package/dist/docx/serializer/runSerializer.js +11 -10
  115. package/dist/docx/serializer/tableSerializer.js +2 -1
  116. package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
  117. package/dist/docx/server/createBilingualDocx.js +3 -2
  118. package/dist/docx/streamingXmlParser.d.ts +13 -1
  119. package/dist/docx/streamingXmlParser.js +49 -11
  120. package/dist/docx/unzip.d.ts +1 -2
  121. package/dist/docx/unzip.js +25 -6
  122. package/dist/docx/xmlParser.d.ts +3 -1
  123. package/dist/docx/xmlParser.js +33 -27
  124. package/dist/export-pdf.d.ts +43 -0
  125. package/dist/export-pdf.js +98 -0
  126. package/dist/fonts/headlessMeasure.d.ts +76 -0
  127. package/dist/fonts/headlessMeasure.js +0 -0
  128. package/dist/fonts/sfnt/parse.d.ts +51 -0
  129. package/dist/fonts/sfnt/parse.js +525 -0
  130. package/dist/fonts/sfnt/subset.d.ts +20 -0
  131. package/dist/fonts/sfnt/subset.js +350 -0
  132. package/dist/fonts/sfnt/tables.d.ts +88 -0
  133. package/dist/fonts/sfnt/tables.js +131 -0
  134. package/dist/fonts/sfnt/woff.d.ts +14 -0
  135. package/dist/fonts/sfnt/woff.js +163 -0
  136. package/dist/generated/text_shaper.js +324 -0
  137. package/dist/generated/text_shaper_bg.wasm +0 -0
  138. package/dist/headless-layout.d.ts +75 -0
  139. package/dist/headless-layout.js +350 -0
  140. package/dist/index.d.ts +9 -6
  141. package/dist/index.js +6 -3
  142. package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
  143. package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
  144. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
  145. package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
  146. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
  147. package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
  148. package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
  149. package/dist/layout-engine/measure/advanceComposition.js +148 -0
  150. package/dist/layout-engine/measure/cache.d.ts +2 -0
  151. package/dist/layout-engine/measure/measureContainer.js +13 -0
  152. package/dist/layout-engine/measure/measureParagraph.js +1 -2
  153. package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
  154. package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
  155. package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
  156. package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
  157. package/dist/layout-engine/tableIndentCompatibility.js +15 -0
  158. package/dist/layout-engine/types.d.ts +8 -0
  159. package/dist/layout-painter/renderPage.d.ts +29 -1
  160. package/dist/layout-painter/renderPage.js +47 -19
  161. package/dist/layout-painter/renderParagraph.d.ts +21 -2
  162. package/dist/layout-painter/renderParagraph.js +1 -1
  163. package/dist/layout-painter/renderTable.js +37 -4
  164. package/dist/managers/editorShortcuts.d.ts +33 -1
  165. package/dist/managers/editorShortcuts.js +20 -1
  166. package/dist/markdown/renderRuns.js +7 -2
  167. package/dist/markdown/renderTable.js +5 -1
  168. package/dist/model.d.ts +3 -3
  169. package/dist/model.js +2 -2
  170. package/dist/pdf/contentStream.d.ts +73 -0
  171. package/dist/pdf/contentStream.js +143 -0
  172. package/dist/pdf/fonts.d.ts +96 -0
  173. package/dist/pdf/fonts.js +593 -0
  174. package/dist/pdf/images.d.ts +28 -0
  175. package/dist/pdf/images.js +439 -0
  176. package/dist/pdf/objects.d.ts +93 -0
  177. package/dist/pdf/objects.js +237 -0
  178. package/dist/pdf/pageSpace.d.ts +43 -0
  179. package/dist/pdf/pageSpace.js +57 -0
  180. package/dist/pdf/paint.d.ts +32 -0
  181. package/dist/pdf/paint.js +442 -0
  182. package/dist/pdf/writePdf.d.ts +56 -0
  183. package/dist/pdf/writePdf.js +345 -0
  184. package/dist/prosemirror/commands/comments.js +91 -6
  185. package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
  186. package/dist/prosemirror/conversion/toProseDoc.js +41 -4
  187. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
  188. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
  189. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
  190. package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
  191. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
  192. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
  193. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
  194. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
  195. package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
  196. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
  197. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
  198. package/dist/prosemirror/plugins/suggestionMode.js +1 -0
  199. package/dist/prosemirror/revisionCarriers.js +19 -2
  200. package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
  201. package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
  202. package/dist/prosemirror/validation.js +55 -27
  203. package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
  204. package/dist/prosemirror/zeroWidthAnchors.js +23 -0
  205. package/dist/redline.js +12 -79
  206. package/dist/server.d.ts +3 -3
  207. package/dist/server.js +2 -2
  208. package/dist/shaping/placeRun.d.ts +51 -0
  209. package/dist/shaping/placeRun.js +84 -0
  210. package/dist/shaping/shaper.d.ts +92 -0
  211. package/dist/shaping/shaper.js +102 -0
  212. package/dist/types/block-id.d.ts +18 -2
  213. package/dist/types/block-id.js +49 -5
  214. package/dist/types/content.d.ts +2 -2
  215. package/dist/utils/clipboard.js +4 -3
  216. package/dist/utils/fontResolver.js +183 -0
  217. package/dist/utils/htmlComments.d.ts +17 -0
  218. package/dist/utils/htmlComments.js +22 -0
  219. package/dist/utils/scriptSegments.d.ts +12 -1
  220. package/dist/utils/scriptSegments.js +17 -1
  221. package/dist/version-comparison.d.ts +1 -1
  222. package/package.json +7 -3
@@ -0,0 +1,1059 @@
1
+ import { createFolioAITextRangeHandle, trailingBodyBlockId } from "../ai-edits/snapshot.js";
2
+ import { alignFolioBlocks } from "../version-comparison.js";
3
+ import { alignTableColumns } from "./column-alignment.js";
4
+ import { inlineFormattingSegments } from "./formatting.js";
5
+ import { panic } from "better-result";
6
+ //#region src/compare/plan.ts
7
+ /**
8
+ * Turn two block snapshots of one story into the change list a caller reads
9
+ * and the {@link FolioAIEditOperation}s that reproduce the target when every
10
+ * generated tracked change is accepted.
11
+ *
12
+ * Pure: no parsing, no serialization, no clock. Every alignment runs through
13
+ * {@link alignFolioBlocks}, shared with the version-diff and redline paths.
14
+ *
15
+ * ## Aligning by container, not by block
16
+ *
17
+ * A single alignment over every paragraph in the story cannot see structure.
18
+ * It pairs on text and document order, so it will put a paragraph inside a
19
+ * table cell opposite one outside it, or a cell of one row opposite a cell of
20
+ * the next — and rewriting either pair in place leaves the target's text in
21
+ * the wrong container. A whole row that was added or removed likewise arrives
22
+ * as a scatter of unmatched paragraphs, which no block operation can turn back
23
+ * into a row: a block insertion anchored in a table lands beside the table,
24
+ * never as a new row in it.
25
+ *
26
+ * So the story is aligned in three nested passes, each over things that can
27
+ * actually stand in for one another:
28
+ *
29
+ * 1. **Segments.** Maximal runs of body text and of one table each, so a table
30
+ * is only ever compared against a table.
31
+ * 2. **Rows**, within a paired table segment, so a whole-row change stays whole
32
+ * and becomes `insertTableRow` / `deleteTableRow`.
33
+ * 3. **Cells**, within a paired row, matched by physical cell index.
34
+ *
35
+ * ## Move detection
36
+ *
37
+ * A base-only and a target-only block with identical text and at least
38
+ * {@link MOVE_MINIMUM_WORD_COUNT} words are one relocation. The operations stay
39
+ * a delete plus an insert — the tracked-change grammar Word round-trips has no
40
+ * durable "moved from here" mark on this path — but the change list reports a
41
+ * single `move` so the relocation is not read as unrelated churn. The word
42
+ * floor keeps boilerplate one-liners from pairing as spurious moves.
43
+ */
44
+ /** Words a relocated block needs before the move pass will pair it. */
45
+ const MOVE_MINIMUM_WORD_COUNT = 3;
46
+ /**
47
+ * Cap on same-text base-only blocks the move pass keeps per text. Without it a
48
+ * document repeating one paragraph thousands of times would make the pass
49
+ * quadratic on attacker-controlled input.
50
+ */
51
+ const MAX_MOVE_CANDIDATES_PER_TEXT = 64;
52
+ /**
53
+ * How much of a relocated paragraph must survive the relocation for it still
54
+ * to read as one. Below it the two paragraphs are a deletion and an unrelated
55
+ * insertion, and calling them a move would tell the reader the wrong story
56
+ * about where the text came from.
57
+ */
58
+ const MOVE_SIMILARITY_THRESHOLD = .8;
59
+ /**
60
+ * Total pair comparisons the similarity pass may make in one story. Exact text
61
+ * matches are found by lookup; only what is left pays this, and it is capped
62
+ * so two documents of unmatched paragraphs cannot make the pass quadratic.
63
+ */
64
+ const MAX_MOVE_SIMILARITY_COMPARISONS = 2e4;
65
+ /**
66
+ * Dice coefficient over word tokens: twice the shared tokens over the two
67
+ * token counts. Multiset rather than set, so a paragraph repeating a word does
68
+ * not match one that says it once.
69
+ */
70
+ const tokenSimilarity = (left, right) => {
71
+ const leftTokens = left.split(/\s+/u).filter((token) => token.length > 0);
72
+ const rightTokens = right.split(/\s+/u).filter((token) => token.length > 0);
73
+ if (leftTokens.length === 0 || rightTokens.length === 0) return 0;
74
+ const remaining = /* @__PURE__ */ new Map();
75
+ for (const token of leftTokens) remaining.set(token, (remaining.get(token) ?? 0) + 1);
76
+ let shared = 0;
77
+ for (const token of rightTokens) {
78
+ const count = remaining.get(token) ?? 0;
79
+ if (count > 0) {
80
+ remaining.set(token, count - 1);
81
+ shared += 1;
82
+ }
83
+ }
84
+ return 2 * shared / (leftTokens.length + rightTokens.length);
85
+ };
86
+ const wordCountReaches = (text, minimum) => {
87
+ let count = 0;
88
+ for (const word of text.split(/\s+/u)) if (word.length > 0 && ++count >= minimum) return true;
89
+ return false;
90
+ };
91
+ /**
92
+ * The document's segments, normalized to strictly alternate body, table, body,
93
+ * ... and to begin and end with a body segment — inserting empty body segments
94
+ * where the document has none.
95
+ *
96
+ * Without the padding the two sides can hold different segment shapes (a
97
+ * document that opens with a table against one that opens with a heading), and
98
+ * the alignment's positional fallback would then put a body segment opposite a
99
+ * table. That pair is unusable, so the whole table would be reissued as a
100
+ * deletion plus an insertion. Padding keeps like opposite like.
101
+ */
102
+ const splitSegments = (blocks) => {
103
+ const segments = [];
104
+ let currentTableIndex = null;
105
+ for (const block of blocks) {
106
+ const tableIndex = block.table?.outerTableIndex ?? null;
107
+ const current = segments.at(-1);
108
+ if (current !== void 0 && currentTableIndex === tableIndex) {
109
+ current.blocks.push(block);
110
+ continue;
111
+ }
112
+ if (block.table && segments.at(-1)?.kind !== "body") segments.push({
113
+ kind: "body",
114
+ blocks: []
115
+ });
116
+ segments.push({
117
+ kind: block.table ? "table" : "body",
118
+ blocks: [block]
119
+ });
120
+ currentTableIndex = tableIndex;
121
+ }
122
+ if (segments.length === 0 || segments.at(-1)?.kind === "table") segments.push({
123
+ kind: "body",
124
+ blocks: []
125
+ });
126
+ return segments;
127
+ };
128
+ /**
129
+ * Blocks of one table segment grouped into rows, in document order.
130
+ *
131
+ * Keyed by table AND row, not by row alone: a segment holds the whole
132
+ * outermost table, so a nested table's first row would otherwise merge with
133
+ * its parent's first row and the alignment would compare one against the
134
+ * other.
135
+ */
136
+ const groupRows = (blocks) => {
137
+ const rows = /* @__PURE__ */ new Map();
138
+ for (const block of blocks) {
139
+ if (!block.table) continue;
140
+ const key = `${String(block.table.tableIndex)}:${String(block.table.rowIndex)}`;
141
+ const row = rows.get(key);
142
+ if (row) row.push(block);
143
+ else rows.set(key, [block]);
144
+ }
145
+ return [...rows.values()];
146
+ };
147
+ const rowText = (row) => row.map(({ text }) => text).join(" ");
148
+ const rowLocation = (row) => row.at(0)?.table ?? null;
149
+ /** Zip one paired row's cells by physical cell index, then by paragraph order. */
150
+ const alignRowCells = (baseRow, targetRow, baseColumnKeys, targetColumnKeys) => {
151
+ const byCell = (row, columnKeys) => {
152
+ const cells = /* @__PURE__ */ new Map();
153
+ for (const block of row) {
154
+ const table = block.table;
155
+ let cellIndex = table?.cellIndex ?? 0;
156
+ if (table && columnKeys) {
157
+ const alignedColumn = columnKeys.get(table.gridColumnIndex);
158
+ if (alignedColumn === void 0) return panic("A paired table cell has no aligned grid column", { gridColumnIndex: table.gridColumnIndex });
159
+ cellIndex = alignedColumn;
160
+ }
161
+ const blocks = cells.get(cellIndex);
162
+ if (blocks) blocks.push(block);
163
+ else cells.set(cellIndex, [block]);
164
+ }
165
+ return cells;
166
+ };
167
+ const baseCells = byCell(baseRow, baseColumnKeys);
168
+ const targetCells = byCell(targetRow, targetColumnKeys);
169
+ const cellIndexes = [.../* @__PURE__ */ new Set([...baseCells.keys(), ...targetCells.keys()])].toSorted((left, right) => left - right);
170
+ const steps = [];
171
+ for (const cellIndex of cellIndexes) {
172
+ const baseBlocks = baseCells.get(cellIndex) ?? [];
173
+ const targetBlocks = targetCells.get(cellIndex) ?? [];
174
+ const paired = Math.min(baseBlocks.length, targetBlocks.length);
175
+ for (let index = 0; index < paired; index++) {
176
+ const baseBlock = baseBlocks[index];
177
+ const targetBlock = targetBlocks[index];
178
+ if (baseBlock && targetBlock) steps.push({
179
+ type: "pair",
180
+ baseBlock,
181
+ targetBlock
182
+ });
183
+ }
184
+ for (const block of baseBlocks.slice(paired)) steps.push({
185
+ type: "baseOnly",
186
+ block
187
+ });
188
+ for (const block of targetBlocks.slice(paired)) steps.push({
189
+ type: "targetOnly",
190
+ block
191
+ });
192
+ }
193
+ return steps;
194
+ };
195
+ /**
196
+ * Align one table's rows, then its cells.
197
+ *
198
+ * Running the block alignment straight over a table's paragraphs cannot see
199
+ * rows: it happily pairs a cell of one row with a cell of another, and a row
200
+ * that was wholly added or removed shows up as a scatter of unmatched
201
+ * paragraphs. Aligning rows first — each row standing in as one proxy block of
202
+ * its joined cell text — keeps a whole-row change whole, and confines every
203
+ * other difference to a cell that really corresponds.
204
+ */
205
+ /**
206
+ * One segment's blocks split per table, in first-appearance order. A segment
207
+ * is a whole outermost table, so it holds the parent's blocks and every nested
208
+ * table's; a row alignment that mixed them would compare the parent's first
209
+ * row against a nested table's.
210
+ */
211
+ const groupTables = (blocks) => {
212
+ const tables = /* @__PURE__ */ new Map();
213
+ for (const block of blocks) {
214
+ if (!block.table) continue;
215
+ const existing = tables.get(block.table.tableIndex);
216
+ if (existing) existing.push(block);
217
+ else tables.set(block.table.tableIndex, [block]);
218
+ }
219
+ return [...tables.values()];
220
+ };
221
+ /**
222
+ * Align a paired table segment: each table in it against the table at the same
223
+ * place on the other side, then that table's rows, then its cells. Tables are
224
+ * paired by order within the segment because the segment IS one table plus
225
+ * whatever nests inside it — the nth nested table of one answers to the nth of
226
+ * the other.
227
+ */
228
+ const buildTableSegmentSteps = (baseBlocks, targetBlocks) => {
229
+ const baseTables = groupTables(baseBlocks);
230
+ const targetTables = groupTables(targetBlocks);
231
+ const steps = [];
232
+ const paired = Math.min(baseTables.length, targetTables.length);
233
+ for (let index = 0; index < paired; index++) steps.push(...buildTableSteps(baseTables[index] ?? [], targetTables[index] ?? []));
234
+ for (const blocks of baseTables.slice(paired)) {
235
+ const location = blocks.at(0)?.table;
236
+ if (location) steps.push({
237
+ type: "baseTable",
238
+ blocks,
239
+ location
240
+ });
241
+ }
242
+ for (const blocks of targetTables.slice(paired)) {
243
+ const location = blocks.at(0)?.table;
244
+ if (location) steps.push({
245
+ type: "targetTable",
246
+ blocks,
247
+ location
248
+ });
249
+ }
250
+ return steps;
251
+ };
252
+ /**
253
+ * How alike two rows must be for one to be read as the other, edited. Below
254
+ * it the pair is a deleted row and an inserted one.
255
+ */
256
+ const ROW_PAIR_SIMILARITY = .5;
257
+ /**
258
+ * How alike two rows are: their text, halved when their shapes differ.
259
+ *
260
+ * A row's shape is its physical cell count. Two rows with the same words in a
261
+ * different number of cells are not the same row edited, and pairing them
262
+ * would report every cell as changed rather than the row as replaced.
263
+ */
264
+ const rowSimilarity = (base, target) => {
265
+ if (!base || !target) return 0;
266
+ const text = tokenSimilarity(rowText(base), rowText(target));
267
+ return rowCellTexts(base).length === rowCellTexts(target).length ? text : text / 2;
268
+ };
269
+ /**
270
+ * Align one table's rows.
271
+ *
272
+ * Exact text first, then SIMILARITY rather than position. Positional fallback
273
+ * is what made a deleted row plus a few cell edits report as a change in every
274
+ * row of the table: each row was paired with the one below it, so every cell
275
+ * differed. Pairing on similarity, and stepping one row on the side whose next
276
+ * row matches better, keeps the deletion where it happened.
277
+ */
278
+ const alignTableRows = (baseRows, targetRows, baseColumnKeys, targetColumnKeys) => {
279
+ const steps = [];
280
+ const pushRow = (row, side) => {
281
+ const location = rowLocation(row);
282
+ if (location) steps.push({
283
+ type: side === "base" ? "baseRow" : "targetRow",
284
+ blocks: row,
285
+ location
286
+ });
287
+ };
288
+ let baseCursor = 0;
289
+ let targetCursor = 0;
290
+ while (baseCursor < baseRows.length && targetCursor < targetRows.length) {
291
+ const baseRow = baseRows[baseCursor];
292
+ const targetRow = targetRows[targetCursor];
293
+ if (!baseRow || !targetRow) break;
294
+ const here = rowSimilarity(baseRow, targetRow);
295
+ if (here < 1) {
296
+ const baseAhead = rowSimilarity(baseRows[baseCursor + 1], targetRow);
297
+ const targetAhead = rowSimilarity(baseRow, targetRows[targetCursor + 1]);
298
+ if (baseAhead >= ROW_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
299
+ pushRow(baseRow, "base");
300
+ baseCursor += 1;
301
+ continue;
302
+ }
303
+ if (targetAhead >= ROW_PAIR_SIMILARITY && targetAhead > here) {
304
+ pushRow(targetRow, "target");
305
+ targetCursor += 1;
306
+ continue;
307
+ }
308
+ }
309
+ steps.push(...alignRowCells(baseRow, targetRow, baseColumnKeys, targetColumnKeys));
310
+ baseCursor += 1;
311
+ targetCursor += 1;
312
+ }
313
+ for (const row of baseRows.slice(baseCursor)) pushRow(row, "base");
314
+ for (const row of targetRows.slice(targetCursor)) pushRow(row, "target");
315
+ return steps;
316
+ };
317
+ const buildTableSteps = (baseBlocks, targetBlocks) => {
318
+ const columns = alignTableColumns(baseBlocks, targetBlocks);
319
+ return columns ? [...columns.steps, ...alignTableRows(groupRows(columns.baseBlocks), groupRows(columns.targetBlocks), columns.baseColumnKeys, columns.targetColumnKeys)] : alignTableRows(groupRows(baseBlocks), groupRows(targetBlocks));
320
+ };
321
+ const buildBodySteps = (baseBlocks, targetBlocks) => alignFolioBlocks(baseBlocks, targetBlocks).map((event) => {
322
+ switch (event.type) {
323
+ case "pair": return {
324
+ type: "pair",
325
+ baseBlock: event.baseBlock,
326
+ targetBlock: event.revisedBlock
327
+ };
328
+ case "baseOnly": return {
329
+ type: "baseOnly",
330
+ block: event.block
331
+ };
332
+ case "revisedOnly": return {
333
+ type: "targetOnly",
334
+ block: event.block
335
+ };
336
+ default: return panic("Unhandled block alignment event", { event });
337
+ }
338
+ });
339
+ /**
340
+ * Every block of an unpaired segment, as one-sided steps. A whole table stays
341
+ * whole: reissuing it row by row would need a table to put the rows in, and
342
+ * the point of an unpaired table segment is that there is none.
343
+ */
344
+ const unpairedSegmentSteps = (segment, side) => {
345
+ if (segment.kind !== "table") return segment.blocks.map((block) => side === "base" ? {
346
+ type: "baseOnly",
347
+ block
348
+ } : {
349
+ type: "targetOnly",
350
+ block
351
+ });
352
+ const location = segment.blocks.at(0)?.table;
353
+ if (!location) return [];
354
+ return [{
355
+ type: side === "base" ? "baseTable" : "targetTable",
356
+ blocks: segment.blocks,
357
+ location
358
+ }];
359
+ };
360
+ /**
361
+ * Align the two stories segment by segment, in order.
362
+ *
363
+ * Segments are paired by position rather than by text. Both sides have been
364
+ * normalized to the same alternating shape, so position already carries the
365
+ * meaning: the nth table of one document answers to the nth table of the
366
+ * other, and the body text between two tables answers to the body text between
367
+ * the same two tables. Matching segments on text instead lets a paragraph that
368
+ * moved across a table steal the table's own pairing, which reissues the whole
369
+ * table as a deletion and an insertion.
370
+ *
371
+ * When the two documents hold different numbers of tables the shapes diverge,
372
+ * and the surplus segments are reported one-sided. The operation builder turns
373
+ * those segments into `insertTable` or `deleteTable` operations.
374
+ */
375
+ const segmentText = (segment) => segment.blocks.map(({ text }) => text).join(" ");
376
+ /**
377
+ * How alike two table segments must be before a lookahead match may steal the
378
+ * pairing from the table in front of it. Two tables drawn from one document's
379
+ * vocabulary score alike by chance, so a lookahead has to clear this bar as
380
+ * well as beat what it displaces.
381
+ */
382
+ const TABLE_PAIR_SIMILARITY = .5;
383
+ /**
384
+ * Pair the two segment sequences, marking the segments only one side has.
385
+ *
386
+ * Pairing by index cannot see a table added or removed: the nth table of one
387
+ * document is put opposite the nth of the other, so deleting the first table
388
+ * shifts every later one and the comparison rewrites each table's contents
389
+ * into the next table along. Segments strictly alternate body, table, body,
390
+ * ..., so a one-segment lookahead on each side is enough to tell "this table
391
+ * changed a lot" from "this table is gone": the next table on the other side
392
+ * matching better is what says the current one is unpaired.
393
+ */
394
+ const alignSegments = (baseSegments, targetSegments) => {
395
+ const paired = [];
396
+ const similarity = (left, right) => left === void 0 || right === void 0 || left.kind !== right.kind ? 0 : tokenSimilarity(segmentText(left), segmentText(right));
397
+ let baseCursor = 0;
398
+ let targetCursor = 0;
399
+ while (baseCursor < baseSegments.length && targetCursor < targetSegments.length) {
400
+ const baseSegment = baseSegments[baseCursor];
401
+ const targetSegment = targetSegments[targetCursor];
402
+ if (!baseSegment || !targetSegment) break;
403
+ if (baseSegment.kind !== targetSegment.kind) {
404
+ paired.push({
405
+ baseSegment,
406
+ targetSegment: null
407
+ });
408
+ baseCursor += 1;
409
+ continue;
410
+ }
411
+ if (baseSegment.kind === "body") {
412
+ paired.push({
413
+ baseSegment,
414
+ targetSegment
415
+ });
416
+ baseCursor += 1;
417
+ targetCursor += 1;
418
+ continue;
419
+ }
420
+ const here = similarity(baseSegment, targetSegment);
421
+ const baseAhead = similarity(baseSegments[baseCursor + 2], targetSegment);
422
+ const targetAhead = similarity(baseSegment, targetSegments[targetCursor + 2]);
423
+ if (baseAhead >= TABLE_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
424
+ paired.push({
425
+ baseSegment,
426
+ targetSegment: null
427
+ });
428
+ baseCursor += 1;
429
+ continue;
430
+ }
431
+ if (targetAhead >= TABLE_PAIR_SIMILARITY && targetAhead > here) {
432
+ paired.push({
433
+ baseSegment: null,
434
+ targetSegment
435
+ });
436
+ targetCursor += 1;
437
+ continue;
438
+ }
439
+ paired.push({
440
+ baseSegment,
441
+ targetSegment
442
+ });
443
+ baseCursor += 1;
444
+ targetCursor += 1;
445
+ }
446
+ for (const segment of baseSegments.slice(baseCursor)) paired.push({
447
+ baseSegment: segment,
448
+ targetSegment: null
449
+ });
450
+ for (const segment of targetSegments.slice(targetCursor)) paired.push({
451
+ baseSegment: null,
452
+ targetSegment: segment
453
+ });
454
+ return paired;
455
+ };
456
+ const isEmptyParagraphNode = (block, snapshot) => {
457
+ const anchor = snapshot.anchors[block.id] ?? panic("A comparison snapshot block has no matching anchor", { blockId: block.id });
458
+ return block.text === "" && block.table === void 0 && anchor.to - anchor.from === 2;
459
+ };
460
+ const buildSteps = ({ story, baseSnapshot, targetSnapshot }) => {
461
+ const baseBlocks = baseSnapshot.blocks;
462
+ const targetBlocks = targetSnapshot.blocks;
463
+ const baseLast = baseBlocks.at(-1);
464
+ const targetLast = targetBlocks.at(-1);
465
+ const terminalCarrierPair = story.type === "main" && baseLast !== void 0 && isEmptyParagraphNode(baseLast, baseSnapshot) && targetLast !== void 0 && isEmptyParagraphNode(targetLast, targetSnapshot) ? {
466
+ type: "pair",
467
+ baseBlock: baseLast,
468
+ targetBlock: targetLast
469
+ } : null;
470
+ const alignedBaseBlocks = terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks;
471
+ const alignedTargetBlocks = terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks;
472
+ const steps = [];
473
+ for (const { baseSegment, targetSegment } of alignSegments(splitSegments(alignedBaseBlocks), splitSegments(alignedTargetBlocks))) {
474
+ if (baseSegment && targetSegment) {
475
+ steps.push(...baseSegment.kind === "table" ? buildTableSegmentSteps(baseSegment.blocks, targetSegment.blocks) : buildBodySteps(baseSegment.blocks, targetSegment.blocks));
476
+ continue;
477
+ }
478
+ if (baseSegment) {
479
+ steps.push(...unpairedSegmentSteps(baseSegment, "base"));
480
+ continue;
481
+ }
482
+ if (targetSegment) steps.push(...unpairedSegmentSteps(targetSegment, "target"));
483
+ }
484
+ if (terminalCarrierPair) steps.push(terminalCarrierPair);
485
+ return steps;
486
+ };
487
+ /**
488
+ * Two blocks are in the same container when a paragraph mark between them
489
+ * exists at all: two body paragraphs, or two paragraphs of one table cell. A
490
+ * mark cannot span a cell boundary, so a split or a merge across one is not a
491
+ * paragraph-mark edit however similar the text looks.
492
+ */
493
+ const shareAContainer = (left, right) => {
494
+ if (!left.table || !right.table) return left.table === void 0 && right.table === void 0;
495
+ return left.table.tableIndex === right.table.tableIndex && left.table.rowIndex === right.table.rowIndex && left.table.cellIndex === right.table.cellIndex;
496
+ };
497
+ /**
498
+ * The text between `head` and `tail` inside `whole`, when `whole` is exactly
499
+ * the two joined by whitespace (or by nothing). `null` when it is not: any
500
+ * other difference is a rewrite, not a moved paragraph mark.
501
+ */
502
+ const separatorBetween = (whole, head, tail) => {
503
+ if (head.length === 0 || tail.length === 0 || whole.length < head.length + tail.length) return null;
504
+ if (!whole.startsWith(head) || !whole.endsWith(tail)) return null;
505
+ const separator = whole.slice(head.length, whole.length - tail.length);
506
+ return separator.length === 0 || /^\s+$/u.test(separator) ? separator : null;
507
+ };
508
+ /**
509
+ * Where the alignment produced a rewrite plus an insertion or a deletion that
510
+ * is really one paragraph mark moving, by step index of the PAIR step. The
511
+ * step after it is consumed with it.
512
+ *
513
+ * The alignment cannot see this: it pairs the base paragraph with the target
514
+ * half that still matches it and leaves the other half unpaired, which is a
515
+ * correct alignment and a misleading redline.
516
+ */
517
+ const detectParagraphMarkEdits = (steps) => {
518
+ const plans = /* @__PURE__ */ new Map();
519
+ for (const [index, step] of steps.entries()) {
520
+ const next = steps[index + 1];
521
+ if (step.type !== "pair" || next === void 0) continue;
522
+ if (next.type === "targetOnly") {
523
+ const separator = separatorBetween(step.baseBlock.text, step.targetBlock.text, next.block.text);
524
+ if (separator !== null && shareAContainer(step.targetBlock, next.block)) plans.set(index, {
525
+ type: "split",
526
+ baseBlock: step.baseBlock,
527
+ targetBlocks: [step.targetBlock, next.block],
528
+ offset: step.targetBlock.text.length,
529
+ separator
530
+ });
531
+ continue;
532
+ }
533
+ if (next.type !== "baseOnly") continue;
534
+ const separator = separatorBetween(step.targetBlock.text, step.baseBlock.text, next.block.text);
535
+ if (separator !== null && shareAContainer(step.baseBlock, next.block)) plans.set(index, {
536
+ type: "merge",
537
+ baseBlocks: [step.baseBlock, next.block],
538
+ targetBlock: step.targetBlock,
539
+ separator
540
+ });
541
+ }
542
+ return plans;
543
+ };
544
+ /** Base block id -> target block id for every relocation the move pass found. */
545
+ const detectMoves = (steps, consumed) => {
546
+ const candidatesByText = /* @__PURE__ */ new Map();
547
+ for (const [index, step] of steps.entries()) {
548
+ if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
549
+ const queue = candidatesByText.get(step.block.text);
550
+ if (!queue) {
551
+ candidatesByText.set(step.block.text, [step.block.id]);
552
+ continue;
553
+ }
554
+ if (queue.length < MAX_MOVE_CANDIDATES_PER_TEXT) queue.push(step.block.id);
555
+ }
556
+ const unmatched = [];
557
+ for (const [index, step] of steps.entries()) {
558
+ if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
559
+ unmatched.push(step.block);
560
+ }
561
+ const movesByBaseBlockId = /* @__PURE__ */ new Map();
562
+ const takenBaseBlockIds = /* @__PURE__ */ new Set();
563
+ let comparisonBudget = MAX_MOVE_SIMILARITY_COMPARISONS;
564
+ for (const [index, step] of steps.entries()) {
565
+ if (consumed.has(index) || step.type !== "targetOnly") continue;
566
+ const exact = candidatesByText.get(step.block.text)?.shift();
567
+ if (exact !== void 0) {
568
+ takenBaseBlockIds.add(exact);
569
+ movesByBaseBlockId.set(exact, step.block.id);
570
+ continue;
571
+ }
572
+ if (!wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
573
+ let best = null;
574
+ for (const candidate of unmatched) {
575
+ if (comparisonBudget <= 0) break;
576
+ if (takenBaseBlockIds.has(candidate.id)) continue;
577
+ comparisonBudget -= 1;
578
+ const similarity = tokenSimilarity(candidate.text, step.block.text);
579
+ if (similarity >= MOVE_SIMILARITY_THRESHOLD && (best === null || similarity > best.similarity)) best = {
580
+ block: candidate,
581
+ similarity
582
+ };
583
+ }
584
+ if (best) {
585
+ takenBaseBlockIds.add(best.block.id);
586
+ movesByBaseBlockId.set(best.block.id, step.block.id);
587
+ }
588
+ }
589
+ return movesByBaseBlockId;
590
+ };
591
+ /**
592
+ * For each step, the id of the next base block at or after it — the anchor a
593
+ * target-only insertion is placed before. `null` once no base block follows.
594
+ */
595
+ const nextBaseBlockIdByStep = (steps) => {
596
+ const anchors = Array.from({ length: steps.length });
597
+ let next = null;
598
+ for (let index = steps.length - 1; index >= 0; index--) {
599
+ anchors[index] = next;
600
+ const step = steps[index];
601
+ if (step?.type === "pair") next = step.baseBlock.id;
602
+ else if (step?.type === "baseOnly") next = step.block.id;
603
+ else if (step?.type === "baseRow") next = step.blocks[0]?.id ?? next;
604
+ else if (step?.type === "baseColumn") next = step.blocks[0]?.id ?? next;
605
+ }
606
+ return anchors;
607
+ };
608
+ const baseTableBlockOf = (step) => {
609
+ switch (step.type) {
610
+ case "pair": return step.baseBlock.table ? step.baseBlock : null;
611
+ case "baseOnly": return step.block.table ? step.block : null;
612
+ case "baseRow":
613
+ case "baseTable":
614
+ case "baseColumn": return step.blocks[0] ?? null;
615
+ case "targetOnly":
616
+ case "targetRow":
617
+ case "targetTable":
618
+ case "targetColumn": return null;
619
+ default: return panic("Unhandled compare step", { step });
620
+ }
621
+ };
622
+ /**
623
+ * The base-document row a new row is inserted next to: the nearest base block
624
+ * inside a table, preferring the one before the insertion so a run of new rows
625
+ * keeps its order.
626
+ */
627
+ const findRowAnchor = (steps, stepIndex) => {
628
+ for (let index = stepIndex - 1; index >= 0; index--) {
629
+ const step = steps[index];
630
+ const block = step ? baseTableBlockOf(step) : null;
631
+ if (block) return {
632
+ blockId: block.id,
633
+ position: "after"
634
+ };
635
+ }
636
+ for (let index = stepIndex + 1; index < steps.length; index++) {
637
+ const step = steps[index];
638
+ const block = step ? baseTableBlockOf(step) : null;
639
+ if (block) return {
640
+ blockId: block.id,
641
+ position: "before"
642
+ };
643
+ }
644
+ return null;
645
+ };
646
+ /**
647
+ * A row's text per physical cell, indexed BY cell so an empty cell keeps its
648
+ * slot. An empty cell carries no block at all, so packing only the cells that
649
+ * have text would shift every later cell one column left.
650
+ */
651
+ /** One table's cell texts, row by row, for a whole-table change. */
652
+ /**
653
+ * One table's cell texts, row by row, padded to the widest row.
654
+ *
655
+ * A row's own width is its highest occupied cell index, so a table with
656
+ * merged cells or a short last row produces a ragged grid — and a ragged grid
657
+ * is not a table any consumer can lay out. Padding states the grid the table
658
+ * actually occupies; the empty strings are the cells a `w:gridSpan` covers.
659
+ */
660
+ const tableCellTexts = (blocks) => {
661
+ const rows = groupRows(blocks).map((row) => rowCellTexts(row));
662
+ let width = 0;
663
+ for (const row of rows) width = Math.max(width, row.length);
664
+ for (const row of rows) while (row.length < width) row.push("");
665
+ return rows;
666
+ };
667
+ const rowCellTexts = (blocks) => {
668
+ const byCell = [];
669
+ for (const block of blocks) {
670
+ const cellIndex = block.table?.cellIndex ?? 0;
671
+ const existing = byCell[cellIndex];
672
+ byCell[cellIndex] = existing === void 0 ? block.text : `${existing}\n${block.text}`;
673
+ }
674
+ return Array.from(byCell, (text) => text ?? "");
675
+ };
676
+ const columnCellTexts = (blocks) => {
677
+ const byCell = /* @__PURE__ */ new Map();
678
+ for (const block of blocks) {
679
+ const table = block.table;
680
+ if (!table) continue;
681
+ const key = `${String(table.rowIndex)}:${String(table.cellIndex)}`;
682
+ const existing = byCell.get(key);
683
+ byCell.set(key, existing === void 0 ? block.text : `${existing}\n${block.text}`);
684
+ }
685
+ return [...byCell.values()];
686
+ };
687
+ /**
688
+ * The paragraph properties that differ, or `null` when they agree. Only the
689
+ * ones a block projection can see and an operation can set: a list level and
690
+ * a paragraph style, the two edits that move no words and are invisible in a
691
+ * text diff.
692
+ */
693
+ const changedParagraphProperties = (baseBlock, targetBlock) => {
694
+ const properties = {};
695
+ if ((baseBlock.styleId ?? null) !== (targetBlock.styleId ?? null)) properties.styleId = targetBlock.styleId ?? null;
696
+ if (baseBlock.listLevel !== targetBlock.listLevel) properties.listLevel = targetBlock.listLevel ?? null;
697
+ return Object.keys(properties).length > 0 ? properties : null;
698
+ };
699
+ const locationOf = (story, block) => block.table ? {
700
+ story,
701
+ cell: block.table
702
+ } : { story };
703
+ /**
704
+ * Plan one story's comparison, or `null` when it needs more operations than
705
+ * `maxOperations`.
706
+ */
707
+ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }) => {
708
+ const steps = buildSteps({
709
+ story,
710
+ baseSnapshot,
711
+ targetSnapshot
712
+ });
713
+ const paragraphMarkPlans = detectParagraphMarkEdits(steps);
714
+ const consumedSteps = new Set([...paragraphMarkPlans.keys()].map((index) => index + 1));
715
+ const movesByBaseBlockId = detectMoves(steps, consumedSteps);
716
+ const moveSourceByTargetBlockId = /* @__PURE__ */ new Map();
717
+ for (const [baseBlockId, targetBlockId] of movesByBaseBlockId) moveSourceByTargetBlockId.set(targetBlockId, baseBlockId);
718
+ const anchorIds = nextBaseBlockIdByStep(steps);
719
+ const changes = [];
720
+ const operations = [];
721
+ /**
722
+ * The anchor everything past the base document's content hangs from: its
723
+ * last BODY-LEVEL paragraph, which the format guarantees exists because a
724
+ * table may not be the last child of a body. Anchoring to the last block
725
+ * instead put the anchor inside a table whenever the story ended with one,
726
+ * and an insertion anchored there escapes to the table's boundary, where no
727
+ * paragraph mark can express the break it added.
728
+ *
729
+ * The applier orders insertions that resolve to one position by their order
730
+ * in this array, so the tail is emitted where its step sits rather than
731
+ * collected and appended — a table and a paragraph both added after the last
732
+ * base block otherwise come out in operation order, which is not target
733
+ * order.
734
+ */
735
+ const tailAnchorId = trailingBodyBlockId(baseSnapshot);
736
+ let operationSequence = 0;
737
+ const nextOperationId = () => `compare-${++operationSequence}`;
738
+ /**
739
+ * The relocation this block belongs to, named so the applier can link the
740
+ * deletion at the source with the insertion at the destination as
741
+ * `w:moveFrom` and `w:moveTo` instead of writing two unrelated revisions.
742
+ */
743
+ const moveIdOf = (baseBlockId) => `move-${baseBlockId}`;
744
+ const pushInsertOperation = (block, anchorId) => {
745
+ const moveSourceId = moveSourceByTargetBlockId.get(block.id);
746
+ const shared = {
747
+ text: block.text,
748
+ ...moveSourceId !== void 0 && { moveId: moveIdOf(moveSourceId) },
749
+ styleId: block.styleId ?? null,
750
+ listLevel: block.listLevel ?? null
751
+ };
752
+ if (anchorId !== null) {
753
+ operations.push({
754
+ id: nextOperationId(),
755
+ type: "insertBeforeBlock",
756
+ blockId: anchorId,
757
+ ...shared
758
+ });
759
+ return;
760
+ }
761
+ if (tailAnchorId === null) return;
762
+ operations.push({
763
+ id: nextOperationId(),
764
+ type: "insertAfterBlock",
765
+ blockId: tailAnchorId,
766
+ ...shared
767
+ });
768
+ };
769
+ for (const [stepIndex, step] of steps.entries()) {
770
+ if (consumedSteps.has(stepIndex)) continue;
771
+ const paragraphMarkPlan = paragraphMarkPlans.get(stepIndex);
772
+ if (paragraphMarkPlan?.type === "split") {
773
+ const { baseBlock, targetBlocks: splitInto, offset, separator } = paragraphMarkPlan;
774
+ changes.push({
775
+ kind: "split",
776
+ location: locationOf(story, baseBlock),
777
+ baseBlockId: baseBlock.id,
778
+ targetBlockIds: splitInto.map(({ id }) => id),
779
+ text: baseBlock.text
780
+ });
781
+ operations.push({
782
+ id: nextOperationId(),
783
+ type: "splitBlock",
784
+ blockId: baseBlock.id,
785
+ offset,
786
+ ...separator.length > 0 && { separator }
787
+ });
788
+ continue;
789
+ }
790
+ if (paragraphMarkPlan?.type === "merge") {
791
+ const { baseBlocks, targetBlock, separator } = paragraphMarkPlan;
792
+ changes.push({
793
+ kind: "merge",
794
+ location: locationOf(story, baseBlocks[0]),
795
+ baseBlockIds: baseBlocks.map(({ id }) => id),
796
+ targetBlockId: targetBlock.id,
797
+ text: targetBlock.text
798
+ });
799
+ operations.push({
800
+ id: nextOperationId(),
801
+ type: "mergeBlockWithNext",
802
+ blockId: baseBlocks[0].id,
803
+ ...separator.length > 0 && { separator }
804
+ });
805
+ continue;
806
+ }
807
+ switch (step.type) {
808
+ case "pair": {
809
+ const { baseBlock, targetBlock } = step;
810
+ const properties = changedParagraphProperties(baseBlock, targetBlock);
811
+ if (properties) {
812
+ changes.push({
813
+ kind: "paragraph-format",
814
+ location: locationOf(story, baseBlock),
815
+ baseBlockId: baseBlock.id,
816
+ targetBlockId: targetBlock.id,
817
+ properties
818
+ });
819
+ operations.push({
820
+ id: nextOperationId(),
821
+ type: "setBlockParagraphProperties",
822
+ blockId: baseBlock.id,
823
+ properties
824
+ });
825
+ }
826
+ if (baseBlock.text !== targetBlock.text) {
827
+ changes.push({
828
+ kind: "replace",
829
+ location: locationOf(story, baseBlock),
830
+ baseBlockId: baseBlock.id,
831
+ targetBlockId: targetBlock.id,
832
+ before: baseBlock.text,
833
+ after: targetBlock.text
834
+ });
835
+ operations.push({
836
+ id: nextOperationId(),
837
+ type: "replaceBlock",
838
+ blockId: baseBlock.id,
839
+ text: targetBlock.text
840
+ });
841
+ break;
842
+ }
843
+ const segments = inlineFormattingSegments({
844
+ baseBlock,
845
+ targetBlock,
846
+ maxSegments: maxOperations
847
+ });
848
+ if (segments === null) return null;
849
+ if (segments.length === 0) break;
850
+ changes.push({
851
+ kind: "format",
852
+ location: locationOf(story, baseBlock),
853
+ baseBlockId: baseBlock.id,
854
+ targetBlockId: targetBlock.id,
855
+ text: baseBlock.text,
856
+ ranges: segments
857
+ });
858
+ for (const { startOffset, endOffset, formatting } of segments) {
859
+ const range = createFolioAITextRangeHandle({
860
+ blockId: baseBlock.id,
861
+ text: baseBlock.text,
862
+ startOffset,
863
+ endOffset
864
+ });
865
+ if (!range) panic("An aligned formatting range could not be represented");
866
+ operations.push({
867
+ id: nextOperationId(),
868
+ type: "formatRange",
869
+ range,
870
+ formatting
871
+ });
872
+ }
873
+ break;
874
+ }
875
+ case "baseOnly": {
876
+ const targetBlockId = movesByBaseBlockId.get(step.block.id);
877
+ if (targetBlockId === void 0) changes.push({
878
+ kind: "delete",
879
+ location: locationOf(story, step.block),
880
+ baseBlockId: step.block.id,
881
+ before: step.block.text
882
+ });
883
+ operations.push({
884
+ id: nextOperationId(),
885
+ type: "deleteBlock",
886
+ blockId: step.block.id,
887
+ ...targetBlockId !== void 0 && { moveId: moveIdOf(step.block.id) }
888
+ });
889
+ break;
890
+ }
891
+ case "targetOnly": {
892
+ const baseBlockId = moveSourceByTargetBlockId.get(step.block.id);
893
+ changes.push(baseBlockId === void 0 ? {
894
+ kind: "insert",
895
+ location: locationOf(story, step.block),
896
+ targetBlockId: step.block.id,
897
+ after: step.block.text
898
+ } : {
899
+ kind: "move",
900
+ location: locationOf(story, step.block),
901
+ baseBlockId,
902
+ targetBlockId: step.block.id,
903
+ text: step.block.text
904
+ });
905
+ pushInsertOperation(step.block, anchorIds[stepIndex] ?? null);
906
+ break;
907
+ }
908
+ case "baseRow": {
909
+ const anchorBlockId = step.blocks[0]?.id;
910
+ if (anchorBlockId === void 0) panic("A collapsed table row carried no blocks");
911
+ changes.push({
912
+ kind: "table-row-delete",
913
+ location: {
914
+ story,
915
+ cell: step.location
916
+ },
917
+ tableIndex: step.location.tableIndex,
918
+ rowIndex: step.location.rowIndex,
919
+ cells: rowCellTexts(step.blocks),
920
+ baseBlockIds: step.blocks.map(({ id }) => id)
921
+ });
922
+ operations.push({
923
+ id: nextOperationId(),
924
+ type: "deleteTableRow",
925
+ blockId: anchorBlockId
926
+ });
927
+ break;
928
+ }
929
+ case "baseColumn": {
930
+ const anchorBlockId = step.blocks.at(0)?.id;
931
+ if (anchorBlockId === void 0) panic("A table column carried no blocks");
932
+ changes.push({
933
+ kind: "table-column-delete",
934
+ location: {
935
+ story,
936
+ cell: step.location
937
+ },
938
+ tableIndex: step.location.tableIndex,
939
+ columnIndex: step.columnIndex,
940
+ cells: columnCellTexts(step.blocks),
941
+ baseBlockIds: step.blocks.map(({ id }) => id)
942
+ });
943
+ operations.push({
944
+ id: nextOperationId(),
945
+ type: "deleteTableColumn",
946
+ blockId: anchorBlockId
947
+ });
948
+ break;
949
+ }
950
+ case "baseTable": {
951
+ const anchorBlockId = step.blocks[0]?.id;
952
+ if (anchorBlockId === void 0) panic("An unpaired table segment carried no blocks");
953
+ changes.push({
954
+ kind: "table-delete",
955
+ location: {
956
+ story,
957
+ cell: step.location
958
+ },
959
+ tableIndex: step.location.tableIndex,
960
+ rows: tableCellTexts(step.blocks),
961
+ baseBlockIds: step.blocks.map(({ id }) => id)
962
+ });
963
+ operations.push({
964
+ id: nextOperationId(),
965
+ type: "deleteTable",
966
+ blockId: anchorBlockId
967
+ });
968
+ break;
969
+ }
970
+ case "targetTable": {
971
+ const rows = tableCellTexts(step.blocks);
972
+ changes.push({
973
+ kind: "table-insert",
974
+ location: {
975
+ story,
976
+ cell: step.location
977
+ },
978
+ tableIndex: step.location.tableIndex,
979
+ rows,
980
+ targetBlockIds: step.blocks.map(({ id }) => id)
981
+ });
982
+ const before = anchorIds[stepIndex] ?? null;
983
+ if (before !== null) {
984
+ operations.push({
985
+ id: nextOperationId(),
986
+ type: "insertTable",
987
+ blockId: before,
988
+ position: "before",
989
+ rows
990
+ });
991
+ break;
992
+ }
993
+ if (tailAnchorId !== null) operations.push({
994
+ id: nextOperationId(),
995
+ type: "insertTable",
996
+ blockId: tailAnchorId,
997
+ position: "after",
998
+ rows
999
+ });
1000
+ break;
1001
+ }
1002
+ case "targetRow": {
1003
+ const anchor = findRowAnchor(steps, stepIndex);
1004
+ const cells = rowCellTexts(step.blocks);
1005
+ changes.push({
1006
+ kind: "table-row-insert",
1007
+ location: {
1008
+ story,
1009
+ cell: step.location
1010
+ },
1011
+ tableIndex: step.location.tableIndex,
1012
+ rowIndex: step.location.rowIndex,
1013
+ cells,
1014
+ targetBlockIds: step.blocks.map(({ id }) => id)
1015
+ });
1016
+ if (anchor === null) {
1017
+ for (const block of step.blocks) pushInsertOperation(block, anchorIds[stepIndex] ?? null);
1018
+ break;
1019
+ }
1020
+ operations.push({
1021
+ id: nextOperationId(),
1022
+ type: "insertTableRow",
1023
+ blockId: anchor.blockId,
1024
+ position: anchor.position,
1025
+ cellTexts: cells
1026
+ });
1027
+ break;
1028
+ }
1029
+ case "targetColumn":
1030
+ changes.push({
1031
+ kind: "table-column-insert",
1032
+ location: {
1033
+ story,
1034
+ cell: step.location
1035
+ },
1036
+ tableIndex: step.location.tableIndex,
1037
+ columnIndex: step.columnIndex,
1038
+ cells: columnCellTexts(step.blocks),
1039
+ targetBlockIds: step.blocks.map(({ id }) => id)
1040
+ });
1041
+ operations.push({
1042
+ id: nextOperationId(),
1043
+ type: "insertTableColumn",
1044
+ blockId: step.anchor.blockId,
1045
+ position: step.anchor.position,
1046
+ cellTexts: columnCellTexts(step.blocks)
1047
+ });
1048
+ break;
1049
+ default: panic("Unhandled compare step", { step });
1050
+ }
1051
+ if (operations.length > maxOperations) return null;
1052
+ }
1053
+ return operations.length > maxOperations ? null : {
1054
+ changes,
1055
+ operations
1056
+ };
1057
+ };
1058
+ //#endregion
1059
+ export { planStoryCompare };