@beyondwork/docx-react-component 1.0.36 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) 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 +83 -0
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +173 -11
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/surface-projection.ts +1 -0
  44. package/src/runtime/text-ack-range.ts +49 -0
  45. package/src/ui/WordReviewEditor.tsx +15 -0
  46. package/src/ui/editor-runtime-boundary.ts +10 -1
  47. package/src/ui/editor-surface-controller.tsx +3 -0
  48. package/src/ui/headless/chrome-registry.ts +235 -0
  49. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  50. package/src/ui/headless/selection-tool-context.ts +2 -0
  51. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  52. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  53. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  54. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  55. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  56. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  57. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  58. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  61. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  62. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  63. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  64. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -0,0 +1,705 @@
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
+
47
+ // ---------------------------------------------------------------------------
48
+ // Public read model types (shape-stable, cloned at the facet boundary)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface PublicPageNode {
52
+ pageId: string;
53
+ pageIndex: number;
54
+ sectionIndex: number;
55
+ pageInSection: number;
56
+ startOffset: number;
57
+ endOffset: number;
58
+ /** Whether this page is a blank filler (e.g. from evenPage/oddPage). */
59
+ isBlankFiller: boolean;
60
+ /** Resolved display page number (1-based, honors section restarts). */
61
+ displayPageNumber: number;
62
+ /** Whether this is treated as the first page of its section (title page). */
63
+ isFirstPage: boolean;
64
+ /** Whether the displayed page number is even. */
65
+ isEvenPage: boolean;
66
+ /** Section-derived page layout geometry. */
67
+ layout: PageLayoutSnapshot;
68
+ /** Resolved header/footer/note stories active on this page. */
69
+ stories: PublicResolvedPageStories;
70
+ /** Sub-regions rendered on the page. */
71
+ regions: PublicPageRegions;
72
+ /** Number of line boxes rendered in the body region. */
73
+ lineBoxCount: number;
74
+ /** Footnotes reserved at the bottom of the page, if any. */
75
+ noteAllocations: readonly PublicNoteAllocation[];
76
+ }
77
+
78
+ export interface PublicResolvedPageStories {
79
+ header?: EditorStoryTarget;
80
+ footer?: EditorStoryTarget;
81
+ isFirstPage: boolean;
82
+ isEvenPage: boolean;
83
+ displayPageNumber: number;
84
+ }
85
+
86
+ export interface PublicPageRegions {
87
+ body: PublicPageRegion;
88
+ header?: PublicPageRegion;
89
+ footer?: PublicPageRegion;
90
+ columns?: readonly PublicPageRegion[];
91
+ }
92
+
93
+ export interface PublicPageRegion {
94
+ kind: "body" | "header" | "footer" | "column" | "footnote-area";
95
+ originTwips: number;
96
+ widthTwips: number;
97
+ heightTwips: number;
98
+ fragmentCount: number;
99
+ }
100
+
101
+ export interface PublicBlockFragment {
102
+ fragmentId: string;
103
+ blockId: string;
104
+ pageId: string;
105
+ pageIndex: number;
106
+ regionKind: PublicPageRegion["kind"];
107
+ from: number;
108
+ to: number;
109
+ heightTwips: number;
110
+ orderInRegion: number;
111
+ }
112
+
113
+ export interface PublicLineBox {
114
+ fragmentId: string;
115
+ lineIndex: number;
116
+ baselineTwips: number;
117
+ heightTwips: number;
118
+ widthTwips: number;
119
+ }
120
+
121
+ export interface PublicNoteAllocation {
122
+ noteKind: "footnote" | "endnote";
123
+ noteId: string;
124
+ reservedHeightTwips: number;
125
+ }
126
+
127
+ export interface PublicPageAnchor {
128
+ offset: number;
129
+ pageId: string;
130
+ pageIndex: number;
131
+ fragmentId?: string;
132
+ regionKind?: PublicPageRegion["kind"];
133
+ }
134
+
135
+ export interface PublicPageSpan {
136
+ firstPageIndex: number;
137
+ lastPageIndex: number;
138
+ pageCount: number;
139
+ }
140
+
141
+ export interface PublicSectionNode {
142
+ sectionIndex: number;
143
+ startOffset: number;
144
+ endOffset: number;
145
+ firstPageIndex: number;
146
+ lastPageIndex: number;
147
+ pageCount: number;
148
+ layout: PageLayoutSnapshot;
149
+ }
150
+
151
+ export interface PublicResolvedParagraphFormatting {
152
+ blockId: string;
153
+ spacingBefore: number;
154
+ spacingAfter: number;
155
+ lineHeight: number;
156
+ lineRule: "auto" | "exact" | "atLeast";
157
+ indentLeft: number;
158
+ indentRight: number;
159
+ firstLineIndent: number;
160
+ hangingIndent: number;
161
+ fontSizeHalfPoints: number;
162
+ averageCharWidthTwips: number;
163
+ tabStops: readonly {
164
+ positionTwips: number;
165
+ alignment: string;
166
+ leader?: string;
167
+ }[];
168
+ keepNext: boolean;
169
+ keepLines: boolean;
170
+ pageBreakBefore: boolean;
171
+ widowControl: boolean;
172
+ contextualSpacing: boolean;
173
+ }
174
+
175
+ export interface PublicResolvedRunFormatting {
176
+ runId: string;
177
+ blockId: string;
178
+ fontFamily?: string;
179
+ fontSizeHalfPoints?: number;
180
+ bold: boolean;
181
+ italic: boolean;
182
+ underline: boolean;
183
+ strikethrough: boolean;
184
+ color?: string;
185
+ highlight?: string;
186
+ verticalAlign: "baseline" | "superscript" | "subscript";
187
+ }
188
+
189
+ export interface PublicBlockMeasurement {
190
+ blockId: string;
191
+ lineCount: number;
192
+ /** Total height the block occupies in twips. */
193
+ heightTwips: number;
194
+ }
195
+
196
+ export type PublicMeasurementFidelity =
197
+ | "empirical"
198
+ | "canvas"
199
+ | "canvas-with-font-loading";
200
+
201
+ export interface PublicFieldDirtinessReport {
202
+ families: readonly string[];
203
+ revision: number;
204
+ }
205
+
206
+ export type LayoutFacetEvent =
207
+ | {
208
+ kind: "layout_recomputed";
209
+ revision: number;
210
+ reason?: LayoutFacetInvalidationReason;
211
+ }
212
+ | {
213
+ kind: "page_count_changed";
214
+ previous: number;
215
+ current: number;
216
+ revision: number;
217
+ }
218
+ | {
219
+ kind: "page_field_dirtied";
220
+ families: readonly string[];
221
+ revision: number;
222
+ }
223
+ | {
224
+ kind: "measurement_backend_ready";
225
+ fidelity: PublicMeasurementFidelity;
226
+ revision: number;
227
+ };
228
+
229
+ export type LayoutFacetInvalidationReason =
230
+ | { kind: "content-edit"; from: number; to: number }
231
+ | { kind: "section-change"; sectionIndex: number }
232
+ | { kind: "styles-change" }
233
+ | { kind: "theme-change" }
234
+ | { kind: "numbering-change"; numberingInstanceId?: string }
235
+ | { kind: "field-refresh"; family?: string }
236
+ | { kind: "full" };
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Facet interface
240
+ // ---------------------------------------------------------------------------
241
+
242
+ export interface WordReviewEditorLayoutFacet {
243
+ // Structure ------------------------------------------------------------
244
+ getPageCount(): number;
245
+ getPage(pageIndex: number): PublicPageNode | null;
246
+ getPages(options?: { sectionIndex?: number }): PublicPageNode[];
247
+ getSection(sectionIndex: number): PublicSectionNode | null;
248
+ getSections(): PublicSectionNode[];
249
+
250
+ // Offset / selection navigation ---------------------------------------
251
+ getPageForOffset(
252
+ offset: number,
253
+ story?: EditorStoryTarget,
254
+ ): PublicPageNode | null;
255
+ getPageSpanForSelection(selection: SelectionSnapshot): PublicPageSpan | null;
256
+ getFragmentForOffset(
257
+ offset: number,
258
+ story?: EditorStoryTarget,
259
+ ): PublicBlockFragment | null;
260
+ getAnchorForOffset(
261
+ offset: number,
262
+ story?: EditorStoryTarget,
263
+ ): PublicPageAnchor | null;
264
+
265
+ // Per-page semantic reads ---------------------------------------------
266
+ getActiveStoriesOnPage(pageIndex: number): PublicResolvedPageStories | null;
267
+ getDisplayPageNumber(pageIndex: number): number | null;
268
+ getLineBoxes(
269
+ pageIndex: number,
270
+ options?: { region?: "body" | "header" | "footer" },
271
+ ): PublicLineBox[];
272
+ getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
273
+
274
+ // Measurement exposure -------------------------------------------------
275
+ getResolvedFormatting(blockId: string): PublicResolvedParagraphFormatting | null;
276
+ getResolvedRunFormatting(runId: string): PublicResolvedRunFormatting | null;
277
+ getMeasurement(blockId: string): PublicBlockMeasurement | null;
278
+ getMeasurementFidelity(): PublicMeasurementFidelity;
279
+ whenMeasurementReady(): Promise<void>;
280
+
281
+ // Fields ---------------------------------------------------------------
282
+ getDirtyFieldFamilies(): readonly string[];
283
+ getFieldDirtinessReport(): PublicFieldDirtinessReport;
284
+
285
+ // Events ---------------------------------------------------------------
286
+ subscribe(listener: (event: LayoutFacetEvent) => void): () => void;
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Factory
291
+ // ---------------------------------------------------------------------------
292
+
293
+ export interface CreateLayoutFacetInput {
294
+ engine: LayoutEngineInstance;
295
+ getQueryInput: () => LayoutEngineQueryInput;
296
+ }
297
+
298
+ export function createLayoutFacet(
299
+ input: CreateLayoutFacetInput,
300
+ ): WordReviewEditorLayoutFacet {
301
+ const { engine, getQueryInput } = input;
302
+
303
+ function currentGraph(): RuntimePageGraph {
304
+ return engine.getPageGraph(getQueryInput());
305
+ }
306
+ function currentMapper(): PageFragmentMapper {
307
+ return engine.getFragmentMapper(getQueryInput());
308
+ }
309
+ function currentFormatting(): ResolvedFormattingState {
310
+ return engine.getResolvedFormattingState(getQueryInput());
311
+ }
312
+
313
+ const listeners = new Set<(event: LayoutFacetEvent) => void>();
314
+ const unsubscribeEngine = engine.subscribe((event: LayoutEngineEvent) => {
315
+ const facetEvent = toFacetEvent(event);
316
+ if (!facetEvent) return;
317
+ for (const listener of listeners) {
318
+ try {
319
+ listener(facetEvent);
320
+ } catch {
321
+ // never let listener errors break the engine
322
+ }
323
+ }
324
+ });
325
+ // Keep the handle alive; the facet instance lives as long as the runtime.
326
+ void unsubscribeEngine;
327
+
328
+ return {
329
+ getPageCount() {
330
+ return currentGraph().pages.length;
331
+ },
332
+
333
+ getPage(pageIndex) {
334
+ const graph = currentGraph();
335
+ const node = graph.pages[pageIndex];
336
+ return node ? toPublicPageNode(node, graph) : null;
337
+ },
338
+
339
+ getPages(options) {
340
+ const graph = currentGraph();
341
+ const filtered = options?.sectionIndex !== undefined
342
+ ? graph.pages.filter((p) => p.sectionIndex === options.sectionIndex)
343
+ : graph.pages;
344
+ return filtered.map((node) => toPublicPageNode(node, graph));
345
+ },
346
+
347
+ getSection(sectionIndex) {
348
+ const graph = currentGraph();
349
+ return toPublicSectionNode(graph, sectionIndex);
350
+ },
351
+
352
+ getSections() {
353
+ const graph = currentGraph();
354
+ return graph.sections
355
+ .map((section) => toPublicSectionNode(graph, section.index))
356
+ .filter((node): node is PublicSectionNode => node !== null);
357
+ },
358
+
359
+ getPageForOffset(offset, _story) {
360
+ const graph = currentGraph();
361
+ for (const page of graph.pages) {
362
+ if (!page.isBlankFiller && offset < page.endOffset) {
363
+ return toPublicPageNode(page, graph);
364
+ }
365
+ }
366
+ const last = graph.pages[graph.pages.length - 1];
367
+ return last ? toPublicPageNode(last, graph) : null;
368
+ },
369
+
370
+ getPageSpanForSelection(selection) {
371
+ return currentMapper().mapSelectionToPageSpan(selection);
372
+ },
373
+
374
+ getFragmentForOffset(offset, story) {
375
+ const mapper = currentMapper();
376
+ const graph = currentGraph();
377
+ const location = mapper.mapOffsetToFragment(offset, story);
378
+ if (!location) return null;
379
+ const fragment = graph.fragments.find(
380
+ (f) => f.fragmentId === location.fragmentId,
381
+ );
382
+ if (!fragment) return null;
383
+ return toPublicBlockFragment(fragment, graph);
384
+ },
385
+
386
+ getAnchorForOffset(offset, story) {
387
+ const mapper = currentMapper();
388
+ const graph = currentGraph();
389
+ const anchor = mapper.getAnchorForOffset(offset, story);
390
+ if (!anchor) return null;
391
+ const page = graph.pages.find((p) => p.pageId === anchor.pageId);
392
+ if (!page) return null;
393
+ const fragmentLocation = mapper.mapOffsetToFragment(offset, story);
394
+ return {
395
+ offset: anchor.offset,
396
+ pageId: anchor.pageId,
397
+ pageIndex: page.pageIndex,
398
+ ...(fragmentLocation?.fragmentId !== undefined
399
+ ? { fragmentId: fragmentLocation.fragmentId }
400
+ : {}),
401
+ ...(fragmentLocation?.regionKind !== undefined
402
+ ? { regionKind: fragmentLocation.regionKind }
403
+ : {}),
404
+ };
405
+ },
406
+
407
+ getActiveStoriesOnPage(pageIndex) {
408
+ const graph = currentGraph();
409
+ const node = graph.pages[pageIndex];
410
+ if (!node) return null;
411
+ return toPublicResolvedPageStories(node.stories);
412
+ },
413
+
414
+ getDisplayPageNumber(pageIndex) {
415
+ const graph = currentGraph();
416
+ const node = graph.pages[pageIndex];
417
+ return node ? node.stories.displayPageNumber : null;
418
+ },
419
+
420
+ getLineBoxes(pageIndex, options) {
421
+ const graph = currentGraph();
422
+ const node = graph.pages[pageIndex];
423
+ if (!node) return [];
424
+ const region = options?.region ?? "body";
425
+ // Today all line boxes live on the body. The region filter is
426
+ // defensive for when header/footer metrics are also populated.
427
+ return node.lineBoxes
428
+ .filter(() => region === "body")
429
+ .map((box) => toPublicLineBox(box));
430
+ },
431
+
432
+ getFragmentsForPage(pageIndex) {
433
+ const graph = currentGraph();
434
+ const node = graph.pages[pageIndex];
435
+ if (!node) return [];
436
+ return graph.fragments
437
+ .filter((f) => f.pageId === node.pageId)
438
+ .map((f) => toPublicBlockFragment(f, graph));
439
+ },
440
+
441
+ getResolvedFormatting(blockId) {
442
+ const state = currentFormatting();
443
+ const formatting = state.paragraphs.get(blockId);
444
+ if (!formatting) return null;
445
+ return toPublicParagraphFormatting(blockId, formatting);
446
+ },
447
+
448
+ getResolvedRunFormatting(runId) {
449
+ const state = currentFormatting();
450
+ const run = state.runs.get(runId);
451
+ if (!run) return null;
452
+ const blockId = runId.split(":")[0] ?? "";
453
+ return toPublicRunFormatting(runId, blockId, run);
454
+ },
455
+
456
+ getMeasurement(blockId) {
457
+ const graph = currentGraph();
458
+ const fragments = graph.fragments.filter((f) => f.blockId === blockId);
459
+ if (fragments.length === 0) return null;
460
+ const lineCount = graph.pages.reduce((total, page) => {
461
+ return (
462
+ total +
463
+ page.lineBoxes.filter((line) =>
464
+ fragments.some((f) => f.fragmentId === line.fragmentId),
465
+ ).length
466
+ );
467
+ }, 0);
468
+ const heightTwips = fragments.reduce((t, f) => t + f.heightTwips, 0);
469
+ return {
470
+ blockId,
471
+ lineCount,
472
+ heightTwips,
473
+ };
474
+ },
475
+
476
+ getMeasurementFidelity() {
477
+ return engine.measurementFidelity;
478
+ },
479
+
480
+ whenMeasurementReady() {
481
+ return engine.whenMeasurementReady();
482
+ },
483
+
484
+ getDirtyFieldFamilies() {
485
+ return engine.getDirtyFieldFamilies();
486
+ },
487
+
488
+ getFieldDirtinessReport() {
489
+ const graph = currentGraph();
490
+ return {
491
+ families: engine.getDirtyFieldFamilies(),
492
+ revision: graph.revision,
493
+ };
494
+ },
495
+
496
+ subscribe(listener) {
497
+ listeners.add(listener);
498
+ return () => {
499
+ listeners.delete(listener);
500
+ };
501
+ },
502
+ };
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Internal: graph → public clones
507
+ // ---------------------------------------------------------------------------
508
+
509
+ function toPublicPageNode(
510
+ node: RuntimePageNode,
511
+ graph: RuntimePageGraph,
512
+ ): PublicPageNode {
513
+ return {
514
+ pageId: node.pageId,
515
+ pageIndex: node.pageIndex,
516
+ sectionIndex: node.sectionIndex,
517
+ pageInSection: node.pageInSection,
518
+ startOffset: node.startOffset,
519
+ endOffset: node.endOffset,
520
+ isBlankFiller: node.isBlankFiller,
521
+ displayPageNumber: node.stories.displayPageNumber,
522
+ isFirstPage: node.stories.isFirstPage,
523
+ isEvenPage: node.stories.isEvenPage,
524
+ layout: { ...node.layout },
525
+ stories: toPublicResolvedPageStories(node.stories),
526
+ regions: toPublicPageRegions(node.regions),
527
+ lineBoxCount: node.lineBoxes.length,
528
+ noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
529
+ };
530
+ void graph; // reserved for future cross-page derivations
531
+ }
532
+
533
+ function toPublicResolvedPageStories(
534
+ stories: ResolvedPageStories,
535
+ ): PublicResolvedPageStories {
536
+ return {
537
+ ...(stories.header ? { header: { ...stories.header } } : {}),
538
+ ...(stories.footer ? { footer: { ...stories.footer } } : {}),
539
+ isFirstPage: stories.isFirstPage,
540
+ isEvenPage: stories.isEvenPage,
541
+ displayPageNumber: stories.displayPageNumber,
542
+ };
543
+ }
544
+
545
+ function toPublicPageRegions(regions: RuntimePageRegions): PublicPageRegions {
546
+ return {
547
+ body: toPublicPageRegion(regions.body),
548
+ ...(regions.header ? { header: toPublicPageRegion(regions.header) } : {}),
549
+ ...(regions.footer ? { footer: toPublicPageRegion(regions.footer) } : {}),
550
+ ...(regions.columns ? { columns: regions.columns.map(toPublicPageRegion) } : {}),
551
+ };
552
+ }
553
+
554
+ function toPublicPageRegion(region: RuntimePageRegion): PublicPageRegion {
555
+ return {
556
+ kind: region.kind,
557
+ originTwips: region.originTwips,
558
+ widthTwips: region.widthTwips,
559
+ heightTwips: region.heightTwips,
560
+ fragmentCount: region.fragmentIds.length,
561
+ };
562
+ }
563
+
564
+ function toPublicBlockFragment(
565
+ fragment: RuntimeBlockFragment,
566
+ graph: RuntimePageGraph,
567
+ ): PublicBlockFragment {
568
+ const page = graph.pages.find((p) => p.pageId === fragment.pageId);
569
+ return {
570
+ fragmentId: fragment.fragmentId,
571
+ blockId: fragment.blockId,
572
+ pageId: fragment.pageId,
573
+ pageIndex: page?.pageIndex ?? -1,
574
+ regionKind: fragment.regionKind,
575
+ from: fragment.from,
576
+ to: fragment.to,
577
+ heightTwips: fragment.heightTwips,
578
+ orderInRegion: fragment.orderInRegion,
579
+ };
580
+ }
581
+
582
+ function toPublicLineBox(box: RuntimeLineBox): PublicLineBox {
583
+ return {
584
+ fragmentId: box.fragmentId,
585
+ lineIndex: box.lineIndex,
586
+ baselineTwips: box.baselineTwips,
587
+ heightTwips: box.heightTwips,
588
+ widthTwips: box.widthTwips,
589
+ };
590
+ }
591
+
592
+ function toPublicNoteAllocation(note: RuntimeNoteAllocation): PublicNoteAllocation {
593
+ return {
594
+ noteKind: note.noteKind,
595
+ noteId: note.noteId,
596
+ reservedHeightTwips: note.reservedHeightTwips,
597
+ };
598
+ }
599
+
600
+ function toPublicSectionNode(
601
+ graph: RuntimePageGraph,
602
+ sectionIndex: number,
603
+ ): PublicSectionNode | null {
604
+ const section = graph.sections.find((s) => s.index === sectionIndex);
605
+ if (!section) return null;
606
+ const sectionPages = graph.pages.filter(
607
+ (p) => p.sectionIndex === sectionIndex,
608
+ );
609
+ const firstPage = sectionPages[0];
610
+ const lastPage = sectionPages[sectionPages.length - 1];
611
+ if (!firstPage || !lastPage) return null;
612
+ return {
613
+ sectionIndex,
614
+ startOffset: section.start,
615
+ endOffset: section.end,
616
+ firstPageIndex: firstPage.pageIndex,
617
+ lastPageIndex: lastPage.pageIndex,
618
+ pageCount: sectionPages.length,
619
+ layout: { ...firstPage.layout },
620
+ };
621
+ }
622
+
623
+ function toPublicParagraphFormatting(
624
+ blockId: string,
625
+ formatting: ResolvedParagraphFormatting,
626
+ ): PublicResolvedParagraphFormatting {
627
+ return {
628
+ blockId,
629
+ spacingBefore: formatting.spacingBefore,
630
+ spacingAfter: formatting.spacingAfter,
631
+ lineHeight: formatting.lineHeight,
632
+ lineRule: formatting.lineRule,
633
+ indentLeft: formatting.indentLeft,
634
+ indentRight: formatting.indentRight,
635
+ firstLineIndent: formatting.firstLineIndent,
636
+ hangingIndent: formatting.hangingIndent,
637
+ fontSizeHalfPoints: formatting.fontSizeHalfPoints,
638
+ averageCharWidthTwips: formatting.averageCharWidthTwips,
639
+ tabStops: formatting.tabStops.map((tab) => ({
640
+ positionTwips: tab.position,
641
+ alignment: tab.align,
642
+ ...(tab.leader ? { leader: tab.leader } : {}),
643
+ })),
644
+ keepNext: formatting.keepNext,
645
+ keepLines: formatting.keepLines,
646
+ pageBreakBefore: formatting.pageBreakBefore,
647
+ widowControl: formatting.widowControl,
648
+ contextualSpacing: formatting.contextualSpacing,
649
+ };
650
+ }
651
+
652
+ function toPublicRunFormatting(
653
+ runId: string,
654
+ blockId: string,
655
+ run: ResolvedRunFormatting,
656
+ ): PublicResolvedRunFormatting {
657
+ return {
658
+ runId,
659
+ blockId,
660
+ ...(run.fontFamily ? { fontFamily: run.fontFamily } : {}),
661
+ ...(run.fontSizeHalfPoints !== undefined
662
+ ? { fontSizeHalfPoints: run.fontSizeHalfPoints }
663
+ : {}),
664
+ bold: run.bold,
665
+ italic: run.italic,
666
+ underline: run.underline,
667
+ strikethrough: run.strikethrough,
668
+ ...(run.color ? { color: run.color } : {}),
669
+ ...(run.highlight ? { highlight: run.highlight } : {}),
670
+ verticalAlign: run.verticalAlign,
671
+ };
672
+ }
673
+
674
+ function toFacetEvent(event: LayoutEngineEvent): LayoutFacetEvent | null {
675
+ switch (event.kind) {
676
+ case "layout_recomputed":
677
+ return {
678
+ kind: "layout_recomputed",
679
+ revision: event.revision,
680
+ ...(event.reason ? { reason: event.reason } : {}),
681
+ };
682
+ case "page_count_changed":
683
+ return {
684
+ kind: "page_count_changed",
685
+ previous: event.previousPageCount ?? 0,
686
+ current: event.currentPageCount ?? 0,
687
+ revision: event.revision,
688
+ };
689
+ case "page_field_dirtied":
690
+ return {
691
+ kind: "page_field_dirtied",
692
+ families: event.dirtyFieldFamilies ?? [],
693
+ revision: event.revision,
694
+ };
695
+ case "measurement_backend_ready":
696
+ return {
697
+ kind: "measurement_backend_ready",
698
+ fidelity:
699
+ (event.fidelity as PublicMeasurementFidelity | undefined) ?? "empirical",
700
+ revision: event.revision,
701
+ };
702
+ default:
703
+ return null;
704
+ }
705
+ }