@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.
- 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 +652 -86
- 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 +56 -0
- package/dist/compare/__fixtures__/body-sequence.js +136 -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 +412 -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 +1141 -0
- package/dist/compare/reproducible-package.d.ts +21 -0
- package/dist/compare/reproducible-package.js +47 -0
- package/dist/compare/scenario.d.ts +77 -0
- package/dist/compare/scenario.js +263 -0
- package/dist/compare/types.d.ts +297 -0
- package/dist/compare/types.js +42 -0
- package/dist/compare/verification.d.ts +83 -0
- package/dist/compare/verification.js +189 -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/hyperlinkParser.d.ts +9 -1
- package/dist/docx/hyperlinkParser.js +19 -13
- package/dist/docx/packageParts.d.ts +35 -0
- package/dist/docx/packageParts.js +120 -0
- package/dist/docx/paraIdRangeNormalization.d.ts +40 -0
- package/dist/docx/paraIdRangeNormalization.js +64 -0
- package/dist/docx/paragraphParser.js +101 -26
- package/dist/docx/paragraphTraversal.js +1 -1
- package/dist/docx/renderedPageBreakNormalization.js +3 -1
- package/dist/docx/revisionIdNormalization.d.ts +27 -0
- package/dist/docx/revisionIdNormalization.js +121 -0
- package/dist/docx/rezip.d.ts +6 -0
- package/dist/docx/rezip.js +51 -19
- package/dist/docx/selectiveSave.js +2 -2
- package/dist/docx/serializer/commentSerializer.js +6 -1
- package/dist/docx/serializer/paragraphSerializer.js +73 -20
- package/dist/docx/serializer/runSerializer.js +11 -10
- package/dist/docx/serializer/tableSerializer.js +26 -10
- 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 +96 -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,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 };
|