@stll/folio-core 0.32.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-edits/__fixtures__/paragraphs.d.ts +17 -0
- package/dist/ai-edits/__fixtures__/paragraphs.js +33 -0
- package/dist/ai-edits/apply.d.ts +49 -3
- package/dist/ai-edits/apply.js +649 -85
- package/dist/ai-edits/blockRange.d.ts +7 -3
- package/dist/ai-edits/blockRange.js +14 -6
- package/dist/ai-edits/headless.d.ts +39 -5
- package/dist/ai-edits/headless.js +76 -17
- package/dist/ai-edits/index.d.ts +6 -6
- package/dist/ai-edits/index.js +3 -3
- package/dist/ai-edits/read.d.ts +4 -0
- package/dist/ai-edits/read.js +30 -6
- package/dist/ai-edits/scoped-reading.js +4 -3
- package/dist/ai-edits/snapshot.d.ts +42 -2
- package/dist/ai-edits/snapshot.js +127 -62
- package/dist/ai-edits/table-row-column-mutations.d.ts +24 -1
- package/dist/ai-edits/table-row-column-mutations.js +98 -6
- package/dist/ai-edits/table-targets.d.ts +12 -3
- package/dist/ai-edits/table-targets.js +15 -5
- package/dist/ai-edits/types.d.ts +219 -15
- package/dist/ai-edits/word-diff.d.ts +54 -11
- package/dist/ai-edits/word-diff.js +221 -61
- package/dist/compare/__fixtures__/body-sequence.d.ts +33 -0
- package/dist/compare/__fixtures__/body-sequence.js +67 -0
- package/dist/compare/__fixtures__/nested-table.d.ts +16 -0
- package/dist/compare/__fixtures__/nested-table.js +50 -0
- package/dist/compare/__fixtures__/numbered-list.d.ts +34 -0
- package/dist/compare/__fixtures__/numbered-list.js +85 -0
- package/dist/compare/column-alignment.d.ts +27 -0
- package/dist/compare/column-alignment.js +162 -0
- package/dist/compare/compare.d.ts +107 -0
- package/dist/compare/compare.js +406 -0
- package/dist/compare/formatting.d.ts +29 -0
- package/dist/compare/formatting.js +84 -0
- package/dist/compare/plan.d.ts +22 -0
- package/dist/compare/plan.js +1059 -0
- package/dist/compare/reproducible-package.d.ts +17 -0
- package/dist/compare/reproducible-package.js +30 -0
- package/dist/compare/scenario.d.ts +77 -0
- package/dist/compare/scenario.js +263 -0
- package/dist/compare/types.d.ts +282 -0
- package/dist/compare/types.js +32 -0
- package/dist/compare/verification.d.ts +55 -0
- package/dist/compare/verification.js +146 -0
- package/dist/compat/eigenpal.d.ts +9 -6
- package/dist/compat/eigenpal.js +6 -3
- package/dist/controller/layoutPipeline.d.ts +13 -0
- package/dist/controller/layoutPipeline.js +15 -1
- package/dist/display-list/build/buildContext.d.ts +39 -0
- package/dist/display-list/build/buildContext.js +31 -0
- package/dist/display-list/build/buildDisplayList.d.ts +79 -0
- package/dist/display-list/build/buildDisplayList.js +283 -0
- package/dist/display-list/build/colors.d.ts +20 -0
- package/dist/display-list/build/colors.js +136 -0
- package/dist/display-list/build/floatingImages.d.ts +24 -0
- package/dist/display-list/build/floatingImages.js +41 -0
- package/dist/display-list/build/fontTable.d.ts +31 -0
- package/dist/display-list/build/fontTable.js +97 -0
- package/dist/display-list/build/furniture.d.ts +86 -0
- package/dist/display-list/build/furniture.js +98 -0
- package/dist/display-list/build/glyphs.d.ts +55 -0
- package/dist/display-list/build/glyphs.js +103 -0
- package/dist/display-list/build/headerFooterPrimitives.d.ts +17 -0
- package/dist/display-list/build/headerFooterPrimitives.js +40 -0
- package/dist/display-list/build/imagePrimitives.d.ts +35 -0
- package/dist/display-list/build/imagePrimitives.js +264 -0
- package/dist/display-list/build/pageBorderPrimitives.d.ts +23 -0
- package/dist/display-list/build/pageBorderPrimitives.js +111 -0
- package/dist/display-list/build/pageFurniture.d.ts +35 -0
- package/dist/display-list/build/pageFurniture.js +141 -0
- package/dist/display-list/build/paragraphPrimitives.d.ts +19 -0
- package/dist/display-list/build/paragraphPrimitives.js +819 -0
- package/dist/display-list/build/regions.d.ts +46 -0
- package/dist/display-list/build/regions.js +56 -0
- package/dist/display-list/build/storyPrimitives.d.ts +29 -0
- package/dist/display-list/build/storyPrimitives.js +279 -0
- package/dist/display-list/build/strokes.d.ts +19 -0
- package/dist/display-list/build/strokes.js +78 -0
- package/dist/display-list/build/tablePrimitives.d.ts +38 -0
- package/dist/display-list/build/tablePrimitives.js +409 -0
- package/dist/display-list/build/textBoxPrimitives.d.ts +14 -0
- package/dist/display-list/build/textBoxPrimitives.js +129 -0
- package/dist/display-list/build/textDecorations.d.ts +29 -0
- package/dist/display-list/build/textDecorations.js +29 -0
- package/dist/display-list/build/unsupported.d.ts +57 -0
- package/dist/display-list/build/unsupported.js +0 -0
- package/dist/display-list/build/watermarkPrimitives.d.ts +15 -0
- package/dist/display-list/build/watermarkPrimitives.js +128 -0
- package/dist/display-list/dom/renderDisplayListToDom.d.ts +26 -0
- package/dist/display-list/dom/renderDisplayListToDom.js +620 -0
- package/dist/display-list/editor/displayListPagePainter.d.ts +22 -0
- package/dist/display-list/editor/displayListPagePainter.js +63 -0
- package/dist/display-list/editor/pageRenderer.d.ts +24 -0
- package/dist/display-list/editor/pageRenderer.js +36 -0
- package/dist/display-list/primitives.d.ts +95 -0
- package/dist/display-list/primitives.js +127 -0
- package/dist/display-list/types.d.ts +472 -0
- package/dist/display-list/types.js +0 -0
- package/dist/document-operations.d.ts +39 -8
- package/dist/document-operations.js +169 -16
- package/dist/document-stories.d.ts +16 -0
- package/dist/document-stories.js +48 -4
- package/dist/docx/packageParts.d.ts +35 -0
- package/dist/docx/packageParts.js +120 -0
- package/dist/docx/paragraphParser.js +22 -22
- package/dist/docx/paragraphTraversal.js +1 -1
- package/dist/docx/renderedPageBreakNormalization.js +3 -1
- package/dist/docx/revisionIdNormalization.d.ts +13 -0
- package/dist/docx/revisionIdNormalization.js +103 -0
- package/dist/docx/rezip.js +26 -17
- package/dist/docx/selectiveSave.js +2 -2
- package/dist/docx/serializer/commentSerializer.js +6 -1
- package/dist/docx/serializer/paragraphSerializer.js +33 -4
- package/dist/docx/serializer/runSerializer.js +11 -10
- package/dist/docx/serializer/tableSerializer.js +2 -1
- package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
- package/dist/docx/server/createBilingualDocx.js +3 -2
- package/dist/docx/streamingXmlParser.d.ts +13 -1
- package/dist/docx/streamingXmlParser.js +49 -11
- package/dist/docx/unzip.d.ts +1 -2
- package/dist/docx/unzip.js +25 -6
- package/dist/docx/xmlParser.d.ts +3 -1
- package/dist/docx/xmlParser.js +33 -27
- package/dist/export-pdf.d.ts +43 -0
- package/dist/export-pdf.js +98 -0
- package/dist/fonts/headlessMeasure.d.ts +76 -0
- package/dist/fonts/headlessMeasure.js +0 -0
- package/dist/fonts/sfnt/parse.d.ts +51 -0
- package/dist/fonts/sfnt/parse.js +525 -0
- package/dist/fonts/sfnt/subset.d.ts +20 -0
- package/dist/fonts/sfnt/subset.js +350 -0
- package/dist/fonts/sfnt/tables.d.ts +88 -0
- package/dist/fonts/sfnt/tables.js +131 -0
- package/dist/fonts/sfnt/woff.d.ts +14 -0
- package/dist/fonts/sfnt/woff.js +163 -0
- package/dist/generated/text_shaper.js +324 -0
- package/dist/generated/text_shaper_bg.wasm +0 -0
- package/dist/headless-layout.d.ts +75 -0
- package/dist/headless-layout.js +350 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.js +6 -3
- package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
- package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
- package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
- package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
- package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
- package/dist/layout-engine/measure/advanceComposition.js +148 -0
- package/dist/layout-engine/measure/cache.d.ts +2 -0
- package/dist/layout-engine/measure/measureContainer.js +13 -0
- package/dist/layout-engine/measure/measureParagraph.js +1 -2
- package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
- package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
- package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
- package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
- package/dist/layout-engine/tableIndentCompatibility.js +15 -0
- package/dist/layout-engine/types.d.ts +8 -0
- package/dist/layout-painter/renderPage.d.ts +29 -1
- package/dist/layout-painter/renderPage.js +47 -19
- package/dist/layout-painter/renderParagraph.d.ts +21 -2
- package/dist/layout-painter/renderParagraph.js +1 -1
- package/dist/layout-painter/renderTable.js +37 -4
- package/dist/managers/editorShortcuts.d.ts +33 -1
- package/dist/managers/editorShortcuts.js +20 -1
- package/dist/markdown/renderRuns.js +7 -2
- package/dist/markdown/renderTable.js +5 -1
- package/dist/model.d.ts +3 -3
- package/dist/model.js +2 -2
- package/dist/pdf/contentStream.d.ts +73 -0
- package/dist/pdf/contentStream.js +143 -0
- package/dist/pdf/fonts.d.ts +96 -0
- package/dist/pdf/fonts.js +593 -0
- package/dist/pdf/images.d.ts +28 -0
- package/dist/pdf/images.js +439 -0
- package/dist/pdf/objects.d.ts +93 -0
- package/dist/pdf/objects.js +237 -0
- package/dist/pdf/pageSpace.d.ts +43 -0
- package/dist/pdf/pageSpace.js +57 -0
- package/dist/pdf/paint.d.ts +32 -0
- package/dist/pdf/paint.js +442 -0
- package/dist/pdf/writePdf.d.ts +56 -0
- package/dist/pdf/writePdf.js +345 -0
- package/dist/prosemirror/commands/comments.js +91 -6
- package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
- package/dist/prosemirror/conversion/toProseDoc.js +41 -4
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
- package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
- package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
- package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
- package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
- package/dist/prosemirror/plugins/suggestionMode.js +1 -0
- package/dist/prosemirror/revisionCarriers.js +19 -2
- package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
- package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
- package/dist/prosemirror/validation.js +55 -27
- package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
- package/dist/prosemirror/zeroWidthAnchors.js +23 -0
- package/dist/redline.js +12 -79
- package/dist/server.d.ts +3 -3
- package/dist/server.js +2 -2
- package/dist/shaping/placeRun.d.ts +51 -0
- package/dist/shaping/placeRun.js +84 -0
- package/dist/shaping/shaper.d.ts +92 -0
- package/dist/shaping/shaper.js +102 -0
- package/dist/types/block-id.d.ts +18 -2
- package/dist/types/block-id.js +49 -5
- package/dist/types/content.d.ts +2 -2
- package/dist/utils/clipboard.js +4 -3
- package/dist/utils/fontResolver.js +183 -0
- package/dist/utils/htmlComments.d.ts +17 -0
- package/dist/utils/htmlComments.js +22 -0
- package/dist/utils/scriptSegments.d.ts +12 -1
- package/dist/utils/scriptSegments.js +17 -1
- package/dist/version-comparison.d.ts +1 -1
- 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
|
-
|
|
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 <
|
|
8
|
-
while (cursor <
|
|
9
|
-
if (cursor ===
|
|
10
|
-
while (cursor <
|
|
11
|
-
tokens.push(
|
|
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
|
|
16
|
-
if (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)
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const m =
|
|
33
|
-
const n =
|
|
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
|
|
50
|
-
if (!row || !
|
|
51
|
-
|
|
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
|
|
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 (
|
|
74
|
-
|
|
90
|
+
if (beforeKeys[i - 1] === afterKeys[j - 1]) {
|
|
91
|
+
reversed.push({
|
|
75
92
|
type: "equal",
|
|
76
|
-
|
|
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
|
-
|
|
102
|
+
reversed.push({
|
|
84
103
|
type: "del",
|
|
85
|
-
text:
|
|
104
|
+
text: before[i - 1] ?? ""
|
|
86
105
|
});
|
|
87
106
|
i--;
|
|
88
107
|
} else {
|
|
89
|
-
|
|
108
|
+
reversed.push({
|
|
90
109
|
type: "ins",
|
|
91
|
-
text:
|
|
110
|
+
text: after[j - 1] ?? ""
|
|
92
111
|
});
|
|
93
112
|
j--;
|
|
94
113
|
}
|
|
95
114
|
}
|
|
96
115
|
while (i > 0) {
|
|
97
|
-
|
|
116
|
+
reversed.push({
|
|
98
117
|
type: "del",
|
|
99
|
-
text:
|
|
118
|
+
text: before[i - 1] ?? ""
|
|
100
119
|
});
|
|
101
120
|
i--;
|
|
102
121
|
}
|
|
103
122
|
while (j > 0) {
|
|
104
|
-
|
|
123
|
+
reversed.push({
|
|
105
124
|
type: "ins",
|
|
106
|
-
text:
|
|
125
|
+
text: after[j - 1] ?? ""
|
|
107
126
|
});
|
|
108
127
|
j--;
|
|
109
128
|
}
|
|
110
|
-
|
|
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 };
|