@beyondwork/docx-react-component 1.0.37 → 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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +319 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +815 -55
  6. package/src/io/export/serialize-main-document.ts +2 -11
  7. package/src/io/export/serialize-numbering.ts +1 -2
  8. package/src/io/export/serialize-tables.ts +74 -0
  9. package/src/io/export/table-properties-xml.ts +139 -4
  10. package/src/io/normalize/normalize-text.ts +15 -0
  11. package/src/io/ooxml/parse-footnotes.ts +60 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  13. package/src/io/ooxml/parse-main-document.ts +137 -0
  14. package/src/io/ooxml/parse-tables.ts +249 -0
  15. package/src/model/canonical-document.ts +34 -0
  16. package/src/runtime/document-layout.ts +4 -2
  17. package/src/runtime/document-navigation.ts +1 -1
  18. package/src/runtime/document-runtime.ts +114 -0
  19. package/src/runtime/layout/default-page-format.ts +96 -0
  20. package/src/runtime/layout/index.ts +45 -0
  21. package/src/runtime/layout/inert-layout-facet.ts +14 -0
  22. package/src/runtime/layout/layout-engine-instance.ts +33 -23
  23. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  24. package/src/runtime/layout/page-format-catalog.ts +233 -0
  25. package/src/runtime/layout/page-graph.ts +19 -0
  26. package/src/runtime/layout/paginated-layout-engine.ts +142 -9
  27. package/src/runtime/layout/project-block-fragments.ts +91 -0
  28. package/src/runtime/layout/public-facet.ts +709 -16
  29. package/src/runtime/layout/table-render-plan.ts +229 -0
  30. package/src/runtime/render/block-fragment-projection.ts +35 -0
  31. package/src/runtime/render/decoration-resolver.ts +189 -0
  32. package/src/runtime/render/index.ts +57 -0
  33. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  34. package/src/runtime/render/render-frame-types.ts +317 -0
  35. package/src/runtime/render/render-kernel.ts +755 -0
  36. package/src/runtime/view-state.ts +67 -0
  37. package/src/runtime/workflow-markup.ts +1 -5
  38. package/src/runtime/workflow-rail-segments.ts +280 -0
  39. package/src/ui/WordReviewEditor.tsx +84 -15
  40. package/src/ui/editor-shell-view.tsx +6 -0
  41. package/src/ui/headless/chrome-registry.ts +280 -14
  42. package/src/ui/headless/scoped-chrome-policy.ts +20 -1
  43. package/src/ui/headless/selection-tool-types.ts +10 -0
  44. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  45. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  46. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  47. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  48. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  49. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  52. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  53. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  54. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  55. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  56. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  57. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  58. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  59. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  60. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +12 -1
  61. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  62. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +3 -0
  63. package/src/ui-tailwind/index.ts +33 -0
  64. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  65. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  66. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  68. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  69. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  70. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  71. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  72. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  73. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +69 -0
  74. package/src/ui-tailwind/tw-review-workspace.tsx +136 -1
@@ -124,11 +124,16 @@ import {
124
124
  findPageForOffset,
125
125
  } from "./document-navigation.ts";
126
126
  import {
127
+ createDocxFontLoader,
127
128
  createLayoutEngine,
128
129
  createLayoutFacet,
130
+ createMeasurementProvider,
131
+ type DocxFontLoader,
129
132
  type LayoutEngineInstance,
133
+ type LayoutMeasurementProvider,
130
134
  type WordReviewEditorLayoutFacet,
131
135
  } from "./layout/index.ts";
136
+ import { createRenderKernel, type RenderKernel } from "./render/index.ts";
132
137
  import {
133
138
  createDocumentOutlineSnapshot,
134
139
  createDocumentSectionSnapshots,
@@ -405,7 +410,20 @@ export function createDocumentRuntime(
405
410
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
406
411
  // (content, styles, subParts). It is the single internal source of truth
407
412
  // for page composition, story resolution, and layout invalidation.
413
+ //
414
+ // R0 measurement wiring: the engine starts with the sync empirical backend
415
+ // so the runtime is available immediately, then we kick off
416
+ // `createMeasurementProvider({ preference: "auto", fontLoader })` which
417
+ // upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
418
+ // emits `measurement_backend_ready` so chrome consumers can re-read metrics.
408
419
  const layoutEngine: LayoutEngineInstance = createLayoutEngine();
420
+ const fontLoader: DocxFontLoader = createDocxFontLoader(
421
+ collectFontLoaderInput(state.document),
422
+ );
423
+ void upgradeMeasurementProvider(layoutEngine, fontLoader);
424
+ // `renderKernelRef` is a forward reference so the facet can reach the
425
+ // kernel after it is created below (kernel creation needs the facet).
426
+ let renderKernelRef: RenderKernel | null = null;
409
427
  const layoutFacet: WordReviewEditorLayoutFacet = createLayoutFacet({
410
428
  engine: layoutEngine,
411
429
  getQueryInput: () => ({
@@ -416,6 +434,27 @@ export function createDocumentRuntime(
416
434
  zoomLevel: viewState.zoomLevel,
417
435
  },
418
436
  }),
437
+ renderKernel: () => renderKernelRef,
438
+ getWorkflowRailInput: () => {
439
+ if (!workflowOverlay) return null;
440
+ const activeWorkItemId = workflowOverlay.activeWorkItemId ?? null;
441
+ const activeWorkItem =
442
+ activeWorkItemId !== null
443
+ ? workflowOverlay.workItems?.find(
444
+ (item) => item.workItemId === activeWorkItemId,
445
+ )
446
+ : undefined;
447
+ return {
448
+ scopes: workflowOverlay.scopes,
449
+ candidates: workflowOverlay.candidates,
450
+ activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
451
+ activeStory,
452
+ };
453
+ },
454
+ });
455
+ renderKernelRef = createRenderKernel({
456
+ facet: layoutFacet,
457
+ getActiveStory: () => activeStory,
419
458
  });
420
459
  let cachedSurface:
421
460
  | {
@@ -2336,6 +2375,14 @@ export function createDocumentRuntime(
2336
2375
  }
2337
2376
  }
2338
2377
 
2378
+ // Font-loader refresh on subParts identity change — this is the
2379
+ // lightweight proxy for "a change that could affect which fonts the
2380
+ // canvas backend measures against". Typing edits don't rebuild
2381
+ // subParts; style + font + numbering imports do.
2382
+ if (previous.document.subParts !== state.document.subParts) {
2383
+ fontLoader.refresh(collectFontLoaderInput(state.document));
2384
+ }
2385
+
2339
2386
  cachedRenderSnapshot = refreshRenderSnapshot();
2340
2387
  notify(previous, state, transaction);
2341
2388
  }
@@ -4390,3 +4437,70 @@ function remapProtectionSnapshot(
4390
4437
  preservedRangeCount: nextRanges.filter((range) => !range.enforced).length,
4391
4438
  };
4392
4439
  }
4440
+
4441
+ // ---------------------------------------------------------------------------
4442
+ // Measurement provider wiring (R0)
4443
+ // ---------------------------------------------------------------------------
4444
+
4445
+ /**
4446
+ * Build the initial input the `DocxFontLoader` needs: a list of font
4447
+ * families the document actively uses, plus any embedded font payloads the
4448
+ * import pipeline may have extracted.
4449
+ *
4450
+ * Walks the document content tree once per call. Embedded font extraction
4451
+ * is not yet wired into the canonical model; we pass an empty map today and
4452
+ * let the loader register system fonts it finds via
4453
+ * `document.fonts.check(...)`.
4454
+ */
4455
+ function collectFontLoaderInput(
4456
+ document: CanonicalDocumentEnvelope,
4457
+ ): { families: readonly string[] } {
4458
+ try {
4459
+ const families = new Set<string>();
4460
+ const visit = (node: unknown): void => {
4461
+ if (!node || typeof node !== "object") return;
4462
+ const record = node as Record<string, unknown>;
4463
+ const rpr = record["runProperties"] as
4464
+ | Record<string, unknown>
4465
+ | undefined;
4466
+ if (rpr && typeof rpr["fontFamily"] === "string") {
4467
+ families.add(rpr["fontFamily"] as string);
4468
+ }
4469
+ for (const value of Object.values(record)) {
4470
+ if (Array.isArray(value)) value.forEach(visit);
4471
+ else if (value && typeof value === "object") visit(value);
4472
+ }
4473
+ };
4474
+ visit(document.content);
4475
+ if (document.styles) {
4476
+ visit(document.styles);
4477
+ }
4478
+ return { families: Array.from(families) };
4479
+ } catch {
4480
+ return { families: [] };
4481
+ }
4482
+ }
4483
+
4484
+ /**
4485
+ * Asynchronously upgrade the engine's measurement backend to canvas once
4486
+ * the platform supports it and fonts have resolved. Errors are swallowed
4487
+ * so a failure in the upgrade path can never break the empirical baseline.
4488
+ */
4489
+ async function upgradeMeasurementProvider(
4490
+ engine: LayoutEngineInstance,
4491
+ fontLoader: DocxFontLoader,
4492
+ ): Promise<void> {
4493
+ try {
4494
+ const provider: LayoutMeasurementProvider = await createMeasurementProvider({
4495
+ preference: "auto",
4496
+ fontLoader,
4497
+ });
4498
+ // If the host is running in SSR or a jsdom test shell, the factory will
4499
+ // fall back to the empirical backend. In that case swapping is a no-op
4500
+ // but still emits `measurement_backend_ready` with `empirical` which is
4501
+ // informational; chrome consumers use the event to refresh metrics.
4502
+ engine.swapMeasurementProvider(provider);
4503
+ } catch {
4504
+ // fall through — the empirical backend remains in place
4505
+ }
4506
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Locale-aware default page format.
3
+ *
4
+ * Word historically defaults to US Letter for `en-US` hosts and A4 everywhere
5
+ * else. This module is the single place that decides which format a
6
+ * newly-created document uses when no section carries an explicit `w:pgSz`.
7
+ *
8
+ * It never overrides an existing section's page size on import — importers
9
+ * always preserve what the source document specified. The default is
10
+ * consulted in two places:
11
+ *
12
+ * 1. `serialize-main-document.ts` when the canonical model carries a
13
+ * section with no `pageSize` (e.g. programmatic document construction).
14
+ * 2. `DocumentRuntime` when rendering a brand-new blank document.
15
+ */
16
+
17
+ import {
18
+ getPageFormatById,
19
+ type PageFormatDefinition,
20
+ } from "./page-format-catalog.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Locale resolution
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Which BCP-47 language tags should fall back to Letter.
28
+ *
29
+ * Anything else defaults to A4. This intentionally uses a small whitelist
30
+ * rather than a `startsWith("en")` check — `en-GB`, `en-AU`, `en-IN` all
31
+ * expect ISO paper sizes, not US Letter.
32
+ */
33
+ const LETTER_LOCALES = new Set<string>([
34
+ "en-us",
35
+ "en-ca",
36
+ "fr-ca",
37
+ "es-mx",
38
+ "es-cl",
39
+ "fil-ph",
40
+ ]);
41
+
42
+ export interface ResolveDefaultPageFormatOptions {
43
+ /** Explicit BCP-47 locale (e.g. "en-US", "de-DE"). */
44
+ locale?: string;
45
+ }
46
+
47
+ /**
48
+ * Return the default `PageFormatDefinition` for a given locale.
49
+ *
50
+ * When `locale` is omitted the function consults `Intl.DateTimeFormat` to
51
+ * infer the current locale. When `Intl` is unavailable (unusual in modern
52
+ * runtimes) it falls back to A4 as the safer international default.
53
+ */
54
+ export function resolveDefaultPageFormat(
55
+ options: ResolveDefaultPageFormatOptions = {},
56
+ ): PageFormatDefinition {
57
+ const raw = options.locale ?? tryResolveHostLocale();
58
+ if (!raw) {
59
+ return getPageFormatById("a4");
60
+ }
61
+ const normalized = raw.toLowerCase();
62
+ if (LETTER_LOCALES.has(normalized)) {
63
+ return getPageFormatById("letter");
64
+ }
65
+ // Match just the language-region prefix (e.g. "en-us-1234" → "en-us")
66
+ const region = normalized.split("-").slice(0, 2).join("-");
67
+ if (LETTER_LOCALES.has(region)) {
68
+ return getPageFormatById("letter");
69
+ }
70
+ return getPageFormatById("a4");
71
+ }
72
+
73
+ function tryResolveHostLocale(): string | undefined {
74
+ try {
75
+ if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function") {
76
+ return undefined;
77
+ }
78
+ return new Intl.DateTimeFormat().resolvedOptions().locale;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Return the default `w:pgSz` payload (width/height in twips) for a given
86
+ * locale. Convenience wrapper used by the export pipeline.
87
+ */
88
+ export function resolveDefaultPageSizeTwips(
89
+ options: ResolveDefaultPageFormatOptions = {},
90
+ ): { widthTwips: number; heightTwips: number } {
91
+ const format = resolveDefaultPageFormat(options);
92
+ return {
93
+ widthTwips: format.portraitWidthTwips,
94
+ heightTwips: format.portraitHeightTwips,
95
+ };
96
+ }
@@ -184,5 +184,50 @@ export {
184
184
  type PublicFieldDirtinessReport,
185
185
  type LayoutFacetEvent,
186
186
  type LayoutFacetInvalidationReason,
187
+ type RenderZoomSummary,
187
188
  type CreateLayoutFacetInput,
189
+ type PageFormatDefinition,
190
+ type ActivePageFormat,
191
+ type MarginPresetDefinition,
192
+ type ActiveMarginPreset,
188
193
  } from "./public-facet.ts";
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Page-format catalog + margin preset catalog + locale defaults (R0.5)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export {
200
+ PAGE_FORMAT_CATALOG,
201
+ matchPageFormat,
202
+ getPageFormatById,
203
+ type PageFormatId,
204
+ type PageFormatRegion,
205
+ type PageFormatLocaleDefault,
206
+ type PageFormatDisplay,
207
+ type MatchPageFormatInput,
208
+ } from "./page-format-catalog.ts";
209
+
210
+ export {
211
+ MARGIN_PRESET_CATALOG,
212
+ matchMarginPreset,
213
+ getMarginPresetById,
214
+ type MarginPresetId,
215
+ type MatchMarginPresetInput,
216
+ } from "./margin-preset-catalog.ts";
217
+
218
+ export {
219
+ resolveDefaultPageFormat,
220
+ resolveDefaultPageSizeTwips,
221
+ type ResolveDefaultPageFormatOptions,
222
+ } from "./default-page-format.ts";
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Workflow rail segments (R3a)
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export {
229
+ collectScopeRailSegments,
230
+ type CollectScopeRailSegmentsInput,
231
+ type ScopeRailPosture,
232
+ type ScopeRailSegment,
233
+ } from "../workflow-rail-segments.ts";
@@ -12,6 +12,8 @@ import type {
12
12
  PublicMeasurementFidelity,
13
13
  WordReviewEditorLayoutFacet,
14
14
  } from "./public-facet.ts";
15
+ import { MARGIN_PRESET_CATALOG } from "./margin-preset-catalog.ts";
16
+ import { PAGE_FORMAT_CATALOG } from "./page-format-catalog.ts";
15
17
 
16
18
  export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
17
19
  const emptyReport: PublicFieldDirtinessReport = {
@@ -32,12 +34,24 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
32
34
  getActiveStoriesOnPage: () => null,
33
35
  getDisplayPageNumber: () => null,
34
36
  getLineBoxes: () => [],
37
+ getLineBoxesForRegion: () => [],
35
38
  getFragmentsForPage: () => [],
39
+ getPageFormatCatalog: () => PAGE_FORMAT_CATALOG,
40
+ getActivePageFormat: () => null,
41
+ getMarginPresetCatalog: () => MARGIN_PRESET_CATALOG,
42
+ getActiveMarginPreset: () => null,
43
+ getRenderFrame: () => null,
44
+ getRenderZoom: () => null,
45
+ hitTest: () => null,
46
+ getAnchorRects: () => [],
47
+ getScopeRailSegments: () => [],
48
+ getAllScopeRailSegments: () => [],
36
49
  getResolvedFormatting: () => null,
37
50
  getResolvedRunFormatting: () => null,
38
51
  getMeasurement: () => null,
39
52
  getMeasurementFidelity: () => fidelity,
40
53
  whenMeasurementReady: () => Promise.resolve(),
54
+ getTableRenderPlan: () => null,
41
55
  getDirtyFieldFamilies: () => [],
42
56
  getFieldDirtinessReport: () => emptyReport,
43
57
  subscribe: (_listener: (event: LayoutFacetEvent) => void) => () => undefined,
@@ -36,6 +36,7 @@ import {
36
36
  } from "../document-layout.ts";
37
37
  import { findNoteReferencePosition } from "../view-state.ts";
38
38
  import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
39
+ import { buildHeadingOutline } from "../document-navigation.ts";
39
40
  import {
40
41
  analyzeInvalidation,
41
42
  computeFieldDirtiness,
@@ -53,6 +54,7 @@ import {
53
54
  type RuntimePageGraph,
54
55
  type RuntimePageNode,
55
56
  } from "./page-graph.ts";
57
+ import { projectSurfaceBlocksToPageFragments } from "./project-block-fragments.ts";
56
58
  import {
57
59
  resolvePageStories,
58
60
  resolveTotalPageCount,
@@ -242,9 +244,18 @@ export function createLayoutEngine(
242
244
  MAIN_STORY_TARGET,
243
245
  );
244
246
  const sections = buildResolvedSections(document);
245
- const pages = buildPageStack(document, sections, mainSurface);
247
+ const pages = buildPageStack(document, sections, mainSurface, measurementProvider);
246
248
  const stories = resolvePageStories(pages);
247
- const graph = buildPageGraph({ pages, sections, stories });
249
+ const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
250
+ mainSurface,
251
+ pages,
252
+ );
253
+ const graph = buildPageGraph({
254
+ pages,
255
+ sections,
256
+ stories,
257
+ fragmentsByPageIndex,
258
+ });
248
259
 
249
260
  // Field dirtiness diff from previous graph
250
261
  const dirtyFamilies = computeFieldDirtiness(cachedGraph, graph);
@@ -309,10 +320,16 @@ export function createLayoutEngine(
309
320
  const sections = buildResolvedSections(document);
310
321
 
311
322
  const dirtyPage = priorGraph.pages[firstDirty]!;
312
- const freshSnapshots = buildPageStackFrom(document, sections, mainSurface, {
313
- startPageIndex: firstDirty,
314
- startOffset: dirtyPage.startOffset,
315
- });
323
+ const freshSnapshots = buildPageStackFrom(
324
+ document,
325
+ sections,
326
+ mainSurface,
327
+ {
328
+ startPageIndex: firstDirty,
329
+ startOffset: dirtyPage.startOffset,
330
+ },
331
+ measurementProvider,
332
+ );
316
333
 
317
334
  // Convert fresh DocumentPageSnapshots into RuntimePageNodes via the
318
335
  // standard buildPageGraph pipeline — this keeps region, story, and
@@ -543,13 +560,19 @@ function buildNavigationFromGraph(
543
560
  ): DocumentNavigationSnapshot {
544
561
  const pages = deriveDocumentPageSnapshots(graph);
545
562
  const sections = graph.sections;
563
+ const mainSurface = createEditorSurfaceSnapshot(
564
+ document,
565
+ createSelectionSnapshot(0, 0),
566
+ MAIN_STORY_TARGET,
567
+ );
568
+ const headings = buildHeadingOutline(document, mainSurface, sections, pages);
546
569
 
547
570
  if (activeStory.kind === "main") {
548
571
  const activePageIndex = deriveActivePageIndex(graph, selectionHead);
549
572
  return {
550
573
  pageCount: pages.length,
551
574
  pages,
552
- headings: buildHeadings(graph),
575
+ headings,
553
576
  activePageIndex,
554
577
  activeSectionIndex: deriveActiveSectionIndex(graph, selectionHead),
555
578
  };
@@ -564,24 +587,19 @@ function buildNavigationFromGraph(
564
587
  return {
565
588
  pageCount: pages.length,
566
589
  pages,
567
- headings: buildHeadings(graph),
590
+ headings,
568
591
  activePageIndex: firstPage >= 0 ? firstPage : 0,
569
592
  activeSectionIndex: sectionIndex,
570
593
  };
571
594
  }
572
595
 
573
596
  if (activeStory.kind === "footnote" || activeStory.kind === "endnote") {
574
- const mainSurface = createEditorSurfaceSnapshot(
575
- document,
576
- createSelectionSnapshot(0, 0),
577
- MAIN_STORY_TARGET,
578
- );
579
597
  const referencePosition = findNoteReferencePosition(mainSurface, activeStory);
580
598
  const activePageIndex = deriveActivePageIndex(graph, referencePosition);
581
599
  return {
582
600
  pageCount: pages.length,
583
601
  pages,
584
- headings: buildHeadings(graph),
602
+ headings,
585
603
  activePageIndex,
586
604
  activeSectionIndex:
587
605
  graph.pages[activePageIndex]?.sectionIndex ??
@@ -592,20 +610,12 @@ function buildNavigationFromGraph(
592
610
  return {
593
611
  pageCount: pages.length,
594
612
  pages,
595
- headings: buildHeadings(graph),
613
+ headings,
596
614
  activePageIndex: 0,
597
615
  activeSectionIndex: 0,
598
616
  };
599
617
  }
600
618
 
601
- function buildHeadings(graph: RuntimePageGraph) {
602
- // Headings are derived elsewhere (createDocumentNavigationSnapshot pipes
603
- // them from its own helper). For engine-native reads we return an empty
604
- // list; the existing navigation snapshot keeps its headings pipeline.
605
- void graph;
606
- return [];
607
- }
608
-
609
619
  // ---------------------------------------------------------------------------
610
620
  // Convenience: find the active page node directly
611
621
  // ---------------------------------------------------------------------------
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Margin preset catalog — the five named Word margin presets (Normal,
3
+ * Narrow, Moderate, Wide, Mirrored) plus Custom.
4
+ *
5
+ * Values mirror Microsoft Word's defaults in twips so a "Narrow" preset
6
+ * round-trips to `w:top = 720 / w:bottom = 720 / w:left = 720 / w:right = 720`
7
+ * on export.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Public types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export type MarginPresetId =
15
+ | "normal"
16
+ | "narrow"
17
+ | "moderate"
18
+ | "wide"
19
+ | "mirrored"
20
+ | "custom";
21
+
22
+ export interface MarginPresetDefinition {
23
+ id: MarginPresetId;
24
+ label: string;
25
+ topTwips: number;
26
+ bottomTwips: number;
27
+ leftTwips: number;
28
+ rightTwips: number;
29
+ gutterTwips: number;
30
+ mirrored: boolean;
31
+ }
32
+
33
+ export interface ActiveMarginPreset {
34
+ sectionIndex: number;
35
+ preset: MarginPresetDefinition;
36
+ matchesCatalog: boolean;
37
+ customTwips?: {
38
+ top: number;
39
+ bottom: number;
40
+ left: number;
41
+ right: number;
42
+ gutter: number;
43
+ mirrored: boolean;
44
+ };
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Catalog
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export const MARGIN_PRESET_CATALOG: readonly MarginPresetDefinition[] = Object.freeze([
52
+ {
53
+ id: "normal",
54
+ label: "Normal",
55
+ topTwips: 1440,
56
+ bottomTwips: 1440,
57
+ leftTwips: 1440,
58
+ rightTwips: 1440,
59
+ gutterTwips: 0,
60
+ mirrored: false,
61
+ },
62
+ {
63
+ id: "narrow",
64
+ label: "Narrow",
65
+ topTwips: 720,
66
+ bottomTwips: 720,
67
+ leftTwips: 720,
68
+ rightTwips: 720,
69
+ gutterTwips: 0,
70
+ mirrored: false,
71
+ },
72
+ {
73
+ id: "moderate",
74
+ label: "Moderate",
75
+ topTwips: 1440,
76
+ bottomTwips: 1440,
77
+ leftTwips: 1080,
78
+ rightTwips: 1080,
79
+ gutterTwips: 0,
80
+ mirrored: false,
81
+ },
82
+ {
83
+ id: "wide",
84
+ label: "Wide",
85
+ topTwips: 1440,
86
+ bottomTwips: 1440,
87
+ leftTwips: 2880,
88
+ rightTwips: 2880,
89
+ gutterTwips: 0,
90
+ mirrored: false,
91
+ },
92
+ {
93
+ id: "mirrored",
94
+ label: "Mirrored",
95
+ topTwips: 1440,
96
+ bottomTwips: 1440,
97
+ leftTwips: 1800,
98
+ rightTwips: 1440,
99
+ gutterTwips: 0,
100
+ mirrored: true,
101
+ },
102
+ {
103
+ id: "custom",
104
+ label: "Custom",
105
+ topTwips: 0,
106
+ bottomTwips: 0,
107
+ leftTwips: 0,
108
+ rightTwips: 0,
109
+ gutterTwips: 0,
110
+ mirrored: false,
111
+ },
112
+ ]);
113
+
114
+ const MATCH_TOLERANCE_TWIPS = 1;
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Matching
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export interface MatchMarginPresetInput {
121
+ sectionIndex: number;
122
+ topTwips: number;
123
+ bottomTwips: number;
124
+ leftTwips: number;
125
+ rightTwips: number;
126
+ gutterTwips: number;
127
+ mirrored: boolean;
128
+ }
129
+
130
+ export function matchMarginPreset(
131
+ input: MatchMarginPresetInput,
132
+ ): ActiveMarginPreset {
133
+ const { sectionIndex, topTwips, bottomTwips, leftTwips, rightTwips, gutterTwips, mirrored } = input;
134
+
135
+ for (const preset of MARGIN_PRESET_CATALOG) {
136
+ if (preset.id === "custom") continue;
137
+ if (preset.mirrored !== mirrored) continue;
138
+
139
+ if (
140
+ near(preset.topTwips, topTwips) &&
141
+ near(preset.bottomTwips, bottomTwips) &&
142
+ near(preset.leftTwips, leftTwips) &&
143
+ near(preset.rightTwips, rightTwips) &&
144
+ near(preset.gutterTwips, gutterTwips)
145
+ ) {
146
+ return {
147
+ sectionIndex,
148
+ preset,
149
+ matchesCatalog: true,
150
+ };
151
+ }
152
+ }
153
+
154
+ const custom = MARGIN_PRESET_CATALOG.find((p) => p.id === "custom")!;
155
+ return {
156
+ sectionIndex,
157
+ preset: custom,
158
+ matchesCatalog: false,
159
+ customTwips: {
160
+ top: topTwips,
161
+ bottom: bottomTwips,
162
+ left: leftTwips,
163
+ right: rightTwips,
164
+ gutter: gutterTwips,
165
+ mirrored,
166
+ },
167
+ };
168
+ }
169
+
170
+ function near(a: number, b: number): boolean {
171
+ return Math.abs(a - b) <= MATCH_TOLERANCE_TWIPS;
172
+ }
173
+
174
+ export function getMarginPresetById(id: string): MarginPresetDefinition {
175
+ const found = MARGIN_PRESET_CATALOG.find((p) => p.id === id);
176
+ if (found) return found;
177
+ return MARGIN_PRESET_CATALOG.find((p) => p.id === "custom")!;
178
+ }