@beyondwork/docx-react-component 1.0.40 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -45,35 +45,12 @@ export interface DocxFontLoader {
45
45
  refresh(input: FontLoaderInput): void;
46
46
  }
47
47
 
48
- interface MinimalFontFace {
49
- load(): Promise<MinimalFontFace>;
50
- }
51
-
52
- interface MinimalFontFaceDescriptors {
53
- weight?: string;
54
- style?: string;
55
- }
56
-
57
- interface MinimalFontFaceConstructor {
58
- new (
59
- family: string,
60
- source: ArrayBuffer | ArrayBufferView | string,
61
- descriptors?: MinimalFontFaceDescriptors,
62
- ): MinimalFontFace;
63
- }
64
-
65
- interface MinimalFontFaceSet {
66
- add(face: MinimalFontFace): void;
67
- check(font: string): boolean;
68
- ready: Promise<MinimalFontFaceSet>;
69
- }
70
-
71
48
  export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
72
- const globalDocument = (globalThis as { document?: { fonts?: MinimalFontFaceSet } }).document;
73
49
  const supported =
74
- globalDocument !== undefined &&
50
+ typeof document !== "undefined" &&
75
51
  typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
76
- Boolean(globalDocument.fonts);
52
+ // Guard against jsdom which exposes FontFace but not document.fonts
53
+ Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
77
54
 
78
55
  let current: FontLoaderInput = initial;
79
56
  let readyPromise: Promise<void>;
@@ -81,7 +58,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
81
58
 
82
59
  function run(input: FontLoaderInput): Promise<void> {
83
60
  if (!supported) return Promise.resolve();
84
- const fontSet = globalDocument?.fonts;
61
+ const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
85
62
  if (!fontSet) return Promise.resolve();
86
63
 
87
64
  const pending: Array<Promise<unknown>> = [];
@@ -93,8 +70,10 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
93
70
 
94
71
  for (const [descriptor, data] of variantsOf(variants)) {
95
72
  try {
96
- const FontFaceCtor = (globalThis as { FontFace?: MinimalFontFaceConstructor }).FontFace;
97
- if (!FontFaceCtor) continue;
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ const FontFaceCtor = (globalThis as any).FontFace as {
75
+ new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
76
+ };
98
77
  const face = new FontFaceCtor(family, data, descriptor);
99
78
  pending.push(
100
79
  face.load().then((loaded) => {
@@ -109,6 +88,8 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
109
88
  }
110
89
  }
111
90
 
91
+ // Mark declared families as registered if the browser already resolves
92
+ // them (e.g. system fonts like Calibri, Arial).
112
93
  for (const family of input.families) {
113
94
  try {
114
95
  const probe = `12px "${family.replace(/"/g, "'")}", serif`;
@@ -146,7 +127,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
146
127
 
147
128
  function* variantsOf(
148
129
  variants: EmbeddedFontBytes,
149
- ): IterableIterator<[MinimalFontFaceDescriptors, ArrayBuffer]> {
130
+ ): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
150
131
  if (variants.regular) {
151
132
  yield [{ weight: "400", style: "normal" }, variants.regular];
152
133
  }
@@ -35,6 +35,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
35
35
  getDisplayPageNumber: () => null,
36
36
  getLineBoxes: () => [],
37
37
  getLineBoxesForRegion: () => [],
38
+ getStoryRegionsOnPage: () => [],
38
39
  getFragmentsForPage: () => [],
39
40
  getPageFormatCatalog: () => PAGE_FORMAT_CATALOG,
40
41
  getActivePageFormat: () => null,
@@ -54,6 +55,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
54
55
  whenMeasurementReady: () => Promise.resolve(),
55
56
  getFirstPageIndexForBlock: () => null,
56
57
  swapMeasurementProvider: () => undefined,
58
+ invalidateMeasurementCache: () => undefined,
57
59
  getTableRenderPlan: () => null,
58
60
  getDirtyFieldFamilies: () => [],
59
61
  getFieldDirtinessReport: () => emptyReport,
@@ -104,7 +104,19 @@ export interface LayoutEngineEvent {
104
104
  | "incremental_relayout"
105
105
  | "page_count_changed"
106
106
  | "page_field_dirtied"
107
- | "measurement_backend_ready";
107
+ | "measurement_backend_ready"
108
+ /**
109
+ * P14.b — coalesced "the engine just finished a build" event. Emitted
110
+ * exactly once per `fullRebuild` / `incrementalRelayout` AFTER the
111
+ * granular events, carrying the union of dirty-field families, page-
112
+ * count delta, and page-range info. Subscribers that only need to
113
+ * react to "something layout-affecting changed" can listen to this
114
+ * single event and skip the multi-event subscription pattern that
115
+ * triggered N React re-renders per applyPatch. The granular events
116
+ * still fire for backward compat with consumers (TwStatusBar fidelity
117
+ * badge, etc.) that care about specific kinds.
118
+ */
119
+ | "layout_committed";
108
120
  revision: number;
109
121
  previousPageCount?: number;
110
122
  currentPageCount?: number;
@@ -113,6 +125,17 @@ export interface LayoutEngineEvent {
113
125
  fidelity?: LayoutMeasurementProvider["fidelity"];
114
126
  /** First dirty page index for incremental_relayout events. */
115
127
  firstDirtyPageIndex?: number;
128
+ /**
129
+ * P14.b — page-count delta for `layout_committed`. Present when the
130
+ * commit produced a different total page count than the prior graph.
131
+ */
132
+ pageCountDelta?: { previous: number; current: number };
133
+ /**
134
+ * P14.b — when `layout_committed` came from a bounded incremental
135
+ * relayout, the page range that was re-paginated. Absent for full
136
+ * rebuilds.
137
+ */
138
+ pageRange?: { fromPageIndex: number; toPageIndex: number };
116
139
  }
117
140
 
118
141
  export interface LayoutEngineInstance {
@@ -149,6 +172,16 @@ export interface LayoutEngineInstance {
149
172
 
150
173
  // ---- measurement plumbing --------------------------------------------
151
174
  swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
175
+ /**
176
+ * Invalidate the active measurement provider's internal caches (canvas
177
+ * glyph / run-width LRU) AND clear the engine's cached page graph so
178
+ * the next query re-paginates with fresh measurements. Host runtime
179
+ * calls this after `docxFontLoader.refresh(...)` registers new
180
+ * FontFace families — without this call the canvas backend's glyph
181
+ * cache keeps returning pre-refresh widths for already-measured
182
+ * glyphs, and the cached page graph keeps its stale page boundaries.
183
+ */
184
+ invalidateMeasurementCache(): void;
152
185
  }
153
186
 
154
187
  // ---------------------------------------------------------------------------
@@ -284,14 +317,30 @@ export function createLayoutEngine(
284
317
  const formatting = buildResolvedFormattingState(document, mainSurface);
285
318
 
286
319
  const currentPageCount = resolveTotalPageCount(pages);
320
+ let pageCountDelta: { previous: number; current: number } | undefined;
287
321
  if (currentPageCount !== previousPageCount) {
322
+ pageCountDelta = { previous: previousPageCount, current: currentPageCount };
323
+ previousPageCount = currentPageCount;
324
+ }
325
+
326
+ // MUST publish cache before emit: re-entrant getPageGraph() calls from
327
+ // subscribers during emit would otherwise trigger runaway rebuilds.
328
+ cachedKey = {
329
+ content: document.content,
330
+ styles: document.styles,
331
+ subParts: document.subParts,
332
+ };
333
+ cachedGraph = graph;
334
+ cachedFormatting = formatting;
335
+ cachedMapper = createPageFragmentMapper(graph);
336
+
337
+ if (pageCountDelta) {
288
338
  emit({
289
339
  kind: "page_count_changed",
290
340
  revision: graph.revision,
291
- previousPageCount,
292
- currentPageCount,
341
+ previousPageCount: pageCountDelta.previous,
342
+ currentPageCount: pageCountDelta.current,
293
343
  });
294
- previousPageCount = currentPageCount;
295
344
  }
296
345
 
297
346
  if (dirtyFamilies.length > 0) {
@@ -308,14 +357,16 @@ export function createLayoutEngine(
308
357
  ...(reason ? { reason } : {}),
309
358
  });
310
359
 
311
- cachedKey = {
312
- content: document.content,
313
- styles: document.styles,
314
- subParts: document.subParts,
315
- };
316
- cachedGraph = graph;
317
- cachedFormatting = formatting;
318
- cachedMapper = createPageFragmentMapper(graph);
360
+ emit({
361
+ kind: "layout_committed",
362
+ revision: graph.revision,
363
+ ...(reason ? { reason } : {}),
364
+ ...(dirtyFamilies.length > 0
365
+ ? { dirtyFieldFamilies: dirtyFamilies }
366
+ : {}),
367
+ ...(pageCountDelta ? { pageCountDelta } : {}),
368
+ });
369
+
319
370
  return graph;
320
371
  }
321
372
 
@@ -388,7 +439,9 @@ export function createLayoutEngine(
388
439
  const currentPageCount = resolveTotalPageCount(
389
440
  deriveDocumentPageSnapshots(splicedGraph),
390
441
  );
442
+ let pageCountDelta: { previous: number; current: number } | undefined;
391
443
  if (currentPageCount !== previousPageCount) {
444
+ pageCountDelta = { previous: previousPageCount, current: currentPageCount };
392
445
  emit({
393
446
  kind: "page_count_changed",
394
447
  revision: splicedGraph.revision,
@@ -413,6 +466,30 @@ export function createLayoutEngine(
413
466
  firstDirtyPageIndex: firstDirty,
414
467
  });
415
468
 
469
+ // P14.b — coalesced commit event for the bounded-incremental path.
470
+ //
471
+ // Page-range semantics: the current `incrementalRelayout` path uses
472
+ // `buildPageStackFromWithSplits` + `spliceGraph`, which always
473
+ // re-paginates from `firstDirty` through the document tail (we
474
+ // discard the prior tail and replace it with the freshly-paginated
475
+ // pages). So `toPageIndex = pages.length - 1` is correct for every
476
+ // commit produced by this path. Future bounded-middle splices
477
+ // (e.g., a middle-style change that doesn't touch the tail) would
478
+ // need to track an explicit upper bound — guard the assumption
479
+ // here so the contract drift becomes a test failure rather than a
480
+ // silent over-iteration in consumers (Chrome overlay, render kernel
481
+ // diff).
482
+ emit({
483
+ kind: "layout_committed",
484
+ revision: splicedGraph.revision,
485
+ reason: pending.reason,
486
+ pageRange: { fromPageIndex: firstDirty, toPageIndex: splicedGraph.pages.length - 1 },
487
+ ...(dirtyFamilies.length > 0
488
+ ? { dirtyFieldFamilies: dirtyFamilies }
489
+ : {}),
490
+ ...(pageCountDelta ? { pageCountDelta } : {}),
491
+ });
492
+
416
493
  cachedKey = {
417
494
  content: document.content,
418
495
  styles: document.styles,
@@ -605,13 +682,46 @@ export function createLayoutEngine(
605
682
  },
606
683
 
607
684
  swapMeasurementProvider(provider) {
685
+ const previousFidelity = measurementProvider.fidelity;
608
686
  measurementProvider = provider;
687
+ // Hardening: a backend swap changes the measurement numerics the
688
+ // cached graph was built against. Empirical → canvas typically
689
+ // reduces line counts (canvas-accurate glyph widths pack more
690
+ // text per line); canvas → canvas-with-font-loading applies the
691
+ // correct FontFace metrics for embedded DOCX fonts. Either way,
692
+ // the cached graph is stale — invalidate so the next
693
+ // `getGraph()` query re-paginates with the new provider. Skip
694
+ // invalidation when fidelity is unchanged (e.g., an empirical
695
+ // → empirical swap, or a canvas fallback that resolved back to
696
+ // the same backend) so we don't churn.
697
+ if (previousFidelity !== provider.fidelity) {
698
+ cachedKey = null;
699
+ cachedGraph = null;
700
+ cachedFormatting = null;
701
+ cachedMapper = null;
702
+ }
609
703
  emit({
610
704
  kind: "measurement_backend_ready",
611
705
  revision: cachedGraph?.revision ?? 0,
612
706
  fidelity: provider.fidelity,
613
707
  });
614
708
  },
709
+ /**
710
+ * Invalidate the current measurement provider's internal glyph /
711
+ * run-width cache. Called by the host runtime after
712
+ * `fontLoader.refresh(...)` so canvas-backed measurements re-read
713
+ * the newly-registered FontFaces instead of returning stale widths
714
+ * from the pre-refresh glyph cache. The graph cache itself is
715
+ * also cleared because a font change can shift line breaks and
716
+ * therefore page boundaries.
717
+ */
718
+ invalidateMeasurementCache() {
719
+ measurementProvider.invalidateCache();
720
+ cachedKey = null;
721
+ cachedGraph = null;
722
+ cachedFormatting = null;
723
+ cachedMapper = null;
724
+ },
615
725
  };
616
726
  }
617
727
 
@@ -73,6 +73,17 @@ export interface RuntimePageRegions {
73
73
  * pages, the top-level body is used and `columns` is undefined.
74
74
  */
75
75
  columns?: RuntimePageRegion[];
76
+ /**
77
+ * P4 — footnote regions reserved at the bottom of the page (above
78
+ * the footer band) when `noteAllocations` reserved space for one or
79
+ * more footnote bodies. Empty until the page-graph builder
80
+ * populates them, which lands alongside the P8 per-page region
81
+ * rendering work. The shape exists now so consumers
82
+ * (`getStoryRegionsOnPage` / `getLineBoxesForRegion("footnote-area")`)
83
+ * have a stable seam to read from when the builder catches up —
84
+ * pre-P8 reads return `[]` because no entries are populated.
85
+ */
86
+ footnotes?: RuntimePageRegion[];
76
87
  }
77
88
 
78
89
  export interface RuntimePageRegion {
@@ -158,6 +169,14 @@ export interface RuntimeNoteAllocation {
158
169
  noteId: string;
159
170
  /** Twips reserved at the bottom of the page for this note's content. */
160
171
  reservedHeightTwips: number;
172
+ /**
173
+ * P8 — fragment ID of this note's body. The corresponding
174
+ * `RuntimeBlockFragment` lives in `RuntimePageGraph.fragments` with
175
+ * `regionKind: "footnote-area"`. Undefined when the engine hasn't
176
+ * yet emitted a fragment for the allocation (back-compat for pre-P8
177
+ * callers).
178
+ */
179
+ fragmentId?: string;
161
180
  }
162
181
 
163
182
  export interface RuntimePageAnchor {
@@ -196,8 +215,15 @@ export interface BuildPageGraphInput {
196
215
  >;
197
216
  /** Optional per-page line boxes. */
198
217
  lineBoxes?: ReadonlyMap<string, RuntimeLineBox[]>;
199
- /** Optional per-page note allocations. */
218
+ /** Optional per-page note allocations keyed by graph-assigned pageId. */
200
219
  noteAllocations?: ReadonlyMap<string, RuntimeNoteAllocation[]>;
220
+ /**
221
+ * P8 — per-page note allocations keyed by pageIndex (0-based).
222
+ * Parallel to `fragmentsByPageIndex`; `buildPageGraph` uses the index to
223
+ * look up allocations without requiring callers to know the graph-internal
224
+ * `page-${revision}-${index}` pageId in advance.
225
+ */
226
+ noteAllocationsByPageIndex?: ReadonlyMap<number, RuntimeNoteAllocation[]>;
201
227
  }
202
228
 
203
229
  export function buildPageGraph(input: BuildPageGraphInput): RuntimePageGraph;
@@ -244,10 +270,15 @@ export function buildPageGraph(
244
270
  };
245
271
 
246
272
  const pageFragments = aggregatedFragments.filter((f) => f.pageId === pageId);
247
- const fragmentIds = pageFragments.map((f) => f.fragmentId);
273
+ // Split fragments into body-region ones (for the body fragmentIds list)
274
+ // and footnote-area ones (handled by buildRegions via noteAllocations).
275
+ const bodyPageFragments = pageFragments.filter(
276
+ (f) => f.regionKind !== "footnote-area",
277
+ );
278
+ const fragmentIds = bodyPageFragments.map((f) => f.fragmentId);
248
279
 
249
- // If no fragments were supplied, synthesize a coarse body fragment so the
250
- // graph is still internally consistent.
280
+ // If no body fragments were supplied, synthesize a coarse body fragment so
281
+ // the graph is still internally consistent.
251
282
  let bodyFragmentIds = fragmentIds;
252
283
  if (fragmentIds.length === 0 && page.endOffset > page.startOffset) {
253
284
  const coarse: RuntimeBlockFragment = {
@@ -264,6 +295,15 @@ export function buildPageGraph(
264
295
  bodyFragmentIds = [coarse.fragmentId];
265
296
  }
266
297
 
298
+ // Resolve per-page note allocations: prefer noteAllocationsByPageIndex
299
+ // (index-based, suitable when the caller doesn't know the graph-internal
300
+ // pageId) falling back to noteAllocations (pageId-keyed, used by the
301
+ // engine once it emits allocations).
302
+ const pageNoteAllocations: RuntimeNoteAllocation[] =
303
+ input.noteAllocationsByPageIndex?.get(index) ??
304
+ input.noteAllocations?.get(pageId) ??
305
+ [];
306
+
267
307
  const node: RuntimePageNode = {
268
308
  pageId,
269
309
  pageIndex: page.pageIndex,
@@ -273,9 +313,9 @@ export function buildPageGraph(
273
313
  endOffset: page.endOffset,
274
314
  layout: page.layout,
275
315
  stories,
276
- regions: buildRegions(page.layout, bodyFragmentIds, stories),
316
+ regions: buildRegions(page.layout, bodyFragmentIds, stories, pageNoteAllocations),
277
317
  lineBoxes: input.lineBoxes?.get(pageId) ?? [],
278
- noteAllocations: input.noteAllocations?.get(pageId) ?? [],
318
+ noteAllocations: pageNoteAllocations,
279
319
  isBlankFiller: page.pageInSection === -1,
280
320
  };
281
321
  pages.push(node);
@@ -302,12 +342,40 @@ function buildRegions(
302
342
  layout: PageLayoutSnapshot,
303
343
  bodyFragmentIds: readonly string[],
304
344
  stories: ResolvedPageStories,
345
+ noteAllocations: readonly RuntimeNoteAllocation[] = [],
305
346
  ): RuntimePageRegions {
306
347
  const bodyWidth =
307
348
  layout.pageWidth - layout.marginLeft - layout.marginRight;
308
- const bodyHeight =
349
+ let bodyHeight =
309
350
  layout.pageHeight - layout.marginTop - layout.marginBottom;
310
351
 
352
+ // P8 — footnote area sits at the bottom of the page (above the footer
353
+ // band). Body height shrinks by the total reserved note height so
354
+ // band-aware consumers can stack bands without overflowing the paper frame.
355
+ let footnotesRegion: RuntimePageRegion | undefined;
356
+ if (noteAllocations.length > 0) {
357
+ const fragmentIds = noteAllocations
358
+ .filter(
359
+ (a): a is RuntimeNoteAllocation & { fragmentId: string } =>
360
+ Boolean(a.fragmentId),
361
+ )
362
+ .map((a) => a.fragmentId);
363
+ const totalNoteHeight = noteAllocations.reduce(
364
+ (sum, a) => sum + a.reservedHeightTwips,
365
+ 0,
366
+ );
367
+ if (fragmentIds.length > 0) {
368
+ footnotesRegion = {
369
+ kind: "footnote-area",
370
+ originTwips: layout.pageHeight - layout.marginBottom - totalNoteHeight,
371
+ widthTwips: Math.max(0, bodyWidth),
372
+ heightTwips: Math.max(0, totalNoteHeight),
373
+ fragmentIds,
374
+ };
375
+ bodyHeight = Math.max(0, bodyHeight - totalNoteHeight);
376
+ }
377
+ }
378
+
311
379
  const body: RuntimePageRegion = {
312
380
  kind: "body",
313
381
  originTwips: layout.marginTop,
@@ -356,6 +424,10 @@ function buildRegions(
356
424
  regions.columns = columns;
357
425
  }
358
426
 
427
+ if (footnotesRegion) {
428
+ regions.footnotes = [footnotesRegion];
429
+ }
430
+
359
431
  return regions;
360
432
  }
361
433