@stll/folio-core 0.4.0 → 0.6.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 +37 -4
- package/dist/ai-edits/headless.js +134 -16
- 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 +139 -0
- package/dist/document-operations.js +578 -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/i18n/messages/catalogs.gen.d.ts +1054 -34
- package/dist/i18n/messages/catalogs.gen.js +1122 -68
- package/dist/i18n/messages/messages.gen.d.ts +62 -2
- 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 +143 -30
- 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 +50 -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/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/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/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
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/folio-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"document-model",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"build": "rm -rf dist && tsdown",
|
|
102
102
|
"prepack": "bun run build",
|
|
103
103
|
"pack:dry-run": "bun pm pack --dry-run",
|
|
104
|
-
"typecheck": "
|
|
104
|
+
"typecheck": "bun ../../scripts/tsc-native.ts --noEmit -p tsconfig.build.json",
|
|
105
105
|
"test": "bun test src",
|
|
106
106
|
"test:property": "bun test $(grep -rlF 'fc.assert' src --include='*.test.ts')",
|
|
107
107
|
"perf": "bun scripts/profile-editor.ts"
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"prosemirror-transform": "^1.12.0",
|
|
127
127
|
"prosemirror-view": "^1.41.8",
|
|
128
128
|
"utif2": "^4.1.0",
|
|
129
|
-
"valibot": "1.4.
|
|
129
|
+
"valibot": "1.4.2",
|
|
130
130
|
"y-prosemirror": "^1.3.7",
|
|
131
131
|
"yjs": "^13.6.31"
|
|
132
132
|
},
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { HeaderFooterContent, PageMargins, SectionBreakBlock } from "../layout-engine/types.js";
|
|
2
|
-
//#region src/paged-layout/headerFooterMargins.d.ts
|
|
3
|
-
type EffectiveHeaderFooterMarginsInput = {
|
|
4
|
-
margins: PageMargins;
|
|
5
|
-
headerContent?: HeaderFooterContent | undefined;
|
|
6
|
-
footerContent?: HeaderFooterContent | undefined;
|
|
7
|
-
firstPageHeaderContent?: HeaderFooterContent | undefined;
|
|
8
|
-
firstPageFooterContent?: HeaderFooterContent | undefined;
|
|
9
|
-
pageSize?: {
|
|
10
|
-
w: number;
|
|
11
|
-
h: number;
|
|
12
|
-
} | undefined;
|
|
13
|
-
warn?: ((message: string) => void) | undefined;
|
|
14
|
-
};
|
|
15
|
-
/**
|
|
16
|
-
* A function that extends a `PageMargins` to clear the HF overflow computed
|
|
17
|
-
* from a given set of header/footer content. Returned by
|
|
18
|
-
* `computeHeaderFooterMarginExtender` and applied at every margins site:
|
|
19
|
-
* the body fallback, `finalMargins`, and per-section `sectionBreak.margins`.
|
|
20
|
-
*
|
|
21
|
-
* Eigenpal #400 — pre-PR the extension only applied to the body fallback,
|
|
22
|
-
* so a section break carrying its own `sb.margins` from `<w:sectPr>`
|
|
23
|
-
* silently overrode the extension and the footer rendered on top of body
|
|
24
|
-
* text.
|
|
25
|
-
*/
|
|
26
|
-
type PageMarginsExtender = (margins: PageMargins) => PageMargins;
|
|
27
|
-
declare function computeHeaderFooterMarginExtender(input: Omit<EffectiveHeaderFooterMarginsInput, "margins">): PageMarginsExtender;
|
|
28
|
-
/**
|
|
29
|
-
* Like `computeHeaderFooterMarginExtender` but also accounts for the
|
|
30
|
-
* first-page header/footer content. Apply this only to the margins used
|
|
31
|
-
* for page 1 of a `<w:titlePg/>`-enabled section.
|
|
32
|
-
*/
|
|
33
|
-
declare function computeFirstPageHeaderFooterMarginExtender(input: Omit<EffectiveHeaderFooterMarginsInput, "margins">): PageMarginsExtender;
|
|
34
|
-
declare function computeEffectiveHeaderFooterMargins({
|
|
35
|
-
margins,
|
|
36
|
-
headerContent,
|
|
37
|
-
footerContent,
|
|
38
|
-
firstPageHeaderContent,
|
|
39
|
-
firstPageFooterContent,
|
|
40
|
-
pageSize,
|
|
41
|
-
warn
|
|
42
|
-
}: EffectiveHeaderFooterMarginsInput): PageMargins;
|
|
43
|
-
/** Rendered header/footer content shared by every extender on a page. */
|
|
44
|
-
type HeaderFooterExtenderContent = Omit<EffectiveHeaderFooterMarginsInput, "margins" | "pageSize" | "warn">;
|
|
45
|
-
type ExtendSectionBreakMarginsInput = {
|
|
46
|
-
content: HeaderFooterExtenderContent;
|
|
47
|
-
sectionContent?: HeaderFooterExtenderContent[] | undefined; /** Body page size and effective margins — the inheritance seed. */
|
|
48
|
-
bodyPageSize: {
|
|
49
|
-
w: number;
|
|
50
|
-
h: number;
|
|
51
|
-
};
|
|
52
|
-
bodyMargins: PageMargins;
|
|
53
|
-
warn?: ((message: string) => void) | undefined;
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
|
-
* Extend every section break's margins to clear the same header/footer
|
|
57
|
-
* overflow the body margins do (eigenpal #400), so an overflowing footer
|
|
58
|
-
* never re-overlaps body text on a later section.
|
|
59
|
-
*
|
|
60
|
-
* The walk mirrors `collectSectionConfigs`: a break that omits its own
|
|
61
|
-
* `pageSize` or `margins` inherits the previous section's. Each break's
|
|
62
|
-
* authored-or-inherited margins are extended against the section's *own*
|
|
63
|
-
* resolved page height, so a taller page never keeps a smaller page's
|
|
64
|
-
* clamped reservation (and vice versa). Materializes `margins` on every
|
|
65
|
-
* non-inheriting break in place.
|
|
66
|
-
*/
|
|
67
|
-
declare function extendSectionBreakMargins(sectionBreaks: SectionBreakBlock[], {
|
|
68
|
-
content,
|
|
69
|
-
sectionContent,
|
|
70
|
-
bodyPageSize,
|
|
71
|
-
bodyMargins,
|
|
72
|
-
warn
|
|
73
|
-
}: ExtendSectionBreakMarginsInput): void;
|
|
74
|
-
//#endregion
|
|
75
|
-
export { HeaderFooterExtenderContent, PageMarginsExtender, computeEffectiveHeaderFooterMargins, computeFirstPageHeaderFooterMarginExtender, computeHeaderFooterMarginExtender, extendSectionBreakMargins };
|