@beyondwork/docx-react-component 1.0.35 → 1.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +84 -1
- package/src/core/commands/index.ts +19 -2
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +178 -16
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/session-capabilities.ts +7 -4
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-owned page graph — a stable, inspectable representation of the
|
|
3
|
+
* document's paginated structure.
|
|
4
|
+
*
|
|
5
|
+
* Per runtime-owned-paginated-layout-engine.md §4, the graph is the logical
|
|
6
|
+
* successor to `DocumentPageSnapshot[]`. External read models such as
|
|
7
|
+
* `DocumentNavigationSnapshot` and `PageLayoutSnapshot` are derived from it.
|
|
8
|
+
*
|
|
9
|
+
* Enriched in this revision:
|
|
10
|
+
* - `regions` per page (body / header / footer / columns)
|
|
11
|
+
* - `lineBoxes` per page with block-fragment back-references
|
|
12
|
+
* - `noteAllocations` for footnotes reserved on this page
|
|
13
|
+
* - `anchors` for quick offset → page lookup
|
|
14
|
+
*
|
|
15
|
+
* The graph is produced by the PaginatedLayoutEngine from canonical document
|
|
16
|
+
* state. It is never serialized or exported.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
DocumentPageSnapshot,
|
|
21
|
+
EditorStoryTarget,
|
|
22
|
+
PageLayoutSnapshot,
|
|
23
|
+
} from "../../api/public-types";
|
|
24
|
+
import type {
|
|
25
|
+
ResolvedPageStories,
|
|
26
|
+
} from "./page-story-resolver.ts";
|
|
27
|
+
import type { ResolvedDocumentSection } from "../document-layout.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Enriched graph types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface RuntimePageGraph {
|
|
34
|
+
/** Monotonically increasing revision stamp. */
|
|
35
|
+
revision: number;
|
|
36
|
+
/** Ordered page nodes. */
|
|
37
|
+
pages: RuntimePageNode[];
|
|
38
|
+
/** Flat list of every block fragment produced during pagination. */
|
|
39
|
+
fragments: RuntimeBlockFragment[];
|
|
40
|
+
/** Per-offset anchor index for O(log n) lookup. */
|
|
41
|
+
anchors: RuntimePageAnchor[];
|
|
42
|
+
/** Section metadata. */
|
|
43
|
+
sections: ResolvedDocumentSection[];
|
|
44
|
+
/** Total non-blank page count. */
|
|
45
|
+
contentPageCount: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RuntimePageNode {
|
|
49
|
+
pageId: string;
|
|
50
|
+
pageIndex: number;
|
|
51
|
+
sectionIndex: number;
|
|
52
|
+
pageInSection: number;
|
|
53
|
+
startOffset: number;
|
|
54
|
+
endOffset: number;
|
|
55
|
+
layout: PageLayoutSnapshot;
|
|
56
|
+
stories: ResolvedPageStories;
|
|
57
|
+
/** Sub-regions on the page. */
|
|
58
|
+
regions: RuntimePageRegions;
|
|
59
|
+
/** Line boxes rendered in the body region. */
|
|
60
|
+
lineBoxes: RuntimeLineBox[];
|
|
61
|
+
/** Footnote allocations reserved at the bottom of the page. */
|
|
62
|
+
noteAllocations: RuntimeNoteAllocation[];
|
|
63
|
+
/** Whether this page is a blank filler (from even/odd page breaks). */
|
|
64
|
+
isBlankFiller: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RuntimePageRegions {
|
|
68
|
+
body: RuntimePageRegion;
|
|
69
|
+
header?: RuntimePageRegion;
|
|
70
|
+
footer?: RuntimePageRegion;
|
|
71
|
+
/**
|
|
72
|
+
* For multi-column bodies, `body.columns` is populated. For single-column
|
|
73
|
+
* pages, the top-level body is used and `columns` is undefined.
|
|
74
|
+
*/
|
|
75
|
+
columns?: RuntimePageRegion[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RuntimePageRegion {
|
|
79
|
+
kind: "body" | "header" | "footer" | "column" | "footnote-area";
|
|
80
|
+
/** Twips offset from page top (header) or similar region-specific origin. */
|
|
81
|
+
originTwips: number;
|
|
82
|
+
/** Width in twips. */
|
|
83
|
+
widthTwips: number;
|
|
84
|
+
/** Height in twips. */
|
|
85
|
+
heightTwips: number;
|
|
86
|
+
/** IDs of block fragments rendered in this region, in order. */
|
|
87
|
+
fragmentIds: string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RuntimeBlockFragment {
|
|
91
|
+
fragmentId: string;
|
|
92
|
+
/** Canonical block id the fragment slices. */
|
|
93
|
+
blockId: string;
|
|
94
|
+
/** Page this fragment lives on. */
|
|
95
|
+
pageId: string;
|
|
96
|
+
/** Zero-based order within the page region. */
|
|
97
|
+
orderInRegion: number;
|
|
98
|
+
/** Region id the fragment sits in (matches RuntimePageRegion.kind). */
|
|
99
|
+
regionKind: RuntimePageRegion["kind"];
|
|
100
|
+
/** Inclusive from / exclusive to offsets within the main story. */
|
|
101
|
+
from: number;
|
|
102
|
+
to: number;
|
|
103
|
+
/** Height consumed on this page (twips). */
|
|
104
|
+
heightTwips: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RuntimeLineBox {
|
|
108
|
+
/** Fragment this line belongs to. */
|
|
109
|
+
fragmentId: string;
|
|
110
|
+
/** Zero-based line index inside the fragment. */
|
|
111
|
+
lineIndex: number;
|
|
112
|
+
/** Baseline twips from the region's origin. */
|
|
113
|
+
baselineTwips: number;
|
|
114
|
+
/** Line height twips. */
|
|
115
|
+
heightTwips: number;
|
|
116
|
+
/** Approximate inline width consumed on this line. */
|
|
117
|
+
widthTwips: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RuntimeNoteAllocation {
|
|
121
|
+
noteKind: "footnote" | "endnote";
|
|
122
|
+
noteId: string;
|
|
123
|
+
/** Twips reserved at the bottom of the page for this note's content. */
|
|
124
|
+
reservedHeightTwips: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface RuntimePageAnchor {
|
|
128
|
+
/** Story target the anchor is for. Main story is the common case. */
|
|
129
|
+
storyKey: string;
|
|
130
|
+
/** Offset the anchor represents. */
|
|
131
|
+
offset: number;
|
|
132
|
+
/** Page id resolved for this offset. */
|
|
133
|
+
pageId: string;
|
|
134
|
+
/** Fragment id resolved for this offset, if available. */
|
|
135
|
+
fragmentId?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Graph construction
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
let graphRevision = 0;
|
|
143
|
+
|
|
144
|
+
export interface BuildPageGraphInput {
|
|
145
|
+
pages: readonly DocumentPageSnapshot[];
|
|
146
|
+
sections: readonly ResolvedDocumentSection[];
|
|
147
|
+
stories: readonly ResolvedPageStories[];
|
|
148
|
+
/** Optional block fragments pre-computed by pagination; when omitted the
|
|
149
|
+
* graph produces one fragment per page spanning its entire offset range. */
|
|
150
|
+
fragments?: readonly RuntimeBlockFragment[];
|
|
151
|
+
/** Optional per-page line boxes. */
|
|
152
|
+
lineBoxes?: ReadonlyMap<string, RuntimeLineBox[]>;
|
|
153
|
+
/** Optional per-page note allocations. */
|
|
154
|
+
noteAllocations?: ReadonlyMap<string, RuntimeNoteAllocation[]>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function buildPageGraph(input: BuildPageGraphInput): RuntimePageGraph;
|
|
158
|
+
export function buildPageGraph(
|
|
159
|
+
pages: readonly DocumentPageSnapshot[],
|
|
160
|
+
sections: readonly ResolvedDocumentSection[],
|
|
161
|
+
stories: readonly ResolvedPageStories[],
|
|
162
|
+
): RuntimePageGraph;
|
|
163
|
+
export function buildPageGraph(
|
|
164
|
+
inputOrPages: BuildPageGraphInput | readonly DocumentPageSnapshot[],
|
|
165
|
+
sectionsArg?: readonly ResolvedDocumentSection[],
|
|
166
|
+
storiesArg?: readonly ResolvedPageStories[],
|
|
167
|
+
): RuntimePageGraph {
|
|
168
|
+
const input: BuildPageGraphInput = Array.isArray(inputOrPages)
|
|
169
|
+
? {
|
|
170
|
+
pages: inputOrPages as readonly DocumentPageSnapshot[],
|
|
171
|
+
sections: sectionsArg ?? [],
|
|
172
|
+
stories: storiesArg ?? [],
|
|
173
|
+
}
|
|
174
|
+
: (inputOrPages as BuildPageGraphInput);
|
|
175
|
+
|
|
176
|
+
graphRevision += 1;
|
|
177
|
+
|
|
178
|
+
const pages: RuntimePageNode[] = [];
|
|
179
|
+
const aggregatedFragments: RuntimeBlockFragment[] = [...(input.fragments ?? [])];
|
|
180
|
+
const anchors: RuntimePageAnchor[] = [];
|
|
181
|
+
|
|
182
|
+
for (let index = 0; index < input.pages.length; index += 1) {
|
|
183
|
+
const page = input.pages[index]!;
|
|
184
|
+
const pageId = `page-${graphRevision}-${index}`;
|
|
185
|
+
const stories: ResolvedPageStories = input.stories[index] ?? {
|
|
186
|
+
isFirstPage: false,
|
|
187
|
+
isEvenPage: false,
|
|
188
|
+
displayPageNumber: index + 1,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const pageFragments = aggregatedFragments.filter((f) => f.pageId === pageId);
|
|
192
|
+
const fragmentIds = pageFragments.map((f) => f.fragmentId);
|
|
193
|
+
|
|
194
|
+
// If no fragments were supplied, synthesize a coarse body fragment so the
|
|
195
|
+
// graph is still internally consistent.
|
|
196
|
+
let bodyFragmentIds = fragmentIds;
|
|
197
|
+
if (fragmentIds.length === 0 && page.endOffset > page.startOffset) {
|
|
198
|
+
const coarse: RuntimeBlockFragment = {
|
|
199
|
+
fragmentId: `${pageId}-body-0`,
|
|
200
|
+
blockId: "synthetic",
|
|
201
|
+
pageId,
|
|
202
|
+
orderInRegion: 0,
|
|
203
|
+
regionKind: "body",
|
|
204
|
+
from: page.startOffset,
|
|
205
|
+
to: page.endOffset,
|
|
206
|
+
heightTwips: 0,
|
|
207
|
+
};
|
|
208
|
+
aggregatedFragments.push(coarse);
|
|
209
|
+
bodyFragmentIds = [coarse.fragmentId];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const node: RuntimePageNode = {
|
|
213
|
+
pageId,
|
|
214
|
+
pageIndex: page.pageIndex,
|
|
215
|
+
sectionIndex: page.sectionIndex,
|
|
216
|
+
pageInSection: page.pageInSection,
|
|
217
|
+
startOffset: page.startOffset,
|
|
218
|
+
endOffset: page.endOffset,
|
|
219
|
+
layout: page.layout,
|
|
220
|
+
stories,
|
|
221
|
+
regions: buildRegions(page.layout, bodyFragmentIds, stories),
|
|
222
|
+
lineBoxes: input.lineBoxes?.get(pageId) ?? [],
|
|
223
|
+
noteAllocations: input.noteAllocations?.get(pageId) ?? [],
|
|
224
|
+
isBlankFiller: page.pageInSection === -1,
|
|
225
|
+
};
|
|
226
|
+
pages.push(node);
|
|
227
|
+
|
|
228
|
+
anchors.push({
|
|
229
|
+
storyKey: "main",
|
|
230
|
+
offset: page.startOffset,
|
|
231
|
+
pageId,
|
|
232
|
+
...(bodyFragmentIds[0] !== undefined ? { fragmentId: bodyFragmentIds[0] } : {}),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
revision: graphRevision,
|
|
238
|
+
pages,
|
|
239
|
+
fragments: aggregatedFragments,
|
|
240
|
+
anchors,
|
|
241
|
+
sections: [...input.sections],
|
|
242
|
+
contentPageCount: pages.filter((p) => !p.isBlankFiller).length,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildRegions(
|
|
247
|
+
layout: PageLayoutSnapshot,
|
|
248
|
+
bodyFragmentIds: readonly string[],
|
|
249
|
+
stories: ResolvedPageStories,
|
|
250
|
+
): RuntimePageRegions {
|
|
251
|
+
const bodyWidth =
|
|
252
|
+
layout.pageWidth - layout.marginLeft - layout.marginRight;
|
|
253
|
+
const bodyHeight =
|
|
254
|
+
layout.pageHeight - layout.marginTop - layout.marginBottom;
|
|
255
|
+
|
|
256
|
+
const body: RuntimePageRegion = {
|
|
257
|
+
kind: "body",
|
|
258
|
+
originTwips: layout.marginTop,
|
|
259
|
+
widthTwips: Math.max(0, bodyWidth),
|
|
260
|
+
heightTwips: Math.max(0, bodyHeight),
|
|
261
|
+
fragmentIds: [...bodyFragmentIds],
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const regions: RuntimePageRegions = { body };
|
|
265
|
+
|
|
266
|
+
if (stories.header) {
|
|
267
|
+
regions.header = {
|
|
268
|
+
kind: "header",
|
|
269
|
+
originTwips: layout.headerMargin ?? 720,
|
|
270
|
+
widthTwips: Math.max(0, bodyWidth),
|
|
271
|
+
heightTwips: Math.max(0, layout.marginTop - (layout.headerMargin ?? 720)),
|
|
272
|
+
fragmentIds: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (stories.footer) {
|
|
276
|
+
regions.footer = {
|
|
277
|
+
kind: "footer",
|
|
278
|
+
originTwips: layout.pageHeight - layout.marginBottom,
|
|
279
|
+
widthTwips: Math.max(0, bodyWidth),
|
|
280
|
+
heightTwips: Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720)),
|
|
281
|
+
fragmentIds: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (layout.columns > 1) {
|
|
286
|
+
const gap = layout.columnDefinitions?.[0]?.space ?? 720;
|
|
287
|
+
const perColumnWidth = Math.max(
|
|
288
|
+
1,
|
|
289
|
+
Math.floor((bodyWidth - gap * (layout.columns - 1)) / layout.columns),
|
|
290
|
+
);
|
|
291
|
+
const columns: RuntimePageRegion[] = [];
|
|
292
|
+
for (let i = 0; i < layout.columns; i += 1) {
|
|
293
|
+
columns.push({
|
|
294
|
+
kind: "column",
|
|
295
|
+
originTwips: layout.marginTop,
|
|
296
|
+
widthTwips: perColumnWidth,
|
|
297
|
+
heightTwips: Math.max(0, bodyHeight),
|
|
298
|
+
fragmentIds: [],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
regions.columns = columns;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return regions;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Graph queries
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
export function findPageNodeForOffset(
|
|
312
|
+
graph: RuntimePageGraph,
|
|
313
|
+
offset: number,
|
|
314
|
+
): RuntimePageNode | undefined {
|
|
315
|
+
for (const node of graph.pages) {
|
|
316
|
+
if (!node.isBlankFiller && offset < node.endOffset) {
|
|
317
|
+
return node;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return graph.pages.length > 0
|
|
321
|
+
? graph.pages[graph.pages.length - 1]
|
|
322
|
+
: undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function findPagesForSection(
|
|
326
|
+
graph: RuntimePageGraph,
|
|
327
|
+
sectionIndex: number,
|
|
328
|
+
): RuntimePageNode[] {
|
|
329
|
+
return graph.pages.filter((node) => node.sectionIndex === sectionIndex);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function findPageForStoryTarget(
|
|
333
|
+
graph: RuntimePageGraph,
|
|
334
|
+
target: EditorStoryTarget,
|
|
335
|
+
): RuntimePageNode | undefined {
|
|
336
|
+
if (target.kind === "main") {
|
|
337
|
+
return graph.pages[0];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (target.kind === "header" || target.kind === "footer") {
|
|
341
|
+
return graph.pages.find((node) => {
|
|
342
|
+
const story = target.kind === "header" ? node.stories.header : node.stories.footer;
|
|
343
|
+
if (
|
|
344
|
+
story?.kind !== target.kind ||
|
|
345
|
+
story.variant !== target.variant ||
|
|
346
|
+
story.relationshipId !== target.relationshipId
|
|
347
|
+
) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
if (target.sectionIndex !== undefined) {
|
|
351
|
+
return node.sectionIndex === target.sectionIndex;
|
|
352
|
+
}
|
|
353
|
+
return true;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function toDocumentPageSnapshots(
|
|
361
|
+
graph: RuntimePageGraph,
|
|
362
|
+
): DocumentPageSnapshot[] {
|
|
363
|
+
return graph.pages.map((node) => ({
|
|
364
|
+
pageIndex: node.pageIndex,
|
|
365
|
+
sectionIndex: node.sectionIndex,
|
|
366
|
+
pageInSection: node.pageInSection,
|
|
367
|
+
startOffset: node.startOffset,
|
|
368
|
+
endOffset: node.endOffset,
|
|
369
|
+
layout: node.layout,
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Incremental graph splicing
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Produce a new graph by preserving `prior.pages[0..firstDirtyIndex - 1]` by
|
|
379
|
+
* identity and concatenating `freshPages` for the rest of the document.
|
|
380
|
+
*
|
|
381
|
+
* The result:
|
|
382
|
+
* - bumps the graph revision
|
|
383
|
+
* - rebuilds `anchors` from the combined page list
|
|
384
|
+
* - filters `fragments` to those whose pageId still exists in the new graph
|
|
385
|
+
* - recomputes `contentPageCount` from the new page list
|
|
386
|
+
*
|
|
387
|
+
* Page identity is preserved for indices < `firstDirtyIndex`, which is the
|
|
388
|
+
* invariant the engine relies on to keep stable pageIds for unaffected pages.
|
|
389
|
+
*/
|
|
390
|
+
export function spliceGraph(
|
|
391
|
+
prior: RuntimePageGraph,
|
|
392
|
+
freshPages: readonly RuntimePageNode[],
|
|
393
|
+
firstDirtyIndex: number,
|
|
394
|
+
): RuntimePageGraph {
|
|
395
|
+
graphRevision += 1;
|
|
396
|
+
const clampedFirst = Math.max(0, Math.min(firstDirtyIndex, prior.pages.length));
|
|
397
|
+
const preserved = prior.pages.slice(0, clampedFirst);
|
|
398
|
+
const nextPages: RuntimePageNode[] = [...preserved, ...freshPages];
|
|
399
|
+
|
|
400
|
+
const survivingPageIds = new Set(nextPages.map((page) => page.pageId));
|
|
401
|
+
const mergedFragments: RuntimeBlockFragment[] = [];
|
|
402
|
+
for (const fragment of prior.fragments) {
|
|
403
|
+
if (survivingPageIds.has(fragment.pageId)) {
|
|
404
|
+
mergedFragments.push(fragment);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Fragments attached to fresh pages land in the graph through their page
|
|
408
|
+
// node (pageId-prefixed synthetic fragments are created during the fresh
|
|
409
|
+
// build in the same way as buildPageGraph does). For now fresh pages may
|
|
410
|
+
// carry no explicit fragments beyond the synthetic body fragment the
|
|
411
|
+
// layout engine stamped on them; we keep the merged fragment list as a
|
|
412
|
+
// superset index so mappers can find fragments by pageId.
|
|
413
|
+
|
|
414
|
+
const anchors: RuntimePageAnchor[] = nextPages.map((page) => ({
|
|
415
|
+
storyKey: "main",
|
|
416
|
+
offset: page.startOffset,
|
|
417
|
+
pageId: page.pageId,
|
|
418
|
+
...(page.regions.body.fragmentIds[0] !== undefined
|
|
419
|
+
? { fragmentId: page.regions.body.fragmentIds[0]! }
|
|
420
|
+
: {}),
|
|
421
|
+
}));
|
|
422
|
+
|
|
423
|
+
const contentPageCount = nextPages.filter((p) => !p.isBlankFiller).length;
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
revision: graphRevision,
|
|
427
|
+
pages: nextPages,
|
|
428
|
+
fragments: mergedFragments,
|
|
429
|
+
anchors,
|
|
430
|
+
sections: [...prior.sections],
|
|
431
|
+
contentPageCount,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageLayoutSnapshotAdapter — derives the public `PageLayoutSnapshot` and
|
|
3
|
+
* `DocumentPageSnapshot[]` shapes from a `RuntimePageGraph`.
|
|
4
|
+
*
|
|
5
|
+
* This is the bridge that lets the legacy public snapshots stay byte-for-byte
|
|
6
|
+
* backward compatible while their implementations move to the runtime-owned
|
|
7
|
+
* graph. Per the spec (§Integration With Existing Runtime Surfaces), the
|
|
8
|
+
* snapshot types remain public and stable; only the source changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
DocumentPageSnapshot,
|
|
13
|
+
PageLayoutSnapshot,
|
|
14
|
+
} from "../../api/public-types";
|
|
15
|
+
import type {
|
|
16
|
+
RuntimePageGraph,
|
|
17
|
+
RuntimePageNode,
|
|
18
|
+
} from "./page-graph.ts";
|
|
19
|
+
|
|
20
|
+
export function derivePageLayoutSnapshotFromGraph(
|
|
21
|
+
graph: RuntimePageGraph,
|
|
22
|
+
sectionIndex: number,
|
|
23
|
+
): PageLayoutSnapshot | null {
|
|
24
|
+
const node = graph.pages.find((page) => page.sectionIndex === sectionIndex);
|
|
25
|
+
if (node) return node.layout;
|
|
26
|
+
// Blank filler pages never own sections; fall back to the first page.
|
|
27
|
+
return graph.pages[0]?.layout ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deriveDocumentPageSnapshots(
|
|
31
|
+
graph: RuntimePageGraph,
|
|
32
|
+
): DocumentPageSnapshot[] {
|
|
33
|
+
return graph.pages.map((node) => ({
|
|
34
|
+
pageIndex: node.pageIndex,
|
|
35
|
+
sectionIndex: node.sectionIndex,
|
|
36
|
+
pageInSection: node.pageInSection,
|
|
37
|
+
startOffset: node.startOffset,
|
|
38
|
+
endOffset: node.endOffset,
|
|
39
|
+
layout: node.layout,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function deriveActivePageIndex(
|
|
44
|
+
graph: RuntimePageGraph,
|
|
45
|
+
selectionHead: number,
|
|
46
|
+
): number {
|
|
47
|
+
for (let i = 0; i < graph.pages.length; i += 1) {
|
|
48
|
+
const page = graph.pages[i]!;
|
|
49
|
+
if (!page.isBlankFiller && selectionHead < page.endOffset) {
|
|
50
|
+
return i;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Math.max(0, graph.pages.length - 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function deriveActiveSectionIndex(
|
|
57
|
+
graph: RuntimePageGraph,
|
|
58
|
+
selectionHead: number,
|
|
59
|
+
): number {
|
|
60
|
+
const page = deriveActivePage(graph, selectionHead);
|
|
61
|
+
return page?.sectionIndex ?? 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function deriveActivePage(
|
|
65
|
+
graph: RuntimePageGraph,
|
|
66
|
+
selectionHead: number,
|
|
67
|
+
): RuntimePageNode | undefined {
|
|
68
|
+
const idx = deriveActivePageIndex(graph, selectionHead);
|
|
69
|
+
return graph.pages[idx];
|
|
70
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-story resolver — determines which header/footer/note stories
|
|
3
|
+
* are active on each page based on section properties.
|
|
4
|
+
*
|
|
5
|
+
* In OOXML, headers and footers are section-scoped with three variants:
|
|
6
|
+
* - "default" — used for most pages
|
|
7
|
+
* - "first" — used on the first page of a section (when titlePage is set)
|
|
8
|
+
* - "even" — used on even pages (when evenAndOddHeaders setting is set)
|
|
9
|
+
*
|
|
10
|
+
* This resolver computes per-page story assignment from the page stack
|
|
11
|
+
* and section properties, making it engine-owned rather than shell-heuristic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DocumentPageSnapshot,
|
|
16
|
+
EditorStoryTarget,
|
|
17
|
+
PageLayoutSnapshot,
|
|
18
|
+
} from "../../api/public-types";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface ResolvedPageStories {
|
|
25
|
+
/** Header story target for this page, if any. */
|
|
26
|
+
header?: EditorStoryTarget;
|
|
27
|
+
/** Footer story target for this page, if any. */
|
|
28
|
+
footer?: EditorStoryTarget;
|
|
29
|
+
/** Whether this is a "first page" in its section (title page behavior). */
|
|
30
|
+
isFirstPage: boolean;
|
|
31
|
+
/** Whether this is an even-numbered page (1-indexed for display). */
|
|
32
|
+
isEvenPage: boolean;
|
|
33
|
+
/** The effective page number for display (accounting for page numbering settings). */
|
|
34
|
+
displayPageNumber: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Resolver
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve header/footer story assignments for every page in the document.
|
|
43
|
+
*/
|
|
44
|
+
export function resolvePageStories(
|
|
45
|
+
pages: readonly DocumentPageSnapshot[],
|
|
46
|
+
): ResolvedPageStories[] {
|
|
47
|
+
const result: ResolvedPageStories[] = [];
|
|
48
|
+
let runningPageNumber = 1;
|
|
49
|
+
|
|
50
|
+
for (const page of pages) {
|
|
51
|
+
const layout = page.layout;
|
|
52
|
+
|
|
53
|
+
// Check if section restarts page numbering
|
|
54
|
+
if (page.pageInSection === 0 && layout.pageNumbering?.start !== undefined) {
|
|
55
|
+
runningPageNumber = layout.pageNumbering.start;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skip blank filler pages (from evenPage/oddPage section breaks)
|
|
59
|
+
if (page.pageInSection === -1) {
|
|
60
|
+
result.push({
|
|
61
|
+
isFirstPage: false,
|
|
62
|
+
isEvenPage: runningPageNumber % 2 === 0,
|
|
63
|
+
displayPageNumber: runningPageNumber,
|
|
64
|
+
});
|
|
65
|
+
runningPageNumber += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isFirstPage = page.pageInSection === 0 && layout.differentFirstPage;
|
|
70
|
+
const isEvenPage = runningPageNumber % 2 === 0;
|
|
71
|
+
|
|
72
|
+
const header = resolveStoryVariant(
|
|
73
|
+
"header",
|
|
74
|
+
layout,
|
|
75
|
+
isFirstPage,
|
|
76
|
+
isEvenPage,
|
|
77
|
+
);
|
|
78
|
+
const footer = resolveStoryVariant(
|
|
79
|
+
"footer",
|
|
80
|
+
layout,
|
|
81
|
+
isFirstPage,
|
|
82
|
+
isEvenPage,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
result.push({
|
|
86
|
+
header,
|
|
87
|
+
footer,
|
|
88
|
+
isFirstPage,
|
|
89
|
+
isEvenPage,
|
|
90
|
+
displayPageNumber: runningPageNumber,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
runningPageNumber += 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the display page number for a given page index.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveDisplayPageNumber(
|
|
103
|
+
pages: readonly DocumentPageSnapshot[],
|
|
104
|
+
pageIndex: number,
|
|
105
|
+
): number {
|
|
106
|
+
let runningPageNumber = 1;
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i <= pageIndex && i < pages.length; i++) {
|
|
109
|
+
const page = pages[i]!;
|
|
110
|
+
if (page.pageInSection === 0 && page.layout.pageNumbering?.start !== undefined) {
|
|
111
|
+
runningPageNumber = page.layout.pageNumbering.start;
|
|
112
|
+
}
|
|
113
|
+
if (i < pageIndex) {
|
|
114
|
+
runningPageNumber += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return runningPageNumber;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve total page count for NUMPAGES field.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveTotalPageCount(
|
|
125
|
+
pages: readonly DocumentPageSnapshot[],
|
|
126
|
+
): number {
|
|
127
|
+
// Exclude blank filler pages from the count
|
|
128
|
+
return pages.filter((p) => p.pageInSection !== -1).length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Internals
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function resolveStoryVariant(
|
|
136
|
+
kind: "header" | "footer",
|
|
137
|
+
layout: PageLayoutSnapshot,
|
|
138
|
+
isFirstPage: boolean,
|
|
139
|
+
isEvenPage: boolean,
|
|
140
|
+
): EditorStoryTarget | undefined {
|
|
141
|
+
const variants = kind === "header" ? layout.headerVariants : layout.footerVariants;
|
|
142
|
+
if (!variants || variants.length === 0) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// First page variant takes priority when applicable
|
|
147
|
+
if (isFirstPage) {
|
|
148
|
+
const firstVariant = variants.find((v) => v.variant === "first");
|
|
149
|
+
if (firstVariant) {
|
|
150
|
+
return {
|
|
151
|
+
kind,
|
|
152
|
+
variant: "first",
|
|
153
|
+
relationshipId: firstVariant.relationshipId,
|
|
154
|
+
sectionIndex: layout.sectionIndex,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Even page variant when evenAndOddHeaders is active
|
|
160
|
+
if (isEvenPage && layout.differentOddEvenPages) {
|
|
161
|
+
const evenVariant = variants.find((v) => v.variant === "even");
|
|
162
|
+
if (evenVariant) {
|
|
163
|
+
return {
|
|
164
|
+
kind,
|
|
165
|
+
variant: "even",
|
|
166
|
+
relationshipId: evenVariant.relationshipId,
|
|
167
|
+
sectionIndex: layout.sectionIndex,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Default variant
|
|
173
|
+
const defaultVariant = variants.find((v) => v.variant === "default");
|
|
174
|
+
if (defaultVariant) {
|
|
175
|
+
return {
|
|
176
|
+
kind,
|
|
177
|
+
variant: "default",
|
|
178
|
+
relationshipId: defaultVariant.relationshipId,
|
|
179
|
+
sectionIndex: layout.sectionIndex,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Fallback to first available variant
|
|
184
|
+
const first = variants[0];
|
|
185
|
+
if (first) {
|
|
186
|
+
return {
|
|
187
|
+
kind,
|
|
188
|
+
variant: first.variant,
|
|
189
|
+
relationshipId: first.relationshipId,
|
|
190
|
+
sectionIndex: layout.sectionIndex,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|