@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.
Files changed (65) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +84 -1
  5. package/src/core/commands/index.ts +19 -2
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +178 -16
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/session-capabilities.ts +7 -4
  44. package/src/runtime/surface-projection.ts +1 -0
  45. package/src/runtime/text-ack-range.ts +49 -0
  46. package/src/ui/WordReviewEditor.tsx +15 -0
  47. package/src/ui/editor-runtime-boundary.ts +10 -1
  48. package/src/ui/editor-surface-controller.tsx +3 -0
  49. package/src/ui/headless/chrome-registry.ts +235 -0
  50. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  51. package/src/ui/headless/selection-tool-context.ts +2 -0
  52. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  53. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  54. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  57. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  58. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  60. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  62. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  63. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  64. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  65. 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
+ }