@stll/folio-core 0.32.1 → 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 +692 -75
  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 +235 -7
  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 +42 -9
  100. package/dist/document-operations.js +171 -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 +4 -4
  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,162 @@
1
+ import { panic } from "better-result";
2
+ //#region src/compare/column-alignment.ts
3
+ const MAX_TABLE_GRID_COLUMNS = 63;
4
+ const MAX_TABLE_GRID_AREA = 1e6;
5
+ const tableGridColumns = (blocks) => {
6
+ const cellsByPhysicalLocation = /* @__PURE__ */ new Map();
7
+ let width = 0;
8
+ let height = 0;
9
+ for (const block of blocks) {
10
+ const location = block.table;
11
+ if (!location) return null;
12
+ const key = `${String(location.rowIndex)}:${String(location.cellIndex)}`;
13
+ const existing = cellsByPhysicalLocation.get(key);
14
+ if (existing) {
15
+ existing.blocks.push(block);
16
+ continue;
17
+ }
18
+ const right = location.gridColumnIndex + location.columnSpan;
19
+ const bottom = location.rowIndex + location.rowSpan;
20
+ if (location.gridColumnIndex < 0 || location.rowIndex < 0 || location.columnSpan < 1 || location.rowSpan < 1 || right > MAX_TABLE_GRID_COLUMNS) return null;
21
+ cellsByPhysicalLocation.set(key, {
22
+ blocks: [block],
23
+ rowIndex: location.rowIndex,
24
+ gridColumnIndex: location.gridColumnIndex,
25
+ columnSpan: location.columnSpan,
26
+ rowSpan: location.rowSpan
27
+ });
28
+ width = Math.max(width, right);
29
+ height = Math.max(height, bottom);
30
+ }
31
+ if (width === 0 || height === 0 || width * height > MAX_TABLE_GRID_AREA) return null;
32
+ const grid = Array.from({ length: height }, () => Array.from({ length: width }));
33
+ const ownedCellsByColumn = Array.from({ length: width }, () => []);
34
+ for (const cell of cellsByPhysicalLocation.values()) {
35
+ ownedCellsByColumn[cell.gridColumnIndex]?.push(cell);
36
+ for (let row = cell.rowIndex; row < cell.rowIndex + cell.rowSpan; row++) for (let column = cell.gridColumnIndex; column < cell.gridColumnIndex + cell.columnSpan; column++) {
37
+ const gridRow = grid[row];
38
+ if (!gridRow || gridRow[column] !== void 0) return null;
39
+ gridRow[column] = cell;
40
+ }
41
+ }
42
+ return Array.from({ length: width }, (_unused, columnIndex) => {
43
+ const structuralRows = grid.map((row) => {
44
+ const cell = row[columnIndex];
45
+ return cell ? [
46
+ columnIndex - cell.gridColumnIndex,
47
+ cell.columnSpan,
48
+ cell.rowIndex,
49
+ cell.rowSpan,
50
+ cell.blocks.map(({ text }) => text)
51
+ ] : null;
52
+ });
53
+ return {
54
+ index: columnIndex,
55
+ signature: JSON.stringify(structuralRows),
56
+ ownedCells: ownedCellsByColumn[columnIndex] ?? []
57
+ };
58
+ });
59
+ };
60
+ /** The sole exact ordered embedding of `shorter` in `wider`, or null when ambiguous. */
61
+ const uniqueColumnEmbedding = (shorter, wider) => {
62
+ const counts = Array.from({ length: shorter.length + 1 }, () => Array.from({ length: wider.length + 1 }, () => 0));
63
+ for (let wideIndex = 0; wideIndex <= wider.length; wideIndex++) {
64
+ const finalRow = counts[shorter.length];
65
+ if (finalRow) finalRow[wideIndex] = 1;
66
+ }
67
+ for (let shortIndex = shorter.length - 1; shortIndex >= 0; shortIndex--) for (let wideIndex = wider.length - 1; wideIndex >= 0; wideIndex--) {
68
+ const skip = counts[shortIndex]?.[wideIndex + 1] ?? 0;
69
+ const match = shorter[shortIndex]?.signature === wider[wideIndex]?.signature ? counts[shortIndex + 1]?.[wideIndex + 1] ?? 0 : 0;
70
+ const row = counts[shortIndex];
71
+ if (row) row[wideIndex] = Math.min(2, skip + match);
72
+ }
73
+ if (counts[0]?.[0] !== 1) return null;
74
+ const mapping = [];
75
+ let shortIndex = 0;
76
+ let wideIndex = 0;
77
+ while (shortIndex < shorter.length) {
78
+ if (wideIndex >= wider.length) return panic("A unique column embedding ended before every column was mapped");
79
+ const canMatch = shorter[shortIndex]?.signature === wider[wideIndex]?.signature && (counts[shortIndex + 1]?.[wideIndex + 1] ?? 0) > 0;
80
+ const canSkip = (counts[shortIndex]?.[wideIndex + 1] ?? 0) > 0;
81
+ if (canMatch && !canSkip) {
82
+ mapping.push(wideIndex);
83
+ shortIndex += 1;
84
+ }
85
+ wideIndex += 1;
86
+ }
87
+ return mapping;
88
+ };
89
+ const columnOwnedBlocks = ({ ownedCells }) => {
90
+ if (ownedCells.length === 0 || ownedCells.some(({ columnSpan }) => columnSpan !== 1)) return null;
91
+ return ownedCells.flatMap(({ blocks }) => blocks);
92
+ };
93
+ const alignTableColumns = (baseBlocks, targetBlocks) => {
94
+ const baseColumns = tableGridColumns(baseBlocks);
95
+ const targetColumns = tableGridColumns(targetBlocks);
96
+ if (!baseColumns || !targetColumns || baseColumns.length === targetColumns.length) return null;
97
+ const targetIsWider = targetColumns.length > baseColumns.length;
98
+ const mapping = uniqueColumnEmbedding(targetIsWider ? baseColumns : targetColumns, targetIsWider ? targetColumns : baseColumns);
99
+ if (!mapping) return null;
100
+ const widerColumns = targetIsWider ? targetColumns : baseColumns;
101
+ const mapped = new Set(mapping);
102
+ const unmatchedColumns = widerColumns.filter((_column, index) => !mapped.has(index));
103
+ const ownedColumns = [];
104
+ for (const column of unmatchedColumns) {
105
+ const blocks = columnOwnedBlocks(column);
106
+ if (!blocks) return null;
107
+ ownedColumns.push({
108
+ column,
109
+ blocks
110
+ });
111
+ }
112
+ if (ownedColumns.length === 0 || targetIsWider && unmatchedColumns.some(({ ownedCells }) => ownedCells.some(({ rowSpan }) => rowSpan !== 1))) return null;
113
+ const unmatchedIds = new Set(ownedColumns.flatMap(({ blocks }) => blocks.map(({ id }) => id)));
114
+ const baseColumnKeys = /* @__PURE__ */ new Map();
115
+ const targetColumnKeys = /* @__PURE__ */ new Map();
116
+ mapping.forEach((wideIndex, shortIndex) => {
117
+ if (targetIsWider) {
118
+ baseColumnKeys.set(shortIndex, shortIndex);
119
+ targetColumnKeys.set(wideIndex, shortIndex);
120
+ } else {
121
+ baseColumnKeys.set(wideIndex, shortIndex);
122
+ targetColumnKeys.set(shortIndex, shortIndex);
123
+ }
124
+ });
125
+ const steps = [];
126
+ if (!targetIsWider) for (const { column, blocks } of ownedColumns) {
127
+ const location = blocks.at(0)?.table;
128
+ if (!location) return null;
129
+ steps.push({
130
+ type: "baseColumn",
131
+ blocks,
132
+ location,
133
+ columnIndex: column.index
134
+ });
135
+ }
136
+ else for (const { column, blocks } of ownedColumns) {
137
+ const location = blocks.at(0)?.table;
138
+ const rightBaseIndex = mapping.findIndex((targetIndex) => targetIndex > column.index);
139
+ const leftBaseIndex = mapping.findLastIndex((targetIndex) => targetIndex < column.index);
140
+ const anchorBlock = (rightBaseIndex >= 0 ? baseColumns[rightBaseIndex] : baseColumns[leftBaseIndex])?.ownedCells.at(0)?.blocks.at(0);
141
+ if (!location || !anchorBlock) return null;
142
+ steps.push({
143
+ type: "targetColumn",
144
+ blocks,
145
+ location,
146
+ columnIndex: column.index,
147
+ anchor: {
148
+ blockId: anchorBlock.id,
149
+ position: rightBaseIndex >= 0 ? "before" : "after"
150
+ }
151
+ });
152
+ }
153
+ return {
154
+ steps,
155
+ baseBlocks: baseBlocks.filter(({ id }) => targetIsWider || !unmatchedIds.has(id)),
156
+ targetBlocks: targetBlocks.filter(({ id }) => !targetIsWider || !unmatchedIds.has(id)),
157
+ baseColumnKeys,
158
+ targetColumnKeys
159
+ };
160
+ };
161
+ //#endregion
162
+ export { alignTableColumns };
@@ -0,0 +1,107 @@
1
+ import { FolioAIEditSnapshot } from "../ai-edits/types.js";
2
+ import { WordDiffGranularity } from "../ai-edits/word-diff.js";
3
+ import { FolioRevisionStamp } from "../ai-edits/apply.js";
4
+ import { FolioDocumentStoryHandle, FolioDocxReviewer } from "../ai-edits/headless.js";
5
+ import { CompareVerification } from "./verification.js";
6
+ import { CompareChange, CompareDocxApplyError, CompareDocxError, CompareDocxOperationLimitError, CompareDocxOptions, CompareDocxParseError, CompareDocxSerializeError, CompareResult, CompareUnsupportedPart, InvalidCompareDocxOptionsError } from "./types.js";
7
+ import { CompareStoryPlan } from "./plan.js";
8
+ import { Result } from "better-result";
9
+ //#region src/compare/compare.d.ts
10
+ /**
11
+ * Cap on operations one comparison generates. Both inputs are untrusted
12
+ * documents, and each operation costs a document walk plus revision ids.
13
+ */
14
+ declare const MAX_COMPARE_OPERATIONS = 10000;
15
+ /** Two stories the comparison will align against one another. */
16
+ type ComparedStoryPair = {
17
+ baseStory: FolioDocumentStoryHandle;
18
+ targetStory: FolioDocumentStoryHandle;
19
+ baseSnapshot: FolioAIEditSnapshot;
20
+ targetSnapshot: FolioAIEditSnapshot;
21
+ };
22
+ /** Everything the later stages need, and nothing they have to re-derive. */
23
+ type ParsedComparison = {
24
+ /** Token size a changed paragraph's redline is cut at. */
25
+ granularity: WordDiffGranularity;
26
+ /**
27
+ * The base package as it arrived. It is the result when nothing changed, but
28
+ * only when it carried no revisions of its own: otherwise the compared base
29
+ * is its accepted view and these bytes are a different document.
30
+ */
31
+ baseBuffer: ArrayBuffer;
32
+ baseCarriedRevisions: boolean;
33
+ reviewer: FolioDocxReviewer;
34
+ targetReviewer: FolioDocxReviewer;
35
+ revisionStamp: FolioRevisionStamp;
36
+ packageDate: Date;
37
+ pairs: readonly ComparedStoryPair[];
38
+ /** Package-level numbering differences, which belong to no story. */
39
+ numberingChanges: readonly CompareChange[];
40
+ unsupported: readonly CompareUnsupportedPart[];
41
+ };
42
+ /** Stage 1: both packages to editor models, paired story by story. */
43
+ declare const parseComparison: (base: ArrayBuffer, target: ArrayBuffer, options: CompareDocxOptions) => Promise<Result<ParsedComparison, CompareDocxParseError | InvalidCompareDocxOptionsError>>;
44
+ /** One story's plan, kept with the pair it belongs to. */
45
+ type PlannedStoryComparison = {
46
+ pair: ComparedStoryPair;
47
+ plan: CompareStoryPlan;
48
+ };
49
+ /**
50
+ * Stage 2: align every paired story and derive its operations. Pure — no
51
+ * parsing, no serialization, no clock.
52
+ */
53
+ declare const planComparison: ({ pairs }: ParsedComparison) => Result<readonly PlannedStoryComparison[], CompareDocxOperationLimitError>;
54
+ /** What stage 3 produced: the change list, and whether it was proven. */
55
+ type AppliedComparison = {
56
+ changes: readonly CompareChange[];
57
+ verification: CompareVerification;
58
+ };
59
+ /**
60
+ * Stage 3: write the planned operations into the base document as tracked
61
+ * changes, then check the work rather than trust it. Both directions of the
62
+ * round trip are checked, structure included: accepting the story's generated
63
+ * revisions must reproduce the target, and rejecting them must reproduce the
64
+ * base it was compared from. A difference the operation vocabulary cannot
65
+ * express would otherwise leave a redline that reads plausibly and is wrong.
66
+ *
67
+ * The check reports rather than throws. {@link compareDocx} decides what to do
68
+ * with an unverified result, because "give me your best attempt and tell me
69
+ * what you could not represent" and "give me nothing unless you can prove it"
70
+ * are both legitimate asks and only the caller knows which one it is making.
71
+ */
72
+ declare const applyComparison: ({ reviewer, revisionStamp, granularity, numberingChanges }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Result<AppliedComparison, CompareDocxApplyError>;
73
+ /**
74
+ * Stage 4: the result package, with every ZIP entry date pinned.
75
+ *
76
+ * A comparison that found nothing returns the base bytes as they arrived. A
77
+ * change is only ever reported alongside the operations that realize it, so no
78
+ * operations means no changes, and re-serializing then rewrites a document
79
+ * nobody edited: on a 2,200-block pair that was a second of work to reproduce
80
+ * the input.
81
+ *
82
+ * Unless the base carried revisions of its own. Then the compared base was its
83
+ * accepted view, the arriving bytes are a different document, and handing them
84
+ * back would make rejecting the result land before the previous reviewer's
85
+ * edits rather than after them.
86
+ *
87
+ * The short-circuit lives here rather than in {@link compareDocx} so that
88
+ * every caller of the stages sees the same decision. Putting it in the
89
+ * composition let the benchmark's own composition disagree with the shipped
90
+ * one within a single run.
91
+ */
92
+ declare const serializeComparison: ({ baseBuffer, baseCarriedRevisions, reviewer, packageDate }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Promise<Result<ArrayBuffer, CompareDocxSerializeError>>;
93
+ /**
94
+ * Compare `base` against `target` and return `base` carrying the tracked
95
+ * changes that turn it into `target`, alongside the change list describing
96
+ * them.
97
+ *
98
+ * The result is verified by default: a redline whose round trip cannot be
99
+ * proven is refused rather than returned, because one that reads plausibly and
100
+ * is wrong is worse than none. `onUnverified: "emit"` asks for the opposite
101
+ * trade — the best redline available, plus the typed list of what could not be
102
+ * represented — for a caller that would rather show something and say what is
103
+ * missing.
104
+ */
105
+ declare const compareDocx: (base: ArrayBuffer, target: ArrayBuffer, options: CompareDocxOptions) => Promise<Result<CompareResult, CompareDocxError>>;
106
+ //#endregion
107
+ export { AppliedComparison, ComparedStoryPair, MAX_COMPARE_OPERATIONS, ParsedComparison, PlannedStoryComparison, applyComparison, compareDocx, parseComparison, planComparison, serializeComparison };
@@ -0,0 +1,406 @@
1
+ import { FolioDocxReviewer } from "../ai-edits/headless.js";
2
+ import "../document-operations.js";
3
+ import { pairFolioDocumentStories } from "../document-stories.js";
4
+ import { planStoryCompare } from "./plan.js";
5
+ import { withFixedPackageDates } from "./reproducible-package.js";
6
+ import { CompareDocxApplyError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError } from "./types.js";
7
+ import { classifyProjectionMismatch, projectSupportedInlineFormatting } from "./verification.js";
8
+ import { Result, panic } from "better-result";
9
+ //#region src/compare/compare.ts
10
+ /**
11
+ * Deterministic `.docx` compare: two packages in, one redlined package plus a
12
+ * JSON change list out.
13
+ *
14
+ * ## Determinism contract
15
+ *
16
+ * `compareDocx(base, target, options)` is a pure function of its three
17
+ * arguments. It reads no clock and no randomness: revision dates come from
18
+ * `options.timestamp`, and revision ids from a seed derived from the base
19
+ * document's own highest existing revision id, so two runs over the same
20
+ * inputs produce byte-identical buffers and deeply equal change lists.
21
+ *
22
+ * ## Round-trip contract
23
+ *
24
+ * Accepting every tracked change in the result yields the target's content;
25
+ * rejecting every one yields the base's. Both directions are checked before
26
+ * the call returns, and the verdict travels with the result as
27
+ * `verification`. Anything the comparison does not cover is reported in
28
+ * `unsupported`, or fails the call, rather than being silently dropped.
29
+ *
30
+ * An unproven redline is refused by default. `onUnverified: "emit"` returns it
31
+ * anyway, with every invariant that did not hold named: a caller that would
32
+ * rather show its best attempt and say what is missing can, and one that wants
33
+ * a redline it can stand behind still gets nothing else.
34
+ *
35
+ * ## Stages
36
+ *
37
+ * The call is four named steps: {@link parseComparison},
38
+ * {@link planComparison}, {@link applyComparison}, {@link serializeComparison}.
39
+ * `compareDocx` is their composition and nothing else, so the benchmark can
40
+ * time the stages separately without keeping a second copy of the pipeline
41
+ * that would drift from this one.
42
+ *
43
+ * @packageDocumentation
44
+ */
45
+ /**
46
+ * Cap on operations one comparison generates. Both inputs are untrusted
47
+ * documents, and each operation costs a document walk plus revision ids.
48
+ */
49
+ const MAX_COMPARE_OPERATIONS = 1e4;
50
+ const parseSide = async (buffer, side, author) => await Result.tryPromise({
51
+ try: async () => await FolioDocxReviewer.fromBuffer(buffer, { author }),
52
+ catch: (cause) => new CompareDocxParseError({
53
+ message: `The ${side} document could not be parsed.`,
54
+ side,
55
+ cause
56
+ })
57
+ });
58
+ /** Read before either side is resolved, so it describes the package as it arrived. */
59
+ const existingRevisionsOf = (reviewer) => {
60
+ let highest = 0;
61
+ let present = false;
62
+ for (const { handle } of reviewer.listStories()) {
63
+ const story = reviewer.readReviewedStory({
64
+ story: handle,
65
+ view: "current-markup"
66
+ });
67
+ for (const change of story?.changes ?? []) {
68
+ highest = Math.max(highest, change.id);
69
+ present = true;
70
+ }
71
+ }
72
+ return {
73
+ idSeed: highest + 1,
74
+ present
75
+ };
76
+ };
77
+ /**
78
+ * One story's text-and-structure projection: every block's text tagged with
79
+ * the table cell it sits in. The tag is what makes the self-check below see a
80
+ * paragraph that landed beside a table instead of inside it.
81
+ */
82
+ const projectBlocks = (blocks) => {
83
+ return blocks.map(({ text, table, styleId, listLevel }) => {
84
+ return `${table ? `t${String(table.tableIndex)}r${String(table.rowIndex)}c${String(table.cellIndex)}g${String(table.gridColumnIndex)}x${String(table.columnSpan)}y${String(table.rowSpan)}p${String(table.paragraphIndex)}` : "body"}|${styleId ?? ""}|${listLevel ?? ""}|${text}`;
85
+ });
86
+ };
87
+ /** Verify formatting only where the plan claims a text-equal formatting change. */
88
+ const formattingRoundTripFailure = ({ invariant, story, changes, actualBlocks, expectedBlocks, expectedBlockId }) => {
89
+ const expectedIndexById = new Map(expectedBlocks.map(({ id }, index) => [id, index]));
90
+ const checkedExpectedIds = /* @__PURE__ */ new Set();
91
+ for (const change of changes) {
92
+ if (change.kind !== "format") continue;
93
+ const expectedId = expectedBlockId(change);
94
+ if (checkedExpectedIds.has(expectedId)) continue;
95
+ checkedExpectedIds.add(expectedId);
96
+ const expectedIndex = expectedIndexById.get(expectedId) ?? -1;
97
+ const expected = expectedBlocks.at(expectedIndex);
98
+ const actual = actualBlocks.at(expectedIndex);
99
+ if (expectedIndex === -1 || !actual || !expected) return {
100
+ invariant,
101
+ cause: "inline-formatting",
102
+ story,
103
+ detail: "a text-equal aligned block could not be projected for formatting verification"
104
+ };
105
+ if (projectSupportedInlineFormatting(actual) !== projectSupportedInlineFormatting(expected)) return {
106
+ invariant,
107
+ cause: "inline-formatting",
108
+ story,
109
+ detail: "supported inline formatting differs in a text-equal aligned block"
110
+ };
111
+ }
112
+ return null;
113
+ };
114
+ const numberingKey = ({ numId, level }) => `${String(numId)}:${String(level)}`;
115
+ const sameNumbering = (left, right) => left.format === right.format && left.levelText === right.levelText && left.start === right.start;
116
+ const compareNumbering = (base, target) => {
117
+ const baseLevels = new Map(base.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
118
+ const targetLevels = new Map(target.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
119
+ const changes = [];
120
+ for (const [key, before] of baseLevels) {
121
+ const after = targetLevels.get(key) ?? null;
122
+ if (after === null || !sameNumbering(before, after)) changes.push({
123
+ kind: "numbering",
124
+ numId: before.numId,
125
+ level: before.level,
126
+ before,
127
+ after
128
+ });
129
+ }
130
+ for (const [key, after] of targetLevels) if (!baseLevels.has(key)) changes.push({
131
+ kind: "numbering",
132
+ numId: after.numId,
133
+ level: after.level,
134
+ before: null,
135
+ after
136
+ });
137
+ return changes.toSorted((left, right) => left.numId - right.numId || left.level - right.level);
138
+ };
139
+ /** Stage 1: both packages to editor models, paired story by story. */
140
+ const parseComparison = async (base, target, options) => {
141
+ const packageDate = new Date(options.timestamp);
142
+ if (Number.isNaN(packageDate.getTime())) return Result.err(new InvalidCompareDocxOptionsError({
143
+ message: "timestamp must be a date the package can be stamped with.",
144
+ option: "timestamp",
145
+ receivedValue: options.timestamp
146
+ }));
147
+ const baseParse = await parseSide(base, "base", options.author);
148
+ if (baseParse.isErr()) return Result.err(baseParse.error);
149
+ const targetParse = await parseSide(target, "target", options.author);
150
+ if (targetParse.isErr()) return Result.err(targetParse.error);
151
+ const reviewer = baseParse.value;
152
+ const targetReviewer = targetParse.value;
153
+ const existing = existingRevisionsOf(reviewer);
154
+ const pairs = [];
155
+ const unsupported = [];
156
+ for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(reviewer.listStories().map(({ handle }) => handle), targetReviewer.listStories().map(({ handle }) => handle))) {
157
+ if (!baseStory) {
158
+ unsupported.push({
159
+ reason: "story-missing-in-base",
160
+ baseStory: null,
161
+ targetStory
162
+ });
163
+ continue;
164
+ }
165
+ if (!targetStory) {
166
+ unsupported.push({
167
+ reason: "story-missing-in-target",
168
+ baseStory,
169
+ targetStory: null
170
+ });
171
+ continue;
172
+ }
173
+ reviewer.resolveReviewedStory({
174
+ story: baseStory,
175
+ view: "final"
176
+ });
177
+ targetReviewer.resolveReviewedStory({
178
+ story: targetStory,
179
+ view: "final"
180
+ });
181
+ const baseSnapshot = reviewer.snapshotStory(baseStory);
182
+ const targetSnapshot = targetReviewer.snapshotStory(targetStory);
183
+ if (!baseSnapshot || !targetSnapshot) {
184
+ unsupported.push({
185
+ reason: "story-not-editable",
186
+ baseStory,
187
+ targetStory
188
+ });
189
+ continue;
190
+ }
191
+ pairs.push({
192
+ baseStory,
193
+ targetStory,
194
+ baseSnapshot,
195
+ targetSnapshot
196
+ });
197
+ }
198
+ return Result.ok({
199
+ granularity: options.granularity ?? "word",
200
+ baseBuffer: base,
201
+ baseCarriedRevisions: existing.present,
202
+ reviewer,
203
+ targetReviewer,
204
+ revisionStamp: {
205
+ date: options.timestamp,
206
+ idSeed: existing.idSeed
207
+ },
208
+ packageDate,
209
+ pairs,
210
+ numberingChanges: compareNumbering(reviewer, targetReviewer),
211
+ unsupported
212
+ });
213
+ };
214
+ /**
215
+ * Stage 2: align every paired story and derive its operations. Pure — no
216
+ * parsing, no serialization, no clock.
217
+ */
218
+ const planComparison = ({ pairs }) => {
219
+ const planned = [];
220
+ for (const pair of pairs) {
221
+ const plan = planStoryCompare({
222
+ story: pair.baseStory,
223
+ baseSnapshot: pair.baseSnapshot,
224
+ targetSnapshot: pair.targetSnapshot,
225
+ maxOperations: MAX_COMPARE_OPERATIONS
226
+ });
227
+ if (plan === null) return Result.err(new CompareDocxOperationLimitError({
228
+ message: "The comparison needs more operations than the engine generates.",
229
+ limit: MAX_COMPARE_OPERATIONS
230
+ }));
231
+ planned.push({
232
+ pair,
233
+ plan
234
+ });
235
+ }
236
+ return Result.ok(planned);
237
+ };
238
+ /**
239
+ * Stage 3: write the planned operations into the base document as tracked
240
+ * changes, then check the work rather than trust it. Both directions of the
241
+ * round trip are checked, structure included: accepting the story's generated
242
+ * revisions must reproduce the target, and rejecting them must reproduce the
243
+ * base it was compared from. A difference the operation vocabulary cannot
244
+ * express would otherwise leave a redline that reads plausibly and is wrong.
245
+ *
246
+ * The check reports rather than throws. {@link compareDocx} decides what to do
247
+ * with an unverified result, because "give me your best attempt and tell me
248
+ * what you could not represent" and "give me nothing unless you can prove it"
249
+ * are both legitimate asks and only the caller knows which one it is making.
250
+ */
251
+ const applyComparison = ({ reviewer, revisionStamp, granularity, numberingChanges }, planned) => {
252
+ const changes = [...numberingChanges];
253
+ const failures = [];
254
+ let idSeed = revisionStamp.idSeed;
255
+ for (const { pair, plan } of planned) {
256
+ changes.push(...plan.changes);
257
+ if (plan.operations.length === 0) continue;
258
+ const baseBeforeBlocks = reviewer.readReviewedStory({
259
+ story: pair.baseStory,
260
+ view: "final"
261
+ })?.snapshot.blocks ?? [];
262
+ const baseBefore = projectBlocks(baseBeforeBlocks);
263
+ const { skipped, nextRevisionId } = reviewer.applyDocumentOperationsToStory({
264
+ story: pair.baseStory,
265
+ snapshot: pair.baseSnapshot,
266
+ revisionStamp: {
267
+ date: revisionStamp.date,
268
+ idSeed
269
+ },
270
+ wordDiff: { granularity },
271
+ batch: {
272
+ version: 1,
273
+ mode: "tracked-changes",
274
+ operations: plan.operations
275
+ }
276
+ });
277
+ if (nextRevisionId === void 0) panic("The applier did not report where it left the revision-id counter", { story: pair.baseStory });
278
+ idSeed = nextRevisionId;
279
+ if (skipped.length > 0) return Result.err(new CompareDocxApplyError({
280
+ message: "Some derived operations were refused, so the result would not match the target.",
281
+ skipped
282
+ }));
283
+ const acceptedStory = reviewer.readReviewedStory({
284
+ story: pair.baseStory,
285
+ view: "final"
286
+ });
287
+ const acceptFailure = classifyProjectionMismatch({
288
+ invariant: "accept-reproduces-target",
289
+ story: pair.baseStory,
290
+ actual: projectBlocks(acceptedStory?.snapshot.blocks ?? []),
291
+ expected: projectBlocks(pair.targetSnapshot.blocks)
292
+ });
293
+ if (acceptFailure) failures.push(acceptFailure);
294
+ else {
295
+ const formattingFailure = formattingRoundTripFailure({
296
+ invariant: "accept-reproduces-target",
297
+ story: pair.baseStory,
298
+ changes: plan.changes,
299
+ actualBlocks: acceptedStory?.snapshot.blocks ?? [],
300
+ expectedBlocks: pair.targetSnapshot.blocks,
301
+ expectedBlockId: ({ targetBlockId }) => targetBlockId
302
+ });
303
+ if (formattingFailure) failures.push(formattingFailure);
304
+ }
305
+ const rejectedStory = reviewer.readReviewedStory({
306
+ story: pair.baseStory,
307
+ view: "original"
308
+ });
309
+ const rejectFailure = classifyProjectionMismatch({
310
+ invariant: "reject-reproduces-base",
311
+ story: pair.baseStory,
312
+ actual: projectBlocks(rejectedStory?.snapshot.blocks ?? []),
313
+ expected: baseBefore
314
+ });
315
+ if (rejectFailure) failures.push(rejectFailure);
316
+ else {
317
+ const formattingFailure = formattingRoundTripFailure({
318
+ invariant: "reject-reproduces-base",
319
+ story: pair.baseStory,
320
+ changes: plan.changes,
321
+ actualBlocks: rejectedStory?.snapshot.blocks ?? [],
322
+ expectedBlocks: pair.baseSnapshot.blocks,
323
+ expectedBlockId: ({ baseBlockId }) => baseBlockId
324
+ });
325
+ if (formattingFailure) failures.push(formattingFailure);
326
+ }
327
+ }
328
+ return Result.ok({
329
+ changes,
330
+ verification: failures.length === 0 ? { status: "verified" } : {
331
+ status: "unverified",
332
+ failures
333
+ }
334
+ });
335
+ };
336
+ /**
337
+ * Stage 4: the result package, with every ZIP entry date pinned.
338
+ *
339
+ * A comparison that found nothing returns the base bytes as they arrived. A
340
+ * change is only ever reported alongside the operations that realize it, so no
341
+ * operations means no changes, and re-serializing then rewrites a document
342
+ * nobody edited: on a 2,200-block pair that was a second of work to reproduce
343
+ * the input.
344
+ *
345
+ * Unless the base carried revisions of its own. Then the compared base was its
346
+ * accepted view, the arriving bytes are a different document, and handing them
347
+ * back would make rejecting the result land before the previous reviewer's
348
+ * edits rather than after them.
349
+ *
350
+ * The short-circuit lives here rather than in {@link compareDocx} so that
351
+ * every caller of the stages sees the same decision. Putting it in the
352
+ * composition let the benchmark's own composition disagree with the shipped
353
+ * one within a single run.
354
+ */
355
+ const serializeComparison = async ({ baseBuffer, baseCarriedRevisions, reviewer, packageDate }, planned) => {
356
+ if (!baseCarriedRevisions && planned.every(({ plan }) => plan.operations.length === 0)) return Result.ok(baseBuffer);
357
+ return await Result.tryPromise({
358
+ try: async () => await withFixedPackageDates(await reviewer.toBuffer(), packageDate),
359
+ catch: (cause) => new CompareDocxSerializeError({
360
+ message: "The compared document could not be serialized.",
361
+ cause
362
+ })
363
+ });
364
+ };
365
+ /**
366
+ * Compare `base` against `target` and return `base` carrying the tracked
367
+ * changes that turn it into `target`, alongside the change list describing
368
+ * them.
369
+ *
370
+ * The result is verified by default: a redline whose round trip cannot be
371
+ * proven is refused rather than returned, because one that reads plausibly and
372
+ * is wrong is worse than none. `onUnverified: "emit"` asks for the opposite
373
+ * trade — the best redline available, plus the typed list of what could not be
374
+ * represented — for a caller that would rather show something and say what is
375
+ * missing.
376
+ */
377
+ const compareDocx = async (base, target, options) => {
378
+ const parsed = await parseComparison(base, target, options);
379
+ if (parsed.isErr()) return Result.err(parsed.error);
380
+ const planned = planComparison(parsed.value);
381
+ if (planned.isErr()) return Result.err(planned.error);
382
+ const applied = applyComparison(parsed.value, planned.value);
383
+ if (applied.isErr()) return Result.err(applied.error);
384
+ const { changes, verification } = applied.value;
385
+ if (verification.status === "unverified" && (options.onUnverified ?? "refuse") === "refuse") {
386
+ const [firstFailure] = verification.failures;
387
+ if (firstFailure === void 0) panic("An unverified comparison reported no failing invariant");
388
+ return Result.err(new CompareDocxRoundTripError({
389
+ message: `The generated tracked changes do not satisfy ${firstFailure.invariant}: ${firstFailure.detail}`,
390
+ story: firstFailure.story,
391
+ invariant: firstFailure.invariant,
392
+ cause: firstFailure.cause,
393
+ failures: verification.failures
394
+ }));
395
+ }
396
+ const serialized = await serializeComparison(parsed.value, planned.value);
397
+ if (serialized.isErr()) return Result.err(serialized.error);
398
+ return Result.ok({
399
+ buffer: serialized.value,
400
+ changes,
401
+ verification,
402
+ unsupported: parsed.value.unsupported
403
+ });
404
+ };
405
+ //#endregion
406
+ export { MAX_COMPARE_OPERATIONS, applyComparison, compareDocx, parseComparison, planComparison, serializeComparison };