@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.
Files changed (98) hide show
  1. package/dist/ai-edits/apply.d.ts +3 -9
  2. package/dist/ai-edits/apply.js +94 -14
  3. package/dist/ai-edits/blockRange.d.ts +33 -0
  4. package/dist/ai-edits/blockRange.js +58 -0
  5. package/dist/ai-edits/headless.d.ts +37 -4
  6. package/dist/ai-edits/headless.js +134 -16
  7. package/dist/ai-edits/index.d.ts +5 -3
  8. package/dist/ai-edits/index.js +3 -2
  9. package/dist/ai-edits/snapshot.d.ts +14 -2
  10. package/dist/ai-edits/snapshot.js +12 -1
  11. package/dist/ai-edits/types.d.ts +42 -3
  12. package/dist/compat/eigenpal.d.ts +3 -2
  13. package/dist/compat/eigenpal.js +6 -5
  14. package/dist/controller/layoutPipeline.d.ts +1 -0
  15. package/dist/controller/layoutPipeline.js +35 -137
  16. package/dist/controller/layoutScheduler.d.ts +2 -1
  17. package/dist/controller/layoutScheduler.js +6 -0
  18. package/dist/document-operations.d.ts +139 -0
  19. package/dist/document-operations.js +578 -0
  20. package/dist/docx/blockContentParser.js +17 -7
  21. package/dist/docx/encryption/agileDecryption.js +2 -1
  22. package/dist/docx/encryption/compoundFile.js +4 -2
  23. package/dist/docx/footnoteParser.d.ts +1 -17
  24. package/dist/docx/footnoteParser.js +1 -27
  25. package/dist/docx/groupDrawingParser.d.ts +8 -0
  26. package/dist/docx/groupDrawingParser.js +131 -0
  27. package/dist/docx/numberingParser.d.ts +3 -1
  28. package/dist/docx/numberingParser.js +8 -2
  29. package/dist/docx/paragraphParser.js +9 -3
  30. package/dist/docx/runParser.js +8 -1
  31. package/dist/docx/selectiveXmlPatch.js +4 -10
  32. package/dist/docx/serializer/numberingSerializer.js +3 -1
  33. package/dist/docx/settingsParser.d.ts +6 -3
  34. package/dist/docx/settingsParser.js +2 -0
  35. package/dist/docx/xmlParser.js +11 -4
  36. package/dist/i18n/messages/catalogs.gen.d.ts +1054 -34
  37. package/dist/i18n/messages/catalogs.gen.js +1122 -68
  38. package/dist/i18n/messages/messages.gen.d.ts +62 -2
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.js +6 -5
  41. package/dist/layout-bridge/convert/footnoteLayout.d.ts +37 -1
  42. package/dist/layout-bridge/convert/footnoteLayout.js +138 -14
  43. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +5 -7
  44. package/dist/layout-bridge/convert/headerFooterLayout.js +5 -7
  45. package/dist/layout-bridge/convert/toFlowBlocks.js +56 -19
  46. package/dist/layout-engine/index.d.ts +6 -5
  47. package/dist/layout-engine/index.js +143 -30
  48. package/dist/layout-engine/keep-together.d.ts +13 -8
  49. package/dist/layout-engine/keep-together.js +40 -25
  50. package/dist/layout-engine/layoutInstrumentation.d.ts +11 -1
  51. package/dist/layout-engine/layoutInstrumentation.js +10 -1
  52. package/dist/layout-engine/measure/measureBlocks.js +50 -18
  53. package/dist/layout-engine/measure/measureParagraph.js +61 -15
  54. package/dist/layout-engine/paginator.d.ts +2 -1
  55. package/dist/layout-engine/paginator.js +9 -3
  56. package/dist/layout-engine/textBoxGroup.d.ts +7 -0
  57. package/dist/layout-engine/textBoxGroup.js +8 -0
  58. package/dist/layout-engine/types.d.ts +30 -9
  59. package/dist/layout-engine/types.js +20 -2
  60. package/dist/layout-painter/renderPage.d.ts +15 -1
  61. package/dist/layout-painter/renderPage.js +27 -11
  62. package/dist/layout-painter/renderParagraph.js +18 -8
  63. package/dist/layout-painter/renderTable.js +10 -7
  64. package/dist/managers/DocumentLoaderManager.js +12 -5
  65. package/dist/model.js +1 -1
  66. package/dist/paged-layout/pageText.d.ts +14 -0
  67. package/dist/paged-layout/pageText.js +21 -0
  68. package/dist/prosemirror/attrs/index.js +2 -0
  69. package/dist/prosemirror/commands/comments.js +75 -5
  70. package/dist/prosemirror/commands/hyperlink.js +4 -2
  71. package/dist/prosemirror/commands/image.js +2 -1
  72. package/dist/prosemirror/commands/propertyChangeScope.d.ts +68 -0
  73. package/dist/prosemirror/commands/propertyChangeScope.js +242 -0
  74. package/dist/prosemirror/conversion/fromProseDoc.js +14 -3
  75. package/dist/prosemirror/conversion/toProseDoc.js +29 -8
  76. package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -0
  77. package/dist/prosemirror/extensions/features/ListExtension.js +6 -0
  78. package/dist/prosemirror/extensions/nodes/TableExtension.js +10 -11
  79. package/dist/prosemirror/findReplaceSelection.js +2 -1
  80. package/dist/prosemirror/schema/nodes.d.ts +10 -5
  81. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  82. package/dist/prosemirror/utils/tabCalculator.js +4 -2
  83. package/dist/redline.d.ts +20 -0
  84. package/dist/redline.js +127 -0
  85. package/dist/server.d.ts +7 -3
  86. package/dist/server.js +6 -2
  87. package/dist/symbols.d.ts +35 -0
  88. package/dist/symbols.js +512 -0
  89. package/dist/utils/clipboard.js +3 -3
  90. package/dist/utils/fontLoader.js +3 -0
  91. package/dist/utils/fontResolver.js +48 -1
  92. package/dist/utils/zoom.d.ts +21 -0
  93. package/dist/utils/zoom.js +21 -0
  94. package/dist/version-comparison.d.ts +92 -0
  95. package/dist/version-comparison.js +436 -0
  96. package/package.json +3 -3
  97. package/dist/paged-layout/headerFooterMargins.d.ts +0 -75
  98. 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.4.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": "tsgo --noEmit -p tsconfig.build.json",
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.1",
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 };