@stll/folio-core 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-edits/apply.d.ts +3 -9
- package/dist/ai-edits/apply.js +94 -14
- package/dist/ai-edits/blockRange.d.ts +33 -0
- package/dist/ai-edits/blockRange.js +58 -0
- package/dist/ai-edits/headless.d.ts +33 -4
- package/dist/ai-edits/headless.js +83 -15
- package/dist/ai-edits/index.d.ts +5 -3
- package/dist/ai-edits/index.js +3 -2
- package/dist/ai-edits/snapshot.d.ts +14 -2
- package/dist/ai-edits/snapshot.js +12 -1
- package/dist/ai-edits/types.d.ts +42 -3
- package/dist/compat/eigenpal.d.ts +3 -2
- package/dist/compat/eigenpal.js +6 -5
- package/dist/controller/layoutPipeline.d.ts +1 -0
- package/dist/controller/layoutPipeline.js +35 -137
- package/dist/controller/layoutScheduler.d.ts +2 -1
- package/dist/controller/layoutScheduler.js +6 -0
- package/dist/document-operations.d.ts +122 -0
- package/dist/document-operations.js +575 -0
- package/dist/docx/blockContentParser.js +17 -7
- package/dist/docx/encryption/agileDecryption.js +2 -1
- package/dist/docx/encryption/compoundFile.js +4 -2
- package/dist/docx/footnoteParser.d.ts +1 -17
- package/dist/docx/footnoteParser.js +1 -27
- package/dist/docx/groupDrawingParser.d.ts +8 -0
- package/dist/docx/groupDrawingParser.js +131 -0
- package/dist/docx/numberingParser.d.ts +3 -1
- package/dist/docx/numberingParser.js +8 -2
- package/dist/docx/paragraphParser.js +9 -3
- package/dist/docx/runParser.js +8 -1
- package/dist/docx/selectiveXmlPatch.js +4 -10
- package/dist/docx/serializer/numberingSerializer.js +3 -1
- package/dist/docx/settingsParser.d.ts +6 -3
- package/dist/docx/settingsParser.js +2 -0
- package/dist/docx/xmlParser.js +11 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -5
- package/dist/layout-bridge/convert/footnoteLayout.d.ts +37 -1
- package/dist/layout-bridge/convert/footnoteLayout.js +138 -14
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +5 -7
- package/dist/layout-bridge/convert/headerFooterLayout.js +5 -7
- package/dist/layout-bridge/convert/toFlowBlocks.js +56 -19
- package/dist/layout-engine/index.d.ts +6 -5
- package/dist/layout-engine/index.js +89 -26
- package/dist/layout-engine/keep-together.d.ts +13 -8
- package/dist/layout-engine/keep-together.js +40 -25
- package/dist/layout-engine/layoutInstrumentation.d.ts +11 -1
- package/dist/layout-engine/layoutInstrumentation.js +10 -1
- package/dist/layout-engine/measure/measureBlocks.js +28 -18
- package/dist/layout-engine/measure/measureParagraph.js +61 -15
- package/dist/layout-engine/paginator.d.ts +2 -1
- package/dist/layout-engine/paginator.js +9 -3
- package/dist/layout-engine/textBoxGroup.d.ts +7 -0
- package/dist/layout-engine/textBoxGroup.js +8 -0
- package/dist/layout-engine/types.d.ts +30 -9
- package/dist/layout-engine/types.js +20 -2
- package/dist/layout-painter/renderPage.d.ts +15 -1
- package/dist/layout-painter/renderPage.js +27 -11
- package/dist/layout-painter/renderParagraph.js +18 -8
- package/dist/layout-painter/renderTable.js +10 -7
- package/dist/managers/DocumentLoaderManager.js +12 -5
- package/dist/managers/TableSelectionManager.js +1 -1
- package/dist/model.js +1 -1
- package/dist/paged-layout/pageText.d.ts +14 -0
- package/dist/paged-layout/pageText.js +21 -0
- package/dist/prosemirror/attrs/index.js +2 -0
- package/dist/prosemirror/commands/comments.js +75 -5
- package/dist/prosemirror/commands/hyperlink.js +4 -2
- package/dist/prosemirror/commands/image.js +2 -1
- package/dist/prosemirror/commands/index.js +1 -1
- package/dist/prosemirror/commands/propertyChangeScope.d.ts +68 -0
- package/dist/prosemirror/commands/propertyChangeScope.js +242 -0
- package/dist/prosemirror/conversion/fromProseDoc.js +14 -3
- package/dist/prosemirror/conversion/toProseDoc.js +29 -8
- package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -0
- package/dist/prosemirror/extensions/features/ListExtension.js +6 -0
- package/dist/prosemirror/extensions/nodes/TableExtension.js +10 -11
- package/dist/prosemirror/findReplaceSelection.js +2 -1
- package/dist/prosemirror/index.js +1 -1
- package/dist/prosemirror/insertOperations.js +1 -1
- package/dist/prosemirror/schema/nodes.d.ts +10 -5
- package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
- package/dist/prosemirror/utils/tabCalculator.js +4 -2
- package/dist/redline.d.ts +20 -0
- package/dist/redline.js +127 -0
- package/dist/server.d.ts +7 -3
- package/dist/server.js +6 -2
- package/dist/symbols.d.ts +35 -0
- package/dist/symbols.js +512 -0
- package/dist/utils/clipboard.js +3 -3
- package/dist/utils/fontLoader.js +3 -0
- package/dist/utils/fontResolver.js +48 -1
- package/dist/utils/zoom.d.ts +21 -0
- package/dist/utils/zoom.js +21 -0
- package/dist/version-comparison.d.ts +92 -0
- package/dist/version-comparison.js +436 -0
- package/package.json +3 -3
- package/dist/paged-layout/headerFooterMargins.d.ts +0 -75
- package/dist/paged-layout/headerFooterMargins.js +0 -122
package/dist/utils/clipboard.js
CHANGED
|
@@ -86,7 +86,7 @@ async function copyRuns(runs, options = {}) {
|
|
|
86
86
|
try {
|
|
87
87
|
return await writeToClipboard(runsToClipboardContent(runs, includeFormatting));
|
|
88
88
|
} catch (error) {
|
|
89
|
-
onError?.(error);
|
|
89
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -98,7 +98,7 @@ async function copyParagraphs(paragraphs, options = {}) {
|
|
|
98
98
|
try {
|
|
99
99
|
return await writeToClipboard(paragraphsToClipboardContent(paragraphs, includeFormatting));
|
|
100
100
|
} catch (error) {
|
|
101
|
-
onError?.(error);
|
|
101
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
102
102
|
return false;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
@@ -170,7 +170,7 @@ async function readFromClipboard(options = {}) {
|
|
|
170
170
|
try {
|
|
171
171
|
return await parseClipboardItems(await navigator.clipboard.read(), cleanWordFormatting);
|
|
172
172
|
} catch (error) {
|
|
173
|
-
onError?.(error);
|
|
173
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
174
174
|
return null;
|
|
175
175
|
}
|
|
176
176
|
}
|
package/dist/utils/fontLoader.js
CHANGED
|
@@ -17,6 +17,8 @@ let isLoadingAny = false;
|
|
|
17
17
|
* this map is used for font availability checks and preloading.
|
|
18
18
|
*/
|
|
19
19
|
const FONT_MAPPING = {
|
|
20
|
+
Aptos: "Source Sans 3",
|
|
21
|
+
"Aptos Display": "Source Sans 3",
|
|
20
22
|
Calibri: "Carlito",
|
|
21
23
|
Cambria: "Caladea",
|
|
22
24
|
Arial: "Arimo",
|
|
@@ -38,6 +40,7 @@ const BUNDLED_FONTS = /* @__PURE__ */ new Set([
|
|
|
38
40
|
"Arimo",
|
|
39
41
|
"Tinos",
|
|
40
42
|
"Cousine",
|
|
43
|
+
"Source Sans 3",
|
|
41
44
|
"Calibri",
|
|
42
45
|
"Cambria",
|
|
43
46
|
"Arial",
|
|
@@ -44,6 +44,11 @@ const JP_MEASURED_LINE_HEIGHT = {
|
|
|
44
44
|
ratio: 1.303,
|
|
45
45
|
note: "Measured against real Word: 10.5pt Japanese body, non-grid section, renders at 13.68pt line pitch."
|
|
46
46
|
};
|
|
47
|
+
const APTOS_MEASURED_LINE_HEIGHT = {
|
|
48
|
+
source: "measured",
|
|
49
|
+
ratio: 1.2207,
|
|
50
|
+
note: "Measured against real Word: 11pt Aptos body, non-grid section, renders at 13.43pt line pitch."
|
|
51
|
+
};
|
|
47
52
|
/**
|
|
48
53
|
* Mapping of common DOCX fonts to Google Fonts equivalents
|
|
49
54
|
*
|
|
@@ -59,6 +64,30 @@ const JP_MEASURED_LINE_HEIGHT = {
|
|
|
59
64
|
* ratios carried over unchanged, pending measurement.
|
|
60
65
|
*/
|
|
61
66
|
const FONT_MAPPINGS = {
|
|
67
|
+
aptos: {
|
|
68
|
+
googleFont: "Source Sans 3",
|
|
69
|
+
category: "sans-serif",
|
|
70
|
+
fallbackStack: [
|
|
71
|
+
"Aptos",
|
|
72
|
+
"Source Sans 3",
|
|
73
|
+
"Arial",
|
|
74
|
+
"Helvetica",
|
|
75
|
+
"sans-serif"
|
|
76
|
+
],
|
|
77
|
+
singleLineRatio: singleLineRatioOf(APTOS_MEASURED_LINE_HEIGHT)
|
|
78
|
+
},
|
|
79
|
+
"aptos display": {
|
|
80
|
+
googleFont: "Source Sans 3",
|
|
81
|
+
category: "sans-serif",
|
|
82
|
+
fallbackStack: [
|
|
83
|
+
"Aptos Display",
|
|
84
|
+
"Source Sans 3",
|
|
85
|
+
"Arial",
|
|
86
|
+
"Helvetica",
|
|
87
|
+
"sans-serif"
|
|
88
|
+
],
|
|
89
|
+
singleLineRatio: singleLineRatioOf(APTOS_MEASURED_LINE_HEIGHT)
|
|
90
|
+
},
|
|
62
91
|
calibri: {
|
|
63
92
|
googleFont: "Carlito",
|
|
64
93
|
category: "sans-serif",
|
|
@@ -213,6 +242,23 @@ const FONT_MAPPINGS = {
|
|
|
213
242
|
unitsPerEm: 2048
|
|
214
243
|
})
|
|
215
244
|
},
|
|
245
|
+
montserrat: {
|
|
246
|
+
googleFont: "Montserrat",
|
|
247
|
+
category: "sans-serif",
|
|
248
|
+
fallbackStack: [
|
|
249
|
+
"Montserrat",
|
|
250
|
+
"Arial",
|
|
251
|
+
"Helvetica",
|
|
252
|
+
"sans-serif"
|
|
253
|
+
],
|
|
254
|
+
singleLineRatio: singleLineRatioOf({
|
|
255
|
+
source: "hhea",
|
|
256
|
+
hheaAscent: 968,
|
|
257
|
+
hheaDescent: -251,
|
|
258
|
+
hheaLineGap: 0,
|
|
259
|
+
unitsPerEm: 1e3
|
|
260
|
+
})
|
|
261
|
+
},
|
|
216
262
|
"trebuchet ms": {
|
|
217
263
|
googleFont: "Fira Sans",
|
|
218
264
|
category: "sans-serif",
|
|
@@ -759,7 +805,8 @@ function resolveFontFamily(docxFontName) {
|
|
|
759
805
|
singleLineRatio: mapping.singleLineRatio
|
|
760
806
|
};
|
|
761
807
|
}
|
|
762
|
-
const
|
|
808
|
+
const category = detectFontCategory(docxFontName);
|
|
809
|
+
const defaultFallback = DEFAULT_FALLBACKS[category];
|
|
763
810
|
return {
|
|
764
811
|
googleFont: null,
|
|
765
812
|
cssFallback: withArabicFallback(`${quoteFontName(docxFontName)}, ${defaultFallback}`),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/utils/zoom.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical zoom limits shared by every folio adapter (React, Vue, ...).
|
|
4
|
+
*
|
|
5
|
+
* Adapters must source their zoom range from here rather than hard-coding their
|
|
6
|
+
* own, so the reachable zoom span stays identical across framework hosts.
|
|
7
|
+
* Historically React clamped 0.25-4x while Vue clamped 0.5-2x; this module is
|
|
8
|
+
* the single source of truth that keeps them from drifting again.
|
|
9
|
+
*
|
|
10
|
+
* Note: the discrete zoom-level menu each adapter shows in its toolbar dropdown
|
|
11
|
+
* is a separate, deliberately curated subset (50-200%); it is not derived from
|
|
12
|
+
* these limits.
|
|
13
|
+
*/
|
|
14
|
+
/** Minimum reachable zoom level (25%). */
|
|
15
|
+
declare const ZOOM_MIN = 0.25;
|
|
16
|
+
/** Maximum reachable zoom level (400%). */
|
|
17
|
+
declare const ZOOM_MAX = 4;
|
|
18
|
+
/** Step applied per zoom-in / zoom-out action and per keyboard shortcut. */
|
|
19
|
+
declare const ZOOM_STEP = 0.1;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/utils/zoom.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical zoom limits shared by every folio adapter (React, Vue, ...).
|
|
4
|
+
*
|
|
5
|
+
* Adapters must source their zoom range from here rather than hard-coding their
|
|
6
|
+
* own, so the reachable zoom span stays identical across framework hosts.
|
|
7
|
+
* Historically React clamped 0.25-4x while Vue clamped 0.5-2x; this module is
|
|
8
|
+
* the single source of truth that keeps them from drifting again.
|
|
9
|
+
*
|
|
10
|
+
* Note: the discrete zoom-level menu each adapter shows in its toolbar dropdown
|
|
11
|
+
* is a separate, deliberately curated subset (50-200%); it is not derived from
|
|
12
|
+
* these limits.
|
|
13
|
+
*/
|
|
14
|
+
/** Minimum reachable zoom level (25%). */
|
|
15
|
+
const ZOOM_MIN = .25;
|
|
16
|
+
/** Maximum reachable zoom level (400%). */
|
|
17
|
+
const ZOOM_MAX = 4;
|
|
18
|
+
/** Step applied per zoom-in / zoom-out action and per keyboard shortcut. */
|
|
19
|
+
const ZOOM_STEP = .1;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { FolioAIBlock } from "./ai-edits/types.js";
|
|
2
|
+
import { WordDiffSegment } from "./ai-edits/word-diff.js";
|
|
3
|
+
|
|
4
|
+
//#region src/version-comparison.d.ts
|
|
5
|
+
/** One word-level diff segment within a `modified` block. Mirrors {@link WordDiffSegment}. */
|
|
6
|
+
type FolioVersionDiffSegment = WordDiffSegment;
|
|
7
|
+
/** Run-level formatting properties compared for `formatChanged` detection. */
|
|
8
|
+
declare const FORMAT_PROPERTIES: readonly ["bold", "italic", "underline", "strike", "fontFamily", "fontSizePt", "color"];
|
|
9
|
+
/** A run-level formatting property that can differ in a `formatChanged` block. */
|
|
10
|
+
type FolioFormatProperty = (typeof FORMAT_PROPERTIES)[number];
|
|
11
|
+
/** One block-level change between two document versions, in revised-side document order. */
|
|
12
|
+
type FolioBlockDiff = {
|
|
13
|
+
type: "added";
|
|
14
|
+
blockId: string;
|
|
15
|
+
kind: string;
|
|
16
|
+
text: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "deleted";
|
|
19
|
+
blockId: string;
|
|
20
|
+
kind: string;
|
|
21
|
+
text: string;
|
|
22
|
+
} | {
|
|
23
|
+
type: "modified";
|
|
24
|
+
blockId: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
segments: FolioVersionDiffSegment[];
|
|
27
|
+
} | {
|
|
28
|
+
type: "formatChanged";
|
|
29
|
+
blockId: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
text: string;
|
|
32
|
+
changedProperties: FolioFormatProperty[];
|
|
33
|
+
} | {
|
|
34
|
+
type: "movedFrom";
|
|
35
|
+
blockId: string;
|
|
36
|
+
kind: string;
|
|
37
|
+
text: string;
|
|
38
|
+
moveGroupId: number;
|
|
39
|
+
} | {
|
|
40
|
+
type: "movedTo";
|
|
41
|
+
blockId: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
text: string;
|
|
44
|
+
moveGroupId: number;
|
|
45
|
+
};
|
|
46
|
+
/** Result of {@link compareDocxVersions}. */
|
|
47
|
+
type FolioVersionDiff = {
|
|
48
|
+
/** Every changed block, in revised-side document order (deletions and move sources slotted where they sat). */changes: FolioBlockDiff[]; /** Counts across every paired/unpaired block, including the unchanged blocks `changes` omits. `moved` counts pairs, not entries. */
|
|
49
|
+
summaryCounts: {
|
|
50
|
+
added: number;
|
|
51
|
+
deleted: number;
|
|
52
|
+
modified: number;
|
|
53
|
+
formatChanged: number;
|
|
54
|
+
moved: number;
|
|
55
|
+
unchanged: number;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
|
|
59
|
+
declare const exceedsLcsBudget: (unpairedBaseCount: number, unpairedRevisedCount: number) => boolean;
|
|
60
|
+
/**
|
|
61
|
+
* One step of a completed alignment, in revised-side document order with
|
|
62
|
+
* base-only blocks slotted where they sat. `pair` events cover pass 1/2
|
|
63
|
+
* anchors and pass 3's positional zip alike; whether the pair is unchanged,
|
|
64
|
+
* modified, or format-changed is the consumer's call.
|
|
65
|
+
*/
|
|
66
|
+
type FolioAlignedBlockEvent = {
|
|
67
|
+
type: "pair";
|
|
68
|
+
baseBlock: FolioAIBlock;
|
|
69
|
+
revisedBlock: FolioAIBlock;
|
|
70
|
+
} | {
|
|
71
|
+
type: "baseOnly";
|
|
72
|
+
block: FolioAIBlock;
|
|
73
|
+
} | {
|
|
74
|
+
type: "revisedOnly";
|
|
75
|
+
block: FolioAIBlock;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Run the three-pass alignment (see the module doc comment) over two block
|
|
79
|
+
* snapshots and flatten it into an ordered event stream. Shared by
|
|
80
|
+
* {@link compareDocxVersions} and the redline generator so both interpret
|
|
81
|
+
* one document walk instead of re-deriving it.
|
|
82
|
+
*/
|
|
83
|
+
declare const alignFolioBlocks: (baseBlocks: readonly FolioAIBlock[], revisedBlocks: readonly FolioAIBlock[]) => FolioAlignedBlockEvent[];
|
|
84
|
+
/**
|
|
85
|
+
* Compare two `.docx` buffers and return a structured, block-level diff.
|
|
86
|
+
* See the module doc comment for the as-accepted comparison semantics, the
|
|
87
|
+
* three-pass alignment algorithm, move detection, and format-only change
|
|
88
|
+
* detection.
|
|
89
|
+
*/
|
|
90
|
+
declare const compareDocxVersions: (base: ArrayBuffer, revised: ArrayBuffer) => Promise<FolioVersionDiff>;
|
|
91
|
+
//#endregion
|
|
92
|
+
export { FolioAlignedBlockEvent, FolioBlockDiff, FolioFormatProperty, FolioVersionDiff, FolioVersionDiffSegment, alignFolioBlocks, compareDocxVersions, exceedsLcsBudget };
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { getFolioParaIdFromBlockId } from "./types/block-id.js";
|
|
2
|
+
import { diffWordSegments } from "./ai-edits/word-diff.js";
|
|
3
|
+
import { FolioDocxReviewer } from "./ai-edits/headless.js";
|
|
4
|
+
//#region src/version-comparison.ts
|
|
5
|
+
/**
|
|
6
|
+
* Document version-diff engine: compare two `.docx` buffers block by block
|
|
7
|
+
* and produce a structured, LLM-summarizable diff.
|
|
8
|
+
*
|
|
9
|
+
* Both buffers are parsed through {@link FolioDocxReviewer} — the same
|
|
10
|
+
* headless parsing + clean-text path `read_document` / `read_changes` use —
|
|
11
|
+
* so the comparison runs over each document's AS-ACCEPTED view: any pending
|
|
12
|
+
* tracked changes already present in EITHER buffer count as applied before
|
|
13
|
+
* the two are compared. Two documents that agree once their own pending
|
|
14
|
+
* redlines are accepted report as unchanged, even if the underlying
|
|
15
|
+
* tracked-change history differs.
|
|
16
|
+
*
|
|
17
|
+
* ## Alignment
|
|
18
|
+
*
|
|
19
|
+
* Blocks are paired across the two snapshots in three passes, each only
|
|
20
|
+
* considering blocks the previous pass left unpaired:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Stable-id pairing.** Blocks whose ids are equal AND not a `seq-NNNN`
|
|
23
|
+
* positional fallback ({@link getFolioParaIdFromBlockId} returns non-null)
|
|
24
|
+
* are paired directly. This covers both id shapes a snapshot can carry:
|
|
25
|
+
* - A real Word `w14:paraId`: stable identity, independent of text — an
|
|
26
|
+
* equal-id pair with different text is a genuine edit (`modified`).
|
|
27
|
+
* - `FolioDocxReviewer`'s deterministic fallback id (assigned when the
|
|
28
|
+
* source paragraph has no `w14:paraId`), which hashes the paragraph's
|
|
29
|
+
* TEXT plus its document ordinal. It is structurally indistinguishable
|
|
30
|
+
* from a real paraId ({@link getFolioParaIdFromBlockId} can't tell them
|
|
31
|
+
* apart), but pairing on equality is still safe: two equal deterministic
|
|
32
|
+
* ids necessarily came from identical text at an identical ordinal, so
|
|
33
|
+
* the pair is always text-equal (`unchanged`) — never a false
|
|
34
|
+
* `modified`. What it can't do is FIND a paragraph whose ordinal shifted
|
|
35
|
+
* (an insertion/deletion earlier in the document) even though its text
|
|
36
|
+
* is unchanged: that pair has two different fallback ids and falls
|
|
37
|
+
* through to pass 2.
|
|
38
|
+
* 2. **Exact-text pairing.** An order-preserving LCS over remaining blocks,
|
|
39
|
+
* matched by exact text equality. This is what recovers same-text blocks
|
|
40
|
+
* that pass 1 missed because a fallback id shifted with the ordinal. Its
|
|
41
|
+
* O(m·n) table is skipped ({@link exceedsLcsBudget}) once the unpaired
|
|
42
|
+
* counts on both sides would exceed a fixed cell budget, so a document
|
|
43
|
+
* with few/no stable ids can't force a quadratic-sized allocation; those
|
|
44
|
+
* blocks fall through to pass 3 instead.
|
|
45
|
+
* 3. **Positional fallback.** Whatever a monotonicity filter leaves
|
|
46
|
+
* unpaired is split into the gaps between anchored pairs (pass 1 + 2,
|
|
47
|
+
* time-ordered); within each gap the shorter side is zipped positionally
|
|
48
|
+
* against the longer one (`modified`), and any excess on either side is
|
|
49
|
+
* reported as `added` / `deleted`.
|
|
50
|
+
*
|
|
51
|
+
* The combined anchor set from passes 1 and 2 is re-filtered to the longest
|
|
52
|
+
* increasing subsequence by revised-side index before pass 3 runs, so a
|
|
53
|
+
* pathological crossing match (content reordered across versions) can't
|
|
54
|
+
* produce an out-of-order gap — the alignment always walks both documents
|
|
55
|
+
* forward.
|
|
56
|
+
*
|
|
57
|
+
* ## Move detection
|
|
58
|
+
*
|
|
59
|
+
* Relocated content would otherwise report as an unrelated `deleted` +
|
|
60
|
+
* `added` pair (both order-preserving passes drop crossing matches by
|
|
61
|
+
* design). A post-pass re-classifies such pairs: an `added` and a `deleted`
|
|
62
|
+
* block with identical text and at least {@link MOVE_MINIMUM_WORD_COUNT}
|
|
63
|
+
* words become `movedFrom` / `movedTo` entries sharing a `moveGroupId`. The
|
|
64
|
+
* word-count floor keeps boilerplate one-liners ("Confidential", empty
|
|
65
|
+
* headings) from pairing as spurious moves. Blocks a positional zip already
|
|
66
|
+
* mis-paired as `modified` are out of this pass's reach — a known limitation
|
|
67
|
+
* of the gap fallback, not of the move pass.
|
|
68
|
+
*
|
|
69
|
+
* ## Format-only changes
|
|
70
|
+
*
|
|
71
|
+
* A paired block whose text is byte-equal but whose run-level formatting
|
|
72
|
+
* (bold, italic, underline, strike, font family, font size, color) differs
|
|
73
|
+
* reports as `formatChanged` with the set of properties that differ, instead
|
|
74
|
+
* of silently counting as `unchanged`. Detection walks the two blocks'
|
|
75
|
+
* preview runs character-aligned; when a block carries non-text inline
|
|
76
|
+
* content that makes the preview texts disagree, detection backs off to
|
|
77
|
+
* `unchanged` rather than misattribute properties.
|
|
78
|
+
*/
|
|
79
|
+
/** Run-level formatting properties compared for `formatChanged` detection. */
|
|
80
|
+
const FORMAT_PROPERTIES = [
|
|
81
|
+
"bold",
|
|
82
|
+
"italic",
|
|
83
|
+
"underline",
|
|
84
|
+
"strike",
|
|
85
|
+
"fontFamily",
|
|
86
|
+
"fontSizePt",
|
|
87
|
+
"color"
|
|
88
|
+
];
|
|
89
|
+
const isStableBlockId = (id) => getFolioParaIdFromBlockId(id) !== null;
|
|
90
|
+
/**
|
|
91
|
+
* Longest increasing subsequence by `revisedIndex`, assuming `pairs` is
|
|
92
|
+
* already sorted by `baseIndex` ascending. Drops any pair that would make
|
|
93
|
+
* the alignment walk backward in the revised document — the guard against
|
|
94
|
+
* both id collisions (pass 1) and any crossing match (pass 1 + 2 combined).
|
|
95
|
+
*/
|
|
96
|
+
const longestIncreasingByRevisedIndex = (pairs) => {
|
|
97
|
+
if (pairs.length === 0) return [];
|
|
98
|
+
const lengths = new Int32Array(pairs.length).fill(1);
|
|
99
|
+
const predecessors = new Int32Array(pairs.length).fill(-1);
|
|
100
|
+
let bestEnd = 0;
|
|
101
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
102
|
+
for (let j = 0; j < i; j++) {
|
|
103
|
+
const current = pairs[j];
|
|
104
|
+
const candidate = pairs[i];
|
|
105
|
+
if (!current || !candidate) continue;
|
|
106
|
+
if (current.revisedIndex < candidate.revisedIndex && (lengths[j] ?? 0) + 1 > (lengths[i] ?? 0)) {
|
|
107
|
+
lengths[i] = (lengths[j] ?? 0) + 1;
|
|
108
|
+
predecessors[i] = j;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if ((lengths[i] ?? 0) > (lengths[bestEnd] ?? 0)) bestEnd = i;
|
|
112
|
+
}
|
|
113
|
+
const ordered = [];
|
|
114
|
+
for (let cursor = bestEnd; cursor !== -1; cursor = predecessors[cursor] ?? -1) {
|
|
115
|
+
const pair = pairs[cursor];
|
|
116
|
+
if (pair) ordered.push(pair);
|
|
117
|
+
}
|
|
118
|
+
return ordered.toReversed();
|
|
119
|
+
};
|
|
120
|
+
/** Pass 1: pair blocks with equal, non-`seq-NNNN` ids. See the module doc comment. */
|
|
121
|
+
const pairByStableId = (base, revised) => {
|
|
122
|
+
const revisedIndexById = /* @__PURE__ */ new Map();
|
|
123
|
+
revised.forEach((block, revisedIndex) => {
|
|
124
|
+
if (isStableBlockId(block.id)) revisedIndexById.set(block.id, revisedIndex);
|
|
125
|
+
});
|
|
126
|
+
const candidates = [];
|
|
127
|
+
base.forEach((block, baseIndex) => {
|
|
128
|
+
if (!isStableBlockId(block.id)) return;
|
|
129
|
+
const revisedIndex = revisedIndexById.get(block.id);
|
|
130
|
+
if (revisedIndex !== void 0) candidates.push({
|
|
131
|
+
baseIndex,
|
|
132
|
+
revisedIndex
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
return longestIncreasingByRevisedIndex(candidates);
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Cell budget for pass 2's O(m·n) exact-text LCS table (`dp` below allocates
|
|
139
|
+
* `(m + 1) * (n + 1)` numbers). A document with no `w14:paraId`s — or an
|
|
140
|
+
* adversarial one crafted to defeat pass 1 — can leave thousands of blocks
|
|
141
|
+
* unpaired on both sides; without a cap, `pairByExactText` would allocate a
|
|
142
|
+
* quadratic-sized table for it. Past this budget, {@link exceedsLcsBudget}
|
|
143
|
+
* makes pass 2 back off entirely so alignment falls through to pass 3's
|
|
144
|
+
* linear positional zip instead — pairing is less precise for these
|
|
145
|
+
* degenerate inputs, but memory use stays bounded.
|
|
146
|
+
*/
|
|
147
|
+
const MAX_LCS_CELLS = 4e6;
|
|
148
|
+
/** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
|
|
149
|
+
const exceedsLcsBudget = (unpairedBaseCount, unpairedRevisedCount) => unpairedBaseCount * unpairedRevisedCount > MAX_LCS_CELLS;
|
|
150
|
+
/** Pass 2: order-preserving LCS by exact text equality over the blocks pass 1 left unpaired. */
|
|
151
|
+
const pairByExactText = (base, revised) => {
|
|
152
|
+
const m = base.length;
|
|
153
|
+
const n = revised.length;
|
|
154
|
+
if (m === 0 || n === 0) return [];
|
|
155
|
+
if (exceedsLcsBudget(m, n)) return [];
|
|
156
|
+
const baseTexts = base.map(({ block }) => block.text);
|
|
157
|
+
const revisedTexts = revised.map(({ block }) => block.text);
|
|
158
|
+
const stride = n + 1;
|
|
159
|
+
const dp = new Int32Array((m + 1) * stride);
|
|
160
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
161
|
+
const rowOffset = i * stride;
|
|
162
|
+
const nextRowOffset = (i + 1) * stride;
|
|
163
|
+
const baseText = baseTexts[i];
|
|
164
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
165
|
+
const revisedText = revisedTexts[j];
|
|
166
|
+
dp[rowOffset + j] = baseText === revisedText ? (dp[nextRowOffset + j + 1] ?? 0) + 1 : Math.max(dp[nextRowOffset + j] ?? 0, dp[rowOffset + j + 1] ?? 0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const pairs = [];
|
|
170
|
+
let i = 0;
|
|
171
|
+
let j = 0;
|
|
172
|
+
while (i < m && j < n) {
|
|
173
|
+
const baseEntry = base[i];
|
|
174
|
+
const revisedEntry = revised[j];
|
|
175
|
+
if (!baseEntry || !revisedEntry) break;
|
|
176
|
+
if (baseTexts[i] === revisedTexts[j]) {
|
|
177
|
+
pairs.push({
|
|
178
|
+
baseIndex: baseEntry.index,
|
|
179
|
+
revisedIndex: revisedEntry.index
|
|
180
|
+
});
|
|
181
|
+
i++;
|
|
182
|
+
j++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if ((dp[(i + 1) * stride + j] ?? 0) >= (dp[i * stride + j + 1] ?? 0)) i++;
|
|
186
|
+
else j++;
|
|
187
|
+
}
|
|
188
|
+
return pairs;
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Run the three-pass alignment (see the module doc comment) over two block
|
|
192
|
+
* snapshots and flatten it into an ordered event stream. Shared by
|
|
193
|
+
* {@link compareDocxVersions} and the redline generator so both interpret
|
|
194
|
+
* one document walk instead of re-deriving it.
|
|
195
|
+
*/
|
|
196
|
+
const alignFolioBlocks = (baseBlocks, revisedBlocks) => {
|
|
197
|
+
const stableIdAnchors = pairByStableId(baseBlocks, revisedBlocks);
|
|
198
|
+
const usedBaseIndexes = new Set(stableIdAnchors.map((anchor) => anchor.baseIndex));
|
|
199
|
+
const usedRevisedIndexes = new Set(stableIdAnchors.map((anchor) => anchor.revisedIndex));
|
|
200
|
+
const baseRemaining = [];
|
|
201
|
+
baseBlocks.forEach((block, blockIndex) => {
|
|
202
|
+
if (!usedBaseIndexes.has(blockIndex)) baseRemaining.push({
|
|
203
|
+
block,
|
|
204
|
+
index: blockIndex
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
const revisedRemaining = [];
|
|
208
|
+
revisedBlocks.forEach((block, blockIndex) => {
|
|
209
|
+
if (!usedRevisedIndexes.has(blockIndex)) revisedRemaining.push({
|
|
210
|
+
block,
|
|
211
|
+
index: blockIndex
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
const exactTextAnchors = pairByExactText(baseRemaining, revisedRemaining);
|
|
215
|
+
const anchors = longestIncreasingByRevisedIndex([...stableIdAnchors, ...exactTextAnchors].toSorted((a, b) => a.baseIndex - b.baseIndex));
|
|
216
|
+
const events = [];
|
|
217
|
+
/** Pass 3: positionally zip the leftover blocks in one gap between anchors. */
|
|
218
|
+
const emitGap = (baseFrom, baseTo, revisedFrom, revisedTo) => {
|
|
219
|
+
const pairedCount = Math.min(baseTo - baseFrom, revisedTo - revisedFrom);
|
|
220
|
+
for (let k = 0; k < pairedCount; k++) {
|
|
221
|
+
const baseBlock = baseBlocks[baseFrom + k];
|
|
222
|
+
const revisedBlock = revisedBlocks[revisedFrom + k];
|
|
223
|
+
if (baseBlock && revisedBlock) events.push({
|
|
224
|
+
type: "pair",
|
|
225
|
+
baseBlock,
|
|
226
|
+
revisedBlock
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
for (let k = baseFrom + pairedCount; k < baseTo; k++) {
|
|
230
|
+
const block = baseBlocks[k];
|
|
231
|
+
if (block) events.push({
|
|
232
|
+
type: "baseOnly",
|
|
233
|
+
block
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
for (let k = revisedFrom + pairedCount; k < revisedTo; k++) {
|
|
237
|
+
const block = revisedBlocks[k];
|
|
238
|
+
if (block) events.push({
|
|
239
|
+
type: "revisedOnly",
|
|
240
|
+
block
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
let baseCursor = 0;
|
|
245
|
+
let revisedCursor = 0;
|
|
246
|
+
for (const anchor of anchors) {
|
|
247
|
+
emitGap(baseCursor, anchor.baseIndex, revisedCursor, anchor.revisedIndex);
|
|
248
|
+
const baseBlock = baseBlocks[anchor.baseIndex];
|
|
249
|
+
const revisedBlock = revisedBlocks[anchor.revisedIndex];
|
|
250
|
+
if (baseBlock && revisedBlock) events.push({
|
|
251
|
+
type: "pair",
|
|
252
|
+
baseBlock,
|
|
253
|
+
revisedBlock
|
|
254
|
+
});
|
|
255
|
+
baseCursor = anchor.baseIndex + 1;
|
|
256
|
+
revisedCursor = anchor.revisedIndex + 1;
|
|
257
|
+
}
|
|
258
|
+
emitGap(baseCursor, baseBlocks.length, revisedCursor, revisedBlocks.length);
|
|
259
|
+
return events;
|
|
260
|
+
};
|
|
261
|
+
const previewRunsText = (runs) => runs.map((run) => run.text).join("");
|
|
262
|
+
/**
|
|
263
|
+
* Character-aligned formatting diff of two text-equal blocks. Walks both
|
|
264
|
+
* blocks' preview runs in parallel and collects every property whose value
|
|
265
|
+
* differs anywhere in the overlap. A side with `previewRuns === undefined`
|
|
266
|
+
* (the snapshot omits them when every run is unstyled) counts as one
|
|
267
|
+
* unstyled run spanning the whole text. When both sides carry runs but
|
|
268
|
+
* their concatenated texts disagree (non-text inline content can make the
|
|
269
|
+
* preview text drift from the block text), positions can't be aligned, so
|
|
270
|
+
* detection backs off and reports no change.
|
|
271
|
+
*/
|
|
272
|
+
const diffPreviewRunFormatting = (base, revised) => {
|
|
273
|
+
if (base.previewRuns === void 0 && revised.previewRuns === void 0) return [];
|
|
274
|
+
const baseText = base.previewRuns === void 0 ? null : previewRunsText(base.previewRuns);
|
|
275
|
+
const revisedText = revised.previewRuns === void 0 ? null : previewRunsText(revised.previewRuns);
|
|
276
|
+
if (baseText !== null && revisedText !== null && baseText !== revisedText) return [];
|
|
277
|
+
const text = baseText ?? revisedText;
|
|
278
|
+
if (text === null || text.length === 0) return [];
|
|
279
|
+
const baseRuns = base.previewRuns ?? [{ text }];
|
|
280
|
+
const revisedRuns = revised.previewRuns ?? [{ text }];
|
|
281
|
+
const changed = /* @__PURE__ */ new Set();
|
|
282
|
+
let baseRunIndex = 0;
|
|
283
|
+
let revisedRunIndex = 0;
|
|
284
|
+
let baseOffset = 0;
|
|
285
|
+
let revisedOffset = 0;
|
|
286
|
+
while (baseRunIndex < baseRuns.length && revisedRunIndex < revisedRuns.length) {
|
|
287
|
+
const baseRun = baseRuns[baseRunIndex];
|
|
288
|
+
const revisedRun = revisedRuns[revisedRunIndex];
|
|
289
|
+
if (!baseRun || !revisedRun) break;
|
|
290
|
+
const step = Math.min(baseRun.text.length - baseOffset, revisedRun.text.length - revisedOffset);
|
|
291
|
+
if (step > 0) {
|
|
292
|
+
for (const property of FORMAT_PROPERTIES) if (baseRun[property] !== revisedRun[property]) changed.add(property);
|
|
293
|
+
}
|
|
294
|
+
baseOffset += step;
|
|
295
|
+
revisedOffset += step;
|
|
296
|
+
if (baseOffset >= baseRun.text.length) {
|
|
297
|
+
baseRunIndex++;
|
|
298
|
+
baseOffset = 0;
|
|
299
|
+
}
|
|
300
|
+
if (revisedOffset >= revisedRun.text.length) {
|
|
301
|
+
revisedRunIndex++;
|
|
302
|
+
revisedOffset = 0;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return FORMAT_PROPERTIES.filter((property) => changed.has(property));
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Floor for move detection: an added/deleted text must hold at least this
|
|
309
|
+
* many whitespace-separated words before an identical pair re-classifies as
|
|
310
|
+
* a move. Short boilerplate ("Confidential", a bare heading word) recurs
|
|
311
|
+
* throughout real documents and would otherwise pair as spurious moves.
|
|
312
|
+
*/
|
|
313
|
+
const MOVE_MINIMUM_WORD_COUNT = 3;
|
|
314
|
+
const meetsMoveWordCount = (text) => {
|
|
315
|
+
const words = text.matchAll(/\S+/gu);
|
|
316
|
+
let count = 0;
|
|
317
|
+
while (!words.next().done) if (++count >= MOVE_MINIMUM_WORD_COUNT) return true;
|
|
318
|
+
return false;
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Re-classify `deleted` + `added` pairs with identical text as
|
|
322
|
+
* `movedFrom` / `movedTo` entries sharing a `moveGroupId`, in place, so each
|
|
323
|
+
* side keeps its slot in the revised-side document order. Matching is FIFO
|
|
324
|
+
* per text, so duplicated boilerplate above the word floor pairs
|
|
325
|
+
* first-to-first rather than fanning out.
|
|
326
|
+
*/
|
|
327
|
+
const detectMoves = (changes, counts) => {
|
|
328
|
+
const deletedIndexesByText = /* @__PURE__ */ new Map();
|
|
329
|
+
changes.forEach((change, index) => {
|
|
330
|
+
if (change.type === "deleted" && meetsMoveWordCount(change.text)) {
|
|
331
|
+
const queue = deletedIndexesByText.get(change.text) ?? [];
|
|
332
|
+
queue.push(index);
|
|
333
|
+
deletedIndexesByText.set(change.text, queue);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
if (deletedIndexesByText.size === 0) return;
|
|
337
|
+
let moveGroupId = 0;
|
|
338
|
+
changes.forEach((change, index) => {
|
|
339
|
+
if (change.type !== "added") return;
|
|
340
|
+
const deletedIndex = deletedIndexesByText.get(change.text)?.shift();
|
|
341
|
+
if (deletedIndex === void 0) return;
|
|
342
|
+
const deleted = changes[deletedIndex];
|
|
343
|
+
if (!deleted || deleted.type !== "deleted") return;
|
|
344
|
+
moveGroupId++;
|
|
345
|
+
changes[deletedIndex] = {
|
|
346
|
+
type: "movedFrom",
|
|
347
|
+
blockId: deleted.blockId,
|
|
348
|
+
kind: deleted.kind,
|
|
349
|
+
text: deleted.text,
|
|
350
|
+
moveGroupId
|
|
351
|
+
};
|
|
352
|
+
changes[index] = {
|
|
353
|
+
type: "movedTo",
|
|
354
|
+
blockId: change.blockId,
|
|
355
|
+
kind: change.kind,
|
|
356
|
+
text: change.text,
|
|
357
|
+
moveGroupId
|
|
358
|
+
};
|
|
359
|
+
counts.deleted--;
|
|
360
|
+
counts.added--;
|
|
361
|
+
counts.moved++;
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Compare two `.docx` buffers and return a structured, block-level diff.
|
|
366
|
+
* See the module doc comment for the as-accepted comparison semantics, the
|
|
367
|
+
* three-pass alignment algorithm, move detection, and format-only change
|
|
368
|
+
* detection.
|
|
369
|
+
*/
|
|
370
|
+
const compareDocxVersions = async (base, revised) => {
|
|
371
|
+
const [baseReviewer, revisedReviewer] = await Promise.all([FolioDocxReviewer.fromBuffer(base), FolioDocxReviewer.fromBuffer(revised)]);
|
|
372
|
+
const baseBlocks = baseReviewer.snapshot().blocks;
|
|
373
|
+
const revisedBlocks = revisedReviewer.snapshot().blocks;
|
|
374
|
+
const changes = [];
|
|
375
|
+
const counts = {
|
|
376
|
+
added: 0,
|
|
377
|
+
deleted: 0,
|
|
378
|
+
modified: 0,
|
|
379
|
+
formatChanged: 0,
|
|
380
|
+
moved: 0,
|
|
381
|
+
unchanged: 0
|
|
382
|
+
};
|
|
383
|
+
for (const event of alignFolioBlocks(baseBlocks, revisedBlocks)) {
|
|
384
|
+
if (event.type === "pair") {
|
|
385
|
+
const { baseBlock, revisedBlock } = event;
|
|
386
|
+
if (baseBlock.text !== revisedBlock.text) {
|
|
387
|
+
counts.modified++;
|
|
388
|
+
changes.push({
|
|
389
|
+
type: "modified",
|
|
390
|
+
blockId: revisedBlock.id,
|
|
391
|
+
kind: revisedBlock.kind,
|
|
392
|
+
segments: diffWordSegments(baseBlock.text, revisedBlock.text)
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const changedProperties = diffPreviewRunFormatting(baseBlock, revisedBlock);
|
|
397
|
+
if (changedProperties.length > 0) {
|
|
398
|
+
counts.formatChanged++;
|
|
399
|
+
changes.push({
|
|
400
|
+
type: "formatChanged",
|
|
401
|
+
blockId: revisedBlock.id,
|
|
402
|
+
kind: revisedBlock.kind,
|
|
403
|
+
text: revisedBlock.text,
|
|
404
|
+
changedProperties
|
|
405
|
+
});
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
counts.unchanged++;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (event.type === "baseOnly") {
|
|
412
|
+
counts.deleted++;
|
|
413
|
+
changes.push({
|
|
414
|
+
type: "deleted",
|
|
415
|
+
blockId: event.block.id,
|
|
416
|
+
kind: event.block.kind,
|
|
417
|
+
text: event.block.text
|
|
418
|
+
});
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
counts.added++;
|
|
422
|
+
changes.push({
|
|
423
|
+
type: "added",
|
|
424
|
+
blockId: event.block.id,
|
|
425
|
+
kind: event.block.kind,
|
|
426
|
+
text: event.block.text
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
detectMoves(changes, counts);
|
|
430
|
+
return {
|
|
431
|
+
changes,
|
|
432
|
+
summaryCounts: counts
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
//#endregion
|
|
436
|
+
export { alignFolioBlocks, compareDocxVersions, exceedsLcsBudget };
|