@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
@@ -1,114 +1,274 @@
1
1
  //#region src/ai-edits/word-diff.ts
2
+ /**
3
+ * What one token is. `"word"` tokenises on whitespace and is what a redline
4
+ * over prose should use; `"character"` marks the changed letters inside a
5
+ * word, which reads well for a reference number or a date and badly for a
6
+ * sentence.
7
+ */
8
+ const WORD_DIFF_GRANULARITIES = Object.freeze(["word", "character"]);
2
9
  const WHITESPACE = /\s/u;
3
- const tokenize = (s) => {
10
+ /** Punctuation, symbols and whitespace: everything that is not content. */
11
+ const SEPARATOR_ONLY = /^[\s\p{P}\p{S}]*$/u;
12
+ /**
13
+ * A match of at most this many tokens that carries no letters or digits is
14
+ * noise: a lone space, a comma, a stray closing bracket. Matching it splits
15
+ * two rewrites into four.
16
+ */
17
+ const MAX_SEPARATOR_ONLY_MATCH_UNITS = 3;
18
+ /**
19
+ * A match shorter than this, with changes on BOTH sides of it, is dropped into
20
+ * them. An island that small is almost always a coincidence, and it costs the
21
+ * reader two extra fragments to notice. A match that opens or closes the
22
+ * string is not an island: it is where the reader anchors, and striking it
23
+ * through to re-insert it identically reads as an edit nobody made.
24
+ */
25
+ const MINIMUM_ISOLATED_MATCH_UNITS = 2;
26
+ /**
27
+ * Fragmentation floor. `sumOfSquares` rewards few long matches and punishes
28
+ * many short ones — a single run of length L scores L^2, while L runs of
29
+ * length 1 score L — so comparing it against the average string length
30
+ * separates "a few words changed" from "rewritten, with coincidental
31
+ * matches". Below the floor the paragraph is replaced whole.
32
+ */
33
+ const FRAGMENTATION_SCALE = 32;
34
+ /** Below this length every match is a large fraction of the string, so the floor says nothing. */
35
+ const FRAGMENTATION_MINIMUM_AVERAGE_LENGTH = 8;
36
+ const tokenizeWords = (value) => {
4
37
  const tokens = [];
5
38
  let tokenStart = 0;
6
39
  let cursor = 0;
7
- while (cursor < s.length) {
8
- while (cursor < s.length && WHITESPACE.test(s.charAt(cursor))) cursor++;
9
- if (cursor === s.length) break;
10
- while (cursor < s.length && !WHITESPACE.test(s.charAt(cursor))) cursor++;
11
- tokens.push(s.slice(tokenStart, cursor));
40
+ while (cursor < value.length) {
41
+ while (cursor < value.length && WHITESPACE.test(value.charAt(cursor))) cursor++;
42
+ if (cursor === value.length) break;
43
+ while (cursor < value.length && !WHITESPACE.test(value.charAt(cursor))) cursor++;
44
+ tokens.push(value.slice(tokenStart, cursor));
12
45
  tokenStart = cursor;
13
46
  }
14
47
  const last = tokens.at(-1);
15
- if (last === void 0) return s.length === 0 ? [] : [s];
16
- if (tokenStart < s.length) tokens[tokens.length - 1] = last + s.slice(tokenStart);
48
+ if (last === void 0) return value.length === 0 ? [] : [value];
49
+ if (tokenStart < value.length) tokens[tokens.length - 1] = last + value.slice(tokenStart);
17
50
  return tokens;
18
51
  };
52
+ /** Code points, not UTF-16 units, so an emoji or a surrogate pair stays whole. */
53
+ const tokenizeCharacters = (value) => [...value];
54
+ const tokenize = (value, granularity) => granularity === "character" ? tokenizeCharacters(value) : tokenizeWords(value);
55
+ /**
56
+ * The key two tokens are matched on. `toLowerCase` rather than
57
+ * `toLocaleLowerCase`: the ambient locale would make the same two documents
58
+ * diff differently on two machines.
59
+ */
60
+ const comparisonKey = (token, normalization) => {
61
+ const collapsed = normalization.whitespace === true ? token.replaceAll(/\s+/gu, " ").trim() : token;
62
+ return normalization.case === true ? collapsed.toLowerCase() : collapsed;
63
+ };
64
+ const isSeparatorOnly = (text) => SEPARATOR_ONLY.test(text);
19
65
  /**
20
- * Cell budget for the O(n*m) word-diff DP table below, mirroring
21
- * `MAX_LCS_CELLS` in `version-comparison.ts`. `before`/`after` come from
66
+ * Cell budget for the O(n*m) DP table below. `before`/`after` come from
22
67
  * attacker-controlled document text (a `modified` block pair), so an
23
68
  * unbounded pair of large strings would otherwise force a quadratic-sized
24
69
  * allocation. Past this budget, skip the DP and fall back to a single
25
70
  * whole-string `del` + `ins` pair — a coarser diff, but O(1) memory.
26
71
  */
27
72
  const MAX_WORD_DIFF_CELLS = 4e6;
28
- const diffWordSegments = (before, after) => {
29
- const a = tokenize(before);
30
- const b = tokenize(after);
31
- if (a.length === 0 && b.length === 0) return [];
32
- const m = a.length;
33
- const n = b.length;
34
- if (m * n > MAX_WORD_DIFF_CELLS) {
35
- const segments = [];
36
- if (before.length > 0) segments.push({
37
- type: "del",
38
- text: before
39
- });
40
- if (after.length > 0) segments.push({
41
- type: "ins",
42
- text: after
43
- });
44
- return segments;
45
- }
73
+ /** The LCS alignment, as runs. Longest common subsequence on comparison keys. */
74
+ const alignTokens = ({ before, after, normalization }) => {
75
+ const beforeKeys = before.map((token) => comparisonKey(token, normalization));
76
+ const afterKeys = after.map((token) => comparisonKey(token, normalization));
77
+ const m = before.length;
78
+ const n = after.length;
46
79
  const dp = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0));
47
80
  for (let i = 0; i < m; i++) for (let j = 0; j < n; j++) {
48
81
  const row = dp[i + 1];
49
- const prevRow = dp[i];
50
- if (!row || !prevRow) continue;
51
- const prev = prevRow[j] ?? 0;
52
- const left = row[j] ?? 0;
53
- const top = prevRow[j + 1] ?? 0;
54
- row[j + 1] = a[i] === b[j] ? prev + 1 : Math.max(left, top);
82
+ const previousRow = dp[i];
83
+ if (!row || !previousRow) continue;
84
+ row[j + 1] = beforeKeys[i] === afterKeys[j] ? (previousRow[j] ?? 0) + 1 : Math.max(row[j] ?? 0, previousRow[j + 1] ?? 0);
55
85
  }
56
- const segments = [];
57
- const push = (type, text) => {
58
- if (text.length === 0) return;
59
- const last = segments.at(-1);
60
- if (last && last.type === type) {
61
- last.text += text;
62
- return;
63
- }
64
- segments.push({
65
- type,
66
- text
67
- });
68
- };
86
+ const reversed = [];
69
87
  let i = m;
70
88
  let j = n;
71
- const reverse = [];
72
89
  while (i > 0 && j > 0) {
73
- if (a[i - 1] === b[j - 1]) {
74
- reverse.push({
90
+ if (beforeKeys[i - 1] === afterKeys[j - 1]) {
91
+ reversed.push({
75
92
  type: "equal",
76
- text: a[i - 1] ?? ""
93
+ before: before[i - 1] ?? "",
94
+ after: after[j - 1] ?? "",
95
+ units: 1
77
96
  });
78
97
  i--;
79
98
  j--;
80
99
  continue;
81
100
  }
82
101
  if ((dp[i - 1]?.[j] ?? 0) > (dp[i]?.[j - 1] ?? 0)) {
83
- reverse.push({
102
+ reversed.push({
84
103
  type: "del",
85
- text: a[i - 1] ?? ""
104
+ text: before[i - 1] ?? ""
86
105
  });
87
106
  i--;
88
107
  } else {
89
- reverse.push({
108
+ reversed.push({
90
109
  type: "ins",
91
- text: b[j - 1] ?? ""
110
+ text: after[j - 1] ?? ""
92
111
  });
93
112
  j--;
94
113
  }
95
114
  }
96
115
  while (i > 0) {
97
- reverse.push({
116
+ reversed.push({
98
117
  type: "del",
99
- text: a[i - 1] ?? ""
118
+ text: before[i - 1] ?? ""
100
119
  });
101
120
  i--;
102
121
  }
103
122
  while (j > 0) {
104
- reverse.push({
123
+ reversed.push({
105
124
  type: "ins",
106
- text: b[j - 1] ?? ""
125
+ text: after[j - 1] ?? ""
107
126
  });
108
127
  j--;
109
128
  }
110
- for (const seg of reverse.toReversed()) push(seg.type, seg.text);
129
+ const runs = [];
130
+ for (const run of reversed.toReversed()) {
131
+ const last = runs.at(-1);
132
+ if (last?.type === "equal" && run.type === "equal") {
133
+ last.before += run.before;
134
+ last.after += run.after;
135
+ last.units += run.units;
136
+ continue;
137
+ }
138
+ if (last?.type === "del" && run.type === "del" || last?.type === "ins" && run.type === "ins") {
139
+ last.text += run.text;
140
+ continue;
141
+ }
142
+ runs.push({ ...run });
143
+ }
144
+ return runs;
145
+ };
146
+ /**
147
+ * True when the surviving matches are too short, relative to the strings they
148
+ * sit in, to be read as anything but coincidence.
149
+ */
150
+ const isTooFragmented = (runs, averageLength) => {
151
+ if (averageLength < FRAGMENTATION_MINIMUM_AVERAGE_LENGTH) return false;
152
+ let sumOfSquares = 0;
153
+ for (const run of runs) if (run.type === "equal") sumOfSquares += run.before.length * run.before.length;
154
+ return sumOfSquares * FRAGMENTATION_SCALE < averageLength * averageLength;
155
+ };
156
+ /**
157
+ * Turn a match the quality rules rejected back into the change it interrupts.
158
+ *
159
+ * Only a match with a change beside it can be rejected: with nothing to
160
+ * absorb it, demoting would invent a deletion and an insertion of the same
161
+ * text where the two strings agree.
162
+ */
163
+ const demoteRejectedMatches = (runs) => {
164
+ const kept = [];
165
+ for (const [index, run] of runs.entries()) {
166
+ if (run.type !== "equal") {
167
+ kept.push(run);
168
+ continue;
169
+ }
170
+ const interruptsAChange = runs[index - 1] !== void 0 || runs[index + 1] !== void 0;
171
+ const isIsland = runs[index - 1] !== void 0 && runs[index + 1] !== void 0;
172
+ if (!(interruptsAChange && run.units <= MAX_SEPARATOR_ONLY_MATCH_UNITS && isSeparatorOnly(run.before) || isIsland && run.units < MINIMUM_ISOLATED_MATCH_UNITS)) {
173
+ kept.push(run);
174
+ continue;
175
+ }
176
+ kept.push({
177
+ type: "del",
178
+ text: run.before
179
+ }, {
180
+ type: "ins",
181
+ text: run.after
182
+ });
183
+ }
184
+ return kept;
185
+ };
186
+ const toSegments = (runs) => {
187
+ const segments = [];
188
+ const push = (type, text) => {
189
+ if (text.length === 0) return;
190
+ const last = segments.at(-1);
191
+ if (last?.type === type) {
192
+ last.text += text;
193
+ return;
194
+ }
195
+ segments.push({
196
+ type,
197
+ text
198
+ });
199
+ };
200
+ for (const run of runs) {
201
+ if (run.type === "equal") {
202
+ push("equal", run.before);
203
+ continue;
204
+ }
205
+ push(run.type, run.text);
206
+ }
207
+ return segments;
208
+ };
209
+ /**
210
+ * Deletions before insertions within one changed region. Demoting a match
211
+ * emits `del`, `ins`, `del`, `ins`; a reader wants the whole old text struck
212
+ * through and then the whole new text.
213
+ */
214
+ const orderDeletionsFirst = (runs) => {
215
+ const ordered = [];
216
+ let deletions = "";
217
+ let insertions = "";
218
+ const flush = () => {
219
+ if (deletions.length > 0) ordered.push({
220
+ type: "del",
221
+ text: deletions
222
+ });
223
+ if (insertions.length > 0) ordered.push({
224
+ type: "ins",
225
+ text: insertions
226
+ });
227
+ deletions = "";
228
+ insertions = "";
229
+ };
230
+ for (const run of runs) {
231
+ if (run.type === "del") {
232
+ deletions += run.text;
233
+ continue;
234
+ }
235
+ if (run.type === "ins") {
236
+ insertions += run.text;
237
+ continue;
238
+ }
239
+ flush();
240
+ ordered.push(run);
241
+ }
242
+ flush();
243
+ return ordered;
244
+ };
245
+ const wholeStringReplacement = (before, after) => {
246
+ const segments = [];
247
+ if (before.length > 0) segments.push({
248
+ type: "del",
249
+ text: before
250
+ });
251
+ if (after.length > 0) segments.push({
252
+ type: "ins",
253
+ text: after
254
+ });
111
255
  return segments;
112
256
  };
257
+ const diffWordSegments = (before, after, options = {}) => {
258
+ const granularity = options.granularity ?? "word";
259
+ const normalization = options.normalization ?? {};
260
+ const beforeTokens = tokenize(before, granularity);
261
+ const afterTokens = tokenize(after, granularity);
262
+ if (beforeTokens.length === 0 && afterTokens.length === 0) return [];
263
+ if (beforeTokens.length * afterTokens.length > MAX_WORD_DIFF_CELLS) return wholeStringReplacement(before, after);
264
+ const aligned = alignTokens({
265
+ before: beforeTokens,
266
+ after: afterTokens,
267
+ normalization
268
+ });
269
+ const surviving = demoteRejectedMatches(aligned);
270
+ if (isTooFragmented(surviving, (before.length + after.length) / 2)) return wholeStringReplacement(before, after);
271
+ return toSegments(orderDeletionsFirst(surviving));
272
+ };
113
273
  //#endregion
114
- export { diffWordSegments };
274
+ export { WORD_DIFF_GRANULARITIES, diffWordSegments };
@@ -0,0 +1,33 @@
1
+ //#region src/compare/__fixtures__/body-sequence.d.ts
2
+ /**
3
+ * A document body written from a sequence of paragraphs and tables.
4
+ *
5
+ * The edit-script DSL can move words and rows but cannot append a table, so a
6
+ * pair whose difference is "a paragraph AND a table were added after the last
7
+ * one" has to be authored as two packages. Generating both from one description
8
+ * keeps the difference visible in the diff instead of hidden in bytes.
9
+ */
10
+ /**
11
+ * One cell's content: a line of text, or a sequence of its own — which is how
12
+ * a nested table, a blank line inside a cell, or a cell that ends with a table
13
+ * gets written.
14
+ */
15
+ type CellContent = string | readonly BodyItem[];
16
+ /** One body-level item: a paragraph, or a table given row by row. */
17
+ type BodyItem = {
18
+ kind: "paragraph";
19
+ text: string;
20
+ styleId?: string;
21
+ } | {
22
+ kind: "table";
23
+ rows: readonly (readonly CellContent[])[];
24
+ /**
25
+ * Rows a package hides with `w:hidden`. The snapshot skips their whole
26
+ * subtree, so a document that has one is the case where the snapshot
27
+ * walk and the live walk could disagree.
28
+ */
29
+ hiddenRows?: readonly number[];
30
+ };
31
+ declare const buildBodySequenceDocx: (items: readonly BodyItem[]) => Promise<ArrayBuffer>;
32
+ //#endregion
33
+ export { BodyItem, CellContent, buildBodySequenceDocx };
@@ -0,0 +1,67 @@
1
+ import JSZip from "jszip";
2
+ //#region src/compare/__fixtures__/body-sequence.ts
3
+ /**
4
+ * A document body written from a sequence of paragraphs and tables.
5
+ *
6
+ * The edit-script DSL can move words and rows but cannot append a table, so a
7
+ * pair whose difference is "a paragraph AND a table were added after the last
8
+ * one" has to be authored as two packages. Generating both from one description
9
+ * keeps the difference visible in the diff instead of hidden in bytes.
10
+ */
11
+ const NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
12
+ const RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006/relationships";
13
+ const OFFICE_RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
14
+ const WORDPROCESSING = "application/vnd.openxmlformats-officedocument.wordprocessingml";
15
+ /**
16
+ * `createFolders: false` because JSZip stamps the folder entries it
17
+ * synthesizes with `new Date()`, which the fixed date above does not reach.
18
+ */
19
+ const ZIP_ENTRY_OPTIONS = {
20
+ date: new Date(Date.UTC(2e3, 0, 1)),
21
+ createFolders: false
22
+ };
23
+ /**
24
+ * An empty paragraph is a `w:p` with no run at all, which is what a package
25
+ * holds for a blank line or an empty cell. It is not the same thing as a
26
+ * paragraph whose run carries an empty string, and both shapes occur.
27
+ */
28
+ const paragraph = (text, styleId) => {
29
+ const properties = styleId === void 0 ? "" : `<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>`;
30
+ return text.length === 0 ? `<w:p>${properties}</w:p>` : `<w:p>${properties}<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
31
+ };
32
+ const EMPTY_PARAGRAPH = {
33
+ kind: "paragraph",
34
+ text: ""
35
+ };
36
+ /**
37
+ * A container may not end with a table: the format requires a paragraph after
38
+ * one, and a body's section properties do not supply it. One rule for both
39
+ * containers, because a fixture that is well formed in a cell and malformed in
40
+ * the body would be measuring two different things.
41
+ */
42
+ const closedSequence = (items) => {
43
+ const last = items.at(-1);
44
+ return last === void 0 || last.kind === "table" ? [...items, EMPTY_PARAGRAPH] : items;
45
+ };
46
+ /** A cell must also contain a paragraph, which the empty sequence supplies. */
47
+ const cellXml = (content) => typeof content === "string" ? paragraph(content) : itemsXml(closedSequence(content));
48
+ const table = (item) => {
49
+ const hidden = new Set(item.hiddenRows ?? []);
50
+ return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>` + item.rows.map((cells, rowIndex) => `<w:tr>${hidden.has(rowIndex) ? `<w:trPr><w:hidden/></w:trPr>` : ""}${cells.map((content) => `<w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr>${cellXml(content)}</w:tc>`).join("")}</w:tr>`).join("") + `</w:tbl>`;
51
+ };
52
+ const itemsXml = (items) => items.map((item) => item.kind === "paragraph" ? paragraph(item.text, item.styleId) : table(item)).join("");
53
+ const bodyXml = (items) => itemsXml(closedSequence(items));
54
+ const buildBodySequenceDocx = async (items) => {
55
+ const parts = {
56
+ "[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="${WORDPROCESSING}.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="${WORDPROCESSING}.styles+xml"/></Types>`,
57
+ "_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/officeDocument" Target="word/document.xml"/></Relationships>`,
58
+ "word/_rels/document.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/styles" Target="styles.xml"/></Relationships>`,
59
+ "word/styles.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${NAMESPACE}"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/></w:style></w:styles>`,
60
+ "word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${NAMESPACE}"><w:body>` + bodyXml(items) + "<w:sectPr><w:pgSz w:w=\"12240\" w:h=\"15840\"/><w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\"/></w:sectPr></w:body></w:document>"
61
+ };
62
+ const zip = new JSZip();
63
+ for (const name of Object.keys(parts).toSorted()) zip.file(name, parts[name] ?? "", ZIP_ENTRY_OPTIONS);
64
+ return await zip.generateAsync({ type: "arraybuffer" });
65
+ };
66
+ //#endregion
67
+ export { buildBodySequenceDocx };
@@ -0,0 +1,16 @@
1
+ //#region src/compare/__fixtures__/nested-table.d.ts
2
+ /**
3
+ * A document that ends with a table whose last cell holds another table.
4
+ *
5
+ * The shape matters because the story's last block is then two levels deep,
6
+ * and a paragraph appended after the whole table has no block to anchor to
7
+ * that is not inside a cell. Authored here rather than committed as bytes so
8
+ * the structure under test is legible in the diff.
9
+ */
10
+ type NestedTableDocxOptions = {
11
+ /** Appended after the outer table, at body level, when set. */
12
+ trailingParagraph?: string;
13
+ };
14
+ declare const buildNestedTableDocx: ({ trailingParagraph }?: NestedTableDocxOptions) => Promise<ArrayBuffer>;
15
+ //#endregion
16
+ export { NestedTableDocxOptions, buildNestedTableDocx };
@@ -0,0 +1,50 @@
1
+ import JSZip from "jszip";
2
+ //#region src/compare/__fixtures__/nested-table.ts
3
+ /**
4
+ * A document that ends with a table whose last cell holds another table.
5
+ *
6
+ * The shape matters because the story's last block is then two levels deep,
7
+ * and a paragraph appended after the whole table has no block to anchor to
8
+ * that is not inside a cell. Authored here rather than committed as bytes so
9
+ * the structure under test is legible in the diff.
10
+ */
11
+ const NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
12
+ const RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006/relationships";
13
+ const OFFICE_RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
14
+ const WORDPROCESSING = "application/vnd.openxmlformats-officedocument.wordprocessingml";
15
+ /**
16
+ * `createFolders: false` because JSZip stamps the folder entries it
17
+ * synthesizes with `new Date()`, which the fixed date above does not reach.
18
+ */
19
+ const ZIP_ENTRY_OPTIONS = {
20
+ date: new Date(Date.UTC(2e3, 0, 1)),
21
+ createFolders: false
22
+ };
23
+ const paragraph = (text) => text.length === 0 ? `<w:p/>` : `<w:p><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
24
+ /**
25
+ * A table may not be the last child of a body or a cell, so every container
26
+ * that ends with one is closed by a paragraph. Word writes that paragraph and
27
+ * usually leaves it empty; a fixture that omits it is malformed, and the
28
+ * comparison should be exercised against the shape real packages have.
29
+ */
30
+ const closingParagraph = paragraph("");
31
+ const cell = (inner) => `<w:tc><w:tcPr><w:tcW w:w="4680" w:type="dxa"/></w:tcPr>${inner}</w:tc>`;
32
+ const table = (rows) => `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="4680"/><w:gridCol w:w="4680"/></w:tblGrid>${rows.join("")}</w:tbl>`;
33
+ /** The nested table sits in the outer table's last cell, deep in the story. */
34
+ const NESTED_TABLE = table([`<w:tr>${cell(`${paragraph("The nested schedule lists the delivery dates.")}${closingParagraph}`)}</w:tr>`]);
35
+ const OUTER_TABLE = table([`<w:tr>${cell(paragraph("Obligations of the supplier."))}${cell(paragraph("Obligations of the buyer."))}</w:tr>`, `<w:tr>${cell(paragraph("Deliver the goods to the named place."))}${cell(`${paragraph("See the schedule below.")}${NESTED_TABLE}${closingParagraph}`)}</w:tr>`]);
36
+ const buildNestedTableDocx = async ({ trailingParagraph } = {}) => {
37
+ const body = paragraph("This agreement is made between the parties named below.") + OUTER_TABLE + (trailingParagraph === void 0 ? closingParagraph : `${paragraph(trailingParagraph)}${closingParagraph}`);
38
+ const parts = {
39
+ "[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="${WORDPROCESSING}.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="${WORDPROCESSING}.styles+xml"/></Types>`,
40
+ "_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/officeDocument" Target="word/document.xml"/></Relationships>`,
41
+ "word/_rels/document.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/styles" Target="styles.xml"/></Relationships>`,
42
+ "word/styles.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${NAMESPACE}"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style></w:styles>`,
43
+ "word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${NAMESPACE}"><w:body>${body}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
44
+ };
45
+ const zip = new JSZip();
46
+ for (const name of Object.keys(parts).toSorted()) zip.file(name, parts[name] ?? "", ZIP_ENTRY_OPTIONS);
47
+ return await zip.generateAsync({ type: "arraybuffer" });
48
+ };
49
+ //#endregion
50
+ export { buildNestedTableDocx };
@@ -0,0 +1,34 @@
1
+ //#region src/compare/__fixtures__/numbered-list.d.ts
2
+ /**
3
+ * A small multi-level numbered list, authored here rather than committed as
4
+ * bytes.
5
+ *
6
+ * The corpus fixtures carry no numbering at all, so a probe that says "an added
7
+ * list item" while editing an unnumbered paragraph is testing something else
8
+ * and saying it is testing numbering. Generating the package keeps its content
9
+ * legible in the diff and keeps the provenance trivial.
10
+ */
11
+ /**
12
+ * One item: its indent level and its text. `null` is a paragraph that keeps
13
+ * the list style and carries no numbering.
14
+ */
15
+ type NumberedListItem = {
16
+ level: number | null;
17
+ text: string;
18
+ };
19
+ declare const NUMBERED_LIST_ITEMS: readonly NumberedListItem[];
20
+ /** The same list with one item demoted one level: a level change and no text change. */
21
+ declare const withItemDemoted: (items: readonly NumberedListItem[], index: number) => NumberedListItem[];
22
+ /**
23
+ * The same list with one item no longer numbered: the paragraph keeps every
24
+ * word and stops being a list item, which is the edit a level change cannot
25
+ * express.
26
+ */
27
+ declare const withItemUnnumbered: (items: readonly NumberedListItem[], index: number) => NumberedListItem[];
28
+ type NumberedListDocxOptions = {
29
+ /** `w:numFmt` for the top level. Default `"decimal"`. */
30
+ format?: string;
31
+ };
32
+ declare const buildNumberedListDocx: (items?: readonly NumberedListItem[], { format }?: NumberedListDocxOptions) => Promise<ArrayBuffer>;
33
+ //#endregion
34
+ export { NUMBERED_LIST_ITEMS, NumberedListDocxOptions, NumberedListItem, buildNumberedListDocx, withItemDemoted, withItemUnnumbered };
@@ -0,0 +1,85 @@
1
+ import JSZip from "jszip";
2
+ //#region src/compare/__fixtures__/numbered-list.ts
3
+ /**
4
+ * A small multi-level numbered list, authored here rather than committed as
5
+ * bytes.
6
+ *
7
+ * The corpus fixtures carry no numbering at all, so a probe that says "an added
8
+ * list item" while editing an unnumbered paragraph is testing something else
9
+ * and saying it is testing numbering. Generating the package keeps its content
10
+ * legible in the diff and keeps the provenance trivial.
11
+ */
12
+ const NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
13
+ const RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006/relationships";
14
+ const OFFICE_RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
15
+ const WORDPROCESSING = "application/vnd.openxmlformats-officedocument.wordprocessingml";
16
+ /**
17
+ * `createFolders: false` because JSZip stamps the folder entries it
18
+ * synthesizes with `new Date()`, which the fixed date above does not reach.
19
+ */
20
+ const ZIP_ENTRY_OPTIONS = {
21
+ date: new Date(Date.UTC(2e3, 0, 1)),
22
+ createFolders: false
23
+ };
24
+ const LIST_LEVELS = 3;
25
+ const abstractLevels = (topLevelFormat) => Array.from({ length: LIST_LEVELS }, (_unused, level) => {
26
+ return `<w:lvl w:ilvl="${String(level)}"><w:start w:val="1"/><w:numFmt w:val="${level === 0 ? topLevelFormat : "lowerLetter"}"/><w:lvlText w:val="%${String(level + 1)}."/><w:lvlJc w:val="left"/></w:lvl>`;
27
+ }).join("");
28
+ const listParagraph = (level, text) => {
29
+ return `<w:p><w:pPr><w:pStyle w:val="ListParagraph"/>${level === null ? "" : `<w:numPr><w:ilvl w:val="${String(level)}"/><w:numId w:val="1"/></w:numPr>`}</w:pPr><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
30
+ };
31
+ const NUMBERED_LIST_ITEMS = Object.freeze([
32
+ {
33
+ level: 0,
34
+ text: "The supplier shall deliver the goods to the named place."
35
+ },
36
+ {
37
+ level: 1,
38
+ text: "Delivery is complete on unloading at that place."
39
+ },
40
+ {
41
+ level: 1,
42
+ text: "Risk passes to the buyer on completion of delivery."
43
+ },
44
+ {
45
+ level: 0,
46
+ text: "The buyer shall pay within thirty days of the invoice date."
47
+ },
48
+ {
49
+ level: 1,
50
+ text: "Late payment carries interest at the statutory rate."
51
+ },
52
+ {
53
+ level: 0,
54
+ text: "Either party may terminate for material breach."
55
+ }
56
+ ]);
57
+ /** The same list with one item demoted one level: a level change and no text change. */
58
+ const withItemDemoted = (items, index) => items.map((item, at) => at === index ? {
59
+ level: Math.min(LIST_LEVELS - 1, (item.level ?? 0) + 1),
60
+ text: item.text
61
+ } : item);
62
+ /**
63
+ * The same list with one item no longer numbered: the paragraph keeps every
64
+ * word and stops being a list item, which is the edit a level change cannot
65
+ * express.
66
+ */
67
+ const withItemUnnumbered = (items, index) => items.map((item, at) => at === index ? {
68
+ level: null,
69
+ text: item.text
70
+ } : item);
71
+ const buildNumberedListDocx = async (items = NUMBERED_LIST_ITEMS, { format = "decimal" } = {}) => {
72
+ const parts = {
73
+ "[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="${WORDPROCESSING}.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="${WORDPROCESSING}.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="${WORDPROCESSING}.numbering+xml"/></Types>`,
74
+ "_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/officeDocument" Target="word/document.xml"/></Relationships>`,
75
+ "word/_rels/document.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/styles" Target="styles.xml"/><Relationship Id="rId2" Type="${OFFICE_RELATIONSHIPS}/numbering" Target="numbering.xml"/></Relationships>`,
76
+ "word/styles.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${NAMESPACE}"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/></w:style></w:styles>`,
77
+ "word/numbering.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering xmlns:w="${NAMESPACE}"><w:abstractNum w:abstractNumId="0">${abstractLevels(format)}</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>`,
78
+ "word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${NAMESPACE}"><w:body>` + items.map(({ level, text }) => listParagraph(level, text)).join("") + "<w:sectPr><w:pgSz w:w=\"12240\" w:h=\"15840\"/><w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\"/></w:sectPr></w:body></w:document>"
79
+ };
80
+ const zip = new JSZip();
81
+ for (const name of Object.keys(parts).toSorted()) zip.file(name, parts[name] ?? "", ZIP_ENTRY_OPTIONS);
82
+ return await zip.generateAsync({ type: "arraybuffer" });
83
+ };
84
+ //#endregion
85
+ export { NUMBERED_LIST_ITEMS, buildNumberedListDocx, withItemDemoted, withItemUnnumbered };
@@ -0,0 +1,27 @@
1
+ import { FolioAIBlock, FolioAIBlockTableLocation } from "../ai-edits/types.js";
2
+ //#region src/compare/column-alignment.d.ts
3
+ type TableColumnAlignmentStep = {
4
+ type: "baseColumn";
5
+ blocks: readonly FolioAIBlock[];
6
+ location: FolioAIBlockTableLocation;
7
+ columnIndex: number;
8
+ } | {
9
+ type: "targetColumn";
10
+ blocks: readonly FolioAIBlock[];
11
+ location: FolioAIBlockTableLocation;
12
+ columnIndex: number;
13
+ anchor: {
14
+ blockId: string;
15
+ position: "after" | "before";
16
+ };
17
+ };
18
+ type TableColumnAlignment = {
19
+ steps: TableColumnAlignmentStep[];
20
+ baseBlocks: FolioAIBlock[];
21
+ targetBlocks: FolioAIBlock[];
22
+ baseColumnKeys: ReadonlyMap<number, number>;
23
+ targetColumnKeys: ReadonlyMap<number, number>;
24
+ };
25
+ declare const alignTableColumns: (baseBlocks: readonly FolioAIBlock[], targetBlocks: readonly FolioAIBlock[]) => TableColumnAlignment | null;
26
+ //#endregion
27
+ export { TableColumnAlignment, TableColumnAlignmentStep, alignTableColumns };