@beyondwork/docx-react-component 1.0.36 → 1.0.38

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 (107) 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 +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -0,0 +1,1398 @@
1
+ /**
2
+ * Public Layout Facet — the ergonomic surface exposed on
3
+ * `WordReviewEditorRef.layout`.
4
+ *
5
+ * Per the plan (Phase 7), this provides a single coherent object tree for
6
+ * consumers to walk the layout graph, resolve positions, inspect measurement
7
+ * state, and observe layout events — without needing to deal with the legacy
8
+ * opaque JSON snapshots.
9
+ *
10
+ * Design rules:
11
+ * - Every returned value is a deep-cloned read model. Internal graph
12
+ * identities never escape the facet.
13
+ * - Story-aware. `EditorStoryTarget` is accepted anywhere a position is
14
+ * interpreted so header/footer/note queries work cleanly.
15
+ * - No DOM. No PM. No canonical model leakage.
16
+ */
17
+
18
+ import type {
19
+ EditorStoryTarget,
20
+ PageLayoutSnapshot,
21
+ SelectionSnapshot,
22
+ } from "../../api/public-types";
23
+ import type {
24
+ ResolvedPageStories,
25
+ } from "./page-story-resolver.ts";
26
+ import type {
27
+ RuntimeBlockFragment,
28
+ RuntimeLineBox,
29
+ RuntimeNoteAllocation,
30
+ RuntimePageGraph,
31
+ RuntimePageNode,
32
+ RuntimePageRegion,
33
+ RuntimePageRegions,
34
+ } from "./page-graph.ts";
35
+ import type {
36
+ ResolvedFormattingState,
37
+ ResolvedRunFormatting,
38
+ } from "./resolved-formatting-document.ts";
39
+ import type { ResolvedParagraphFormatting } from "./resolved-formatting-state.ts";
40
+ import type {
41
+ LayoutEngineEvent,
42
+ LayoutEngineInstance,
43
+ LayoutEngineQueryInput,
44
+ } from "./layout-engine-instance.ts";
45
+ import type { PageFragmentMapper } from "./page-fragment-mapper.ts";
46
+ import {
47
+ PAGE_FORMAT_CATALOG,
48
+ matchPageFormat,
49
+ type PageFormatDefinition,
50
+ type ActivePageFormat,
51
+ } from "./page-format-catalog.ts";
52
+ import {
53
+ MARGIN_PRESET_CATALOG,
54
+ matchMarginPreset,
55
+ type MarginPresetDefinition,
56
+ type ActiveMarginPreset,
57
+ } from "./margin-preset-catalog.ts";
58
+ import {
59
+ collectScopeRailSegments,
60
+ type ScopeRailSegment,
61
+ } from "../workflow-rail-segments.ts";
62
+ import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
63
+ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
64
+ import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
65
+ import { resolveTableStyleResolution } from "../table-style-resolver.ts";
66
+ import { buildTableRenderPlan } from "./table-render-plan.ts";
67
+ import type {
68
+ SurfaceBlockSnapshot,
69
+ } from "../../api/public-types";
70
+
71
+ export type {
72
+ PageFormatDefinition,
73
+ ActivePageFormat,
74
+ MarginPresetDefinition,
75
+ ActiveMarginPreset,
76
+ };
77
+
78
+ export type { ScopeRailSegment };
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Public read model types (shape-stable, cloned at the facet boundary)
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export interface PublicPageNode {
85
+ pageId: string;
86
+ pageIndex: number;
87
+ sectionIndex: number;
88
+ pageInSection: number;
89
+ startOffset: number;
90
+ endOffset: number;
91
+ /** Whether this page is a blank filler (e.g. from evenPage/oddPage). */
92
+ isBlankFiller: boolean;
93
+ /** Resolved display page number (1-based, honors section restarts). */
94
+ displayPageNumber: number;
95
+ /** Whether this is treated as the first page of its section (title page). */
96
+ isFirstPage: boolean;
97
+ /** Whether the displayed page number is even. */
98
+ isEvenPage: boolean;
99
+ /** Section-derived page layout geometry. */
100
+ layout: PageLayoutSnapshot;
101
+ /** Resolved header/footer/note stories active on this page. */
102
+ stories: PublicResolvedPageStories;
103
+ /** Sub-regions rendered on the page. */
104
+ regions: PublicPageRegions;
105
+ /** Number of line boxes rendered in the body region. */
106
+ lineBoxCount: number;
107
+ /** Footnotes reserved at the bottom of the page, if any. */
108
+ noteAllocations: readonly PublicNoteAllocation[];
109
+ }
110
+
111
+ export interface PublicResolvedPageStories {
112
+ header?: EditorStoryTarget;
113
+ footer?: EditorStoryTarget;
114
+ isFirstPage: boolean;
115
+ isEvenPage: boolean;
116
+ displayPageNumber: number;
117
+ }
118
+
119
+ export interface PublicPageRegions {
120
+ body: PublicPageRegion;
121
+ header?: PublicPageRegion;
122
+ footer?: PublicPageRegion;
123
+ columns?: readonly PublicPageRegion[];
124
+ }
125
+
126
+ export interface PublicPageRegion {
127
+ kind: "body" | "header" | "footer" | "column" | "footnote-area";
128
+ originTwips: number;
129
+ widthTwips: number;
130
+ heightTwips: number;
131
+ fragmentCount: number;
132
+ }
133
+
134
+ export interface PublicBlockFragment {
135
+ fragmentId: string;
136
+ blockId: string;
137
+ pageId: string;
138
+ pageIndex: number;
139
+ regionKind: PublicPageRegion["kind"];
140
+ from: number;
141
+ to: number;
142
+ heightTwips: number;
143
+ orderInRegion: number;
144
+ }
145
+
146
+ export interface PublicLineBox {
147
+ fragmentId: string;
148
+ lineIndex: number;
149
+ baselineTwips: number;
150
+ heightTwips: number;
151
+ widthTwips: number;
152
+ }
153
+
154
+ export interface PublicNoteAllocation {
155
+ noteKind: "footnote" | "endnote";
156
+ noteId: string;
157
+ reservedHeightTwips: number;
158
+ }
159
+
160
+ export interface PublicPageAnchor {
161
+ offset: number;
162
+ pageId: string;
163
+ pageIndex: number;
164
+ fragmentId?: string;
165
+ regionKind?: PublicPageRegion["kind"];
166
+ }
167
+
168
+ export interface PublicPageSpan {
169
+ firstPageIndex: number;
170
+ lastPageIndex: number;
171
+ pageCount: number;
172
+ }
173
+
174
+ export interface PublicSectionNode {
175
+ sectionIndex: number;
176
+ startOffset: number;
177
+ endOffset: number;
178
+ firstPageIndex: number;
179
+ lastPageIndex: number;
180
+ pageCount: number;
181
+ layout: PageLayoutSnapshot;
182
+ }
183
+
184
+ export interface PublicResolvedParagraphFormatting {
185
+ blockId: string;
186
+ spacingBefore: number;
187
+ spacingAfter: number;
188
+ lineHeight: number;
189
+ lineRule: "auto" | "exact" | "atLeast";
190
+ indentLeft: number;
191
+ indentRight: number;
192
+ firstLineIndent: number;
193
+ hangingIndent: number;
194
+ fontSizeHalfPoints: number;
195
+ averageCharWidthTwips: number;
196
+ tabStops: readonly {
197
+ positionTwips: number;
198
+ alignment: string;
199
+ leader?: string;
200
+ }[];
201
+ keepNext: boolean;
202
+ keepLines: boolean;
203
+ pageBreakBefore: boolean;
204
+ widowControl: boolean;
205
+ contextualSpacing: boolean;
206
+ }
207
+
208
+ export interface PublicResolvedRunFormatting {
209
+ runId: string;
210
+ blockId: string;
211
+ fontFamily?: string;
212
+ fontSizeHalfPoints?: number;
213
+ bold: boolean;
214
+ italic: boolean;
215
+ underline: boolean;
216
+ strikethrough: boolean;
217
+ color?: string;
218
+ highlight?: string;
219
+ verticalAlign: "baseline" | "superscript" | "subscript";
220
+ }
221
+
222
+ export interface PublicBlockMeasurement {
223
+ blockId: string;
224
+ lineCount: number;
225
+ /** Total height the block occupies in twips. */
226
+ heightTwips: number;
227
+ }
228
+
229
+ export type PublicMeasurementFidelity =
230
+ | "empirical"
231
+ | "canvas"
232
+ | "canvas-with-font-loading";
233
+
234
+ export interface PublicFieldDirtinessReport {
235
+ families: readonly string[];
236
+ revision: number;
237
+ }
238
+
239
+ export type LayoutFacetEvent =
240
+ | {
241
+ kind: "layout_recomputed";
242
+ revision: number;
243
+ reason?: LayoutFacetInvalidationReason;
244
+ }
245
+ | {
246
+ kind: "page_count_changed";
247
+ previous: number;
248
+ current: number;
249
+ revision: number;
250
+ }
251
+ | {
252
+ kind: "page_field_dirtied";
253
+ families: readonly string[];
254
+ revision: number;
255
+ }
256
+ | {
257
+ kind: "measurement_backend_ready";
258
+ fidelity: PublicMeasurementFidelity;
259
+ revision: number;
260
+ }
261
+ | {
262
+ kind: "incremental_relayout";
263
+ revision: number;
264
+ pageRange: { fromPageIndex: number; toPageIndex: number };
265
+ reason?: LayoutFacetInvalidationReason;
266
+ }
267
+ | {
268
+ kind: "render_frame_ready";
269
+ revision: number;
270
+ pageRange?: { fromPageIndex: number; toPageIndex: number };
271
+ }
272
+ | {
273
+ kind: "zoom_changed";
274
+ revision: number;
275
+ zoom: RenderZoomSummary;
276
+ };
277
+
278
+ /**
279
+ * Minimal zoom summary carried with `zoom_changed`. The render kernel
280
+ * (when shipped) provides richer zoom metadata; consumers today only need
281
+ * the resolved px-per-twip plus the fit mode to reposition chrome.
282
+ */
283
+ export interface RenderZoomSummary {
284
+ pxPerTwip: number;
285
+ viewportWidthPx: number;
286
+ fitMode: "fixed" | "page-width" | "one-page";
287
+ }
288
+
289
+ export type LayoutFacetInvalidationReason =
290
+ | { kind: "content-edit"; from: number; to: number }
291
+ | { kind: "section-change"; sectionIndex: number }
292
+ | { kind: "styles-change" }
293
+ | { kind: "theme-change" }
294
+ | { kind: "numbering-change"; numberingInstanceId?: string }
295
+ | { kind: "field-refresh"; family?: string }
296
+ | { kind: "full" };
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Facet interface
300
+ // ---------------------------------------------------------------------------
301
+
302
+ export interface WordReviewEditorLayoutFacet {
303
+ // Structure ------------------------------------------------------------
304
+ getPageCount(): number;
305
+ getPage(pageIndex: number): PublicPageNode | null;
306
+ getPages(options?: { sectionIndex?: number }): PublicPageNode[];
307
+ getSection(sectionIndex: number): PublicSectionNode | null;
308
+ getSections(): PublicSectionNode[];
309
+
310
+ // Offset / selection navigation ---------------------------------------
311
+ getPageForOffset(
312
+ offset: number,
313
+ story?: EditorStoryTarget,
314
+ ): PublicPageNode | null;
315
+ getPageSpanForSelection(selection: SelectionSnapshot): PublicPageSpan | null;
316
+ getFragmentForOffset(
317
+ offset: number,
318
+ story?: EditorStoryTarget,
319
+ ): PublicBlockFragment | null;
320
+ getAnchorForOffset(
321
+ offset: number,
322
+ story?: EditorStoryTarget,
323
+ ): PublicPageAnchor | null;
324
+
325
+ // Per-page semantic reads ---------------------------------------------
326
+ getActiveStoriesOnPage(pageIndex: number): PublicResolvedPageStories | null;
327
+ getDisplayPageNumber(pageIndex: number): number | null;
328
+ getLineBoxes(
329
+ pageIndex: number,
330
+ options?: { region?: PublicPageRegion["kind"]; columnIndex?: number },
331
+ ): PublicLineBox[];
332
+ /** Back-compat alias; prefer `getLineBoxes(pageIndex, { region })`. */
333
+ getLineBoxesForRegion(
334
+ pageIndex: number,
335
+ region: PublicPageRegion["kind"],
336
+ options?: { columnIndex?: number },
337
+ ): readonly PublicLineBox[];
338
+ getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
339
+
340
+ // Page-format catalog --------------------------------------------------
341
+ getPageFormatCatalog(): readonly PageFormatDefinition[];
342
+ getActivePageFormat(sectionIndex: number): ActivePageFormat | null;
343
+ getMarginPresetCatalog(): readonly MarginPresetDefinition[];
344
+ getActiveMarginPreset(sectionIndex: number): ActiveMarginPreset | null;
345
+
346
+ // Render-frame access (R1) --------------------------------------------
347
+ /**
348
+ * Return the active `RenderFrame` produced by the runtime-owned render
349
+ * kernel. Optional when the host runtime has not yet installed a kernel
350
+ * — returns `null` in that case so consumers can fall back to the page-
351
+ * graph reads above.
352
+ */
353
+ getRenderFrame?(
354
+ options?: import("../render/index.ts").RenderFrameQueryOptions,
355
+ ): import("../render/index.ts").RenderFrame | null;
356
+
357
+ /** Return the render-kernel zoom, if a kernel is installed. */
358
+ getRenderZoom?(): import("../render/index.ts").RenderZoom | null;
359
+
360
+ /**
361
+ * Hit-test a point in the mounted shell's coordinate space against the
362
+ * current render frame. Returns the deepest matching region with runtime
363
+ * offset, block id, fragment id, and line index so chrome surfaces can
364
+ * turn mouse coordinates into canonical positions without consulting the
365
+ * DOM. Returns `null` when no kernel is installed, or when the point
366
+ * falls outside every page.
367
+ */
368
+ hitTest?(
369
+ pointInRoot: import("../render/index.ts").RenderPoint,
370
+ ): import("../render/index.ts").RenderHitResult | null;
371
+
372
+ /**
373
+ * Resolve anchor rects for a `RenderAnchorQuery`. Returns `[]` when the
374
+ * kernel is absent or the query does not match any anchor. Chrome
375
+ * surfaces that need a single rect can read `getAnchorRects(q)[0]`;
376
+ * selection-spanning surfaces read the full list and union as needed.
377
+ */
378
+ getAnchorRects?(
379
+ query: import("../render/index.ts").RenderAnchorQuery,
380
+ ): readonly import("../render/index.ts").RenderFrameRect[];
381
+
382
+ // Scope rail segments (R3a) -------------------------------------------
383
+ /**
384
+ * Return workflow rail segments active on a given page. Returns an empty
385
+ * list when the host runtime did not supply workflow data to the facet.
386
+ */
387
+ getScopeRailSegments(
388
+ pageIndex: number,
389
+ ): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
390
+ /** Return every scope rail segment across the document. */
391
+ getAllScopeRailSegments(): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
392
+
393
+ // Measurement exposure -------------------------------------------------
394
+ getResolvedFormatting(blockId: string): PublicResolvedParagraphFormatting | null;
395
+ getResolvedRunFormatting(runId: string): PublicResolvedRunFormatting | null;
396
+ getMeasurement(blockId: string): PublicBlockMeasurement | null;
397
+ getMeasurementFidelity(): PublicMeasurementFidelity;
398
+ whenMeasurementReady(): Promise<void>;
399
+
400
+ // Table render plan (P3e consumed by the render kernel, P4) ------------
401
+ /**
402
+ * Build a `TableRenderPlan` for a table block on a given page. Returns
403
+ * `null` when the blockId does not resolve to a table in the current
404
+ * surface. The plan carries columnsTwips, bandClasses, verticalMerges,
405
+ * repeatedHeaderRows, and columnResizeHandles so chrome can render
406
+ * band-aware cell styling and place column-resize grips without
407
+ * walking canonical state.
408
+ */
409
+ getTableRenderPlan(
410
+ blockId: string,
411
+ pageIndex: number,
412
+ ): import("./table-render-plan.ts").TableRenderPlan | null;
413
+
414
+ // Fields ---------------------------------------------------------------
415
+ getDirtyFieldFamilies(): readonly string[];
416
+ getFieldDirtinessReport(): PublicFieldDirtinessReport;
417
+
418
+ // Events ---------------------------------------------------------------
419
+ subscribe(listener: (event: LayoutFacetEvent) => void): () => void;
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Factory
424
+ // ---------------------------------------------------------------------------
425
+
426
+ export interface CreateLayoutFacetInput {
427
+ engine: LayoutEngineInstance;
428
+ getQueryInput: () => LayoutEngineQueryInput;
429
+ /**
430
+ * Optional render-kernel accessor. When supplied, the facet exposes
431
+ * `getRenderFrame` + `getRenderZoom` that delegate to the kernel.
432
+ */
433
+ renderKernel?: () =>
434
+ | import("../render/index.ts").RenderKernel
435
+ | null
436
+ | undefined;
437
+ /**
438
+ * Optional workflow-segments accessor. When supplied, the facet computes
439
+ * `getScopeRailSegments` / `getAllScopeRailSegments` by joining the host-
440
+ * supplied workflow state with the current page graph. When omitted,
441
+ * both methods return empty arrays.
442
+ */
443
+ getWorkflowRailInput?: () =>
444
+ | Omit<
445
+ import("../workflow-rail-segments.ts").CollectScopeRailSegmentsInput,
446
+ "pageGraph"
447
+ >
448
+ | null
449
+ | undefined;
450
+ }
451
+
452
+ export function createLayoutFacet(
453
+ input: CreateLayoutFacetInput,
454
+ ): WordReviewEditorLayoutFacet {
455
+ const { engine, getQueryInput } = input;
456
+
457
+ function currentGraph(): RuntimePageGraph {
458
+ return engine.getPageGraph(getQueryInput());
459
+ }
460
+ function currentMapper(): PageFragmentMapper {
461
+ return engine.getFragmentMapper(getQueryInput());
462
+ }
463
+ function currentFormatting(): ResolvedFormattingState {
464
+ return engine.getResolvedFormattingState(getQueryInput());
465
+ }
466
+
467
+ const listeners = new Set<(event: LayoutFacetEvent) => void>();
468
+ const unsubscribeEngine = engine.subscribe((event: LayoutEngineEvent) => {
469
+ // `incremental_relayout` needs the last page index so the facet can
470
+ // report a `pageRange: [fromPageIndex, toPageIndex]` without asking the
471
+ // engine for an extra read. Use a best-effort lookup; on zero-page
472
+ // graphs the range collapses to the same index.
473
+ let lastPageIndex = 0;
474
+ try {
475
+ const pageCount = engine.getPageGraph(getQueryInput()).pages.length;
476
+ lastPageIndex = Math.max(0, pageCount - 1);
477
+ } catch {
478
+ lastPageIndex = 0;
479
+ }
480
+ const facetEvent = toFacetEvent(event, lastPageIndex);
481
+ if (!facetEvent) return;
482
+ for (const listener of listeners) {
483
+ try {
484
+ listener(facetEvent);
485
+ } catch {
486
+ // never let listener errors break the engine
487
+ }
488
+ }
489
+ });
490
+ // Keep the handle alive; the facet instance lives as long as the runtime.
491
+ void unsubscribeEngine;
492
+
493
+ return {
494
+ getPageCount() {
495
+ return currentGraph().pages.length;
496
+ },
497
+
498
+ getPage(pageIndex) {
499
+ const graph = currentGraph();
500
+ const node = graph.pages[pageIndex];
501
+ return node ? toPublicPageNode(node, graph) : null;
502
+ },
503
+
504
+ getPages(options) {
505
+ const graph = currentGraph();
506
+ const filtered = options?.sectionIndex !== undefined
507
+ ? graph.pages.filter((p) => p.sectionIndex === options.sectionIndex)
508
+ : graph.pages;
509
+ return filtered.map((node) => toPublicPageNode(node, graph));
510
+ },
511
+
512
+ getSection(sectionIndex) {
513
+ const graph = currentGraph();
514
+ return toPublicSectionNode(graph, sectionIndex);
515
+ },
516
+
517
+ getSections() {
518
+ const graph = currentGraph();
519
+ return graph.sections
520
+ .map((section) => toPublicSectionNode(graph, section.index))
521
+ .filter((node): node is PublicSectionNode => node !== null);
522
+ },
523
+
524
+ getPageForOffset(offset, story) {
525
+ const graph = currentGraph();
526
+ const page = findPageForOffsetAndStory(graph, offset, story);
527
+ return page ? toPublicPageNode(page, graph) : null;
528
+ },
529
+
530
+ getPageSpanForSelection(selection) {
531
+ return currentMapper().mapSelectionToPageSpan(selection);
532
+ },
533
+
534
+ getFragmentForOffset(offset, story) {
535
+ const mapper = currentMapper();
536
+ const graph = currentGraph();
537
+ const location = mapper.mapOffsetToFragment(offset, story);
538
+ if (!location) return null;
539
+ const fragment = graph.fragments.find(
540
+ (f) => f.fragmentId === location.fragmentId,
541
+ );
542
+ if (!fragment) return null;
543
+ return toPublicBlockFragment(fragment, graph);
544
+ },
545
+
546
+ getAnchorForOffset(offset, story) {
547
+ const mapper = currentMapper();
548
+ const graph = currentGraph();
549
+ const anchor = mapper.getAnchorForOffset(offset, story);
550
+ if (!anchor) return null;
551
+ const page = graph.pages.find((p) => p.pageId === anchor.pageId);
552
+ if (!page) return null;
553
+ const fragmentLocation = mapper.mapOffsetToFragment(offset, story);
554
+ return {
555
+ offset: anchor.offset,
556
+ pageId: anchor.pageId,
557
+ pageIndex: page.pageIndex,
558
+ ...(fragmentLocation?.fragmentId !== undefined
559
+ ? { fragmentId: fragmentLocation.fragmentId }
560
+ : {}),
561
+ ...(fragmentLocation?.regionKind !== undefined
562
+ ? { regionKind: fragmentLocation.regionKind }
563
+ : {}),
564
+ };
565
+ },
566
+
567
+ getActiveStoriesOnPage(pageIndex) {
568
+ const graph = currentGraph();
569
+ const node = graph.pages[pageIndex];
570
+ if (!node) return null;
571
+ return toPublicResolvedPageStories(node.stories);
572
+ },
573
+
574
+ getDisplayPageNumber(pageIndex) {
575
+ const graph = currentGraph();
576
+ const node = graph.pages[pageIndex];
577
+ return node ? node.stories.displayPageNumber : null;
578
+ },
579
+
580
+ getLineBoxes(pageIndex, options) {
581
+ const graph = currentGraph();
582
+ const node = graph.pages[pageIndex];
583
+ if (!node) return [];
584
+ const region = options?.region ?? "body";
585
+ return collectLineBoxesForRegion(
586
+ node,
587
+ region,
588
+ options?.columnIndex,
589
+ ).map((box) => toPublicLineBox(box));
590
+ },
591
+
592
+ getLineBoxesForRegion(pageIndex, region, options) {
593
+ const graph = currentGraph();
594
+ const node = graph.pages[pageIndex];
595
+ if (!node) return [];
596
+ return collectLineBoxesForRegion(node, region, options?.columnIndex).map(
597
+ (box) => toPublicLineBox(box),
598
+ );
599
+ },
600
+
601
+ getPageFormatCatalog() {
602
+ return PAGE_FORMAT_CATALOG;
603
+ },
604
+
605
+ getActivePageFormat(sectionIndex) {
606
+ const graph = currentGraph();
607
+ const sectionPages = graph.pages.filter(
608
+ (p) => p.sectionIndex === sectionIndex,
609
+ );
610
+ const firstPage = sectionPages[0];
611
+ if (!firstPage) return null;
612
+ return matchPageFormat({
613
+ sectionIndex,
614
+ widthTwips: firstPage.layout.pageWidth,
615
+ heightTwips: firstPage.layout.pageHeight,
616
+ });
617
+ },
618
+
619
+ getMarginPresetCatalog() {
620
+ return MARGIN_PRESET_CATALOG;
621
+ },
622
+
623
+ getActiveMarginPreset(sectionIndex) {
624
+ const graph = currentGraph();
625
+ const sectionPages = graph.pages.filter(
626
+ (p) => p.sectionIndex === sectionIndex,
627
+ );
628
+ const firstPage = sectionPages[0];
629
+ if (!firstPage) return null;
630
+ const layout = firstPage.layout;
631
+ // `mirrorMargins` is a document-level setting
632
+ // (`w:mirrorMargins`) that the current canonical model does not
633
+ // surface onto the per-section layout snapshot; pass `false` until
634
+ // that plumbing exists so the Mirrored preset only matches when
635
+ // callers pass the preset-builder helper explicitly.
636
+ return matchMarginPreset({
637
+ sectionIndex,
638
+ topTwips: layout.marginTop,
639
+ bottomTwips: layout.marginBottom,
640
+ leftTwips: layout.marginLeft,
641
+ rightTwips: layout.marginRight,
642
+ gutterTwips: layout.gutter,
643
+ mirrored: false,
644
+ });
645
+ },
646
+
647
+ getRenderFrame(options) {
648
+ const kernel = input.renderKernel?.();
649
+ if (!kernel) return null;
650
+ return kernel.getRenderFrame(options);
651
+ },
652
+
653
+ getRenderZoom() {
654
+ const kernel = input.renderKernel?.();
655
+ if (!kernel) return null;
656
+ return kernel.getZoom();
657
+ },
658
+
659
+ hitTest(pointInRoot) {
660
+ const kernel = input.renderKernel?.();
661
+ if (!kernel) return null;
662
+ const frame = kernel.getRenderFrame();
663
+ return resolveHitTest(frame, pointInRoot);
664
+ },
665
+
666
+ getAnchorRects(query) {
667
+ const kernel = input.renderKernel?.();
668
+ if (!kernel) return [];
669
+ const frame = kernel.getRenderFrame();
670
+ return resolveAnchorRects(frame, query);
671
+ },
672
+
673
+ getScopeRailSegments(pageIndex) {
674
+ return collectScopeRailSegmentsForQuery(
675
+ input.getWorkflowRailInput?.(),
676
+ currentGraph(),
677
+ ).filter((segment) => segment.pageIndex === pageIndex);
678
+ },
679
+
680
+ getAllScopeRailSegments() {
681
+ return collectScopeRailSegmentsForQuery(
682
+ input.getWorkflowRailInput?.(),
683
+ currentGraph(),
684
+ );
685
+ },
686
+
687
+ getFragmentsForPage(pageIndex) {
688
+ const graph = currentGraph();
689
+ const node = graph.pages[pageIndex];
690
+ if (!node) return [];
691
+ return graph.fragments
692
+ .filter((f) => f.pageId === node.pageId)
693
+ .map((f) => toPublicBlockFragment(f, graph));
694
+ },
695
+
696
+ getResolvedFormatting(blockId) {
697
+ const state = currentFormatting();
698
+ const formatting = state.paragraphs.get(blockId);
699
+ if (!formatting) return null;
700
+ return toPublicParagraphFormatting(blockId, formatting);
701
+ },
702
+
703
+ getResolvedRunFormatting(runId) {
704
+ const state = currentFormatting();
705
+ const run = state.runs.get(runId);
706
+ if (!run) return null;
707
+ const blockId = runId.split(":")[0] ?? "";
708
+ return toPublicRunFormatting(runId, blockId, run);
709
+ },
710
+
711
+ getMeasurement(blockId) {
712
+ const graph = currentGraph();
713
+ const fragments = graph.fragments.filter((f) => f.blockId === blockId);
714
+ if (fragments.length === 0) return null;
715
+ const lineCount = graph.pages.reduce((total, page) => {
716
+ return (
717
+ total +
718
+ page.lineBoxes.filter((line) =>
719
+ fragments.some((f) => f.fragmentId === line.fragmentId),
720
+ ).length
721
+ );
722
+ }, 0);
723
+ const heightTwips = fragments.reduce((t, f) => t + f.heightTwips, 0);
724
+ return {
725
+ blockId,
726
+ lineCount,
727
+ heightTwips,
728
+ };
729
+ },
730
+
731
+ getMeasurementFidelity() {
732
+ return engine.measurementFidelity;
733
+ },
734
+
735
+ whenMeasurementReady() {
736
+ return engine.whenMeasurementReady();
737
+ },
738
+
739
+ getTableRenderPlan(blockId, pageIndex) {
740
+ const graph = currentGraph();
741
+ const fragment = graph.fragments.find((f) => f.blockId === blockId);
742
+ if (!fragment) return null;
743
+ const queryInput = getQueryInput();
744
+ const surface = lazyMainSurfaceForFacet(queryInput);
745
+ const tableBlock = findTableBlockByBlockId(surface.blocks, blockId);
746
+ if (!tableBlock) return null;
747
+ const resolved = resolveTableStyleResolutionForPlan(
748
+ tableBlock,
749
+ queryInput.document,
750
+ );
751
+ if (!resolved) return null;
752
+ // Sum all fragments for this block so the grip height spans every
753
+ // page the table occupies.
754
+ const tableHeightTwips = graph.fragments
755
+ .filter((f) => f.blockId === blockId)
756
+ .reduce((total, f) => total + f.heightTwips, 0);
757
+ return buildTableRenderPlan({
758
+ blockId,
759
+ pageIndex,
760
+ block: tableBlock,
761
+ resolved,
762
+ tableHeightTwips,
763
+ });
764
+ },
765
+
766
+ getDirtyFieldFamilies() {
767
+ return engine.getDirtyFieldFamilies();
768
+ },
769
+
770
+ getFieldDirtinessReport() {
771
+ const graph = currentGraph();
772
+ return {
773
+ families: engine.getDirtyFieldFamilies(),
774
+ revision: graph.revision,
775
+ };
776
+ },
777
+
778
+ subscribe(listener) {
779
+ listeners.add(listener);
780
+ return () => {
781
+ listeners.delete(listener);
782
+ };
783
+ },
784
+ };
785
+ }
786
+
787
+ // ---------------------------------------------------------------------------
788
+ // Internal: graph → public clones
789
+ // ---------------------------------------------------------------------------
790
+
791
+ function toPublicPageNode(
792
+ node: RuntimePageNode,
793
+ graph: RuntimePageGraph,
794
+ ): PublicPageNode {
795
+ return {
796
+ pageId: node.pageId,
797
+ pageIndex: node.pageIndex,
798
+ sectionIndex: node.sectionIndex,
799
+ pageInSection: node.pageInSection,
800
+ startOffset: node.startOffset,
801
+ endOffset: node.endOffset,
802
+ isBlankFiller: node.isBlankFiller,
803
+ displayPageNumber: node.stories.displayPageNumber,
804
+ isFirstPage: node.stories.isFirstPage,
805
+ isEvenPage: node.stories.isEvenPage,
806
+ layout: { ...node.layout },
807
+ stories: toPublicResolvedPageStories(node.stories),
808
+ regions: toPublicPageRegions(node.regions),
809
+ lineBoxCount: node.lineBoxes.length,
810
+ noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
811
+ };
812
+ void graph; // reserved for future cross-page derivations
813
+ }
814
+
815
+ function toPublicResolvedPageStories(
816
+ stories: ResolvedPageStories,
817
+ ): PublicResolvedPageStories {
818
+ return {
819
+ ...(stories.header ? { header: { ...stories.header } } : {}),
820
+ ...(stories.footer ? { footer: { ...stories.footer } } : {}),
821
+ isFirstPage: stories.isFirstPage,
822
+ isEvenPage: stories.isEvenPage,
823
+ displayPageNumber: stories.displayPageNumber,
824
+ };
825
+ }
826
+
827
+ function toPublicPageRegions(regions: RuntimePageRegions): PublicPageRegions {
828
+ return {
829
+ body: toPublicPageRegion(regions.body),
830
+ ...(regions.header ? { header: toPublicPageRegion(regions.header) } : {}),
831
+ ...(regions.footer ? { footer: toPublicPageRegion(regions.footer) } : {}),
832
+ ...(regions.columns ? { columns: regions.columns.map(toPublicPageRegion) } : {}),
833
+ };
834
+ }
835
+
836
+ function toPublicPageRegion(region: RuntimePageRegion): PublicPageRegion {
837
+ return {
838
+ kind: region.kind,
839
+ originTwips: region.originTwips,
840
+ widthTwips: region.widthTwips,
841
+ heightTwips: region.heightTwips,
842
+ fragmentCount: region.fragmentIds.length,
843
+ };
844
+ }
845
+
846
+ function toPublicBlockFragment(
847
+ fragment: RuntimeBlockFragment,
848
+ graph: RuntimePageGraph,
849
+ ): PublicBlockFragment {
850
+ const page = graph.pages.find((p) => p.pageId === fragment.pageId);
851
+ return {
852
+ fragmentId: fragment.fragmentId,
853
+ blockId: fragment.blockId,
854
+ pageId: fragment.pageId,
855
+ pageIndex: page?.pageIndex ?? -1,
856
+ regionKind: fragment.regionKind,
857
+ from: fragment.from,
858
+ to: fragment.to,
859
+ heightTwips: fragment.heightTwips,
860
+ orderInRegion: fragment.orderInRegion,
861
+ };
862
+ }
863
+
864
+ function toPublicLineBox(box: RuntimeLineBox): PublicLineBox {
865
+ return {
866
+ fragmentId: box.fragmentId,
867
+ lineIndex: box.lineIndex,
868
+ baselineTwips: box.baselineTwips,
869
+ heightTwips: box.heightTwips,
870
+ widthTwips: box.widthTwips,
871
+ };
872
+ }
873
+
874
+ function toPublicNoteAllocation(note: RuntimeNoteAllocation): PublicNoteAllocation {
875
+ return {
876
+ noteKind: note.noteKind,
877
+ noteId: note.noteId,
878
+ reservedHeightTwips: note.reservedHeightTwips,
879
+ };
880
+ }
881
+
882
+ function toPublicSectionNode(
883
+ graph: RuntimePageGraph,
884
+ sectionIndex: number,
885
+ ): PublicSectionNode | null {
886
+ const section = graph.sections.find((s) => s.index === sectionIndex);
887
+ if (!section) return null;
888
+ const sectionPages = graph.pages.filter(
889
+ (p) => p.sectionIndex === sectionIndex,
890
+ );
891
+ const firstPage = sectionPages[0];
892
+ const lastPage = sectionPages[sectionPages.length - 1];
893
+ if (!firstPage || !lastPage) return null;
894
+ return {
895
+ sectionIndex,
896
+ startOffset: section.start,
897
+ endOffset: section.end,
898
+ firstPageIndex: firstPage.pageIndex,
899
+ lastPageIndex: lastPage.pageIndex,
900
+ pageCount: sectionPages.length,
901
+ layout: { ...firstPage.layout },
902
+ };
903
+ }
904
+
905
+ function toPublicParagraphFormatting(
906
+ blockId: string,
907
+ formatting: ResolvedParagraphFormatting,
908
+ ): PublicResolvedParagraphFormatting {
909
+ return {
910
+ blockId,
911
+ spacingBefore: formatting.spacingBefore,
912
+ spacingAfter: formatting.spacingAfter,
913
+ lineHeight: formatting.lineHeight,
914
+ lineRule: formatting.lineRule,
915
+ indentLeft: formatting.indentLeft,
916
+ indentRight: formatting.indentRight,
917
+ firstLineIndent: formatting.firstLineIndent,
918
+ hangingIndent: formatting.hangingIndent,
919
+ fontSizeHalfPoints: formatting.fontSizeHalfPoints,
920
+ averageCharWidthTwips: formatting.averageCharWidthTwips,
921
+ tabStops: formatting.tabStops.map((tab) => ({
922
+ positionTwips: tab.position,
923
+ alignment: tab.align,
924
+ ...(tab.leader ? { leader: tab.leader } : {}),
925
+ })),
926
+ keepNext: formatting.keepNext,
927
+ keepLines: formatting.keepLines,
928
+ pageBreakBefore: formatting.pageBreakBefore,
929
+ widowControl: formatting.widowControl,
930
+ contextualSpacing: formatting.contextualSpacing,
931
+ };
932
+ }
933
+
934
+ function toPublicRunFormatting(
935
+ runId: string,
936
+ blockId: string,
937
+ run: ResolvedRunFormatting,
938
+ ): PublicResolvedRunFormatting {
939
+ return {
940
+ runId,
941
+ blockId,
942
+ ...(run.fontFamily ? { fontFamily: run.fontFamily } : {}),
943
+ ...(run.fontSizeHalfPoints !== undefined
944
+ ? { fontSizeHalfPoints: run.fontSizeHalfPoints }
945
+ : {}),
946
+ bold: run.bold,
947
+ italic: run.italic,
948
+ underline: run.underline,
949
+ strikethrough: run.strikethrough,
950
+ ...(run.color ? { color: run.color } : {}),
951
+ ...(run.highlight ? { highlight: run.highlight } : {}),
952
+ verticalAlign: run.verticalAlign,
953
+ };
954
+ }
955
+
956
+ function toFacetEvent(
957
+ event: LayoutEngineEvent,
958
+ lastPageIndex: number,
959
+ ): LayoutFacetEvent | null {
960
+ switch (event.kind) {
961
+ case "layout_recomputed":
962
+ return {
963
+ kind: "layout_recomputed",
964
+ revision: event.revision,
965
+ ...(event.reason ? { reason: event.reason } : {}),
966
+ };
967
+ case "page_count_changed":
968
+ return {
969
+ kind: "page_count_changed",
970
+ previous: event.previousPageCount ?? 0,
971
+ current: event.currentPageCount ?? 0,
972
+ revision: event.revision,
973
+ };
974
+ case "page_field_dirtied":
975
+ return {
976
+ kind: "page_field_dirtied",
977
+ families: event.dirtyFieldFamilies ?? [],
978
+ revision: event.revision,
979
+ };
980
+ case "measurement_backend_ready":
981
+ return {
982
+ kind: "measurement_backend_ready",
983
+ fidelity:
984
+ (event.fidelity as PublicMeasurementFidelity | undefined) ?? "empirical",
985
+ revision: event.revision,
986
+ };
987
+ case "incremental_relayout": {
988
+ const fromPageIndex =
989
+ event.firstDirtyPageIndex !== undefined
990
+ ? Math.max(0, event.firstDirtyPageIndex)
991
+ : 0;
992
+ const toPageIndex = Math.max(fromPageIndex, lastPageIndex);
993
+ return {
994
+ kind: "incremental_relayout",
995
+ revision: event.revision,
996
+ pageRange: { fromPageIndex, toPageIndex },
997
+ ...(event.reason ? { reason: event.reason } : {}),
998
+ };
999
+ }
1000
+ default:
1001
+ return null;
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Resolve the page a given offset + story should surface on.
1007
+ *
1008
+ * Contract (per runtime-rendering-and-chrome-phase.md §2.3):
1009
+ * - Main story: the first content page containing the offset (unchanged).
1010
+ * - Header/footer story: the first page in the relevant section that
1011
+ * renders a header/footer matching the target variant. When the
1012
+ * section-index hint is omitted, the first page rendering any
1013
+ * variant of this kind is returned.
1014
+ * - Footnote/endnote story: the first page that reserves a note
1015
+ * allocation for the given note (by note id); falls back to the
1016
+ * first content page so UI surfaces always have an anchor.
1017
+ */
1018
+ function findPageForOffsetAndStory(
1019
+ graph: RuntimePageGraph,
1020
+ offset: number,
1021
+ story?: EditorStoryTarget,
1022
+ ): RuntimePageNode | null {
1023
+ if (!story || story.kind === "main") {
1024
+ for (const page of graph.pages) {
1025
+ if (!page.isBlankFiller && offset < page.endOffset) {
1026
+ return page;
1027
+ }
1028
+ }
1029
+ const last = graph.pages[graph.pages.length - 1];
1030
+ return last ?? null;
1031
+ }
1032
+
1033
+ if (story.kind === "header" || story.kind === "footer") {
1034
+ const targetKind = story.kind;
1035
+ const targetVariant = story.variant;
1036
+ const targetRelationshipId = story.relationshipId;
1037
+ const sectionIndex = story.sectionIndex;
1038
+ const candidates = graph.pages.filter((page) => {
1039
+ if (page.isBlankFiller) return false;
1040
+ if (sectionIndex !== undefined && page.sectionIndex !== sectionIndex) {
1041
+ return false;
1042
+ }
1043
+ const side =
1044
+ targetKind === "header" ? page.stories.header : page.stories.footer;
1045
+ if (!side) return false;
1046
+ if (side.kind !== "header" && side.kind !== "footer") return false;
1047
+ if (targetVariant !== undefined && side.variant !== targetVariant) {
1048
+ return false;
1049
+ }
1050
+ if (
1051
+ targetRelationshipId !== undefined &&
1052
+ side.relationshipId !== targetRelationshipId
1053
+ ) {
1054
+ return false;
1055
+ }
1056
+ return true;
1057
+ });
1058
+ if (candidates[0]) return candidates[0];
1059
+ // Fallback: any page in the given section (or the first page overall)
1060
+ if (sectionIndex !== undefined) {
1061
+ const fallback = graph.pages.find(
1062
+ (p) => p.sectionIndex === sectionIndex && !p.isBlankFiller,
1063
+ );
1064
+ if (fallback) return fallback;
1065
+ }
1066
+ const first = graph.pages.find((p) => !p.isBlankFiller);
1067
+ return first ?? graph.pages[0] ?? null;
1068
+ }
1069
+
1070
+ if (story.kind === "footnote" || story.kind === "endnote") {
1071
+ const noteKind = story.kind;
1072
+ const noteId = story.noteId;
1073
+ if (noteId !== undefined) {
1074
+ const page = graph.pages.find((p) =>
1075
+ p.noteAllocations.some(
1076
+ (a) => a.noteKind === noteKind && a.noteId === noteId,
1077
+ ),
1078
+ );
1079
+ if (page) return page;
1080
+ }
1081
+ for (const page of graph.pages) {
1082
+ if (!page.isBlankFiller && offset < page.endOffset) {
1083
+ return page;
1084
+ }
1085
+ }
1086
+ const last = graph.pages[graph.pages.length - 1];
1087
+ return last ?? null;
1088
+ }
1089
+
1090
+ return null;
1091
+ }
1092
+
1093
+ /**
1094
+ * Select line boxes that belong to a given region on a page.
1095
+ *
1096
+ * Today the engine populates body line boxes only; header/footer/column/
1097
+ * footnote-area regions produce empty arrays until the render kernel lands
1098
+ * (Phase R1). The region filter is now exact: `body` returns only body
1099
+ * line boxes, everything else returns empty. Consumers should prefer
1100
+ * `getLineBoxes(pageIndex, { region: "body" })` — the other kinds are
1101
+ * scaffolded so UI can start reading through the facet without special-
1102
+ * casing region availability.
1103
+ */
1104
+ function collectLineBoxesForRegion(
1105
+ node: RuntimePageNode,
1106
+ region: PublicPageRegion["kind"],
1107
+ _columnIndex: number | undefined,
1108
+ ): readonly RuntimeLineBoxAlias[] {
1109
+ void _columnIndex;
1110
+ if (region === "body") {
1111
+ return node.lineBoxes;
1112
+ }
1113
+ return EMPTY_LINE_BOXES;
1114
+ }
1115
+
1116
+ // Use a shared alias so the region helper doesn't import the runtime
1117
+ // `RuntimeLineBox` type redundantly (`lineBoxes` is already strongly typed on
1118
+ // `RuntimePageNode`).
1119
+ type RuntimeLineBoxAlias = RuntimePageNode["lineBoxes"][number];
1120
+ const EMPTY_LINE_BOXES: readonly RuntimeLineBoxAlias[] = Object.freeze([]);
1121
+
1122
+ /**
1123
+ * Join the host-supplied workflow rail input with the current page graph.
1124
+ * Returns an empty array when no input is available so callers can always
1125
+ * iterate without null-checking.
1126
+ */
1127
+ function collectScopeRailSegmentsForQuery(
1128
+ input:
1129
+ | Omit<
1130
+ import("../workflow-rail-segments.ts").CollectScopeRailSegmentsInput,
1131
+ "pageGraph"
1132
+ >
1133
+ | null
1134
+ | undefined,
1135
+ graph: RuntimePageGraph,
1136
+ ): ScopeRailSegment[] {
1137
+ if (!input) return [];
1138
+ return collectScopeRailSegments({ ...input, pageGraph: graph });
1139
+ }
1140
+
1141
+ function resolveHitTest(
1142
+ frame: import("../render/index.ts").RenderFrame | null,
1143
+ point: import("../render/index.ts").RenderPoint,
1144
+ ): import("../render/index.ts").RenderHitResult | null {
1145
+ if (!frame) return null;
1146
+ for (const page of frame.pages) {
1147
+ if (!containsPoint(page.frame, point)) continue;
1148
+ const regionHit = hitTestRegion(page, point, "body");
1149
+ if (regionHit) return regionHit;
1150
+ const header = hitTestRegion(page, point, "header");
1151
+ if (header) return header;
1152
+ const footer = hitTestRegion(page, point, "footer");
1153
+ if (footer) return footer;
1154
+ }
1155
+ return null;
1156
+ }
1157
+
1158
+ function hitTestRegion(
1159
+ page: import("../render/index.ts").RenderPage,
1160
+ point: import("../render/index.ts").RenderPoint,
1161
+ kind: "body" | "header" | "footer",
1162
+ ): import("../render/index.ts").RenderHitResult | null {
1163
+ const region =
1164
+ kind === "body"
1165
+ ? page.regions.body
1166
+ : kind === "header"
1167
+ ? page.regions.header
1168
+ : page.regions.footer;
1169
+ if (!region) return null;
1170
+ if (!containsPoint(region.frame, point)) return null;
1171
+ for (const block of region.blocks) {
1172
+ if (!containsPoint(block.frame, point)) continue;
1173
+ let bestLineIndex = -1;
1174
+ let bestLineDistance = Number.POSITIVE_INFINITY;
1175
+ for (let i = 0; i < block.lines.length; i++) {
1176
+ const line = block.lines[i]!;
1177
+ const midY = line.frame.topPx + line.frame.heightPx / 2;
1178
+ const distance = Math.abs(midY - point.yPx);
1179
+ if (distance < bestLineDistance) {
1180
+ bestLineDistance = distance;
1181
+ bestLineIndex = i;
1182
+ }
1183
+ }
1184
+ const lineIndex = bestLineIndex >= 0 ? bestLineIndex : 0;
1185
+ const line = block.lines[lineIndex];
1186
+ const runtimeOffset = line?.anchors[0]?.runtimeOffset ?? block.fragment.from;
1187
+ return {
1188
+ pageIndex: page.page.pageIndex,
1189
+ regionKind: region.region.kind,
1190
+ blockId: block.fragment.blockId,
1191
+ fragmentId: block.fragment.fragmentId,
1192
+ lineIndex,
1193
+ runtimeOffset,
1194
+ };
1195
+ }
1196
+ // Point fell inside the region but between blocks — snap to the nearest block.
1197
+ let nearestBlock: import("../render/index.ts").RenderBlock | null = null;
1198
+ let nearestDistance = Number.POSITIVE_INFINITY;
1199
+ for (const block of region.blocks) {
1200
+ const distance = Math.min(
1201
+ Math.abs(point.yPx - block.frame.topPx),
1202
+ Math.abs(point.yPx - (block.frame.topPx + block.frame.heightPx)),
1203
+ );
1204
+ if (distance < nearestDistance) {
1205
+ nearestBlock = block;
1206
+ nearestDistance = distance;
1207
+ }
1208
+ }
1209
+ if (!nearestBlock) return null;
1210
+ return {
1211
+ pageIndex: page.page.pageIndex,
1212
+ regionKind: region.region.kind,
1213
+ blockId: nearestBlock.fragment.blockId,
1214
+ fragmentId: nearestBlock.fragment.fragmentId,
1215
+ lineIndex: 0,
1216
+ runtimeOffset: nearestBlock.fragment.from,
1217
+ };
1218
+ }
1219
+
1220
+ function containsPoint(
1221
+ rect: import("../render/index.ts").RenderFrameRect,
1222
+ point: import("../render/index.ts").RenderPoint,
1223
+ ): boolean {
1224
+ return (
1225
+ point.xPx >= rect.leftPx &&
1226
+ point.xPx <= rect.leftPx + rect.widthPx &&
1227
+ point.yPx >= rect.topPx &&
1228
+ point.yPx <= rect.topPx + rect.heightPx
1229
+ );
1230
+ }
1231
+
1232
+ function resolveAnchorRects(
1233
+ frame: import("../render/index.ts").RenderFrame | null,
1234
+ query: import("../render/index.ts").RenderAnchorQuery,
1235
+ ): readonly import("../render/index.ts").RenderFrameRect[] {
1236
+ if (!frame) return [];
1237
+ switch (query.kind) {
1238
+ case "runtime-offset": {
1239
+ const offset =
1240
+ typeof query.value === "number" ? query.value : Number(query.value);
1241
+ if (!Number.isFinite(offset)) return [];
1242
+ const rect = frame.anchorIndex.byRuntimeOffset(offset, query.story);
1243
+ return rect ? [rect] : [];
1244
+ }
1245
+ case "block-id": {
1246
+ const rect = frame.anchorIndex.byBlockId(String(query.value));
1247
+ return rect ? [rect] : [];
1248
+ }
1249
+ case "fragment-id": {
1250
+ const rect = frame.anchorIndex.byFragmentId(String(query.value));
1251
+ return rect ? [rect] : [];
1252
+ }
1253
+ case "page-index": {
1254
+ const pageIndex =
1255
+ typeof query.value === "number" ? query.value : Number(query.value);
1256
+ if (!Number.isFinite(pageIndex)) return [];
1257
+ const rect = frame.anchorIndex.byPageIndex(pageIndex);
1258
+ return rect ? [rect] : [];
1259
+ }
1260
+ case "scope-id": {
1261
+ const id = String(query.value);
1262
+ return frame.decorationIndex.workflow
1263
+ .filter((decoration) => decoration.refId === id)
1264
+ .map((decoration) => decoration.frame);
1265
+ }
1266
+ case "comment-id": {
1267
+ const id = String(query.value);
1268
+ return frame.decorationIndex.comments
1269
+ .filter((decoration) => decoration.refId === id)
1270
+ .map((decoration) => decoration.frame);
1271
+ }
1272
+ case "revision-id": {
1273
+ const id = String(query.value);
1274
+ return frame.decorationIndex.revisions
1275
+ .filter((decoration) => decoration.refId === id)
1276
+ .map((decoration) => decoration.frame);
1277
+ }
1278
+ default: {
1279
+ const exhaustive: never = query.kind;
1280
+ void exhaustive;
1281
+ return [];
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ // ---------------------------------------------------------------------------
1287
+ // Table render plan helpers (P4)
1288
+ // ---------------------------------------------------------------------------
1289
+
1290
+ /**
1291
+ * Produce (or reuse) the main surface snapshot the facet needs for table
1292
+ * plan resolution. The engine already rebuilds a surface on every full
1293
+ * relayout, but that output is not cached externally — for a per-call
1294
+ * `getTableRenderPlan` we reconstruct the surface with a static zero
1295
+ * selection. This is a pure read and only runs on chrome requests, so
1296
+ * the cost is bounded.
1297
+ */
1298
+ function lazyMainSurfaceForFacet(
1299
+ input: LayoutEngineQueryInput,
1300
+ ): ReturnType<typeof createEditorSurfaceSnapshot> {
1301
+ return createEditorSurfaceSnapshot(
1302
+ input.document,
1303
+ createSelectionSnapshot(0, 0),
1304
+ MAIN_STORY_TARGET,
1305
+ );
1306
+ }
1307
+
1308
+ /**
1309
+ * Walk a flat surface block list (with recursion into sdt/table nested
1310
+ * children) to find a table block by its projected blockId.
1311
+ */
1312
+ function findTableBlockByBlockId(
1313
+ blocks: readonly SurfaceBlockSnapshot[],
1314
+ blockId: string,
1315
+ ): Extract<SurfaceBlockSnapshot, { kind: "table" }> | null {
1316
+ for (const block of blocks) {
1317
+ if (block.kind === "table") {
1318
+ if (block.blockId === blockId) return block;
1319
+ // Recurse into nested tables (cell contents may contain tables).
1320
+ for (const row of block.rows) {
1321
+ for (const cell of row.cells) {
1322
+ const nested = findTableBlockByBlockId(cell.content, blockId);
1323
+ if (nested) return nested;
1324
+ }
1325
+ }
1326
+ } else if (block.kind === "sdt_block") {
1327
+ const nested = findTableBlockByBlockId(block.children, blockId);
1328
+ if (nested) return nested;
1329
+ }
1330
+ }
1331
+ return null;
1332
+ }
1333
+
1334
+ /**
1335
+ * Resolve the table-style cascade (direct properties → style chain → band
1336
+ * conditional formatting) for a surface table block. Needs the canonical
1337
+ * document to look up the underlying `TableNode` by blockId so the full
1338
+ * resolver runs against canonical state, not the pre-projected surface.
1339
+ */
1340
+ function resolveTableStyleResolutionForPlan(
1341
+ surfaceTable: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
1342
+ document: LayoutEngineQueryInput["document"],
1343
+ ): ReturnType<typeof resolveTableStyleResolution> | null {
1344
+ const canonicalTable = findCanonicalTableByBlockId(
1345
+ document.content.children,
1346
+ surfaceTable.blockId,
1347
+ { counter: { value: 0 } },
1348
+ );
1349
+ if (!canonicalTable) return null;
1350
+ return resolveTableStyleResolution(
1351
+ canonicalTable,
1352
+ document.styles.tables ?? {},
1353
+ );
1354
+ }
1355
+
1356
+ /**
1357
+ * Walk canonical content depth-first incrementing the table counter to
1358
+ * locate the `TableNode` whose projected blockId would be `table-${N}`.
1359
+ * Mirrors the counter discipline in `src/runtime/surface-projection.ts`.
1360
+ */
1361
+ function findCanonicalTableByBlockId(
1362
+ children: import("../../model/canonical-document.ts").BlockNode[],
1363
+ targetBlockId: string,
1364
+ state: { counter: { value: number } },
1365
+ ): import("../../model/canonical-document.ts").TableNode | null {
1366
+ for (const child of children) {
1367
+ if (child.type === "table") {
1368
+ const index = state.counter.value;
1369
+ state.counter.value += 1;
1370
+ if (`table-${index}` === targetBlockId) return child;
1371
+ for (const row of child.rows) {
1372
+ for (const cell of row.cells) {
1373
+ const nested = findCanonicalTableByBlockId(
1374
+ cell.children,
1375
+ targetBlockId,
1376
+ state,
1377
+ );
1378
+ if (nested) return nested;
1379
+ }
1380
+ }
1381
+ } else if (child.type === "sdt") {
1382
+ const nested = findCanonicalTableByBlockId(
1383
+ child.children,
1384
+ targetBlockId,
1385
+ state,
1386
+ );
1387
+ if (nested) return nested;
1388
+ } else if (child.type === "custom_xml") {
1389
+ const nested = findCanonicalTableByBlockId(
1390
+ child.children,
1391
+ targetBlockId,
1392
+ state,
1393
+ );
1394
+ if (nested) return nested;
1395
+ }
1396
+ }
1397
+ return null;
1398
+ }