@stll/folio-core 0.35.1 → 0.37.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/README.md +59 -1
- package/dist/ai-edits/apply.js +878 -377
- package/dist/ai-edits/block-identity.d.ts +4 -1
- package/dist/ai-edits/block-identity.js +4 -1
- package/dist/ai-edits/blockRange.js +14 -14
- package/dist/ai-edits/clean-text.d.ts +40 -1
- package/dist/ai-edits/clean-text.js +52 -2
- package/dist/ai-edits/headless.d.ts +3 -1
- package/dist/ai-edits/headless.js +141 -73
- package/dist/ai-edits/index.d.ts +3 -3
- package/dist/ai-edits/index.js +2 -2
- package/dist/ai-edits/read.js +38 -20
- package/dist/ai-edits/snapshot.d.ts +14 -6
- package/dist/ai-edits/snapshot.js +105 -20
- package/dist/ai-edits/table-cell-mutations.js +1 -1
- package/dist/ai-edits/table-row-column-mutations.js +7 -0
- package/dist/ai-edits/table-template.d.ts +11 -1
- package/dist/ai-edits/table-template.js +49 -17
- package/dist/ai-edits/types.d.ts +62 -61
- package/dist/ai-edits/word-diff.d.ts +18 -3
- package/dist/ai-edits/word-diff.js +556 -67
- package/dist/ai-suggestions/conflict.js +1 -4
- package/dist/ai-suggestions/text-positions.d.ts +8 -0
- package/dist/ai-suggestions/text-positions.js +27 -5
- package/dist/compare/__fixtures__/body-sequence.js +3 -4
- package/dist/compare/column-alignment.d.ts +13 -13
- package/dist/compare/column-alignment.js +78 -39
- package/dist/compare/compare.d.ts +4 -3
- package/dist/compare/compare.js +48 -21
- package/dist/compare/content-alignment.d.ts +95 -0
- package/dist/compare/content-alignment.js +1450 -0
- package/dist/compare/content-types.d.ts +100 -0
- package/dist/compare/content-types.js +0 -0
- package/dist/compare/content.d.ts +209 -0
- package/dist/compare/content.js +885 -0
- package/dist/compare/formatting.d.ts +5 -5
- package/dist/compare/formatting.js +6 -4
- package/dist/compare/plan.d.ts +6 -1
- package/dist/compare/plan.js +171 -566
- package/dist/compare/scenario.d.ts +2 -2
- package/dist/compare/types.d.ts +3 -3
- package/dist/compare/verification.d.ts +8 -9
- package/dist/compare/verification.js +72 -50
- package/dist/compat/eigenpal.d.ts +5 -3
- package/dist/compat/eigenpal.js +3 -2
- package/dist/controller/headerFooterEditorManager.js +18 -10
- package/dist/controller/hiddenEditorApi.js +1 -1
- package/dist/controller/hiddenEditorManager.d.ts +7 -2
- package/dist/controller/hiddenEditorManager.js +15 -6
- package/dist/controller/layoutPipeline.js +5 -3
- package/dist/controller/noteEditorManager.js +12 -7
- package/dist/display-list/build/headerFooterPrimitives.js +1 -0
- package/dist/display-list/build/storyPrimitives.d.ts +5 -2
- package/dist/display-list/build/storyPrimitives.js +60 -3
- package/dist/display-list/build/tablePrimitives.js +30 -0
- package/dist/display-list/primitives.d.ts +1 -1
- package/dist/document-operations.d.ts +8 -4
- package/dist/document-operations.js +104 -12
- package/dist/docx/blockContentParser.js +6 -27
- package/dist/docx/commentParser.js +2 -4
- package/dist/docx/commentRangeIntegrity.js +2 -4
- package/dist/docx/documentClone.d.ts +2 -0
- package/dist/docx/documentClone.js +2 -0
- package/dist/docx/ensureParaIds.js +9 -4
- package/dist/docx/headerFooterParser.js +2 -4
- package/dist/docx/normalizeBaseDirection.js +5 -7
- package/dist/docx/numberingParser.d.ts +3 -1
- package/dist/docx/numberingParser.js +4 -1
- package/dist/docx/paragraphParser.js +34 -35
- package/dist/docx/paragraphPropertySource.d.ts +49 -0
- package/dist/docx/paragraphPropertySource.js +113 -0
- package/dist/docx/paragraphTraversal.js +30 -34
- package/dist/docx/rezip.d.ts +0 -8
- package/dist/docx/rezip.js +41 -16
- package/dist/docx/runConsolidator.js +2 -4
- package/dist/docx/runParser.js +6 -17
- package/dist/docx/sectionParser.js +1 -15
- package/dist/docx/serializer/paragraphSerializer.js +289 -30
- package/dist/docx/serializer/runSerializer.js +6 -12
- package/dist/docx/serializer/sectionPropertiesSerializer.js +2 -10
- package/dist/docx/serializer/tableSerializer.js +4 -23
- package/dist/docx/serializer/trackedChangeAttributes.d.ts +11 -0
- package/dist/docx/serializer/trackedChangeAttributes.js +31 -0
- package/dist/docx/server/build.d.ts +1 -1
- package/dist/docx/server/createBilingualDocument.js +8 -10
- package/dist/docx/server/evaluateDocxXmlPatchProposal.js +22 -13
- package/dist/docx/server/validateDocxConformance.js +17 -13
- package/dist/docx/tableParser.js +1 -37
- package/dist/docx/trackedChangeInfo.d.ts +11 -0
- package/dist/docx/trackedChangeInfo.js +38 -0
- package/dist/docx/verbatimCapture.d.ts +20 -9
- package/dist/docx/verbatimCapture.js +208 -8
- package/dist/docx/xmlParser.d.ts +3 -1
- package/dist/docx/xmlParser.js +6 -1
- package/dist/docx/xmlSafety.d.ts +1 -1
- package/dist/docx/xmlSafety.js +157 -0
- package/dist/headless-layout.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/internal/headlessRevisionResolution.d.ts +23 -0
- package/dist/internal/headlessRevisionResolution.js +155 -0
- package/dist/internal/headlessRevisionResolutionGuard.d.ts +12 -0
- package/dist/internal/headlessRevisionResolutionGuard.js +28 -0
- package/dist/internal/pageBreakRunDescendantIndex.d.ts +9 -0
- package/dist/internal/pageBreakRunDescendantIndex.js +27 -0
- package/dist/internal/pageBreakRunPartition.d.ts +26 -0
- package/dist/internal/pageBreakRunPartition.js +35 -0
- package/dist/internal/pageBreakRunSourceDescendantIndex.d.ts +15 -0
- package/dist/internal/pageBreakRunSourceDescendantIndex.js +126 -0
- package/dist/internal/sectionEndpointResolution.d.ts +32 -0
- package/dist/internal/sectionEndpointResolution.js +38 -0
- package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +16 -4
- package/dist/layout-bridge/convert/headerFooterLayout.js +52 -9
- package/dist/layout-bridge/convert/toFlowBlocks.d.ts +4 -0
- package/dist/layout-bridge/convert/toFlowBlocks.js +391 -82
- package/dist/layout-bridge/dom/clickToPositionDom.d.ts +8 -1
- package/dist/layout-bridge/dom/clickToPositionDom.js +114 -40
- package/dist/layout-bridge/dom/textStreamDom.d.ts +24 -0
- package/dist/layout-bridge/dom/textStreamDom.js +75 -0
- package/dist/layout-bridge/headerFooterLayout.js +16 -30
- package/dist/layout-engine/index.js +12 -3
- package/dist/layout-engine/measure/tableFragmentBorderGeometry.d.ts +17 -0
- package/dist/layout-engine/measure/tableFragmentBorderGeometry.js +24 -0
- package/dist/layout-engine/renderedBreakReconciliation.js +1 -1
- package/dist/layout-engine/types.d.ts +7 -0
- package/dist/layout-painter/imageLayout.d.ts +1 -1
- package/dist/layout-painter/renderTable.js +20 -0
- package/dist/markdown/renderBlock.js +7 -10
- package/dist/model.d.ts +1 -1
- package/dist/paged-layout/rangeProjection.js +6 -9
- package/dist/paged-layout/transactionDirtyRange.d.ts +3 -1
- package/dist/paged-layout/transactionDirtyRange.js +23 -1
- package/dist/prosemirror/attrs/index.d.ts +7 -3
- package/dist/prosemirror/attrs/index.js +128 -7
- package/dist/prosemirror/bookmarkBoundaryIntegrity.d.ts +9 -0
- package/dist/prosemirror/bookmarkBoundaryIntegrity.js +22 -0
- package/dist/prosemirror/commands/comments.d.ts +10 -1
- package/dist/prosemirror/commands/comments.js +551 -87
- package/dist/prosemirror/commands/formatPainter.d.ts +45 -10
- package/dist/prosemirror/commands/formatPainter.js +319 -63
- package/dist/prosemirror/commands/index.d.ts +2 -2
- package/dist/prosemirror/commands/pageBreak.d.ts +5 -2
- package/dist/prosemirror/commands/pageBreak.js +18 -36
- package/dist/prosemirror/commands/propertyChangeScope.d.ts +27 -3
- package/dist/prosemirror/commands/propertyChangeScope.js +85 -14
- package/dist/prosemirror/conversion/fromProseDoc.d.ts +8 -5
- package/dist/prosemirror/conversion/fromProseDoc.js +935 -223
- package/dist/prosemirror/conversion/runShadingMark.js +2 -2
- package/dist/prosemirror/conversion/toProseDoc.d.ts +26 -1
- package/dist/prosemirror/conversion/toProseDoc.js +439 -291
- package/dist/prosemirror/extensions/StarterKit.js +4 -0
- package/dist/prosemirror/extensions/core/ParagraphExtension.js +54 -6
- package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -1
- package/dist/prosemirror/extensions/features/ListExtension.js +95 -123
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +19 -6
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +27 -1
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +134 -30
- package/dist/prosemirror/extensions/features/pasteCleanup.js +9 -15
- package/dist/prosemirror/extensions/marks/BoldExtension.js +4 -3
- package/dist/prosemirror/extensions/marks/CharacterStyleExtension.d.ts +4 -7
- package/dist/prosemirror/extensions/marks/CharacterStyleExtension.js +5 -11
- package/dist/prosemirror/extensions/marks/FontSizeExtension.js +3 -3
- package/dist/prosemirror/extensions/marks/ItalicExtension.js +4 -3
- package/dist/prosemirror/extensions/marks/PageBreakRunOwnerExtension.d.ts +13 -0
- package/dist/prosemirror/extensions/marks/PageBreakRunOwnerExtension.js +39 -0
- package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.d.ts +7 -1
- package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +67 -21
- package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +2 -0
- package/dist/prosemirror/extensions/marks/markUtils.d.ts +8 -1
- package/dist/prosemirror/extensions/marks/markUtils.js +88 -2
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +44 -3
- package/dist/prosemirror/extensions/nodes/FieldExtension.js +1 -1
- package/dist/prosemirror/extensions/nodes/HardBreakExtension.js +15 -4
- package/dist/prosemirror/extensions/nodes/PageBreakExtension.d.ts +6 -0
- package/dist/prosemirror/extensions/nodes/PageBreakExtension.js +6 -0
- package/dist/prosemirror/extensions/nodes/PageBreakRunExtension.d.ts +6 -0
- package/dist/prosemirror/extensions/nodes/PageBreakRunExtension.js +36 -0
- package/dist/prosemirror/findReplaceSelection.js +8 -3
- package/dist/prosemirror/index.d.ts +3 -3
- package/dist/prosemirror/listMarker.d.ts +26 -1
- package/dist/prosemirror/listMarker.js +108 -14
- package/dist/prosemirror/pageBreakRunProjection.d.ts +28 -0
- package/dist/prosemirror/pageBreakRunProjection.js +47 -0
- package/dist/prosemirror/paraText.js +6 -0
- package/dist/prosemirror/paragraphAlignment.d.ts +12 -0
- package/dist/prosemirror/paragraphAlignment.js +13 -0
- package/dist/prosemirror/paragraphSpacing.d.ts +29 -0
- package/dist/prosemirror/paragraphSpacing.js +125 -0
- package/dist/prosemirror/plugins/documentNumbering.d.ts +8 -0
- package/dist/prosemirror/plugins/documentNumbering.js +18 -0
- package/dist/prosemirror/plugins/pmTextScan.d.ts +3 -2
- package/dist/prosemirror/plugins/pmTextScan.js +14 -4
- package/dist/prosemirror/plugins/suggestionMode.js +9 -11
- package/dist/prosemirror/rebaseParagraphRunFormatting.d.ts +16 -0
- package/dist/prosemirror/rebaseParagraphRunFormatting.js +61 -0
- package/dist/prosemirror/runFormattingInlineCarriers.d.ts +64 -0
- package/dist/prosemirror/runFormattingInlineCarriers.js +121 -0
- package/dist/prosemirror/runFormattingProvenance.d.ts +12 -0
- package/dist/prosemirror/runFormattingProvenance.js +48 -0
- package/dist/prosemirror/runFormattingReconciliation.d.ts +25 -0
- package/dist/prosemirror/runFormattingReconciliation.js +54 -0
- package/dist/prosemirror/runPropertyChangeResolution.d.ts +18 -0
- package/dist/prosemirror/runPropertyChangeResolution.js +18 -0
- package/dist/prosemirror/runStyleFormatting.d.ts +50 -0
- package/dist/prosemirror/runStyleFormatting.js +105 -0
- package/dist/prosemirror/schema/index.d.ts +3 -3
- package/dist/prosemirror/schema/marks.d.ts +84 -9
- package/dist/prosemirror/schema/marks.js +73 -1
- package/dist/prosemirror/schema/nodes.d.ts +30 -6
- package/dist/prosemirror/styles/resolvedStyleAttrs.d.ts +14 -2
- package/dist/prosemirror/styles/resolvedStyleAttrs.js +45 -23
- package/dist/prosemirror/styles/styleResolver.d.ts +10 -13
- package/dist/prosemirror/styles/styleResolver.js +3 -3
- package/dist/prosemirror/styles/styleToggleCascade.d.ts +8 -1
- package/dist/prosemirror/styles/styleToggleCascade.js +24 -1
- package/dist/prosemirror/tableGridMutation.d.ts +20 -0
- package/dist/prosemirror/tableGridMutation.js +30 -0
- package/dist/prosemirror/trackedRunInlineAtoms.d.ts +31 -0
- package/dist/prosemirror/trackedRunInlineAtoms.js +39 -0
- package/dist/prosemirror/utils/visualLineNavigation.js +27 -31
- package/dist/prosemirror/validation.js +23 -4
- package/dist/redline.js +4 -1
- package/dist/render-dom/HeaderFooterSelectionOverlay.js +20 -20
- package/dist/render-dom/RenderedDomContext.js +17 -34
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/style-engine/styleEngine.d.ts +1 -1
- package/dist/utils/clipboard.js +1 -1
- package/dist/utils/findReplace.js +38 -21
- package/dist/utils/mergeDocumentContent.js +3 -3
- package/dist/utils/replaceText.js +2 -1
- package/dist/version-comparison.d.ts +38 -44
- package/dist/version-comparison.js +270 -399
- package/dist/watermark/index.js +2 -4
- package/package.json +8 -2
package/dist/compare/plan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { folioAIBlockIdStability } from "../ai-edits/block-identity.js";
|
|
1
2
|
import { createFolioAITextRangeHandle, trailingBodyBlockId } from "../ai-edits/snapshot.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { alignFolioContentStructure, contentBlocksShareContainer, createFolioContentAlignmentWorkSession, groupFolioContentTableRows } from "./content-alignment.js";
|
|
4
|
+
import { changedFolioContentParagraphFormatting, createContentComparisonWorkSession, detectFolioContentMoves, detectFolioContentParagraphMarkPlans } from "./content.js";
|
|
4
5
|
import { inlineFormattingSegments } from "./formatting.js";
|
|
5
6
|
import { panic } from "better-result";
|
|
6
7
|
//#region src/compare/plan.ts
|
|
@@ -10,7 +11,7 @@ import { panic } from "better-result";
|
|
|
10
11
|
* generated tracked change is accepted.
|
|
11
12
|
*
|
|
12
13
|
* Pure: no parsing, no serialization, no clock. Every alignment runs through
|
|
13
|
-
* {@link
|
|
14
|
+
* {@link alignFolioContentStructure}, shared with representation-neutral comparison.
|
|
14
15
|
*
|
|
15
16
|
* ## Aligning by container, not by block
|
|
16
17
|
*
|
|
@@ -34,445 +35,113 @@ import { panic } from "better-result";
|
|
|
34
35
|
*
|
|
35
36
|
* ## Move detection
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* durable "moved from here" mark on this path — but the change list reports a
|
|
41
|
-
* single `move` so the relocation is not read as unrelated churn. The word
|
|
42
|
-
* floor keeps boilerplate one-liners from pairing as spurious moves.
|
|
38
|
+
* Move, split/merge, and formatting classification come from the same neutral
|
|
39
|
+
* comparison core used by non-DOCX callers. The operations remain specific to
|
|
40
|
+
* the tracked-change format, but the meaning of the comparison does not fork.
|
|
43
41
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Cap on same-text base-only blocks the move pass keeps per text. Without it a
|
|
48
|
-
* document repeating one paragraph thousands of times would make the pass
|
|
49
|
-
* quadratic on attacker-controlled input.
|
|
50
|
-
*/
|
|
51
|
-
const MAX_MOVE_CANDIDATES_PER_TEXT = 64;
|
|
52
|
-
/**
|
|
53
|
-
* How much of a relocated paragraph must survive the relocation for it still
|
|
54
|
-
* to read as one. Below it the two paragraphs are a deletion and an unrelated
|
|
55
|
-
* insertion, and calling them a move would tell the reader the wrong story
|
|
56
|
-
* about where the text came from.
|
|
57
|
-
*/
|
|
58
|
-
const MOVE_SIMILARITY_THRESHOLD = .8;
|
|
59
|
-
/**
|
|
60
|
-
* Total pair comparisons the similarity pass may make in one story. Exact text
|
|
61
|
-
* matches are found by lookup; only what is left pays this, and it is capped
|
|
62
|
-
* so two documents of unmatched paragraphs cannot make the pass quadratic.
|
|
63
|
-
*/
|
|
64
|
-
const MAX_MOVE_SIMILARITY_COMPARISONS = 2e4;
|
|
65
|
-
/**
|
|
66
|
-
* Dice coefficient over word tokens: twice the shared tokens over the two
|
|
67
|
-
* token counts. Multiset rather than set, so a paragraph repeating a word does
|
|
68
|
-
* not match one that says it once.
|
|
69
|
-
*/
|
|
70
|
-
const tokenSimilarity = (left, right) => {
|
|
71
|
-
const leftTokens = left.split(/\s+/u).filter((token) => token.length > 0);
|
|
72
|
-
const rightTokens = right.split(/\s+/u).filter((token) => token.length > 0);
|
|
73
|
-
if (leftTokens.length === 0 || rightTokens.length === 0) return 0;
|
|
74
|
-
const remaining = /* @__PURE__ */ new Map();
|
|
75
|
-
for (const token of leftTokens) remaining.set(token, (remaining.get(token) ?? 0) + 1);
|
|
76
|
-
let shared = 0;
|
|
77
|
-
for (const token of rightTokens) {
|
|
78
|
-
const count = remaining.get(token) ?? 0;
|
|
79
|
-
if (count > 0) {
|
|
80
|
-
remaining.set(token, count - 1);
|
|
81
|
-
shared += 1;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return 2 * shared / (leftTokens.length + rightTokens.length);
|
|
85
|
-
};
|
|
86
|
-
const wordCountReaches = (text, minimum) => {
|
|
87
|
-
let count = 0;
|
|
88
|
-
for (const word of text.split(/\s+/u)) if (word.length > 0 && ++count >= minimum) return true;
|
|
89
|
-
return false;
|
|
90
|
-
};
|
|
91
|
-
/**
|
|
92
|
-
* The document's segments, normalized to strictly alternate body, table, body,
|
|
93
|
-
* ... and to begin and end with a body segment — inserting empty body segments
|
|
94
|
-
* where the document has none.
|
|
95
|
-
*
|
|
96
|
-
* Without the padding the two sides can hold different segment shapes (a
|
|
97
|
-
* document that opens with a table against one that opens with a heading), and
|
|
98
|
-
* the alignment's positional fallback would then put a body segment opposite a
|
|
99
|
-
* table. That pair is unusable, so the whole table would be reissued as a
|
|
100
|
-
* deletion plus an insertion. Padding keeps like opposite like.
|
|
101
|
-
*/
|
|
102
|
-
const splitSegments = (blocks) => {
|
|
103
|
-
const segments = [];
|
|
104
|
-
let currentTableIndex = null;
|
|
105
|
-
for (const block of blocks) {
|
|
106
|
-
const tableIndex = block.table?.outerTableIndex ?? null;
|
|
107
|
-
const current = segments.at(-1);
|
|
108
|
-
if (current !== void 0 && currentTableIndex === tableIndex) {
|
|
109
|
-
current.blocks.push(block);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (block.table && segments.at(-1)?.kind !== "body") segments.push({
|
|
113
|
-
kind: "body",
|
|
114
|
-
blocks: []
|
|
115
|
-
});
|
|
116
|
-
segments.push({
|
|
117
|
-
kind: block.table ? "table" : "body",
|
|
118
|
-
blocks: [block]
|
|
119
|
-
});
|
|
120
|
-
currentTableIndex = tableIndex;
|
|
121
|
-
}
|
|
122
|
-
if (segments.length === 0 || segments.at(-1)?.kind === "table") segments.push({
|
|
123
|
-
kind: "body",
|
|
124
|
-
blocks: []
|
|
125
|
-
});
|
|
126
|
-
return segments;
|
|
127
|
-
};
|
|
128
|
-
/**
|
|
129
|
-
* Blocks of one table segment grouped into rows, in document order.
|
|
130
|
-
*
|
|
131
|
-
* Keyed by table AND row, not by row alone: a segment holds the whole
|
|
132
|
-
* outermost table, so a nested table's first row would otherwise merge with
|
|
133
|
-
* its parent's first row and the alignment would compare one against the
|
|
134
|
-
* other.
|
|
135
|
-
*/
|
|
136
|
-
const groupRows = (blocks) => {
|
|
137
|
-
const rows = /* @__PURE__ */ new Map();
|
|
138
|
-
for (const block of blocks) {
|
|
139
|
-
if (!block.table) continue;
|
|
140
|
-
const key = `${String(block.table.tableIndex)}:${String(block.table.rowIndex)}`;
|
|
141
|
-
const row = rows.get(key);
|
|
142
|
-
if (row) row.push(block);
|
|
143
|
-
else rows.set(key, [block]);
|
|
144
|
-
}
|
|
145
|
-
return [...rows.values()];
|
|
146
|
-
};
|
|
147
|
-
const rowText = (row) => row.map(({ text }) => text).join(" ");
|
|
148
|
-
const rowLocation = (row) => row.at(0)?.table ?? null;
|
|
149
|
-
/** Zip one paired row's cells by physical cell index, then by paragraph order. */
|
|
150
|
-
const alignRowCells = (baseRow, targetRow, baseColumnKeys, targetColumnKeys) => {
|
|
151
|
-
const byCell = (row, columnKeys) => {
|
|
152
|
-
const cells = /* @__PURE__ */ new Map();
|
|
153
|
-
for (const block of row) {
|
|
154
|
-
const table = block.table;
|
|
155
|
-
let cellIndex = table?.cellIndex ?? 0;
|
|
156
|
-
if (table && columnKeys) {
|
|
157
|
-
const alignedColumn = columnKeys.get(table.gridColumnIndex);
|
|
158
|
-
if (alignedColumn === void 0) return panic("A paired table cell has no aligned grid column", { gridColumnIndex: table.gridColumnIndex });
|
|
159
|
-
cellIndex = alignedColumn;
|
|
160
|
-
}
|
|
161
|
-
const blocks = cells.get(cellIndex);
|
|
162
|
-
if (blocks) blocks.push(block);
|
|
163
|
-
else cells.set(cellIndex, [block]);
|
|
164
|
-
}
|
|
165
|
-
return cells;
|
|
166
|
-
};
|
|
167
|
-
const baseCells = byCell(baseRow, baseColumnKeys);
|
|
168
|
-
const targetCells = byCell(targetRow, targetColumnKeys);
|
|
169
|
-
const cellIndexes = [.../* @__PURE__ */ new Set([...baseCells.keys(), ...targetCells.keys()])].toSorted((left, right) => left - right);
|
|
170
|
-
const steps = [];
|
|
171
|
-
for (const cellIndex of cellIndexes) {
|
|
172
|
-
const baseBlocks = baseCells.get(cellIndex) ?? [];
|
|
173
|
-
const targetBlocks = targetCells.get(cellIndex) ?? [];
|
|
174
|
-
const paired = Math.min(baseBlocks.length, targetBlocks.length);
|
|
175
|
-
for (let index = 0; index < paired; index++) {
|
|
176
|
-
const baseBlock = baseBlocks[index];
|
|
177
|
-
const targetBlock = targetBlocks[index];
|
|
178
|
-
if (baseBlock && targetBlock) steps.push({
|
|
179
|
-
type: "pair",
|
|
180
|
-
baseBlock,
|
|
181
|
-
targetBlock
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
for (const block of baseBlocks.slice(paired)) steps.push({
|
|
185
|
-
type: "baseOnly",
|
|
186
|
-
block
|
|
187
|
-
});
|
|
188
|
-
for (const block of targetBlocks.slice(paired)) steps.push({
|
|
189
|
-
type: "targetOnly",
|
|
190
|
-
block
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
return steps;
|
|
194
|
-
};
|
|
195
|
-
/**
|
|
196
|
-
* Align one table's rows, then its cells.
|
|
197
|
-
*
|
|
198
|
-
* Running the block alignment straight over a table's paragraphs cannot see
|
|
199
|
-
* rows: it happily pairs a cell of one row with a cell of another, and a row
|
|
200
|
-
* that was wholly added or removed shows up as a scatter of unmatched
|
|
201
|
-
* paragraphs. Aligning rows first — each row standing in as one proxy block of
|
|
202
|
-
* its joined cell text — keeps a whole-row change whole, and confines every
|
|
203
|
-
* other difference to a cell that really corresponds.
|
|
204
|
-
*/
|
|
205
|
-
/**
|
|
206
|
-
* One segment's blocks split per table, in first-appearance order. A segment
|
|
207
|
-
* is a whole outermost table, so it holds the parent's blocks and every nested
|
|
208
|
-
* table's; a row alignment that mixed them would compare the parent's first
|
|
209
|
-
* row against a nested table's.
|
|
210
|
-
*/
|
|
211
|
-
const groupTables = (blocks) => {
|
|
212
|
-
const tables = /* @__PURE__ */ new Map();
|
|
213
|
-
for (const block of blocks) {
|
|
214
|
-
if (!block.table) continue;
|
|
215
|
-
const existing = tables.get(block.table.tableIndex);
|
|
216
|
-
if (existing) existing.push(block);
|
|
217
|
-
else tables.set(block.table.tableIndex, [block]);
|
|
218
|
-
}
|
|
219
|
-
return [...tables.values()];
|
|
220
|
-
};
|
|
221
|
-
/**
|
|
222
|
-
* Align a paired table segment: each table in it against the table at the same
|
|
223
|
-
* place on the other side, then that table's rows, then its cells. Tables are
|
|
224
|
-
* paired by order within the segment because the segment IS one table plus
|
|
225
|
-
* whatever nests inside it — the nth nested table of one answers to the nth of
|
|
226
|
-
* the other.
|
|
227
|
-
*/
|
|
228
|
-
const buildTableSegmentSteps = (baseBlocks, targetBlocks) => {
|
|
229
|
-
const baseTables = groupTables(baseBlocks);
|
|
230
|
-
const targetTables = groupTables(targetBlocks);
|
|
231
|
-
const steps = [];
|
|
232
|
-
const paired = Math.min(baseTables.length, targetTables.length);
|
|
233
|
-
for (let index = 0; index < paired; index++) steps.push(...buildTableSteps(baseTables[index] ?? [], targetTables[index] ?? []));
|
|
234
|
-
for (const blocks of baseTables.slice(paired)) {
|
|
235
|
-
const location = blocks.at(0)?.table;
|
|
236
|
-
if (location) steps.push({
|
|
237
|
-
type: "baseTable",
|
|
238
|
-
blocks,
|
|
239
|
-
location
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
for (const blocks of targetTables.slice(paired)) {
|
|
243
|
-
const location = blocks.at(0)?.table;
|
|
244
|
-
if (location) steps.push({
|
|
245
|
-
type: "targetTable",
|
|
246
|
-
blocks,
|
|
247
|
-
location
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return steps;
|
|
251
|
-
};
|
|
252
|
-
/**
|
|
253
|
-
* How alike two rows must be for one to be read as the other, edited. Below
|
|
254
|
-
* it the pair is a deleted row and an inserted one.
|
|
255
|
-
*/
|
|
256
|
-
const ROW_PAIR_SIMILARITY = .5;
|
|
257
|
-
/**
|
|
258
|
-
* How alike two rows are: their text, halved when their shapes differ.
|
|
259
|
-
*
|
|
260
|
-
* A row's shape is its physical cell count. Two rows with the same words in a
|
|
261
|
-
* different number of cells are not the same row edited, and pairing them
|
|
262
|
-
* would report every cell as changed rather than the row as replaced.
|
|
263
|
-
*/
|
|
264
|
-
const rowSimilarity = (base, target) => {
|
|
265
|
-
if (!base || !target) return 0;
|
|
266
|
-
const text = tokenSimilarity(rowText(base), rowText(target));
|
|
267
|
-
return rowCellTexts(base).length === rowCellTexts(target).length ? text : text / 2;
|
|
268
|
-
};
|
|
269
|
-
/**
|
|
270
|
-
* Align one table's rows.
|
|
271
|
-
*
|
|
272
|
-
* Exact text first, then SIMILARITY rather than position. Positional fallback
|
|
273
|
-
* is what made a deleted row plus a few cell edits report as a change in every
|
|
274
|
-
* row of the table: each row was paired with the one below it, so every cell
|
|
275
|
-
* differed. Pairing on similarity, and stepping one row on the side whose next
|
|
276
|
-
* row matches better, keeps the deletion where it happened.
|
|
277
|
-
*/
|
|
278
|
-
const alignTableRows = (baseRows, targetRows, baseColumnKeys, targetColumnKeys) => {
|
|
279
|
-
const steps = [];
|
|
280
|
-
const pushRow = (row, side) => {
|
|
281
|
-
const location = rowLocation(row);
|
|
282
|
-
if (location) steps.push({
|
|
283
|
-
type: side === "base" ? "baseRow" : "targetRow",
|
|
284
|
-
blocks: row,
|
|
285
|
-
location
|
|
286
|
-
});
|
|
287
|
-
};
|
|
288
|
-
let baseCursor = 0;
|
|
289
|
-
let targetCursor = 0;
|
|
290
|
-
while (baseCursor < baseRows.length && targetCursor < targetRows.length) {
|
|
291
|
-
const baseRow = baseRows[baseCursor];
|
|
292
|
-
const targetRow = targetRows[targetCursor];
|
|
293
|
-
if (!baseRow || !targetRow) break;
|
|
294
|
-
const here = rowSimilarity(baseRow, targetRow);
|
|
295
|
-
if (here < 1) {
|
|
296
|
-
const baseAhead = rowSimilarity(baseRows[baseCursor + 1], targetRow);
|
|
297
|
-
const targetAhead = rowSimilarity(baseRow, targetRows[targetCursor + 1]);
|
|
298
|
-
if (baseAhead >= ROW_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
|
|
299
|
-
pushRow(baseRow, "base");
|
|
300
|
-
baseCursor += 1;
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (targetAhead >= ROW_PAIR_SIMILARITY && targetAhead > here) {
|
|
304
|
-
pushRow(targetRow, "target");
|
|
305
|
-
targetCursor += 1;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
steps.push(...alignRowCells(baseRow, targetRow, baseColumnKeys, targetColumnKeys));
|
|
310
|
-
baseCursor += 1;
|
|
311
|
-
targetCursor += 1;
|
|
312
|
-
}
|
|
313
|
-
for (const row of baseRows.slice(baseCursor)) pushRow(row, "base");
|
|
314
|
-
for (const row of targetRows.slice(targetCursor)) pushRow(row, "target");
|
|
315
|
-
return steps;
|
|
316
|
-
};
|
|
317
|
-
const buildTableSteps = (baseBlocks, targetBlocks) => {
|
|
318
|
-
const columns = alignTableColumns(baseBlocks, targetBlocks);
|
|
319
|
-
return columns ? [...columns.steps, ...alignTableRows(groupRows(columns.baseBlocks), groupRows(columns.targetBlocks), columns.baseColumnKeys, columns.targetColumnKeys)] : alignTableRows(groupRows(baseBlocks), groupRows(targetBlocks));
|
|
320
|
-
};
|
|
321
|
-
const buildBodySteps = (baseBlocks, targetBlocks) => alignFolioBlocks(baseBlocks, targetBlocks).map((event) => {
|
|
322
|
-
switch (event.type) {
|
|
42
|
+
const toCompareStep = (step) => {
|
|
43
|
+
switch (step.type) {
|
|
323
44
|
case "pair": return {
|
|
324
45
|
type: "pair",
|
|
325
|
-
baseBlock:
|
|
326
|
-
targetBlock:
|
|
46
|
+
baseBlock: step.baseBlock,
|
|
47
|
+
targetBlock: step.revisedBlock
|
|
327
48
|
};
|
|
328
49
|
case "baseOnly": return {
|
|
329
50
|
type: "baseOnly",
|
|
330
|
-
block:
|
|
51
|
+
block: step.block,
|
|
52
|
+
moveScope: step.moveScope
|
|
331
53
|
};
|
|
332
54
|
case "revisedOnly": return {
|
|
333
55
|
type: "targetOnly",
|
|
334
|
-
block:
|
|
56
|
+
block: step.block,
|
|
57
|
+
moveScope: step.moveScope
|
|
58
|
+
};
|
|
59
|
+
case "baseRow": return {
|
|
60
|
+
type: "baseRow",
|
|
61
|
+
blocks: step.blocks,
|
|
62
|
+
location: step.location
|
|
63
|
+
};
|
|
64
|
+
case "revisedRow": return {
|
|
65
|
+
type: "targetRow",
|
|
66
|
+
blocks: step.blocks,
|
|
67
|
+
location: step.location
|
|
68
|
+
};
|
|
69
|
+
case "baseTable": return {
|
|
70
|
+
type: "baseTable",
|
|
71
|
+
blocks: step.blocks,
|
|
72
|
+
location: step.location
|
|
73
|
+
};
|
|
74
|
+
case "revisedTable": return {
|
|
75
|
+
type: "targetTable",
|
|
76
|
+
blocks: step.blocks,
|
|
77
|
+
location: step.location
|
|
78
|
+
};
|
|
79
|
+
case "baseColumn": return {
|
|
80
|
+
type: "baseColumn",
|
|
81
|
+
blocks: step.blocks,
|
|
82
|
+
location: step.location,
|
|
83
|
+
columnIndex: step.columnIndex
|
|
84
|
+
};
|
|
85
|
+
case "revisedColumn": return {
|
|
86
|
+
type: "targetColumn",
|
|
87
|
+
blocks: step.blocks,
|
|
88
|
+
location: step.location,
|
|
89
|
+
columnIndex: step.columnIndex,
|
|
90
|
+
anchor: step.anchor
|
|
335
91
|
};
|
|
336
|
-
default: return panic("Unhandled
|
|
92
|
+
default: return panic("Unhandled content alignment step", { step });
|
|
337
93
|
}
|
|
338
|
-
});
|
|
339
|
-
/**
|
|
340
|
-
* Every block of an unpaired segment, as one-sided steps. A whole table stays
|
|
341
|
-
* whole: reissuing it row by row would need a table to put the rows in, and
|
|
342
|
-
* the point of an unpaired table segment is that there is none.
|
|
343
|
-
*/
|
|
344
|
-
const unpairedSegmentSteps = (segment, side) => {
|
|
345
|
-
if (segment.kind !== "table") return segment.blocks.map((block) => side === "base" ? {
|
|
346
|
-
type: "baseOnly",
|
|
347
|
-
block
|
|
348
|
-
} : {
|
|
349
|
-
type: "targetOnly",
|
|
350
|
-
block
|
|
351
|
-
});
|
|
352
|
-
const location = segment.blocks.at(0)?.table;
|
|
353
|
-
if (!location) return [];
|
|
354
|
-
return [{
|
|
355
|
-
type: side === "base" ? "baseTable" : "targetTable",
|
|
356
|
-
blocks: segment.blocks,
|
|
357
|
-
location
|
|
358
|
-
}];
|
|
359
94
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const alignSegments = (baseSegments, targetSegments) => {
|
|
395
|
-
const paired = [];
|
|
396
|
-
const similarity = (left, right) => left === void 0 || right === void 0 || left.kind !== right.kind ? 0 : tokenSimilarity(segmentText(left), segmentText(right));
|
|
397
|
-
let baseCursor = 0;
|
|
398
|
-
let targetCursor = 0;
|
|
399
|
-
while (baseCursor < baseSegments.length && targetCursor < targetSegments.length) {
|
|
400
|
-
const baseSegment = baseSegments[baseCursor];
|
|
401
|
-
const targetSegment = targetSegments[targetCursor];
|
|
402
|
-
if (!baseSegment || !targetSegment) break;
|
|
403
|
-
if (baseSegment.kind !== targetSegment.kind) {
|
|
404
|
-
paired.push({
|
|
405
|
-
baseSegment,
|
|
406
|
-
targetSegment: null
|
|
407
|
-
});
|
|
408
|
-
baseCursor += 1;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (baseSegment.kind === "body") {
|
|
412
|
-
paired.push({
|
|
413
|
-
baseSegment,
|
|
414
|
-
targetSegment
|
|
415
|
-
});
|
|
416
|
-
baseCursor += 1;
|
|
417
|
-
targetCursor += 1;
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
const here = similarity(baseSegment, targetSegment);
|
|
421
|
-
const baseAhead = similarity(baseSegments[baseCursor + 2], targetSegment);
|
|
422
|
-
const targetAhead = similarity(baseSegment, targetSegments[targetCursor + 2]);
|
|
423
|
-
if (baseAhead >= TABLE_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
|
|
424
|
-
paired.push({
|
|
425
|
-
baseSegment,
|
|
426
|
-
targetSegment: null
|
|
427
|
-
});
|
|
428
|
-
baseCursor += 1;
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
if (targetAhead >= TABLE_PAIR_SIMILARITY && targetAhead > here) {
|
|
432
|
-
paired.push({
|
|
433
|
-
baseSegment: null,
|
|
434
|
-
targetSegment
|
|
435
|
-
});
|
|
436
|
-
targetCursor += 1;
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
paired.push({
|
|
440
|
-
baseSegment,
|
|
441
|
-
targetSegment
|
|
442
|
-
});
|
|
443
|
-
baseCursor += 1;
|
|
444
|
-
targetCursor += 1;
|
|
95
|
+
const toContentAlignmentStep = (step) => {
|
|
96
|
+
switch (step.type) {
|
|
97
|
+
case "pair": return {
|
|
98
|
+
type: "pair",
|
|
99
|
+
baseBlock: step.baseBlock,
|
|
100
|
+
revisedBlock: step.targetBlock
|
|
101
|
+
};
|
|
102
|
+
case "baseOnly": return step;
|
|
103
|
+
case "targetOnly": return {
|
|
104
|
+
type: "revisedOnly",
|
|
105
|
+
block: step.block,
|
|
106
|
+
moveScope: step.moveScope
|
|
107
|
+
};
|
|
108
|
+
case "baseRow":
|
|
109
|
+
case "baseTable":
|
|
110
|
+
case "baseColumn": return step;
|
|
111
|
+
case "targetRow": return {
|
|
112
|
+
type: "revisedRow",
|
|
113
|
+
blocks: step.blocks,
|
|
114
|
+
location: step.location
|
|
115
|
+
};
|
|
116
|
+
case "targetTable": return {
|
|
117
|
+
type: "revisedTable",
|
|
118
|
+
blocks: step.blocks,
|
|
119
|
+
location: step.location
|
|
120
|
+
};
|
|
121
|
+
case "targetColumn": return {
|
|
122
|
+
type: "revisedColumn",
|
|
123
|
+
blocks: step.blocks,
|
|
124
|
+
location: step.location,
|
|
125
|
+
columnIndex: step.columnIndex,
|
|
126
|
+
anchor: step.anchor
|
|
127
|
+
};
|
|
128
|
+
default: return panic("Unhandled compare step", { step });
|
|
445
129
|
}
|
|
446
|
-
for (const segment of baseSegments.slice(baseCursor)) paired.push({
|
|
447
|
-
baseSegment: segment,
|
|
448
|
-
targetSegment: null
|
|
449
|
-
});
|
|
450
|
-
for (const segment of targetSegments.slice(targetCursor)) paired.push({
|
|
451
|
-
baseSegment: null,
|
|
452
|
-
targetSegment: segment
|
|
453
|
-
});
|
|
454
|
-
return paired;
|
|
455
130
|
};
|
|
456
131
|
const isEmptyParagraphNode = (block, snapshot) => {
|
|
457
132
|
const anchor = snapshot.anchors[block.id] ?? panic("A comparison snapshot block has no matching anchor", { blockId: block.id });
|
|
458
133
|
return block.text === "" && block.table === void 0 && anchor.to - anchor.from === 2;
|
|
459
134
|
};
|
|
460
135
|
/** Align both stories, optionally holding the two last paragraphs out as a pair. */
|
|
461
|
-
const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair) => {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (baseSegment) {
|
|
471
|
-
steps.push(...unpairedSegmentSteps(baseSegment, "base"));
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
if (targetSegment) steps.push(...unpairedSegmentSteps(targetSegment, "target"));
|
|
475
|
-
}
|
|
136
|
+
const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair, wholeTableReplacement, workSession = createFolioContentAlignmentWorkSession()) => {
|
|
137
|
+
const steps = alignFolioContentStructure({
|
|
138
|
+
baseBlocks: terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks,
|
|
139
|
+
revisedBlocks: terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks,
|
|
140
|
+
wholeTableReplacement,
|
|
141
|
+
workSession,
|
|
142
|
+
stableIdMismatch: "pair",
|
|
143
|
+
idStability: folioAIBlockIdStability
|
|
144
|
+
}).map(toCompareStep);
|
|
476
145
|
if (terminalCarrierPair) steps.push(terminalCarrierPair);
|
|
477
146
|
return steps;
|
|
478
147
|
};
|
|
@@ -520,7 +189,7 @@ const addedTerminalCarrierIsStranded = (steps, baseBlocks, targetBlocks) => {
|
|
|
520
189
|
const baseLastStep = steps.findIndex((step) => step.type === "pair" && step.baseBlock.id === baseLast.id || step.type === "baseOnly" && step.block.id === baseLast.id);
|
|
521
190
|
return baseLastStep === -1 || steps.slice(baseLastStep + 1).some((step) => step.type !== "targetOnly");
|
|
522
191
|
};
|
|
523
|
-
const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
|
|
192
|
+
const buildSteps = ({ baseSnapshot, targetSnapshot, wholeTableReplacement, workSession }) => {
|
|
524
193
|
const baseBlocks = baseSnapshot.blocks;
|
|
525
194
|
const targetBlocks = targetSnapshot.blocks;
|
|
526
195
|
const baseLast = baseBlocks.at(-1);
|
|
@@ -531,9 +200,13 @@ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
|
|
|
531
200
|
targetBlock: targetLast
|
|
532
201
|
} : null;
|
|
533
202
|
const bothStoriesEndBlank = carrierPair !== null && isEmptyParagraphNode(carrierPair.baseBlock, baseSnapshot) && isEmptyParagraphNode(carrierPair.targetBlock, targetSnapshot);
|
|
534
|
-
const
|
|
203
|
+
const alignmentBudgetBeforeAttempt = workSession.remainingLcsCells;
|
|
204
|
+
const structuralTokenBudgetBeforeAttempt = workSession.remainingStructuralTokenLookups;
|
|
205
|
+
const steps = alignedSteps(baseBlocks, targetBlocks, bothStoriesEndBlank ? carrierPair : null, wholeTableReplacement, workSession);
|
|
535
206
|
if (carrierPair === null || bothStoriesEndBlank || !shareAContainer(carrierPair.baseBlock, carrierPair.targetBlock) || !(terminalCarrierIsStranded(steps, baseBlocks) || addedTerminalCarrierIsStranded(steps, baseBlocks, targetBlocks))) return steps;
|
|
536
|
-
|
|
207
|
+
workSession.remainingLcsCells = alignmentBudgetBeforeAttempt;
|
|
208
|
+
workSession.remainingStructuralTokenLookups = structuralTokenBudgetBeforeAttempt;
|
|
209
|
+
return alignedSteps(baseBlocks, targetBlocks, carrierPair, wholeTableReplacement, workSession);
|
|
537
210
|
};
|
|
538
211
|
/**
|
|
539
212
|
* Two blocks are in the same container when a paragraph mark between them
|
|
@@ -542,103 +215,15 @@ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
|
|
|
542
215
|
* paragraph-mark edit however similar the text looks.
|
|
543
216
|
*/
|
|
544
217
|
const shareAContainer = (left, right) => {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
if (head.length === 0 || tail.length === 0 || whole.length < head.length + tail.length) return null;
|
|
555
|
-
if (!whole.startsWith(head) || !whole.endsWith(tail)) return null;
|
|
556
|
-
const separator = whole.slice(head.length, whole.length - tail.length);
|
|
557
|
-
return separator.length === 0 || /^\s+$/u.test(separator) ? separator : null;
|
|
558
|
-
};
|
|
559
|
-
/**
|
|
560
|
-
* Where the alignment produced a rewrite plus an insertion or a deletion that
|
|
561
|
-
* is really one paragraph mark moving, by step index of the PAIR step. The
|
|
562
|
-
* step after it is consumed with it.
|
|
563
|
-
*
|
|
564
|
-
* The alignment cannot see this: it pairs the base paragraph with the target
|
|
565
|
-
* half that still matches it and leaves the other half unpaired, which is a
|
|
566
|
-
* correct alignment and a misleading redline.
|
|
567
|
-
*/
|
|
568
|
-
const detectParagraphMarkEdits = (steps) => {
|
|
569
|
-
const plans = /* @__PURE__ */ new Map();
|
|
570
|
-
for (const [index, step] of steps.entries()) {
|
|
571
|
-
const next = steps[index + 1];
|
|
572
|
-
if (step.type !== "pair" || next === void 0) continue;
|
|
573
|
-
if (next.type === "targetOnly") {
|
|
574
|
-
const separator = separatorBetween(step.baseBlock.text, step.targetBlock.text, next.block.text);
|
|
575
|
-
if (separator !== null && shareAContainer(step.targetBlock, next.block)) plans.set(index, {
|
|
576
|
-
type: "split",
|
|
577
|
-
baseBlock: step.baseBlock,
|
|
578
|
-
targetBlocks: [step.targetBlock, next.block],
|
|
579
|
-
offset: step.targetBlock.text.length,
|
|
580
|
-
separator
|
|
581
|
-
});
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
if (next.type !== "baseOnly") continue;
|
|
585
|
-
const separator = separatorBetween(step.targetBlock.text, step.baseBlock.text, next.block.text);
|
|
586
|
-
if (separator !== null && shareAContainer(step.baseBlock, next.block)) plans.set(index, {
|
|
587
|
-
type: "merge",
|
|
588
|
-
baseBlocks: [step.baseBlock, next.block],
|
|
589
|
-
targetBlock: step.targetBlock,
|
|
590
|
-
separator
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
return plans;
|
|
594
|
-
};
|
|
595
|
-
/** Base block id -> target block id for every relocation the move pass found. */
|
|
596
|
-
const detectMoves = (steps, consumed) => {
|
|
597
|
-
const candidatesByText = /* @__PURE__ */ new Map();
|
|
598
|
-
for (const [index, step] of steps.entries()) {
|
|
599
|
-
if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
600
|
-
const queue = candidatesByText.get(step.block.text);
|
|
601
|
-
if (!queue) {
|
|
602
|
-
candidatesByText.set(step.block.text, [step.block.id]);
|
|
603
|
-
continue;
|
|
604
|
-
}
|
|
605
|
-
if (queue.length < MAX_MOVE_CANDIDATES_PER_TEXT) queue.push(step.block.id);
|
|
606
|
-
}
|
|
607
|
-
const unmatched = [];
|
|
608
|
-
for (const [index, step] of steps.entries()) {
|
|
609
|
-
if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
610
|
-
unmatched.push(step.block);
|
|
611
|
-
}
|
|
612
|
-
const movesByBaseBlockId = /* @__PURE__ */ new Map();
|
|
613
|
-
const takenBaseBlockIds = /* @__PURE__ */ new Set();
|
|
614
|
-
let comparisonBudget = MAX_MOVE_SIMILARITY_COMPARISONS;
|
|
615
|
-
for (const [index, step] of steps.entries()) {
|
|
616
|
-
if (consumed.has(index) || step.type !== "targetOnly") continue;
|
|
617
|
-
const exact = candidatesByText.get(step.block.text)?.shift();
|
|
618
|
-
if (exact !== void 0) {
|
|
619
|
-
takenBaseBlockIds.add(exact);
|
|
620
|
-
movesByBaseBlockId.set(exact, step.block.id);
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
if (!wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
624
|
-
let best = null;
|
|
625
|
-
for (const candidate of unmatched) {
|
|
626
|
-
if (comparisonBudget <= 0) break;
|
|
627
|
-
if (takenBaseBlockIds.has(candidate.id)) continue;
|
|
628
|
-
comparisonBudget -= 1;
|
|
629
|
-
const similarity = tokenSimilarity(candidate.text, step.block.text);
|
|
630
|
-
if (similarity >= MOVE_SIMILARITY_THRESHOLD && (best === null || similarity > best.similarity)) best = {
|
|
631
|
-
block: candidate,
|
|
632
|
-
similarity
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
if (best) {
|
|
636
|
-
takenBaseBlockIds.add(best.block.id);
|
|
637
|
-
movesByBaseBlockId.set(best.block.id, step.block.id);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return movesByBaseBlockId;
|
|
641
|
-
};
|
|
218
|
+
return contentBlocksShareContainer(left, right);
|
|
219
|
+
};
|
|
220
|
+
/** Narrow the neutral patch to the operation contract without explicit undefined fields. */
|
|
221
|
+
const toFolioAIBlockParagraphProperties = (properties) => ({
|
|
222
|
+
...properties.styleId !== void 0 && { styleId: properties.styleId },
|
|
223
|
+
...properties.listLevel !== void 0 && { listLevel: properties.listLevel },
|
|
224
|
+
...properties.alignment !== void 0 && { alignment: properties.alignment },
|
|
225
|
+
...properties.spacing !== void 0 && { spacing: properties.spacing }
|
|
226
|
+
});
|
|
642
227
|
/**
|
|
643
228
|
* For each step, the id of the next base block at or after it — the anchor a
|
|
644
229
|
* target-only insertion is placed before. `null` once no base block follows.
|
|
@@ -709,7 +294,7 @@ const findRowAnchor = (steps, stepIndex) => {
|
|
|
709
294
|
* actually occupies; the empty strings are the cells a `w:gridSpan` covers.
|
|
710
295
|
*/
|
|
711
296
|
const tableCellTexts = (blocks) => {
|
|
712
|
-
const rows =
|
|
297
|
+
const rows = groupFolioContentTableRows(blocks).map((row) => rowCellTexts(row));
|
|
713
298
|
let width = 0;
|
|
714
299
|
for (const row of rows) width = Math.max(width, row.length);
|
|
715
300
|
for (const row of rows) while (row.length < width) row.push("");
|
|
@@ -735,18 +320,6 @@ const columnCellTexts = (blocks) => {
|
|
|
735
320
|
}
|
|
736
321
|
return [...byCell.values()];
|
|
737
322
|
};
|
|
738
|
-
/**
|
|
739
|
-
* The paragraph properties that differ, or `null` when they agree. Only the
|
|
740
|
-
* ones a block projection can see and an operation can set: a list level and
|
|
741
|
-
* a paragraph style, the two edits that move no words and are invisible in a
|
|
742
|
-
* text diff.
|
|
743
|
-
*/
|
|
744
|
-
const changedParagraphProperties = (baseBlock, targetBlock) => {
|
|
745
|
-
const properties = {};
|
|
746
|
-
if ((baseBlock.styleId ?? null) !== (targetBlock.styleId ?? null)) properties.styleId = targetBlock.styleId ?? null;
|
|
747
|
-
if (baseBlock.listLevel !== targetBlock.listLevel) properties.listLevel = targetBlock.listLevel ?? null;
|
|
748
|
-
return Object.keys(properties).length > 0 ? properties : null;
|
|
749
|
-
};
|
|
750
323
|
const locationOf = (story, block) => block.table ? {
|
|
751
324
|
story,
|
|
752
325
|
cell: block.table
|
|
@@ -797,11 +370,11 @@ const lastBlockByContainer = (blocks) => {
|
|
|
797
370
|
* rather than an edit of its own, so it adds no entry to the change list: what
|
|
798
371
|
* the reader is told is that paragraphs were removed and paragraphs added.
|
|
799
372
|
*/
|
|
800
|
-
const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, nextOperationId }) => {
|
|
373
|
+
const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, tableTemplates, nextOperationId }) => {
|
|
801
374
|
const plan = [...operations];
|
|
802
375
|
const deletionIndexByBlockId = /* @__PURE__ */ new Map();
|
|
803
376
|
const insertIndexesByAnchor = /* @__PURE__ */ new Map();
|
|
804
|
-
const
|
|
377
|
+
const tableInsertIndexesByAnchor = /* @__PURE__ */ new Map();
|
|
805
378
|
const markedBlockIds = /* @__PURE__ */ new Set();
|
|
806
379
|
for (const [index, operation] of plan.entries()) switch (operation.type) {
|
|
807
380
|
case "deleteBlock":
|
|
@@ -814,9 +387,12 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
814
387
|
insertIndexesByAnchor.set(operation.blockId, placed);
|
|
815
388
|
break;
|
|
816
389
|
}
|
|
817
|
-
case "insertTable":
|
|
818
|
-
|
|
390
|
+
case "insertTable": {
|
|
391
|
+
const placed = tableInsertIndexesByAnchor.get(operation.blockId) ?? [];
|
|
392
|
+
placed.push(index);
|
|
393
|
+
tableInsertIndexesByAnchor.set(operation.blockId, placed);
|
|
819
394
|
break;
|
|
395
|
+
}
|
|
820
396
|
case "splitBlock":
|
|
821
397
|
case "mergeBlockWithNext":
|
|
822
398
|
markedBlockIds.add(operation.blockId);
|
|
@@ -835,8 +411,8 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
835
411
|
const trailingRunOf = (container, carrier) => {
|
|
836
412
|
const blockIds = [];
|
|
837
413
|
const insertIndexes = [];
|
|
414
|
+
const tableInsertIndexes = [];
|
|
838
415
|
let chainStart = null;
|
|
839
|
-
let holdsAnInsertedTable = false;
|
|
840
416
|
let index = indexById.get(carrier.id) ?? panic("A container's last block is not in the snapshot it came from", { blockId: carrier.id });
|
|
841
417
|
for (; index >= 0; index--) {
|
|
842
418
|
const block = blocks[index];
|
|
@@ -847,23 +423,35 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
847
423
|
}
|
|
848
424
|
blockIds.push(block.id);
|
|
849
425
|
insertIndexes.push(...insertIndexesByAnchor.get(block.id) ?? []);
|
|
850
|
-
|
|
426
|
+
tableInsertIndexes.push(...tableInsertIndexesByAnchor.get(block.id) ?? []);
|
|
851
427
|
}
|
|
852
428
|
return {
|
|
853
429
|
blockIds,
|
|
854
430
|
chainStart,
|
|
855
431
|
insertIndexes: insertIndexes.toSorted((left, right) => left - right),
|
|
856
|
-
|
|
432
|
+
tableInsertIndexes: tableInsertIndexes.toSorted((left, right) => left - right)
|
|
857
433
|
};
|
|
858
434
|
};
|
|
859
435
|
const targetLastByContainer = lastBlockByContainer(targetSnapshot.blocks);
|
|
436
|
+
const terminalTargetTableIndex = targetSnapshot.blocks.at(-1)?.table?.outerTableIndex;
|
|
437
|
+
const targetTableIndexByOperationId = new Map(tableTemplates.map(({ operationId, targetTableIndex }) => [operationId, targetTableIndex]));
|
|
860
438
|
const dropped = /* @__PURE__ */ new Set();
|
|
861
439
|
const appended = [];
|
|
862
440
|
for (const [container, carrier] of lastBlockByContainer(blocks)) {
|
|
863
441
|
const carrierDeletionIndex = deletionIndexByBlockId.get(carrier.id);
|
|
864
442
|
if (carrierDeletionIndex === void 0) continue;
|
|
865
443
|
const run = trailingRunOf(container, carrier);
|
|
866
|
-
if (run.
|
|
444
|
+
if (run.tableInsertIndexes.length > 0) {
|
|
445
|
+
const tableInsertIndex = run.tableInsertIndexes.length === 1 ? run.tableInsertIndexes.at(0) : void 0;
|
|
446
|
+
const tableInsert = tableInsertIndex === void 0 ? void 0 : plan[tableInsertIndex];
|
|
447
|
+
if (container !== "body" || !isEmptyParagraphNode(carrier, baseSnapshot) || tableInsertIndex === void 0 || terminalTargetTableIndex === void 0 || tableInsert?.type !== "insertTable" || targetTableIndexByOperationId.get(tableInsert.id) !== terminalTargetTableIndex) continue;
|
|
448
|
+
plan[tableInsertIndex] = {
|
|
449
|
+
...tableInsert,
|
|
450
|
+
blockId: carrier.id,
|
|
451
|
+
position: "after"
|
|
452
|
+
};
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
867
455
|
const lastInsertIndex = run.insertIndexes.at(-1);
|
|
868
456
|
if (lastInsertIndex === void 0) {
|
|
869
457
|
if (run.chainStart) appended.push({
|
|
@@ -895,18 +483,23 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
895
483
|
text: insert.text,
|
|
896
484
|
...insert.moveId !== void 0 && { moveId: insert.moveId },
|
|
897
485
|
styleId: insert.styleId ?? null,
|
|
898
|
-
listLevel: insert.listLevel ?? null
|
|
486
|
+
listLevel: insert.listLevel ?? null,
|
|
487
|
+
alignment: insert.alignment ?? null,
|
|
488
|
+
spacing: insert.spacing ?? null
|
|
899
489
|
};
|
|
900
490
|
}
|
|
901
491
|
}
|
|
902
492
|
const targetCarrier = targetLastByContainer.get(container);
|
|
903
|
-
const
|
|
904
|
-
if (
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
493
|
+
const contentProperties = targetCarrier && changedFolioContentParagraphFormatting(carrier, targetCarrier);
|
|
494
|
+
if (contentProperties) {
|
|
495
|
+
const properties = toFolioAIBlockParagraphProperties(contentProperties);
|
|
496
|
+
appended.push({
|
|
497
|
+
id: nextOperationId(),
|
|
498
|
+
type: "setBlockParagraphProperties",
|
|
499
|
+
blockId: carrier.id,
|
|
500
|
+
properties
|
|
501
|
+
});
|
|
502
|
+
}
|
|
910
503
|
}
|
|
911
504
|
return [...plan.filter((_, index) => !dropped.has(index)), ...appended];
|
|
912
505
|
};
|
|
@@ -942,14 +535,23 @@ const tableGeometryPairingsOf = (steps) => {
|
|
|
942
535
|
* Plan one story's comparison, or `null` when it needs more operations than
|
|
943
536
|
* `maxOperations`.
|
|
944
537
|
*/
|
|
945
|
-
const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }) => {
|
|
538
|
+
const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations, wholeTableReplacement = "allow", workSession = createContentComparisonWorkSession() }) => {
|
|
539
|
+
const effectiveWholeTableReplacement = wholeTableReplacement === "allow" && trailingBodyBlockId(baseSnapshot) === null ? "avoid" : wholeTableReplacement;
|
|
946
540
|
const steps = buildSteps({
|
|
947
541
|
baseSnapshot,
|
|
948
|
-
targetSnapshot
|
|
542
|
+
targetSnapshot,
|
|
543
|
+
wholeTableReplacement: effectiveWholeTableReplacement,
|
|
544
|
+
workSession: workSession.alignment
|
|
949
545
|
});
|
|
950
|
-
const
|
|
546
|
+
const contentSteps = steps.map(toContentAlignmentStep);
|
|
547
|
+
const paragraphMarkPlans = detectFolioContentParagraphMarkPlans(contentSteps);
|
|
951
548
|
const consumedSteps = new Set([...paragraphMarkPlans.keys()].map((index) => index + 1));
|
|
952
|
-
const movesByBaseBlockId =
|
|
549
|
+
const movesByBaseBlockId = new Map(detectFolioContentMoves({
|
|
550
|
+
steps: contentSteps,
|
|
551
|
+
consumedStepIndexes: consumedSteps,
|
|
552
|
+
workSession,
|
|
553
|
+
idStability: folioAIBlockIdStability
|
|
554
|
+
}).map(({ baseBlock, revisedBlock }) => [baseBlock.id, revisedBlock.id]));
|
|
953
555
|
const moveSourceByTargetBlockId = /* @__PURE__ */ new Map();
|
|
954
556
|
for (const [baseBlockId, targetBlockId] of movesByBaseBlockId) moveSourceByTargetBlockId.set(targetBlockId, baseBlockId);
|
|
955
557
|
const anchorIds = nextBaseBlockIdByStep(steps);
|
|
@@ -958,11 +560,10 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
958
560
|
const tableTemplates = [];
|
|
959
561
|
/**
|
|
960
562
|
* The anchor everything past the base document's content hangs from: its
|
|
961
|
-
* last BODY-LEVEL paragraph,
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
* paragraph mark can express the break it added.
|
|
563
|
+
* last BODY-LEVEL paragraph, when it has one. Anchoring to the last block
|
|
564
|
+
* instead puts the anchor inside a terminal table, and a paragraph insertion
|
|
565
|
+
* then escapes to the table's boundary where no paragraph mark can express
|
|
566
|
+
* the break it added.
|
|
966
567
|
*
|
|
967
568
|
* The applier orders insertions that resolve to one position by their order
|
|
968
569
|
* in this array, so the tail is emitted where its step sits rather than
|
|
@@ -985,7 +586,9 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
985
586
|
text: block.text,
|
|
986
587
|
...moveSourceId !== void 0 && { moveId: moveIdOf(moveSourceId) },
|
|
987
588
|
styleId: block.styleId ?? null,
|
|
988
|
-
listLevel: block.listLevel ?? null
|
|
589
|
+
listLevel: block.listLevel ?? null,
|
|
590
|
+
alignment: block.directAlignment ?? null,
|
|
591
|
+
spacing: block.directSpacing ?? null
|
|
989
592
|
};
|
|
990
593
|
if (anchorId !== null) {
|
|
991
594
|
operations.push({
|
|
@@ -1008,7 +611,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1008
611
|
if (consumedSteps.has(stepIndex)) continue;
|
|
1009
612
|
const paragraphMarkPlan = paragraphMarkPlans.get(stepIndex);
|
|
1010
613
|
if (paragraphMarkPlan?.type === "split") {
|
|
1011
|
-
const { baseBlock,
|
|
614
|
+
const { baseBlock, revisedBlocks: splitInto, offset, separator } = paragraphMarkPlan;
|
|
1012
615
|
changes.push({
|
|
1013
616
|
kind: "split",
|
|
1014
617
|
location: locationOf(story, baseBlock),
|
|
@@ -1026,7 +629,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1026
629
|
continue;
|
|
1027
630
|
}
|
|
1028
631
|
if (paragraphMarkPlan?.type === "merge") {
|
|
1029
|
-
const { baseBlocks, targetBlock, separator } = paragraphMarkPlan;
|
|
632
|
+
const { baseBlocks, revisedBlock: targetBlock, separator } = paragraphMarkPlan;
|
|
1030
633
|
changes.push({
|
|
1031
634
|
kind: "merge",
|
|
1032
635
|
location: locationOf(story, baseBlocks[0]),
|
|
@@ -1045,8 +648,9 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1045
648
|
switch (step.type) {
|
|
1046
649
|
case "pair": {
|
|
1047
650
|
const { baseBlock, targetBlock } = step;
|
|
1048
|
-
const
|
|
1049
|
-
if (
|
|
651
|
+
const contentProperties = changedFolioContentParagraphFormatting(baseBlock, targetBlock);
|
|
652
|
+
if (contentProperties) {
|
|
653
|
+
const properties = toFolioAIBlockParagraphProperties(contentProperties);
|
|
1050
654
|
changes.push({
|
|
1051
655
|
kind: "paragraph-format",
|
|
1052
656
|
location: locationOf(story, baseBlock),
|
|
@@ -1295,6 +899,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1295
899
|
baseSnapshot,
|
|
1296
900
|
targetSnapshot,
|
|
1297
901
|
operations,
|
|
902
|
+
tableTemplates,
|
|
1298
903
|
nextOperationId
|
|
1299
904
|
});
|
|
1300
905
|
return planned.length > maxOperations ? null : {
|