@stll/folio-core 0.35.1 → 0.37.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 (236) hide show
  1. package/README.md +59 -1
  2. package/dist/ai-edits/apply.js +878 -377
  3. package/dist/ai-edits/block-identity.d.ts +4 -1
  4. package/dist/ai-edits/block-identity.js +4 -1
  5. package/dist/ai-edits/blockRange.js +14 -14
  6. package/dist/ai-edits/clean-text.d.ts +40 -1
  7. package/dist/ai-edits/clean-text.js +52 -2
  8. package/dist/ai-edits/headless.d.ts +3 -1
  9. package/dist/ai-edits/headless.js +141 -73
  10. package/dist/ai-edits/index.d.ts +3 -3
  11. package/dist/ai-edits/index.js +2 -2
  12. package/dist/ai-edits/read.js +38 -20
  13. package/dist/ai-edits/snapshot.d.ts +14 -6
  14. package/dist/ai-edits/snapshot.js +105 -20
  15. package/dist/ai-edits/table-cell-mutations.js +1 -1
  16. package/dist/ai-edits/table-row-column-mutations.js +7 -0
  17. package/dist/ai-edits/table-template.d.ts +11 -1
  18. package/dist/ai-edits/table-template.js +49 -17
  19. package/dist/ai-edits/types.d.ts +62 -61
  20. package/dist/ai-edits/word-diff.d.ts +18 -3
  21. package/dist/ai-edits/word-diff.js +556 -67
  22. package/dist/ai-suggestions/conflict.js +1 -4
  23. package/dist/ai-suggestions/text-positions.d.ts +8 -0
  24. package/dist/ai-suggestions/text-positions.js +27 -5
  25. package/dist/compare/__fixtures__/body-sequence.js +3 -4
  26. package/dist/compare/column-alignment.d.ts +13 -13
  27. package/dist/compare/column-alignment.js +78 -39
  28. package/dist/compare/compare.d.ts +4 -3
  29. package/dist/compare/compare.js +48 -21
  30. package/dist/compare/content-alignment.d.ts +95 -0
  31. package/dist/compare/content-alignment.js +1450 -0
  32. package/dist/compare/content-types.d.ts +100 -0
  33. package/dist/compare/content-types.js +0 -0
  34. package/dist/compare/content.d.ts +209 -0
  35. package/dist/compare/content.js +885 -0
  36. package/dist/compare/formatting.d.ts +5 -5
  37. package/dist/compare/formatting.js +6 -4
  38. package/dist/compare/plan.d.ts +6 -1
  39. package/dist/compare/plan.js +171 -566
  40. package/dist/compare/scenario.d.ts +2 -2
  41. package/dist/compare/types.d.ts +3 -3
  42. package/dist/compare/verification.d.ts +8 -9
  43. package/dist/compare/verification.js +72 -50
  44. package/dist/compat/eigenpal.d.ts +5 -3
  45. package/dist/compat/eigenpal.js +3 -2
  46. package/dist/controller/headerFooterEditorManager.js +18 -10
  47. package/dist/controller/hiddenEditorApi.js +1 -1
  48. package/dist/controller/hiddenEditorManager.d.ts +7 -2
  49. package/dist/controller/hiddenEditorManager.js +15 -6
  50. package/dist/controller/layoutPipeline.js +5 -3
  51. package/dist/controller/noteEditorManager.js +12 -7
  52. package/dist/display-list/build/headerFooterPrimitives.js +1 -0
  53. package/dist/display-list/build/storyPrimitives.d.ts +5 -2
  54. package/dist/display-list/build/storyPrimitives.js +60 -3
  55. package/dist/display-list/build/tablePrimitives.js +30 -0
  56. package/dist/display-list/primitives.d.ts +1 -1
  57. package/dist/document-operations.d.ts +8 -4
  58. package/dist/document-operations.js +104 -12
  59. package/dist/docx/blockContentParser.js +6 -27
  60. package/dist/docx/commentParser.js +2 -4
  61. package/dist/docx/commentRangeIntegrity.js +2 -4
  62. package/dist/docx/documentClone.d.ts +2 -0
  63. package/dist/docx/documentClone.js +2 -0
  64. package/dist/docx/ensureParaIds.js +9 -4
  65. package/dist/docx/headerFooterParser.js +2 -4
  66. package/dist/docx/normalizeBaseDirection.js +5 -7
  67. package/dist/docx/numberingParser.d.ts +3 -1
  68. package/dist/docx/numberingParser.js +4 -1
  69. package/dist/docx/paragraphParser.js +34 -35
  70. package/dist/docx/paragraphPropertySource.d.ts +49 -0
  71. package/dist/docx/paragraphPropertySource.js +113 -0
  72. package/dist/docx/paragraphTraversal.js +30 -34
  73. package/dist/docx/rezip.d.ts +0 -8
  74. package/dist/docx/rezip.js +41 -16
  75. package/dist/docx/runConsolidator.js +2 -4
  76. package/dist/docx/runParser.js +6 -17
  77. package/dist/docx/sectionParser.js +1 -15
  78. package/dist/docx/serializer/paragraphSerializer.js +289 -30
  79. package/dist/docx/serializer/runSerializer.js +6 -12
  80. package/dist/docx/serializer/sectionPropertiesSerializer.js +2 -10
  81. package/dist/docx/serializer/tableSerializer.js +4 -23
  82. package/dist/docx/serializer/trackedChangeAttributes.d.ts +11 -0
  83. package/dist/docx/serializer/trackedChangeAttributes.js +31 -0
  84. package/dist/docx/server/build.d.ts +1 -1
  85. package/dist/docx/server/createBilingualDocument.js +8 -10
  86. package/dist/docx/server/evaluateDocxXmlPatchProposal.js +22 -13
  87. package/dist/docx/server/validateDocxConformance.js +17 -13
  88. package/dist/docx/tableParser.js +1 -37
  89. package/dist/docx/trackedChangeInfo.d.ts +11 -0
  90. package/dist/docx/trackedChangeInfo.js +38 -0
  91. package/dist/docx/verbatimCapture.d.ts +20 -9
  92. package/dist/docx/verbatimCapture.js +208 -8
  93. package/dist/docx/xmlParser.d.ts +3 -1
  94. package/dist/docx/xmlParser.js +6 -1
  95. package/dist/docx/xmlSafety.d.ts +1 -1
  96. package/dist/docx/xmlSafety.js +157 -0
  97. package/dist/headless-layout.js +5 -4
  98. package/dist/index.d.ts +5 -3
  99. package/dist/index.js +3 -2
  100. package/dist/internal/headlessRevisionResolution.d.ts +23 -0
  101. package/dist/internal/headlessRevisionResolution.js +155 -0
  102. package/dist/internal/headlessRevisionResolutionGuard.d.ts +12 -0
  103. package/dist/internal/headlessRevisionResolutionGuard.js +28 -0
  104. package/dist/internal/pageBreakRunDescendantIndex.d.ts +9 -0
  105. package/dist/internal/pageBreakRunDescendantIndex.js +27 -0
  106. package/dist/internal/pageBreakRunPartition.d.ts +26 -0
  107. package/dist/internal/pageBreakRunPartition.js +35 -0
  108. package/dist/internal/pageBreakRunSourceDescendantIndex.d.ts +15 -0
  109. package/dist/internal/pageBreakRunSourceDescendantIndex.js +126 -0
  110. package/dist/internal/sectionEndpointResolution.d.ts +32 -0
  111. package/dist/internal/sectionEndpointResolution.js +38 -0
  112. package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
  113. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +16 -4
  114. package/dist/layout-bridge/convert/headerFooterLayout.js +52 -9
  115. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +4 -0
  116. package/dist/layout-bridge/convert/toFlowBlocks.js +391 -82
  117. package/dist/layout-bridge/dom/clickToPositionDom.d.ts +8 -1
  118. package/dist/layout-bridge/dom/clickToPositionDom.js +114 -40
  119. package/dist/layout-bridge/dom/textStreamDom.d.ts +24 -0
  120. package/dist/layout-bridge/dom/textStreamDom.js +75 -0
  121. package/dist/layout-bridge/headerFooterLayout.js +16 -30
  122. package/dist/layout-engine/index.js +12 -3
  123. package/dist/layout-engine/measure/tableFragmentBorderGeometry.d.ts +17 -0
  124. package/dist/layout-engine/measure/tableFragmentBorderGeometry.js +24 -0
  125. package/dist/layout-engine/renderedBreakReconciliation.js +1 -1
  126. package/dist/layout-engine/types.d.ts +7 -0
  127. package/dist/layout-painter/imageLayout.d.ts +1 -1
  128. package/dist/layout-painter/renderTable.js +20 -0
  129. package/dist/markdown/renderBlock.js +7 -10
  130. package/dist/model.d.ts +1 -1
  131. package/dist/paged-layout/rangeProjection.js +6 -9
  132. package/dist/paged-layout/transactionDirtyRange.d.ts +3 -1
  133. package/dist/paged-layout/transactionDirtyRange.js +23 -1
  134. package/dist/prosemirror/attrs/index.d.ts +7 -3
  135. package/dist/prosemirror/attrs/index.js +128 -7
  136. package/dist/prosemirror/bookmarkBoundaryIntegrity.d.ts +9 -0
  137. package/dist/prosemirror/bookmarkBoundaryIntegrity.js +22 -0
  138. package/dist/prosemirror/commands/comments.d.ts +10 -1
  139. package/dist/prosemirror/commands/comments.js +551 -87
  140. package/dist/prosemirror/commands/formatPainter.d.ts +45 -10
  141. package/dist/prosemirror/commands/formatPainter.js +319 -63
  142. package/dist/prosemirror/commands/index.d.ts +2 -2
  143. package/dist/prosemirror/commands/pageBreak.d.ts +5 -2
  144. package/dist/prosemirror/commands/pageBreak.js +18 -36
  145. package/dist/prosemirror/commands/propertyChangeScope.d.ts +27 -3
  146. package/dist/prosemirror/commands/propertyChangeScope.js +85 -14
  147. package/dist/prosemirror/conversion/fromProseDoc.d.ts +8 -5
  148. package/dist/prosemirror/conversion/fromProseDoc.js +935 -223
  149. package/dist/prosemirror/conversion/runShadingMark.js +2 -2
  150. package/dist/prosemirror/conversion/toProseDoc.d.ts +26 -1
  151. package/dist/prosemirror/conversion/toProseDoc.js +439 -291
  152. package/dist/prosemirror/extensions/StarterKit.js +4 -0
  153. package/dist/prosemirror/extensions/core/ParagraphExtension.js +54 -6
  154. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -1
  155. package/dist/prosemirror/extensions/features/ListExtension.js +95 -123
  156. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +19 -6
  157. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +27 -1
  158. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +134 -30
  159. package/dist/prosemirror/extensions/features/pasteCleanup.js +9 -15
  160. package/dist/prosemirror/extensions/marks/BoldExtension.js +4 -3
  161. package/dist/prosemirror/extensions/marks/CharacterStyleExtension.d.ts +4 -7
  162. package/dist/prosemirror/extensions/marks/CharacterStyleExtension.js +5 -11
  163. package/dist/prosemirror/extensions/marks/FontSizeExtension.js +3 -3
  164. package/dist/prosemirror/extensions/marks/ItalicExtension.js +4 -3
  165. package/dist/prosemirror/extensions/marks/PageBreakRunOwnerExtension.d.ts +13 -0
  166. package/dist/prosemirror/extensions/marks/PageBreakRunOwnerExtension.js +39 -0
  167. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.d.ts +7 -1
  168. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +67 -21
  169. package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +2 -0
  170. package/dist/prosemirror/extensions/marks/markUtils.d.ts +8 -1
  171. package/dist/prosemirror/extensions/marks/markUtils.js +88 -2
  172. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +44 -3
  173. package/dist/prosemirror/extensions/nodes/FieldExtension.js +1 -1
  174. package/dist/prosemirror/extensions/nodes/HardBreakExtension.js +15 -4
  175. package/dist/prosemirror/extensions/nodes/PageBreakExtension.d.ts +6 -0
  176. package/dist/prosemirror/extensions/nodes/PageBreakExtension.js +6 -0
  177. package/dist/prosemirror/extensions/nodes/PageBreakRunExtension.d.ts +6 -0
  178. package/dist/prosemirror/extensions/nodes/PageBreakRunExtension.js +36 -0
  179. package/dist/prosemirror/findReplaceSelection.js +8 -3
  180. package/dist/prosemirror/index.d.ts +3 -3
  181. package/dist/prosemirror/listMarker.d.ts +26 -1
  182. package/dist/prosemirror/listMarker.js +108 -14
  183. package/dist/prosemirror/pageBreakRunProjection.d.ts +28 -0
  184. package/dist/prosemirror/pageBreakRunProjection.js +47 -0
  185. package/dist/prosemirror/paraText.js +6 -0
  186. package/dist/prosemirror/paragraphAlignment.d.ts +12 -0
  187. package/dist/prosemirror/paragraphAlignment.js +13 -0
  188. package/dist/prosemirror/paragraphSpacing.d.ts +29 -0
  189. package/dist/prosemirror/paragraphSpacing.js +125 -0
  190. package/dist/prosemirror/plugins/documentNumbering.d.ts +8 -0
  191. package/dist/prosemirror/plugins/documentNumbering.js +18 -0
  192. package/dist/prosemirror/plugins/pmTextScan.d.ts +3 -2
  193. package/dist/prosemirror/plugins/pmTextScan.js +14 -4
  194. package/dist/prosemirror/plugins/suggestionMode.js +9 -11
  195. package/dist/prosemirror/rebaseParagraphRunFormatting.d.ts +16 -0
  196. package/dist/prosemirror/rebaseParagraphRunFormatting.js +61 -0
  197. package/dist/prosemirror/runFormattingInlineCarriers.d.ts +64 -0
  198. package/dist/prosemirror/runFormattingInlineCarriers.js +121 -0
  199. package/dist/prosemirror/runFormattingProvenance.d.ts +12 -0
  200. package/dist/prosemirror/runFormattingProvenance.js +48 -0
  201. package/dist/prosemirror/runFormattingReconciliation.d.ts +25 -0
  202. package/dist/prosemirror/runFormattingReconciliation.js +54 -0
  203. package/dist/prosemirror/runPropertyChangeResolution.d.ts +18 -0
  204. package/dist/prosemirror/runPropertyChangeResolution.js +18 -0
  205. package/dist/prosemirror/runStyleFormatting.d.ts +50 -0
  206. package/dist/prosemirror/runStyleFormatting.js +105 -0
  207. package/dist/prosemirror/schema/index.d.ts +3 -3
  208. package/dist/prosemirror/schema/marks.d.ts +84 -9
  209. package/dist/prosemirror/schema/marks.js +73 -1
  210. package/dist/prosemirror/schema/nodes.d.ts +30 -6
  211. package/dist/prosemirror/styles/resolvedStyleAttrs.d.ts +14 -2
  212. package/dist/prosemirror/styles/resolvedStyleAttrs.js +45 -23
  213. package/dist/prosemirror/styles/styleResolver.d.ts +10 -13
  214. package/dist/prosemirror/styles/styleResolver.js +3 -3
  215. package/dist/prosemirror/styles/styleToggleCascade.d.ts +8 -1
  216. package/dist/prosemirror/styles/styleToggleCascade.js +24 -1
  217. package/dist/prosemirror/tableGridMutation.d.ts +20 -0
  218. package/dist/prosemirror/tableGridMutation.js +30 -0
  219. package/dist/prosemirror/trackedRunInlineAtoms.d.ts +31 -0
  220. package/dist/prosemirror/trackedRunInlineAtoms.js +39 -0
  221. package/dist/prosemirror/utils/visualLineNavigation.js +27 -31
  222. package/dist/prosemirror/validation.js +23 -4
  223. package/dist/redline.js +4 -1
  224. package/dist/render-dom/HeaderFooterSelectionOverlay.js +20 -20
  225. package/dist/render-dom/RenderedDomContext.js +17 -34
  226. package/dist/server.d.ts +4 -4
  227. package/dist/server.js +2 -2
  228. package/dist/style-engine/styleEngine.d.ts +1 -1
  229. package/dist/utils/clipboard.js +1 -1
  230. package/dist/utils/findReplace.js +38 -21
  231. package/dist/utils/mergeDocumentContent.js +3 -3
  232. package/dist/utils/replaceText.js +2 -1
  233. package/dist/version-comparison.d.ts +38 -44
  234. package/dist/version-comparison.js +270 -399
  235. package/dist/watermark/index.js +2 -4
  236. package/package.json +8 -2
@@ -1,6 +1,7 @@
1
+ import { folioAIBlockIdStability } from "../ai-edits/block-identity.js";
1
2
  import { createFolioAITextRangeHandle, trailingBodyBlockId } from "../ai-edits/snapshot.js";
2
- import { alignFolioBlocks } from "../version-comparison.js";
3
- import { alignTableColumns } from "./column-alignment.js";
3
+ import { alignFolioContentStructure, contentBlocksShareContainer, createFolioContentAlignmentWorkSession, groupFolioContentTableRows } from "./content-alignment.js";
4
+ import { changedFolioContentParagraphFormatting, createContentComparisonWorkSession, detectFolioContentMoves, detectFolioContentParagraphMarkPlans } from "./content.js";
4
5
  import { inlineFormattingSegments } from "./formatting.js";
5
6
  import { panic } from "better-result";
6
7
  //#region src/compare/plan.ts
@@ -10,7 +11,7 @@ import { panic } from "better-result";
10
11
  * generated tracked change is accepted.
11
12
  *
12
13
  * Pure: no parsing, no serialization, no clock. Every alignment runs through
13
- * {@link alignFolioBlocks}, shared with the version-diff and redline paths.
14
+ * {@link alignFolioContentStructure}, shared with representation-neutral comparison.
14
15
  *
15
16
  * ## Aligning by container, not by block
16
17
  *
@@ -34,445 +35,113 @@ import { panic } from "better-result";
34
35
  *
35
36
  * ## Move detection
36
37
  *
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.
38
+ * Move, split/merge, and formatting classification come from the same neutral
39
+ * comparison core used by non-DOCX callers. The operations remain specific to
40
+ * the tracked-change format, but the meaning of the comparison does not fork.
43
41
  */
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) {
42
+ const toCompareStep = (step) => {
43
+ switch (step.type) {
323
44
  case "pair": return {
324
45
  type: "pair",
325
- baseBlock: event.baseBlock,
326
- targetBlock: event.revisedBlock
46
+ baseBlock: step.baseBlock,
47
+ targetBlock: step.revisedBlock
327
48
  };
328
49
  case "baseOnly": return {
329
50
  type: "baseOnly",
330
- block: event.block
51
+ block: step.block,
52
+ moveScope: step.moveScope
331
53
  };
332
54
  case "revisedOnly": return {
333
55
  type: "targetOnly",
334
- block: event.block
56
+ block: step.block,
57
+ moveScope: step.moveScope
58
+ };
59
+ case "baseRow": return {
60
+ type: "baseRow",
61
+ blocks: step.blocks,
62
+ location: step.location
63
+ };
64
+ case "revisedRow": return {
65
+ type: "targetRow",
66
+ blocks: step.blocks,
67
+ location: step.location
68
+ };
69
+ case "baseTable": return {
70
+ type: "baseTable",
71
+ blocks: step.blocks,
72
+ location: step.location
73
+ };
74
+ case "revisedTable": return {
75
+ type: "targetTable",
76
+ blocks: step.blocks,
77
+ location: step.location
78
+ };
79
+ case "baseColumn": return {
80
+ type: "baseColumn",
81
+ blocks: step.blocks,
82
+ location: step.location,
83
+ columnIndex: step.columnIndex
84
+ };
85
+ case "revisedColumn": return {
86
+ type: "targetColumn",
87
+ blocks: step.blocks,
88
+ location: step.location,
89
+ columnIndex: step.columnIndex,
90
+ anchor: step.anchor
335
91
  };
336
- default: return panic("Unhandled block alignment event", { event });
92
+ default: return panic("Unhandled content alignment step", { step });
337
93
  }
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
94
  };
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;
95
+ const toContentAlignmentStep = (step) => {
96
+ switch (step.type) {
97
+ case "pair": return {
98
+ type: "pair",
99
+ baseBlock: step.baseBlock,
100
+ revisedBlock: step.targetBlock
101
+ };
102
+ case "baseOnly": return step;
103
+ case "targetOnly": return {
104
+ type: "revisedOnly",
105
+ block: step.block,
106
+ moveScope: step.moveScope
107
+ };
108
+ case "baseRow":
109
+ case "baseTable":
110
+ case "baseColumn": return step;
111
+ case "targetRow": return {
112
+ type: "revisedRow",
113
+ blocks: step.blocks,
114
+ location: step.location
115
+ };
116
+ case "targetTable": return {
117
+ type: "revisedTable",
118
+ blocks: step.blocks,
119
+ location: step.location
120
+ };
121
+ case "targetColumn": return {
122
+ type: "revisedColumn",
123
+ blocks: step.blocks,
124
+ location: step.location,
125
+ columnIndex: step.columnIndex,
126
+ anchor: step.anchor
127
+ };
128
+ default: return panic("Unhandled compare step", { step });
445
129
  }
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
130
  };
456
131
  const isEmptyParagraphNode = (block, snapshot) => {
457
132
  const anchor = snapshot.anchors[block.id] ?? panic("A comparison snapshot block has no matching anchor", { blockId: block.id });
458
133
  return block.text === "" && block.table === void 0 && anchor.to - anchor.from === 2;
459
134
  };
460
135
  /** Align both stories, optionally holding the two last paragraphs out as a pair. */
461
- const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair) => {
462
- const alignedBaseBlocks = terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks;
463
- const alignedTargetBlocks = terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks;
464
- const steps = [];
465
- for (const { baseSegment, targetSegment } of alignSegments(splitSegments(alignedBaseBlocks), splitSegments(alignedTargetBlocks))) {
466
- if (baseSegment && targetSegment) {
467
- steps.push(...baseSegment.kind === "table" ? buildTableSegmentSteps(baseSegment.blocks, targetSegment.blocks) : buildBodySteps(baseSegment.blocks, targetSegment.blocks));
468
- continue;
469
- }
470
- if (baseSegment) {
471
- steps.push(...unpairedSegmentSteps(baseSegment, "base"));
472
- continue;
473
- }
474
- if (targetSegment) steps.push(...unpairedSegmentSteps(targetSegment, "target"));
475
- }
136
+ const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair, wholeTableReplacement, workSession = createFolioContentAlignmentWorkSession()) => {
137
+ const steps = alignFolioContentStructure({
138
+ baseBlocks: terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks,
139
+ revisedBlocks: terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks,
140
+ wholeTableReplacement,
141
+ workSession,
142
+ stableIdMismatch: "pair",
143
+ idStability: folioAIBlockIdStability
144
+ }).map(toCompareStep);
476
145
  if (terminalCarrierPair) steps.push(terminalCarrierPair);
477
146
  return steps;
478
147
  };
@@ -520,7 +189,7 @@ const addedTerminalCarrierIsStranded = (steps, baseBlocks, targetBlocks) => {
520
189
  const baseLastStep = steps.findIndex((step) => step.type === "pair" && step.baseBlock.id === baseLast.id || step.type === "baseOnly" && step.block.id === baseLast.id);
521
190
  return baseLastStep === -1 || steps.slice(baseLastStep + 1).some((step) => step.type !== "targetOnly");
522
191
  };
523
- const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
192
+ const buildSteps = ({ baseSnapshot, targetSnapshot, wholeTableReplacement, workSession }) => {
524
193
  const baseBlocks = baseSnapshot.blocks;
525
194
  const targetBlocks = targetSnapshot.blocks;
526
195
  const baseLast = baseBlocks.at(-1);
@@ -531,9 +200,13 @@ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
531
200
  targetBlock: targetLast
532
201
  } : null;
533
202
  const bothStoriesEndBlank = carrierPair !== null && isEmptyParagraphNode(carrierPair.baseBlock, baseSnapshot) && isEmptyParagraphNode(carrierPair.targetBlock, targetSnapshot);
534
- const steps = alignedSteps(baseBlocks, targetBlocks, bothStoriesEndBlank ? carrierPair : null);
203
+ const alignmentBudgetBeforeAttempt = workSession.remainingLcsCells;
204
+ const structuralTokenBudgetBeforeAttempt = workSession.remainingStructuralTokenLookups;
205
+ const steps = alignedSteps(baseBlocks, targetBlocks, bothStoriesEndBlank ? carrierPair : null, wholeTableReplacement, workSession);
535
206
  if (carrierPair === null || bothStoriesEndBlank || !shareAContainer(carrierPair.baseBlock, carrierPair.targetBlock) || !(terminalCarrierIsStranded(steps, baseBlocks) || addedTerminalCarrierIsStranded(steps, baseBlocks, targetBlocks))) return steps;
536
- return alignedSteps(baseBlocks, targetBlocks, carrierPair);
207
+ workSession.remainingLcsCells = alignmentBudgetBeforeAttempt;
208
+ workSession.remainingStructuralTokenLookups = structuralTokenBudgetBeforeAttempt;
209
+ return alignedSteps(baseBlocks, targetBlocks, carrierPair, wholeTableReplacement, workSession);
537
210
  };
538
211
  /**
539
212
  * Two blocks are in the same container when a paragraph mark between them
@@ -542,103 +215,15 @@ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
542
215
  * paragraph-mark edit however similar the text looks.
543
216
  */
544
217
  const shareAContainer = (left, right) => {
545
- if (!left.table || !right.table) return left.table === void 0 && right.table === void 0;
546
- return left.table.tableIndex === right.table.tableIndex && left.table.rowIndex === right.table.rowIndex && left.table.cellIndex === right.table.cellIndex;
547
- };
548
- /**
549
- * The text between `head` and `tail` inside `whole`, when `whole` is exactly
550
- * the two joined by whitespace (or by nothing). `null` when it is not: any
551
- * other difference is a rewrite, not a moved paragraph mark.
552
- */
553
- const separatorBetween = (whole, head, tail) => {
554
- if (head.length === 0 || tail.length === 0 || whole.length < head.length + tail.length) return null;
555
- if (!whole.startsWith(head) || !whole.endsWith(tail)) return null;
556
- const separator = whole.slice(head.length, whole.length - tail.length);
557
- return separator.length === 0 || /^\s+$/u.test(separator) ? separator : null;
558
- };
559
- /**
560
- * Where the alignment produced a rewrite plus an insertion or a deletion that
561
- * is really one paragraph mark moving, by step index of the PAIR step. The
562
- * step after it is consumed with it.
563
- *
564
- * The alignment cannot see this: it pairs the base paragraph with the target
565
- * half that still matches it and leaves the other half unpaired, which is a
566
- * correct alignment and a misleading redline.
567
- */
568
- const detectParagraphMarkEdits = (steps) => {
569
- const plans = /* @__PURE__ */ new Map();
570
- for (const [index, step] of steps.entries()) {
571
- const next = steps[index + 1];
572
- if (step.type !== "pair" || next === void 0) continue;
573
- if (next.type === "targetOnly") {
574
- const separator = separatorBetween(step.baseBlock.text, step.targetBlock.text, next.block.text);
575
- if (separator !== null && shareAContainer(step.targetBlock, next.block)) plans.set(index, {
576
- type: "split",
577
- baseBlock: step.baseBlock,
578
- targetBlocks: [step.targetBlock, next.block],
579
- offset: step.targetBlock.text.length,
580
- separator
581
- });
582
- continue;
583
- }
584
- if (next.type !== "baseOnly") continue;
585
- const separator = separatorBetween(step.targetBlock.text, step.baseBlock.text, next.block.text);
586
- if (separator !== null && shareAContainer(step.baseBlock, next.block)) plans.set(index, {
587
- type: "merge",
588
- baseBlocks: [step.baseBlock, next.block],
589
- targetBlock: step.targetBlock,
590
- separator
591
- });
592
- }
593
- return plans;
594
- };
595
- /** Base block id -> target block id for every relocation the move pass found. */
596
- const detectMoves = (steps, consumed) => {
597
- const candidatesByText = /* @__PURE__ */ new Map();
598
- for (const [index, step] of steps.entries()) {
599
- if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
600
- const queue = candidatesByText.get(step.block.text);
601
- if (!queue) {
602
- candidatesByText.set(step.block.text, [step.block.id]);
603
- continue;
604
- }
605
- if (queue.length < MAX_MOVE_CANDIDATES_PER_TEXT) queue.push(step.block.id);
606
- }
607
- const unmatched = [];
608
- for (const [index, step] of steps.entries()) {
609
- if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
610
- unmatched.push(step.block);
611
- }
612
- const movesByBaseBlockId = /* @__PURE__ */ new Map();
613
- const takenBaseBlockIds = /* @__PURE__ */ new Set();
614
- let comparisonBudget = MAX_MOVE_SIMILARITY_COMPARISONS;
615
- for (const [index, step] of steps.entries()) {
616
- if (consumed.has(index) || step.type !== "targetOnly") continue;
617
- const exact = candidatesByText.get(step.block.text)?.shift();
618
- if (exact !== void 0) {
619
- takenBaseBlockIds.add(exact);
620
- movesByBaseBlockId.set(exact, step.block.id);
621
- continue;
622
- }
623
- if (!wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
624
- let best = null;
625
- for (const candidate of unmatched) {
626
- if (comparisonBudget <= 0) break;
627
- if (takenBaseBlockIds.has(candidate.id)) continue;
628
- comparisonBudget -= 1;
629
- const similarity = tokenSimilarity(candidate.text, step.block.text);
630
- if (similarity >= MOVE_SIMILARITY_THRESHOLD && (best === null || similarity > best.similarity)) best = {
631
- block: candidate,
632
- similarity
633
- };
634
- }
635
- if (best) {
636
- takenBaseBlockIds.add(best.block.id);
637
- movesByBaseBlockId.set(best.block.id, step.block.id);
638
- }
639
- }
640
- return movesByBaseBlockId;
641
- };
218
+ return contentBlocksShareContainer(left, right);
219
+ };
220
+ /** Narrow the neutral patch to the operation contract without explicit undefined fields. */
221
+ const toFolioAIBlockParagraphProperties = (properties) => ({
222
+ ...properties.styleId !== void 0 && { styleId: properties.styleId },
223
+ ...properties.listLevel !== void 0 && { listLevel: properties.listLevel },
224
+ ...properties.alignment !== void 0 && { alignment: properties.alignment },
225
+ ...properties.spacing !== void 0 && { spacing: properties.spacing }
226
+ });
642
227
  /**
643
228
  * For each step, the id of the next base block at or after it — the anchor a
644
229
  * target-only insertion is placed before. `null` once no base block follows.
@@ -709,7 +294,7 @@ const findRowAnchor = (steps, stepIndex) => {
709
294
  * actually occupies; the empty strings are the cells a `w:gridSpan` covers.
710
295
  */
711
296
  const tableCellTexts = (blocks) => {
712
- const rows = groupRows(blocks).map((row) => rowCellTexts(row));
297
+ const rows = groupFolioContentTableRows(blocks).map((row) => rowCellTexts(row));
713
298
  let width = 0;
714
299
  for (const row of rows) width = Math.max(width, row.length);
715
300
  for (const row of rows) while (row.length < width) row.push("");
@@ -735,18 +320,6 @@ const columnCellTexts = (blocks) => {
735
320
  }
736
321
  return [...byCell.values()];
737
322
  };
738
- /**
739
- * The paragraph properties that differ, or `null` when they agree. Only the
740
- * ones a block projection can see and an operation can set: a list level and
741
- * a paragraph style, the two edits that move no words and are invisible in a
742
- * text diff.
743
- */
744
- const changedParagraphProperties = (baseBlock, targetBlock) => {
745
- const properties = {};
746
- if ((baseBlock.styleId ?? null) !== (targetBlock.styleId ?? null)) properties.styleId = targetBlock.styleId ?? null;
747
- if (baseBlock.listLevel !== targetBlock.listLevel) properties.listLevel = targetBlock.listLevel ?? null;
748
- return Object.keys(properties).length > 0 ? properties : null;
749
- };
750
323
  const locationOf = (story, block) => block.table ? {
751
324
  story,
752
325
  cell: block.table
@@ -797,11 +370,11 @@ const lastBlockByContainer = (blocks) => {
797
370
  * rather than an edit of its own, so it adds no entry to the change list: what
798
371
  * the reader is told is that paragraphs were removed and paragraphs added.
799
372
  */
800
- const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, nextOperationId }) => {
373
+ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, tableTemplates, nextOperationId }) => {
801
374
  const plan = [...operations];
802
375
  const deletionIndexByBlockId = /* @__PURE__ */ new Map();
803
376
  const insertIndexesByAnchor = /* @__PURE__ */ new Map();
804
- const tableAnchorIds = /* @__PURE__ */ new Set();
377
+ const tableInsertIndexesByAnchor = /* @__PURE__ */ new Map();
805
378
  const markedBlockIds = /* @__PURE__ */ new Set();
806
379
  for (const [index, operation] of plan.entries()) switch (operation.type) {
807
380
  case "deleteBlock":
@@ -814,9 +387,12 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
814
387
  insertIndexesByAnchor.set(operation.blockId, placed);
815
388
  break;
816
389
  }
817
- case "insertTable":
818
- tableAnchorIds.add(operation.blockId);
390
+ case "insertTable": {
391
+ const placed = tableInsertIndexesByAnchor.get(operation.blockId) ?? [];
392
+ placed.push(index);
393
+ tableInsertIndexesByAnchor.set(operation.blockId, placed);
819
394
  break;
395
+ }
820
396
  case "splitBlock":
821
397
  case "mergeBlockWithNext":
822
398
  markedBlockIds.add(operation.blockId);
@@ -835,8 +411,8 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
835
411
  const trailingRunOf = (container, carrier) => {
836
412
  const blockIds = [];
837
413
  const insertIndexes = [];
414
+ const tableInsertIndexes = [];
838
415
  let chainStart = null;
839
- let holdsAnInsertedTable = false;
840
416
  let index = indexById.get(carrier.id) ?? panic("A container's last block is not in the snapshot it came from", { blockId: carrier.id });
841
417
  for (; index >= 0; index--) {
842
418
  const block = blocks[index];
@@ -847,23 +423,35 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
847
423
  }
848
424
  blockIds.push(block.id);
849
425
  insertIndexes.push(...insertIndexesByAnchor.get(block.id) ?? []);
850
- holdsAnInsertedTable ||= tableAnchorIds.has(block.id);
426
+ tableInsertIndexes.push(...tableInsertIndexesByAnchor.get(block.id) ?? []);
851
427
  }
852
428
  return {
853
429
  blockIds,
854
430
  chainStart,
855
431
  insertIndexes: insertIndexes.toSorted((left, right) => left - right),
856
- holdsAnInsertedTable
432
+ tableInsertIndexes: tableInsertIndexes.toSorted((left, right) => left - right)
857
433
  };
858
434
  };
859
435
  const targetLastByContainer = lastBlockByContainer(targetSnapshot.blocks);
436
+ const terminalTargetTableIndex = targetSnapshot.blocks.at(-1)?.table?.outerTableIndex;
437
+ const targetTableIndexByOperationId = new Map(tableTemplates.map(({ operationId, targetTableIndex }) => [operationId, targetTableIndex]));
860
438
  const dropped = /* @__PURE__ */ new Set();
861
439
  const appended = [];
862
440
  for (const [container, carrier] of lastBlockByContainer(blocks)) {
863
441
  const carrierDeletionIndex = deletionIndexByBlockId.get(carrier.id);
864
442
  if (carrierDeletionIndex === void 0) continue;
865
443
  const run = trailingRunOf(container, carrier);
866
- if (run.holdsAnInsertedTable) continue;
444
+ if (run.tableInsertIndexes.length > 0) {
445
+ const tableInsertIndex = run.tableInsertIndexes.length === 1 ? run.tableInsertIndexes.at(0) : void 0;
446
+ const tableInsert = tableInsertIndex === void 0 ? void 0 : plan[tableInsertIndex];
447
+ if (container !== "body" || !isEmptyParagraphNode(carrier, baseSnapshot) || tableInsertIndex === void 0 || terminalTargetTableIndex === void 0 || tableInsert?.type !== "insertTable" || targetTableIndexByOperationId.get(tableInsert.id) !== terminalTargetTableIndex) continue;
448
+ plan[tableInsertIndex] = {
449
+ ...tableInsert,
450
+ blockId: carrier.id,
451
+ position: "after"
452
+ };
453
+ continue;
454
+ }
867
455
  const lastInsertIndex = run.insertIndexes.at(-1);
868
456
  if (lastInsertIndex === void 0) {
869
457
  if (run.chainStart) appended.push({
@@ -895,18 +483,23 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
895
483
  text: insert.text,
896
484
  ...insert.moveId !== void 0 && { moveId: insert.moveId },
897
485
  styleId: insert.styleId ?? null,
898
- listLevel: insert.listLevel ?? null
486
+ listLevel: insert.listLevel ?? null,
487
+ alignment: insert.alignment ?? null,
488
+ spacing: insert.spacing ?? null
899
489
  };
900
490
  }
901
491
  }
902
492
  const targetCarrier = targetLastByContainer.get(container);
903
- const properties = targetCarrier && changedParagraphProperties(carrier, targetCarrier);
904
- if (properties) appended.push({
905
- id: nextOperationId(),
906
- type: "setBlockParagraphProperties",
907
- blockId: carrier.id,
908
- properties
909
- });
493
+ const contentProperties = targetCarrier && changedFolioContentParagraphFormatting(carrier, targetCarrier);
494
+ if (contentProperties) {
495
+ const properties = toFolioAIBlockParagraphProperties(contentProperties);
496
+ appended.push({
497
+ id: nextOperationId(),
498
+ type: "setBlockParagraphProperties",
499
+ blockId: carrier.id,
500
+ properties
501
+ });
502
+ }
910
503
  }
911
504
  return [...plan.filter((_, index) => !dropped.has(index)), ...appended];
912
505
  };
@@ -942,14 +535,23 @@ const tableGeometryPairingsOf = (steps) => {
942
535
  * Plan one story's comparison, or `null` when it needs more operations than
943
536
  * `maxOperations`.
944
537
  */
945
- const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }) => {
538
+ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations, wholeTableReplacement = "allow", workSession = createContentComparisonWorkSession() }) => {
539
+ const effectiveWholeTableReplacement = wholeTableReplacement === "allow" && trailingBodyBlockId(baseSnapshot) === null ? "avoid" : wholeTableReplacement;
946
540
  const steps = buildSteps({
947
541
  baseSnapshot,
948
- targetSnapshot
542
+ targetSnapshot,
543
+ wholeTableReplacement: effectiveWholeTableReplacement,
544
+ workSession: workSession.alignment
949
545
  });
950
- const paragraphMarkPlans = detectParagraphMarkEdits(steps);
546
+ const contentSteps = steps.map(toContentAlignmentStep);
547
+ const paragraphMarkPlans = detectFolioContentParagraphMarkPlans(contentSteps);
951
548
  const consumedSteps = new Set([...paragraphMarkPlans.keys()].map((index) => index + 1));
952
- const movesByBaseBlockId = detectMoves(steps, consumedSteps);
549
+ const movesByBaseBlockId = new Map(detectFolioContentMoves({
550
+ steps: contentSteps,
551
+ consumedStepIndexes: consumedSteps,
552
+ workSession,
553
+ idStability: folioAIBlockIdStability
554
+ }).map(({ baseBlock, revisedBlock }) => [baseBlock.id, revisedBlock.id]));
953
555
  const moveSourceByTargetBlockId = /* @__PURE__ */ new Map();
954
556
  for (const [baseBlockId, targetBlockId] of movesByBaseBlockId) moveSourceByTargetBlockId.set(targetBlockId, baseBlockId);
955
557
  const anchorIds = nextBaseBlockIdByStep(steps);
@@ -958,11 +560,10 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
958
560
  const tableTemplates = [];
959
561
  /**
960
562
  * The anchor everything past the base document's content hangs from: its
961
- * last BODY-LEVEL paragraph, which the format guarantees exists because a
962
- * table may not be the last child of a body. Anchoring to the last block
963
- * instead put the anchor inside a table whenever the story ended with one,
964
- * and an insertion anchored there escapes to the table's boundary, where no
965
- * paragraph mark can express the break it added.
563
+ * last BODY-LEVEL paragraph, when it has one. Anchoring to the last block
564
+ * instead puts the anchor inside a terminal table, and a paragraph insertion
565
+ * then escapes to the table's boundary where no paragraph mark can express
566
+ * the break it added.
966
567
  *
967
568
  * The applier orders insertions that resolve to one position by their order
968
569
  * in this array, so the tail is emitted where its step sits rather than
@@ -985,7 +586,9 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
985
586
  text: block.text,
986
587
  ...moveSourceId !== void 0 && { moveId: moveIdOf(moveSourceId) },
987
588
  styleId: block.styleId ?? null,
988
- listLevel: block.listLevel ?? null
589
+ listLevel: block.listLevel ?? null,
590
+ alignment: block.directAlignment ?? null,
591
+ spacing: block.directSpacing ?? null
989
592
  };
990
593
  if (anchorId !== null) {
991
594
  operations.push({
@@ -1008,7 +611,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
1008
611
  if (consumedSteps.has(stepIndex)) continue;
1009
612
  const paragraphMarkPlan = paragraphMarkPlans.get(stepIndex);
1010
613
  if (paragraphMarkPlan?.type === "split") {
1011
- const { baseBlock, targetBlocks: splitInto, offset, separator } = paragraphMarkPlan;
614
+ const { baseBlock, revisedBlocks: splitInto, offset, separator } = paragraphMarkPlan;
1012
615
  changes.push({
1013
616
  kind: "split",
1014
617
  location: locationOf(story, baseBlock),
@@ -1026,7 +629,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
1026
629
  continue;
1027
630
  }
1028
631
  if (paragraphMarkPlan?.type === "merge") {
1029
- const { baseBlocks, targetBlock, separator } = paragraphMarkPlan;
632
+ const { baseBlocks, revisedBlock: targetBlock, separator } = paragraphMarkPlan;
1030
633
  changes.push({
1031
634
  kind: "merge",
1032
635
  location: locationOf(story, baseBlocks[0]),
@@ -1045,8 +648,9 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
1045
648
  switch (step.type) {
1046
649
  case "pair": {
1047
650
  const { baseBlock, targetBlock } = step;
1048
- const properties = changedParagraphProperties(baseBlock, targetBlock);
1049
- if (properties) {
651
+ const contentProperties = changedFolioContentParagraphFormatting(baseBlock, targetBlock);
652
+ if (contentProperties) {
653
+ const properties = toFolioAIBlockParagraphProperties(contentProperties);
1050
654
  changes.push({
1051
655
  kind: "paragraph-format",
1052
656
  location: locationOf(story, baseBlock),
@@ -1295,6 +899,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
1295
899
  baseSnapshot,
1296
900
  targetSnapshot,
1297
901
  operations,
902
+ tableTemplates,
1298
903
  nextOperationId
1299
904
  });
1300
905
  return planned.length > maxOperations ? null : {