@stll/folio-core 0.32.2 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) 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 +652 -86
  5. package/dist/ai-edits/blockRange.d.ts +7 -3
  6. package/dist/ai-edits/blockRange.js +14 -6
  7. package/dist/ai-edits/headless.d.ts +39 -5
  8. package/dist/ai-edits/headless.js +76 -17
  9. package/dist/ai-edits/index.d.ts +6 -6
  10. package/dist/ai-edits/index.js +3 -3
  11. package/dist/ai-edits/read.d.ts +4 -0
  12. package/dist/ai-edits/read.js +30 -6
  13. package/dist/ai-edits/scoped-reading.js +4 -3
  14. package/dist/ai-edits/snapshot.d.ts +42 -2
  15. package/dist/ai-edits/snapshot.js +127 -62
  16. package/dist/ai-edits/table-row-column-mutations.d.ts +24 -1
  17. package/dist/ai-edits/table-row-column-mutations.js +98 -6
  18. package/dist/ai-edits/table-targets.d.ts +12 -3
  19. package/dist/ai-edits/table-targets.js +15 -5
  20. package/dist/ai-edits/types.d.ts +219 -15
  21. package/dist/ai-edits/word-diff.d.ts +54 -11
  22. package/dist/ai-edits/word-diff.js +221 -61
  23. package/dist/compare/__fixtures__/body-sequence.d.ts +56 -0
  24. package/dist/compare/__fixtures__/body-sequence.js +136 -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 +412 -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 +1141 -0
  37. package/dist/compare/reproducible-package.d.ts +21 -0
  38. package/dist/compare/reproducible-package.js +47 -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 +297 -0
  42. package/dist/compare/types.js +42 -0
  43. package/dist/compare/verification.d.ts +83 -0
  44. package/dist/compare/verification.js +189 -0
  45. package/dist/compat/eigenpal.d.ts +9 -6
  46. package/dist/compat/eigenpal.js +6 -3
  47. package/dist/controller/layoutPipeline.d.ts +13 -0
  48. package/dist/controller/layoutPipeline.js +15 -1
  49. package/dist/display-list/build/buildContext.d.ts +39 -0
  50. package/dist/display-list/build/buildContext.js +31 -0
  51. package/dist/display-list/build/buildDisplayList.d.ts +79 -0
  52. package/dist/display-list/build/buildDisplayList.js +283 -0
  53. package/dist/display-list/build/colors.d.ts +20 -0
  54. package/dist/display-list/build/colors.js +136 -0
  55. package/dist/display-list/build/floatingImages.d.ts +24 -0
  56. package/dist/display-list/build/floatingImages.js +41 -0
  57. package/dist/display-list/build/fontTable.d.ts +31 -0
  58. package/dist/display-list/build/fontTable.js +97 -0
  59. package/dist/display-list/build/furniture.d.ts +86 -0
  60. package/dist/display-list/build/furniture.js +98 -0
  61. package/dist/display-list/build/glyphs.d.ts +55 -0
  62. package/dist/display-list/build/glyphs.js +103 -0
  63. package/dist/display-list/build/headerFooterPrimitives.d.ts +17 -0
  64. package/dist/display-list/build/headerFooterPrimitives.js +40 -0
  65. package/dist/display-list/build/imagePrimitives.d.ts +35 -0
  66. package/dist/display-list/build/imagePrimitives.js +264 -0
  67. package/dist/display-list/build/pageBorderPrimitives.d.ts +23 -0
  68. package/dist/display-list/build/pageBorderPrimitives.js +111 -0
  69. package/dist/display-list/build/pageFurniture.d.ts +35 -0
  70. package/dist/display-list/build/pageFurniture.js +141 -0
  71. package/dist/display-list/build/paragraphPrimitives.d.ts +19 -0
  72. package/dist/display-list/build/paragraphPrimitives.js +819 -0
  73. package/dist/display-list/build/regions.d.ts +46 -0
  74. package/dist/display-list/build/regions.js +56 -0
  75. package/dist/display-list/build/storyPrimitives.d.ts +29 -0
  76. package/dist/display-list/build/storyPrimitives.js +279 -0
  77. package/dist/display-list/build/strokes.d.ts +19 -0
  78. package/dist/display-list/build/strokes.js +78 -0
  79. package/dist/display-list/build/tablePrimitives.d.ts +38 -0
  80. package/dist/display-list/build/tablePrimitives.js +409 -0
  81. package/dist/display-list/build/textBoxPrimitives.d.ts +14 -0
  82. package/dist/display-list/build/textBoxPrimitives.js +129 -0
  83. package/dist/display-list/build/textDecorations.d.ts +29 -0
  84. package/dist/display-list/build/textDecorations.js +29 -0
  85. package/dist/display-list/build/unsupported.d.ts +57 -0
  86. package/dist/display-list/build/unsupported.js +0 -0
  87. package/dist/display-list/build/watermarkPrimitives.d.ts +15 -0
  88. package/dist/display-list/build/watermarkPrimitives.js +128 -0
  89. package/dist/display-list/dom/renderDisplayListToDom.d.ts +26 -0
  90. package/dist/display-list/dom/renderDisplayListToDom.js +620 -0
  91. package/dist/display-list/editor/displayListPagePainter.d.ts +22 -0
  92. package/dist/display-list/editor/displayListPagePainter.js +63 -0
  93. package/dist/display-list/editor/pageRenderer.d.ts +24 -0
  94. package/dist/display-list/editor/pageRenderer.js +36 -0
  95. package/dist/display-list/primitives.d.ts +95 -0
  96. package/dist/display-list/primitives.js +127 -0
  97. package/dist/display-list/types.d.ts +472 -0
  98. package/dist/display-list/types.js +0 -0
  99. package/dist/document-operations.d.ts +39 -8
  100. package/dist/document-operations.js +169 -16
  101. package/dist/document-stories.d.ts +16 -0
  102. package/dist/document-stories.js +48 -4
  103. package/dist/docx/hyperlinkParser.d.ts +9 -1
  104. package/dist/docx/hyperlinkParser.js +19 -13
  105. package/dist/docx/packageParts.d.ts +35 -0
  106. package/dist/docx/packageParts.js +120 -0
  107. package/dist/docx/paraIdRangeNormalization.d.ts +40 -0
  108. package/dist/docx/paraIdRangeNormalization.js +64 -0
  109. package/dist/docx/paragraphParser.js +101 -26
  110. package/dist/docx/paragraphTraversal.js +1 -1
  111. package/dist/docx/renderedPageBreakNormalization.js +3 -1
  112. package/dist/docx/revisionIdNormalization.d.ts +27 -0
  113. package/dist/docx/revisionIdNormalization.js +121 -0
  114. package/dist/docx/rezip.d.ts +6 -0
  115. package/dist/docx/rezip.js +51 -19
  116. package/dist/docx/selectiveSave.js +2 -2
  117. package/dist/docx/serializer/commentSerializer.js +6 -1
  118. package/dist/docx/serializer/paragraphSerializer.js +73 -20
  119. package/dist/docx/serializer/runSerializer.js +11 -10
  120. package/dist/docx/serializer/tableSerializer.js +26 -10
  121. package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
  122. package/dist/docx/server/createBilingualDocx.js +3 -2
  123. package/dist/docx/streamingXmlParser.d.ts +13 -1
  124. package/dist/docx/streamingXmlParser.js +49 -11
  125. package/dist/docx/unzip.d.ts +1 -2
  126. package/dist/docx/unzip.js +25 -6
  127. package/dist/docx/xmlParser.d.ts +3 -1
  128. package/dist/docx/xmlParser.js +33 -27
  129. package/dist/export-pdf.d.ts +43 -0
  130. package/dist/export-pdf.js +98 -0
  131. package/dist/fonts/headlessMeasure.d.ts +76 -0
  132. package/dist/fonts/headlessMeasure.js +0 -0
  133. package/dist/fonts/sfnt/parse.d.ts +51 -0
  134. package/dist/fonts/sfnt/parse.js +525 -0
  135. package/dist/fonts/sfnt/subset.d.ts +20 -0
  136. package/dist/fonts/sfnt/subset.js +350 -0
  137. package/dist/fonts/sfnt/tables.d.ts +88 -0
  138. package/dist/fonts/sfnt/tables.js +131 -0
  139. package/dist/fonts/sfnt/woff.d.ts +14 -0
  140. package/dist/fonts/sfnt/woff.js +163 -0
  141. package/dist/generated/text_shaper.js +324 -0
  142. package/dist/generated/text_shaper_bg.wasm +0 -0
  143. package/dist/headless-layout.d.ts +75 -0
  144. package/dist/headless-layout.js +350 -0
  145. package/dist/index.d.ts +9 -6
  146. package/dist/index.js +6 -3
  147. package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
  148. package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
  149. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
  150. package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
  151. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
  152. package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
  153. package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
  154. package/dist/layout-engine/measure/advanceComposition.js +148 -0
  155. package/dist/layout-engine/measure/cache.d.ts +2 -0
  156. package/dist/layout-engine/measure/measureContainer.js +13 -0
  157. package/dist/layout-engine/measure/measureParagraph.js +1 -2
  158. package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
  159. package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
  160. package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
  161. package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
  162. package/dist/layout-engine/tableIndentCompatibility.js +15 -0
  163. package/dist/layout-engine/types.d.ts +8 -0
  164. package/dist/layout-painter/renderPage.d.ts +29 -1
  165. package/dist/layout-painter/renderPage.js +47 -19
  166. package/dist/layout-painter/renderParagraph.d.ts +21 -2
  167. package/dist/layout-painter/renderParagraph.js +1 -1
  168. package/dist/layout-painter/renderTable.js +37 -4
  169. package/dist/managers/editorShortcuts.d.ts +33 -1
  170. package/dist/managers/editorShortcuts.js +20 -1
  171. package/dist/markdown/renderRuns.js +7 -2
  172. package/dist/markdown/renderTable.js +5 -1
  173. package/dist/model.d.ts +3 -3
  174. package/dist/model.js +2 -2
  175. package/dist/pdf/contentStream.d.ts +73 -0
  176. package/dist/pdf/contentStream.js +143 -0
  177. package/dist/pdf/fonts.d.ts +96 -0
  178. package/dist/pdf/fonts.js +593 -0
  179. package/dist/pdf/images.d.ts +28 -0
  180. package/dist/pdf/images.js +439 -0
  181. package/dist/pdf/objects.d.ts +93 -0
  182. package/dist/pdf/objects.js +237 -0
  183. package/dist/pdf/pageSpace.d.ts +43 -0
  184. package/dist/pdf/pageSpace.js +57 -0
  185. package/dist/pdf/paint.d.ts +32 -0
  186. package/dist/pdf/paint.js +442 -0
  187. package/dist/pdf/writePdf.d.ts +56 -0
  188. package/dist/pdf/writePdf.js +345 -0
  189. package/dist/prosemirror/commands/comments.js +96 -6
  190. package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
  191. package/dist/prosemirror/conversion/toProseDoc.js +41 -4
  192. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
  193. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
  194. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
  195. package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
  196. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
  197. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
  198. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
  199. package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
  200. package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
  201. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
  202. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
  203. package/dist/prosemirror/plugins/suggestionMode.js +1 -0
  204. package/dist/prosemirror/revisionCarriers.js +19 -2
  205. package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
  206. package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
  207. package/dist/prosemirror/validation.js +55 -27
  208. package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
  209. package/dist/prosemirror/zeroWidthAnchors.js +23 -0
  210. package/dist/redline.js +12 -79
  211. package/dist/server.d.ts +3 -3
  212. package/dist/server.js +2 -2
  213. package/dist/shaping/placeRun.d.ts +51 -0
  214. package/dist/shaping/placeRun.js +84 -0
  215. package/dist/shaping/shaper.d.ts +92 -0
  216. package/dist/shaping/shaper.js +102 -0
  217. package/dist/types/block-id.d.ts +18 -2
  218. package/dist/types/block-id.js +49 -5
  219. package/dist/types/content.d.ts +2 -2
  220. package/dist/utils/clipboard.js +4 -3
  221. package/dist/utils/fontResolver.js +183 -0
  222. package/dist/utils/htmlComments.d.ts +17 -0
  223. package/dist/utils/htmlComments.js +22 -0
  224. package/dist/utils/scriptSegments.d.ts +12 -1
  225. package/dist/utils/scriptSegments.js +17 -1
  226. package/dist/version-comparison.d.ts +1 -1
  227. 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,56 @@
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
+ /**
17
+ * One inline of a paragraph: plain text, or text carrying an external
18
+ * hyperlink. A link is the case where a revision wrapper and the linked runs
19
+ * have to nest one inside the other, so the fixture has to be able to author
20
+ * one.
21
+ */
22
+ type ParagraphInline = string | {
23
+ text: string;
24
+ href: string;
25
+ };
26
+ /** One body-level item: a paragraph, or a table given row by row. */
27
+ type BodyItem = {
28
+ kind: "paragraph";
29
+ text: string | readonly ParagraphInline[];
30
+ styleId?: string;
31
+ /**
32
+ * An authored `w14:paraId`, for a package whose ids a producer wrote
33
+ * without respecting the 31-bit bound the schema puts on them.
34
+ */
35
+ paraId?: string;
36
+ } | {
37
+ kind: "table";
38
+ rows: readonly (readonly CellContent[])[];
39
+ /**
40
+ * Rows a package hides with `w:hidden`. The snapshot skips their whole
41
+ * subtree, so a document that has one is the case where the snapshot
42
+ * walk and the live walk could disagree.
43
+ */
44
+ hiddenRows?: readonly number[];
45
+ };
46
+ type BodySequenceOptions = {
47
+ /**
48
+ * A default header part, written as its own sequence. A header is a story of
49
+ * its own: it ends with its own paragraph, and a comparison writes it with
50
+ * its own revision ids.
51
+ */
52
+ header?: readonly BodyItem[];
53
+ };
54
+ declare const buildBodySequenceDocx: (items: readonly BodyItem[], { header }?: BodySequenceOptions) => Promise<ArrayBuffer>;
55
+ //#endregion
56
+ export { BodyItem, BodySequenceOptions, CellContent, ParagraphInline, buildBodySequenceDocx };
@@ -0,0 +1,136 @@
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
+ const MARKUP_COMPATIBILITY = "http://schemas.openxmlformats.org/markup-compatibility/2006";
16
+ const PACKAGE_RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006";
17
+ const CORE_PROPERTIES_TYPE = "application/vnd.openxmlformats-package.core-properties+xml";
18
+ const WORDML_2010 = "http://schemas.microsoft.com/office/word/2010/wordml";
19
+ /**
20
+ * `createFolders: false` because JSZip stamps the folder entries it
21
+ * synthesizes with `new Date()`, which the fixed date above does not reach.
22
+ */
23
+ const ZIP_ENTRY_OPTIONS = {
24
+ date: new Date(Date.UTC(2e3, 0, 1)),
25
+ createFolders: false
26
+ };
27
+ /**
28
+ * A paragraph's id is derived from its own content, not from its position.
29
+ *
30
+ * Two packages authored from two descriptions are a base and a target, and a
31
+ * paragraph that appears in both is the same paragraph. Numbering the ids in
32
+ * document order would instead give the same id to the paragraph that happens
33
+ * to sit at the same index, which is how the fixture would tell a comparison
34
+ * that a removed paragraph was a rewrite of the one after it.
35
+ */
36
+ const createParaIdAllocator = () => {
37
+ const taken = /* @__PURE__ */ new Set();
38
+ return (content) => {
39
+ let hash = 2166136261;
40
+ for (let index = 0; index < content.length; index += 1) hash = Math.imul(hash ^ content.charCodeAt(index), 16777619) >>> 0;
41
+ let candidate = hash % 2147483646;
42
+ while (taken.has((candidate + 1).toString(16).toUpperCase().padStart(8, "0"))) candidate = (candidate + 1) % 2147483646;
43
+ const paraId = (candidate + 1).toString(16).toUpperCase().padStart(8, "0");
44
+ taken.add(paraId);
45
+ return paraId;
46
+ };
47
+ };
48
+ const run = (text) => `<w:r><w:t xml:space="preserve">${text}</w:t></w:r>`;
49
+ const inlineXml = (inline, { links }) => typeof inline === "string" ? run(inline) : `<w:hyperlink r:id="${links.get(inline.href) ?? ""}">${run(inline.text)}</w:hyperlink>`;
50
+ /**
51
+ * An empty paragraph is a `w:p` with no run at all, which is what a package
52
+ * holds for a blank line or an empty cell. It is not the same thing as a
53
+ * paragraph whose run carries an empty string, and both shapes occur.
54
+ */
55
+ /** A blank line carries no run at all, so an empty string is no inline. */
56
+ const nonEmptyInlines = (text) => text.length === 0 ? [] : [text];
57
+ const paragraph = (text, context, { styleId, paraId } = {}) => {
58
+ const properties = styleId === void 0 ? "" : `<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>`;
59
+ const inlines = typeof text === "string" ? nonEmptyInlines(text) : text;
60
+ const content = inlines.map((inline) => typeof inline === "string" ? inline : inline.text).join("");
61
+ const id = paraId ?? context.paraId(`${styleId ?? ""}|${content}`);
62
+ return `<w:p w14:paraId="${id}" w14:textId="${id}">${properties}${inlines.map((inline) => inlineXml(inline, context)).join("")}</w:p>`;
63
+ };
64
+ /** Every href the body carries, in document order, so the ids are stable. */
65
+ const collectHrefs = (items, hrefs) => {
66
+ for (const item of items) {
67
+ if (item.kind === "paragraph") {
68
+ if (typeof item.text === "string") continue;
69
+ for (const inline of item.text) if (typeof inline !== "string" && !hrefs.includes(inline.href)) hrefs.push(inline.href);
70
+ continue;
71
+ }
72
+ for (const row of item.rows) for (const cell of row) if (typeof cell !== "string") collectHrefs(cell, hrefs);
73
+ }
74
+ };
75
+ const EMPTY_PARAGRAPH = {
76
+ kind: "paragraph",
77
+ text: ""
78
+ };
79
+ /**
80
+ * A container may not end with a table: the format requires a paragraph after
81
+ * one, and a body's section properties do not supply it. One rule for both
82
+ * containers, because a fixture that is well formed in a cell and malformed in
83
+ * the body would be measuring two different things.
84
+ */
85
+ const closedSequence = (items) => {
86
+ const last = items.at(-1);
87
+ return last === void 0 || last.kind === "table" ? [...items, EMPTY_PARAGRAPH] : items;
88
+ };
89
+ /** A cell must also contain a paragraph, which the empty sequence supplies. */
90
+ const cellXml = (content, context) => typeof content === "string" ? paragraph(content, context) : itemsXml(closedSequence(content), context);
91
+ /** `w:tbl` is `w:tblPr, w:tblGrid, rows`: a fixture without the grid is not one. */
92
+ const tableGrid = (rows) => {
93
+ let columns = 0;
94
+ for (const cells of rows) columns = Math.max(columns, cells.length);
95
+ return `<w:tblGrid>${`<w:gridCol w:w="2000"/>`.repeat(columns)}</w:tblGrid>`;
96
+ };
97
+ const table = (item, context) => {
98
+ const hidden = new Set(item.hiddenRows ?? []);
99
+ return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>` + tableGrid(item.rows) + 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, context)}</w:tc>`).join("")}</w:tr>`).join("") + `</w:tbl>`;
100
+ };
101
+ const itemsXml = (items, context) => items.map((item) => item.kind === "paragraph" ? paragraph(item.text, context, {
102
+ ...item.styleId === void 0 ? {} : { styleId: item.styleId },
103
+ ...item.paraId === void 0 ? {} : { paraId: item.paraId }
104
+ }) : table(item, context)).join("");
105
+ const bodyXml = (items, context) => itemsXml(closedSequence(items), context);
106
+ /** The relationship id the default header takes when a fixture asks for one. */
107
+ const HEADER_RELATIONSHIP_ID = "rId2";
108
+ const buildBodySequenceDocx = async (items, { header } = {}) => {
109
+ const hrefs = [];
110
+ collectHrefs(closedSequence(items), hrefs);
111
+ collectHrefs(closedSequence(header ?? []), hrefs);
112
+ const firstLinkRelationship = header ? 3 : 2;
113
+ const links = new Map(hrefs.map((href, index) => [href, `rId${index + firstLinkRelationship}`]));
114
+ const linkRelationships = hrefs.map((href, index) => `<Relationship Id="rId${index + firstLinkRelationship}" Type="${OFFICE_RELATIONSHIPS}/hyperlink" Target="${href}" TargetMode="External"/>`).join("");
115
+ const paraId = createParaIdAllocator();
116
+ const parts = {
117
+ "[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"/>` + (header ? `<Override PartName="/word/header1.xml" ContentType="${WORDPROCESSING}.header+xml"/>` : "") + `<Override PartName="/docProps/core.xml" ContentType="${CORE_PROPERTIES_TYPE}"/></Types>`,
118
+ "_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"/><Relationship Id="rId2" Type="${PACKAGE_RELATIONSHIPS}/metadata/core-properties" Target="docProps/core.xml"/></Relationships>`,
119
+ "docProps/core.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="${PACKAGE_RELATIONSHIPS}/metadata/core-properties" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dcterms:created xsi:type="dcterms:W3CDTF">2000-01-01T00:00:00Z</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">2000-01-01T00:00:00Z</dcterms:modified></cp:coreProperties>`,
120
+ "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"/>` + (header ? `<Relationship Id="${HEADER_RELATIONSHIP_ID}" Type="${OFFICE_RELATIONSHIPS}/header" Target="header1.xml"/>` : "") + linkRelationships + `</Relationships>`,
121
+ "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>`,
122
+ "word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIPS}" xmlns:mc="${MARKUP_COMPATIBILITY}" xmlns:w14="${WORDML_2010}" mc:Ignorable="w14"><w:body>` + bodyXml(items, {
123
+ links,
124
+ paraId
125
+ }) + `<w:sectPr>` + (header ? `<w:headerReference w:type="default" r:id="${HEADER_RELATIONSHIP_ID}"/>` : "") + "<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>",
126
+ ...header ? { "word/header1.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:hdr xmlns:w="${NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIPS}" xmlns:mc="${MARKUP_COMPATIBILITY}" xmlns:w14="${WORDML_2010}" mc:Ignorable="w14">${itemsXml(closedSequence(header), {
127
+ links,
128
+ paraId
129
+ })}</w:hdr>` } : {}
130
+ };
131
+ const zip = new JSZip();
132
+ for (const name of Object.keys(parts).toSorted()) zip.file(name, parts[name] ?? "", ZIP_ENTRY_OPTIONS);
133
+ return await zip.generateAsync({ type: "arraybuffer" });
134
+ };
135
+ //#endregion
136
+ 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 };