@beyondwork/docx-react-component 1.0.105 → 1.0.106

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.
@@ -118,6 +118,21 @@ export interface UiController {
118
118
  * error rather than silently no-op.
119
119
  */
120
120
  readonly subscribeViewport?: (listener: UiListener<ViewportState>) => UiUnsubscribe;
121
+ /**
122
+ * PE2 page-residency policy read. L10 owns the observable residency
123
+ * shape; L11 owns realized DOM/PM attachment and L05 owns geometry
124
+ * caches. Omitting the hook makes `ui.viewport.getPageResidency`
125
+ * return an explicit unavailable snapshot.
126
+ */
127
+ readonly getPageResidency?: (pageIndex: number) => PageResidencySnapshot;
128
+ /**
129
+ * Page-residency subscription. Fires when the mounted surface changes
130
+ * whether a page is realized, cold, or evicted.
131
+ */
132
+ readonly subscribePageResidency?: (
133
+ pageIndex: number,
134
+ listener: UiListener<PageResidencySnapshot>,
135
+ ) => UiUnsubscribe;
121
136
  /**
122
137
  * Overlay invalidation subscription. Fires when geometry invalidation
123
138
  * ranges overlap attached overlay queries (U7). `ui.overlays.subscribe`
@@ -234,6 +249,29 @@ export interface ViewportState {
234
249
  readonly devicePixelRatio: number;
235
250
  }
236
251
 
252
+ export type PageResidency = "realized" | "cold" | "evicted";
253
+
254
+ export type RehydrationStatus =
255
+ | "available"
256
+ | "requires-rehydration"
257
+ | "unavailable";
258
+
259
+ export type PageResidencySource =
260
+ | "controller"
261
+ | "unavailable";
262
+
263
+ export interface PageResidencySnapshot {
264
+ readonly __mock?: true;
265
+ /** 0-based page index, matching GeometryFacet.getPage(index). */
266
+ readonly pageIndex: number;
267
+ readonly pageId?: string;
268
+ readonly residency: PageResidency;
269
+ readonly status: RehydrationStatus;
270
+ readonly revision: number;
271
+ readonly source: PageResidencySource;
272
+ readonly reason?: string;
273
+ }
274
+
237
275
  export type ScrollTargetBehavior = "auto" | "smooth" | "instant";
238
276
 
239
277
  export type ScrollTarget =
@@ -452,6 +490,21 @@ export interface ApiV3UiSurface {
452
490
  export interface ApiV3UiViewport {
453
491
  get(): ViewportState;
454
492
  subscribe(listener: UiListener<ViewportState>): UiUnsubscribe;
493
+ /**
494
+ * Read L10's page-residency policy for a 0-based page index. When no
495
+ * mounted controller has supplied a residency policy yet, returns an
496
+ * explicit `unavailable` mock snapshot instead of probing geometry or
497
+ * realizing DOM.
498
+ */
499
+ getPageResidency(pageIndex: number): PageResidencySnapshot;
500
+ /**
501
+ * Subscribe to residency changes for a 0-based page index. The listener
502
+ * receives plain snapshots; realization/teardown stays owned by L11.
503
+ */
504
+ subscribePageResidency(
505
+ pageIndex: number,
506
+ listener: UiListener<PageResidencySnapshot>,
507
+ ): UiUnsubscribe;
455
508
 
456
509
  /**
457
510
  * Scroll the mounted surface to a specific 1-based page number.
@@ -24,6 +24,10 @@ export type {
24
24
  UiScopeListFilter,
25
25
  UiScopeRailOptions,
26
26
  ViewportState,
27
+ PageResidency,
28
+ PageResidencySnapshot,
29
+ PageResidencySource,
30
+ RehydrationStatus,
27
31
  ScrollTarget,
28
32
  ScrollTargetBehavior,
29
33
  ScrollToPageResult,
@@ -24,6 +24,7 @@
24
24
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
25
25
  import type {
26
26
  ViewportState,
27
+ PageResidencySnapshot,
27
28
  UiListener,
28
29
  UiUnsubscribe,
29
30
  WorkflowMarkupMode,
@@ -86,6 +87,52 @@ export const subscribeMetadata: ApiV3FnMetadata = {
86
87
  rwdReference: "§UI API § ui.viewport.subscribe. Adapter delegates to UiController.subscribeViewport; throws when the active binding has no hook. Subscribe call emits one `ux.response.ui.viewport.subscribe` acknowledgement; per-tick ViewportState deliveries flow through the listener (rAF-coalesced, U7).",
87
88
  };
88
89
 
90
+ export const getPageResidencyMetadata: ApiV3FnMetadata = {
91
+ name: "ui.viewport.getPageResidency",
92
+ status: "live-with-adapter",
93
+ sourceLayer: "presentation",
94
+ liveEvidence: {
95
+ runnerTest: "test/api/v3/ui/viewport-residency.test.ts",
96
+ commit: "refactor-10-pe2-residency",
97
+ },
98
+ mockShape: {
99
+ deterministic: true,
100
+ seededFrom: "fixed",
101
+ shapeDescription: "Mock PageResidencySnapshot with residency='evicted' and status='unavailable' when no mounted controller exposes L10 residency policy.",
102
+ carriesMockFlag: true,
103
+ },
104
+ uxIntent: { uiVisible: false },
105
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "ui-viewport-read" },
106
+ stateClass: "C-local",
107
+ persistsTo: "none",
108
+ rwdReference: "§PE2 § Virtual Page Windowing. Reads L10 page residency policy (realized/cold/evicted) without probing L05 geometry or realizing L11 DOM.",
109
+ };
110
+
111
+ export const subscribePageResidencyMetadata: ApiV3FnMetadata = {
112
+ name: "ui.viewport.subscribePageResidency",
113
+ status: "live-with-adapter",
114
+ sourceLayer: "presentation",
115
+ liveEvidence: {
116
+ runnerTest: "test/api/v3/ui/viewport-residency.test.ts",
117
+ commit: "refactor-10-pe2-residency",
118
+ },
119
+ uxIntent: {
120
+ uiVisible: true,
121
+ expectsUxResponse: "surface-refresh",
122
+ expectedDelta: "page-residency subscriber attached; future realized/cold/evicted changes propagate through the listener",
123
+ },
124
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "ui-viewport-subscribe" },
125
+ stateClass: "C-local",
126
+ persistsTo: "none",
127
+ bidirectional: true,
128
+ subscriptionShape: {
129
+ eventType: "ui.viewport.page_residency_changed",
130
+ payloadType: "PageResidencySnapshot",
131
+ coalescing: "raf",
132
+ },
133
+ rwdReference: "§PE2 § Virtual Page Windowing. Adapter delegates to UiController.subscribePageResidency; emissions are plain snapshots and do not dispatch PM transactions or force geometry rehydration.",
134
+ };
135
+
89
136
  // ----- scrollToPage (coord-10 §γ — visual-fidelity / Go-to-page UX) -----
90
137
 
91
138
  export const scrollToPageMetadata: ApiV3FnMetadata = {
@@ -245,6 +292,23 @@ export function createViewportFamily(ctx: UiApiContext) {
245
292
  notifyMarkupModeSubscribers();
246
293
  });
247
294
 
295
+ function normalizePageIndex(pageIndex: number): number {
296
+ if (!Number.isFinite(pageIndex)) return 0;
297
+ return Math.max(0, Math.floor(pageIndex));
298
+ }
299
+
300
+ function unavailableResidency(pageIndex: number): PageResidencySnapshot {
301
+ return Object.freeze({
302
+ __mock: true,
303
+ pageIndex: normalizePageIndex(pageIndex),
304
+ residency: "evicted",
305
+ status: "unavailable",
306
+ revision: 0,
307
+ source: "unavailable",
308
+ reason: "page-residency policy is not wired on the active controller",
309
+ } as const);
310
+ }
311
+
248
312
  return {
249
313
  // Slice 11 — composes scroll/dpr/zoom from `handle.geometry` with
250
314
  // width/height from the bound controller (see
@@ -279,6 +343,39 @@ export function createViewportFamily(ctx: UiApiContext) {
279
343
  });
280
344
  return unsubscribe;
281
345
  },
346
+ getPageResidency(pageIndex: number): PageResidencySnapshot {
347
+ const normalized = normalizePageIndex(pageIndex);
348
+ const resolver = ctx.binding?.controller.getPageResidency;
349
+ if (!resolver) return unavailableResidency(normalized);
350
+ return resolver(normalized);
351
+ },
352
+ subscribePageResidency(
353
+ pageIndex: number,
354
+ listener: UiListener<PageResidencySnapshot>,
355
+ ): UiUnsubscribe {
356
+ const controller = ctx.binding?.controller;
357
+ if (!controller) {
358
+ throw new Error(
359
+ "ui.viewport.subscribePageResidency: no controller bound — call ui.session.bind(controller) first",
360
+ );
361
+ }
362
+ if (!controller.subscribePageResidency) {
363
+ throw new Error(
364
+ `ui.viewport.subscribePageResidency: controller of kind "${controller.kind}" did not provide a subscribePageResidency hook`,
365
+ );
366
+ }
367
+ const normalized = normalizePageIndex(pageIndex);
368
+ const unsubscribe = controller.subscribePageResidency(normalized, listener);
369
+ emitUxResponse(ctx.handle, {
370
+ apiFn: subscribePageResidencyMetadata.name,
371
+ intent: subscribePageResidencyMetadata.uxIntent.expectedDelta ?? "",
372
+ mockOrLive: "live-with-adapter",
373
+ uiVisible: true,
374
+ expectedDelta: subscribePageResidencyMetadata.uxIntent.expectedDelta,
375
+ actualDelta: { kind: "surface-refresh", payload: { subscribed: "ui.viewport.pageResidency", pageIndex: normalized } },
376
+ });
377
+ return unsubscribe;
378
+ },
282
379
 
283
380
  // ----- scrollToPage (coord-10 §γ) -----
284
381
 
@@ -202,6 +202,27 @@ export interface RuntimeTableVerticalMergeCarry {
202
202
  restartRowIndex: number;
203
203
  }
204
204
 
205
+ export type RuntimeFragmentLayoutObjectKind =
206
+ | "paragraph"
207
+ | "numbered-paragraph"
208
+ | "table"
209
+ | "field-region"
210
+ | "footnote-body"
211
+ | "sdt-block"
212
+ | "opaque-block";
213
+
214
+ export interface RuntimeFragmentLayoutObject {
215
+ objectId: string;
216
+ kind: RuntimeFragmentLayoutObjectKind;
217
+ sourceBlockId: string;
218
+ paginationRole: "whole" | "slice" | "continuation";
219
+ measuredExtentTwips: {
220
+ heightTwips: number;
221
+ widthTwips?: number;
222
+ };
223
+ fieldFamilies?: readonly string[];
224
+ }
225
+
205
226
  // ---------------------------------------------------------------------------
206
227
  // Fragment types
207
228
  // ---------------------------------------------------------------------------
@@ -265,6 +286,14 @@ export interface RuntimeBlockFragment {
265
286
  * consumers without reading DOM or canonical tables again.
266
287
  */
267
288
  continuation?: RuntimeLayoutContinuationCursor;
289
+ /**
290
+ * PE2 Slice 3 — measured layout-object descriptor for this fragment.
291
+ * This is the internal object protocol layer: one stable row per
292
+ * page-local fragment, expressed only in ids/enums/twips. It lets debug,
293
+ * render, and future truth joins reason about paragraph/table/note
294
+ * placement without re-reading DOM or source model objects.
295
+ */
296
+ layoutObject?: RuntimeFragmentLayoutObject;
268
297
  /**
269
298
  * Slice 5 — opaque style-chain ref derived from the block's `styleId`.
270
299
  * Used by `analyzeStylesChange` to bound invalidation to the first page
@@ -3271,14 +3271,6 @@ export function createDocumentRuntime(
3271
3271
  const timestamp = clock();
3272
3272
 
3273
3273
  if (suggesting) {
3274
- if (activeStory.kind !== "main") {
3275
- this.emitBlockedCommand(commandName, [{
3276
- code: "suggesting_unsupported",
3277
- message: `"${commandName}" is not supported in suggesting mode for this story.`,
3278
- }]);
3279
- return;
3280
- }
3281
-
3282
3274
  if (
3283
3275
  operation.type === "set-alignment" ||
3284
3276
  operation.type === "indent" ||
@@ -3313,9 +3305,14 @@ export function createDocumentRuntime(
3313
3305
  },
3314
3306
  timestamp,
3315
3307
  );
3308
+ const nextFullDocument = stitchActiveStoryDocument(
3309
+ persistedDocument,
3310
+ activeStory,
3311
+ nextDocument,
3312
+ );
3316
3313
  this.dispatch({
3317
3314
  type: "document.replace",
3318
- document: { ...nextDocument, updatedAt: timestamp },
3315
+ document: { ...nextFullDocument, updatedAt: timestamp },
3319
3316
  mapping: createEmptyMapping(),
3320
3317
  selection: toInternalSelectionSnapshot(result.selection),
3321
3318
  origin: createOrigin("api", timestamp),
@@ -3353,9 +3350,14 @@ export function createDocumentRuntime(
3353
3350
  },
3354
3351
  timestamp,
3355
3352
  );
3353
+ const nextFullDocument = stitchActiveStoryDocument(
3354
+ persistedDocument,
3355
+ activeStory,
3356
+ nextDocument,
3357
+ );
3356
3358
  this.dispatch({
3357
3359
  type: "document.replace",
3358
- document: { ...nextDocument, updatedAt: timestamp },
3360
+ document: { ...nextFullDocument, updatedAt: timestamp },
3359
3361
  mapping: createEmptyMapping(),
3360
3362
  selection: toInternalSelectionSnapshot(result.selection),
3361
3363
  origin: createOrigin("api", timestamp),
@@ -4187,13 +4189,6 @@ export function createDocumentRuntime(
4187
4189
  const suggesting =
4188
4190
  workflowCoordinator.getEffectiveDocumentMode(state.selection) === "suggesting";
4189
4191
  if (suggesting) {
4190
- if (activeStory.kind !== "main") {
4191
- this.emitBlockedCommand("clearHighlight", [{
4192
- code: "suggesting_unsupported",
4193
- message: `"clearHighlight" is not supported in suggesting mode for this story.`,
4194
- }]);
4195
- return;
4196
- }
4197
4192
  const segment = findSingleSelectedTextSegment(syntheticSnapshot);
4198
4193
  if (!segment) {
4199
4194
  this.emitBlockedCommand("clearHighlight", [{
@@ -4225,9 +4220,14 @@ export function createDocumentRuntime(
4225
4220
  },
4226
4221
  timestamp,
4227
4222
  );
4223
+ const nextFullDocument = stitchActiveStoryDocument(
4224
+ state.document,
4225
+ activeStory,
4226
+ nextDocument,
4227
+ );
4228
4228
  this.dispatch({
4229
4229
  type: "document.replace",
4230
- document: nextDocument,
4230
+ document: { ...nextFullDocument, updatedAt: timestamp },
4231
4231
  mapping: createEmptyMapping(),
4232
4232
  origin: createOrigin("api", timestamp),
4233
4233
  });
@@ -9223,6 +9223,27 @@ function appendPropertyChangeSuggestion(
9223
9223
  };
9224
9224
  }
9225
9225
 
9226
+ function stitchActiveStoryDocument(
9227
+ persistedDocument: CanonicalDocumentEnvelope,
9228
+ activeStory: EditorStoryTarget,
9229
+ storyDocument: CanonicalDocumentEnvelope,
9230
+ ): CanonicalDocumentEnvelope {
9231
+ if (activeStory.kind === "main") {
9232
+ return storyDocument;
9233
+ }
9234
+
9235
+ return replaceStoryBlocks(
9236
+ {
9237
+ ...persistedDocument,
9238
+ review: storyDocument.review,
9239
+ diagnostics: storyDocument.diagnostics,
9240
+ preservation: storyDocument.preservation,
9241
+ },
9242
+ activeStory,
9243
+ storyDocument.content.children,
9244
+ );
9245
+ }
9246
+
9226
9247
  function createRuntimeSuggestionChangeId(
9227
9248
  existing: CanonicalDocumentEnvelope["review"]["revisions"],
9228
9249
  timestamp: string,
@@ -259,6 +259,11 @@ export function projectGeometryIndexFromFrame(
259
259
  regionIds,
260
260
  });
261
261
  recordPrecision(precision, "exact");
262
+ appendPageLocalObjectHandleEntries({
263
+ page,
264
+ entries: objectHandleEntries,
265
+ precision,
266
+ });
262
267
  }
263
268
 
264
269
  appendScopeReplacementEnvelopeEntries({
@@ -762,6 +767,75 @@ function appendCanonicalObjectHandleEntries(input: {
762
767
  }
763
768
  }
764
769
 
770
+ function appendPageLocalObjectHandleEntries(input: {
771
+ page: RenderPage;
772
+ entries: Map<string, MutableObjectHandleEntry>;
773
+ precision: GeometryPrecisionCounts;
774
+ }): void {
775
+ const { page, entries, precision } = input;
776
+ const stories = page.page.frame?.pageLocalStories ?? [];
777
+ for (const story of stories) {
778
+ const regionFrame =
779
+ story.kind === "header" ? page.regions.header?.frame : page.regions.footer?.frame;
780
+ if (!regionFrame) continue;
781
+ for (const object of story.anchoredObjects) {
782
+ const objectFrame = pageLocalObjectFrame(
783
+ regionFrame,
784
+ object.extentTwips,
785
+ page.page.layout.pageWidth > 0
786
+ ? page.frame.widthPx / page.page.layout.pageWidth
787
+ : 1,
788
+ );
789
+ const handleRects = buildObjectHandleRectsFromRect(objectFrame, "heuristic");
790
+ const sourceIdentity: GeometrySourceIdentity = {
791
+ storyKey: story.storyKey,
792
+ objectKey: object.objectId,
793
+ objectKind: object.sourceType,
794
+ editPosture: object.preserveOnly ? "preserve-only" : "editable",
795
+ joinKind: "block-scoped",
796
+ };
797
+ const existing = entries.get(object.objectId);
798
+ if (existing) {
799
+ appendUnique(existing.pageIds, page.page.pageId);
800
+ existing.rects.push(...handleRects);
801
+ if (existing.precision !== "heuristic") {
802
+ existing.precision = "heuristic";
803
+ existing.status = "requires-rehydration";
804
+ existing.sourceIdentity = sourceIdentity;
805
+ }
806
+ continue;
807
+ }
808
+ entries.set(object.objectId, {
809
+ objectId: object.objectId,
810
+ pageIds: [page.page.pageId],
811
+ rects: [...handleRects],
812
+ status: "requires-rehydration",
813
+ precision: "heuristic",
814
+ sourceIdentity,
815
+ });
816
+ recordPrecision(precision, "heuristic");
817
+ }
818
+ }
819
+ }
820
+
821
+ function pageLocalObjectFrame(
822
+ regionFrame: RenderFrameRect,
823
+ extentTwips:
824
+ | { readonly widthTwips: number; readonly heightTwips: number }
825
+ | undefined,
826
+ pxPerTwip: number,
827
+ ): RenderFrameRect {
828
+ if (!extentTwips) return regionFrame;
829
+ const widthPx = Math.max(0, extentTwips.widthTwips * pxPerTwip);
830
+ const heightPx = Math.max(0, extentTwips.heightTwips * pxPerTwip);
831
+ return {
832
+ leftPx: regionFrame.leftPx,
833
+ topPx: regionFrame.topPx,
834
+ widthPx: widthPx > 0 ? Math.min(widthPx, regionFrame.widthPx) : regionFrame.widthPx,
835
+ heightPx: heightPx > 0 ? Math.min(heightPx, regionFrame.heightPx) : regionFrame.heightPx,
836
+ };
837
+ }
838
+
765
839
  function finalizeObjectHandleEntries(
766
840
  entries: ReadonlyMap<string, MutableObjectHandleEntry>,
767
841
  ): GeometryObjectHandleEntry[] {
@@ -1074,8 +1074,15 @@
1074
1074
  * table header rows, and vertical-merge carry metadata. Cache envelopes
1075
1075
  * from v67 invalidate because fragment payloads can now include
1076
1076
  * continuation state. Shipped via pe2 commit `33fbf45ac`.
1077
+ *
1078
+ * 69 — PE2 Slice 3 measured layout-object descriptors (Layer 04). Body
1079
+ * fragments and footnote-area fragments now carry a twips/plain
1080
+ * `layoutObject` row with stable object id, source block id, object kind,
1081
+ * pagination role, measured extent, and field-family hints where applicable.
1082
+ * Cache envelopes from v68 invalidate because fragment payloads can now
1083
+ * include layout-object descriptors. Shipped via pe2 commit `9c4417418`.
1077
1084
  */
1078
- export const LAYOUT_ENGINE_VERSION = 68 as const;
1085
+ export const LAYOUT_ENGINE_VERSION = 69 as const;
1079
1086
 
1080
1087
  /**
1081
1088
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -72,6 +72,8 @@ export type {
72
72
  RuntimeParagraphContinuationCursor,
73
73
  RuntimeTableContinuationCursor,
74
74
  RuntimeTableVerticalMergeCarry,
75
+ RuntimeFragmentLayoutObjectKind,
76
+ RuntimeFragmentLayoutObject,
75
77
  RuntimeBlockFragment,
76
78
  RuntimeLineBox,
77
79
  RuntimeNoteAllocation,
@@ -1791,6 +1791,16 @@ export function paginateSectionBlocksWithSplits(
1791
1791
  to: refRange.blockTo,
1792
1792
  heightTwips: heightTwips + FOOTNOTE_REFERENCE_RESERVATION_TWIPS,
1793
1793
  kind: "whole",
1794
+ layoutObject: {
1795
+ objectId: `footnote-body:${fragmentId}`,
1796
+ kind: "footnote-body",
1797
+ sourceBlockId: `note-body-${noteKind}-${noteId}`,
1798
+ paginationRole: "whole",
1799
+ measuredExtentTwips: {
1800
+ heightTwips: heightTwips + FOOTNOTE_REFERENCE_RESERVATION_TWIPS,
1801
+ widthTwips: effectiveColumnWidth,
1802
+ },
1803
+ },
1794
1804
  };
1795
1805
 
1796
1806
  allocations.push(allocation);
@@ -126,6 +126,14 @@ export function projectSurfaceBlocksToPageFragments(
126
126
  if (pageIndex === null) continue;
127
127
 
128
128
  const columnIndex = columnIndexFor(pageIndex, block.blockId);
129
+ const heightTwips = measuredHeightFor(
130
+ pageIndex,
131
+ block.blockId,
132
+ estimateBlockHeightFromSpan(block),
133
+ );
134
+ const widthTwips = fragmentMeasurementsByPageIndex
135
+ ?.get(pageIndex)
136
+ ?.get(block.blockId)?.widthTwips;
129
137
  const fragment: FragmentWithoutPageId = {
130
138
  fragmentId: `fragment-${block.blockId}`,
131
139
  blockId: block.blockId,
@@ -133,13 +141,16 @@ export function projectSurfaceBlocksToPageFragments(
133
141
  regionKind: "body",
134
142
  from: block.from,
135
143
  to: block.to,
136
- heightTwips: measuredHeightFor(
137
- pageIndex,
138
- block.blockId,
139
- estimateBlockHeightFromSpan(block),
140
- ),
144
+ heightTwips,
141
145
  ...deriveStyleMetadata(block),
142
146
  kind: "whole",
147
+ layoutObject: buildFragmentLayoutObject({
148
+ block,
149
+ fragmentId: `fragment-${block.blockId}`,
150
+ heightTwips,
151
+ widthTwips,
152
+ paginationRole: "whole",
153
+ }),
143
154
  ...(columnIndex !== undefined ? { columnIndex } : {}),
144
155
  };
145
156
 
@@ -224,6 +235,7 @@ function emitSlicedParagraph(
224
235
  ): void {
225
236
  for (let i = 0; i < slices.length; i += 1) {
226
237
  const slice = slices[i]!;
238
+ const heightTwips = slice.heightTwips ?? estimateSliceHeightFromLines(slice.lineRange);
227
239
  const fragment: FragmentWithoutPageId = {
228
240
  fragmentId: `fragment-${block.blockId}-slice-${i}`,
229
241
  blockId: block.blockId,
@@ -231,11 +243,18 @@ function emitSlicedParagraph(
231
243
  regionKind: "body",
232
244
  from: block.from,
233
245
  to: block.to,
234
- heightTwips: slice.heightTwips ?? estimateSliceHeightFromLines(slice.lineRange),
246
+ heightTwips,
235
247
  ...deriveStyleMetadata(block),
236
248
  kind: "paragraph-slice",
237
249
  paragraphLineRange: slice.lineRange,
238
250
  continuation: buildParagraphContinuationCursor(slice, i, slices.length),
251
+ layoutObject: buildFragmentLayoutObject({
252
+ block,
253
+ fragmentId: `fragment-${block.blockId}-slice-${i}`,
254
+ heightTwips,
255
+ widthTwips: slice.widthTwips,
256
+ paginationRole: slice.lineRange.from > 0 ? "continuation" : "slice",
257
+ }),
239
258
  };
240
259
  emit(slice.pageIndex, fragment);
241
260
  }
@@ -281,6 +300,7 @@ function emitSlicedTable(
281
300
  ): void {
282
301
  for (let i = 0; i < slices.length; i += 1) {
283
302
  const slice = slices[i]!;
303
+ const heightTwips = slice.heightTwips ?? estimateSliceHeightFromRows(slice.rowRange);
284
304
  const fragment: FragmentWithoutPageId = {
285
305
  fragmentId: `fragment-${block.blockId}-rowslice-${i}`,
286
306
  blockId: block.blockId,
@@ -288,11 +308,17 @@ function emitSlicedTable(
288
308
  regionKind: "body",
289
309
  from: block.from,
290
310
  to: block.to,
291
- heightTwips: slice.heightTwips ?? estimateSliceHeightFromRows(slice.rowRange),
311
+ heightTwips,
292
312
  ...deriveStyleMetadata(block),
293
313
  kind: "table-slice",
294
314
  tableRowRange: slice.rowRange,
295
315
  continuation: buildTableContinuationCursor(block, slice, i, slices.length),
316
+ layoutObject: buildFragmentLayoutObject({
317
+ block,
318
+ fragmentId: `fragment-${block.blockId}-rowslice-${i}`,
319
+ heightTwips,
320
+ paginationRole: slice.rowRange.from > 0 ? "continuation" : "slice",
321
+ }),
296
322
  ...(slice.columnIndex !== undefined ? { columnIndex: slice.columnIndex } : {}),
297
323
  };
298
324
  emit(slice.pageIndex, fragment);
@@ -391,6 +417,59 @@ function estimateSliceHeightFromRows(rowRange: {
391
417
  return rows * 360; // ~1 line + padding per row, approximate
392
418
  }
393
419
 
420
+ function buildFragmentLayoutObject(input: {
421
+ block: SurfaceBlockSnapshot;
422
+ fragmentId: string;
423
+ heightTwips: number;
424
+ widthTwips?: number;
425
+ paginationRole: NonNullable<RuntimeBlockFragment["layoutObject"]>["paginationRole"];
426
+ }): NonNullable<RuntimeBlockFragment["layoutObject"]> {
427
+ const fieldFamilies = collectFieldFamilies(input.block);
428
+ const kind = resolveFragmentLayoutObjectKind(input.block, fieldFamilies);
429
+ return {
430
+ objectId: `${kind}:${input.fragmentId}`,
431
+ kind,
432
+ sourceBlockId: input.block.blockId,
433
+ paginationRole: input.paginationRole,
434
+ measuredExtentTwips: {
435
+ heightTwips: Math.max(0, input.heightTwips),
436
+ ...(input.widthTwips !== undefined
437
+ ? { widthTwips: Math.max(0, input.widthTwips) }
438
+ : {}),
439
+ },
440
+ ...(fieldFamilies.length > 0 ? { fieldFamilies } : {}),
441
+ };
442
+ }
443
+
444
+ function resolveFragmentLayoutObjectKind(
445
+ block: SurfaceBlockSnapshot,
446
+ fieldFamilies: readonly string[],
447
+ ): NonNullable<RuntimeBlockFragment["layoutObject"]>["kind"] {
448
+ switch (block.kind) {
449
+ case "paragraph":
450
+ if (fieldFamilies.length > 0) return "field-region";
451
+ return block.numbering ? "numbered-paragraph" : "paragraph";
452
+ case "table":
453
+ return "table";
454
+ case "sdt_block":
455
+ return "sdt-block";
456
+ case "opaque_block":
457
+ return "opaque-block";
458
+ }
459
+ }
460
+
461
+ function collectFieldFamilies(block: SurfaceBlockSnapshot): string[] {
462
+ if (block.kind !== "paragraph") return [];
463
+ const families: string[] = [];
464
+ const seen = new Set<string>();
465
+ for (const segment of block.segments) {
466
+ if (segment.kind !== "field_ref" || seen.has(segment.fieldFamily)) continue;
467
+ seen.add(segment.fieldFamily);
468
+ families.push(segment.fieldFamily);
469
+ }
470
+ return families;
471
+ }
472
+
394
473
  function findPageIndexForOffset(
395
474
  pages: readonly DocumentPageSnapshot[],
396
475
  offset: number,