@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -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 +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  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/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -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,
@@ -44,6 +45,8 @@ import {
44
45
  import {
45
46
  buildPageStack,
46
47
  buildPageStackFrom,
48
+ buildPageStackFromWithSplits,
49
+ buildPageStackWithSplits,
47
50
  type LayoutInvalidationReason,
48
51
  } from "./paginated-layout-engine.ts";
49
52
  import {
@@ -53,6 +56,7 @@ import {
53
56
  type RuntimePageGraph,
54
57
  type RuntimePageNode,
55
58
  } from "./page-graph.ts";
59
+ import { projectSurfaceBlocksToPageFragments } from "./project-block-fragments.ts";
56
60
  import {
57
61
  resolvePageStories,
58
62
  resolveTotalPageCount,
@@ -193,6 +197,14 @@ function recordFullRebuildReason(reasonKind: string): void {
193
197
  export interface CreateLayoutEngineOptions {
194
198
  /** Optional measurement provider. Defaults to empirical. */
195
199
  measurementProvider?: LayoutMeasurementProvider;
200
+ /**
201
+ * When true and a browser-like `document` global is available, the engine
202
+ * dynamically imports the Canvas2D measurement backend and swaps to it at
203
+ * init time, emitting `measurement_backend_ready`. SSR stays on the
204
+ * empirical backend. Callers that want to stay on empirical (for
205
+ * determinism or tests) pass `false`. Default: true.
206
+ */
207
+ autoUpgradeToCanvasBackend?: boolean;
196
208
  }
197
209
 
198
210
  export function createLayoutEngine(
@@ -200,6 +212,7 @@ export function createLayoutEngine(
200
212
  ): LayoutEngineInstance {
201
213
  let measurementProvider: LayoutMeasurementProvider =
202
214
  options.measurementProvider ?? createEmpiricalMeasurementProvider();
215
+ const autoUpgradeToCanvas = options.autoUpgradeToCanvasBackend !== false;
203
216
  const dirtyFieldFamilies = new Set<string>();
204
217
  const listeners = new Set<(event: LayoutEngineEvent) => void>();
205
218
  let cachedKey: CacheKey | null = null;
@@ -242,9 +255,25 @@ export function createLayoutEngine(
242
255
  MAIN_STORY_TARGET,
243
256
  );
244
257
  const sections = buildResolvedSections(document);
245
- const pages = buildPageStack(document, sections, mainSurface);
258
+ const pageStack = buildPageStackWithSplits(
259
+ document,
260
+ sections,
261
+ mainSurface,
262
+ measurementProvider,
263
+ );
264
+ const pages = pageStack.pages;
246
265
  const stories = resolvePageStories(pages);
247
- const graph = buildPageGraph({ pages, sections, stories });
266
+ const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
267
+ mainSurface,
268
+ pages,
269
+ pageStack.splits,
270
+ );
271
+ const graph = buildPageGraph({
272
+ pages,
273
+ sections,
274
+ stories,
275
+ fragmentsByPageIndex,
276
+ });
248
277
 
249
278
  // Field dirtiness diff from previous graph
250
279
  const dirtyFamilies = computeFieldDirtiness(cachedGraph, graph);
@@ -309,10 +338,17 @@ export function createLayoutEngine(
309
338
  const sections = buildResolvedSections(document);
310
339
 
311
340
  const dirtyPage = priorGraph.pages[firstDirty]!;
312
- const freshSnapshots = buildPageStackFrom(document, sections, mainSurface, {
313
- startPageIndex: firstDirty,
314
- startOffset: dirtyPage.startOffset,
315
- });
341
+ const freshResult = buildPageStackFromWithSplits(
342
+ document,
343
+ sections,
344
+ mainSurface,
345
+ {
346
+ startPageIndex: firstDirty,
347
+ startOffset: dirtyPage.startOffset,
348
+ },
349
+ measurementProvider,
350
+ );
351
+ const freshSnapshots = freshResult.pages;
316
352
 
317
353
  // Convert fresh DocumentPageSnapshots into RuntimePageNodes via the
318
354
  // standard buildPageGraph pipeline — this keeps region, story, and
@@ -324,10 +360,18 @@ export function createLayoutEngine(
324
360
  return null;
325
361
  }
326
362
  const freshStories = resolvePageStories(freshSnapshots);
363
+ // Project fragments for the fresh tail pages, threading paragraph
364
+ // line-range splits produced by intra-paragraph pagination.
365
+ const freshFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
366
+ mainSurface,
367
+ freshSnapshots,
368
+ freshResult.splits,
369
+ );
327
370
  const freshGraph = buildPageGraph({
328
371
  pages: freshSnapshots,
329
372
  sections,
330
373
  stories: freshStories,
374
+ fragmentsByPageIndex: freshFragmentsByPageIndex,
331
375
  });
332
376
  const freshNodes = freshGraph.pages;
333
377
 
@@ -427,6 +471,46 @@ export function createLayoutEngine(
427
471
  return cachedFormatting!;
428
472
  }
429
473
 
474
+ // -----------------------------------------------------------------------
475
+ // Auto-upgrade to the Canvas2D measurement backend in browsers. Dynamic
476
+ // import keeps SSR bundles lean. We only attempt the upgrade when the
477
+ // caller didn't provide their own provider and `document` is available.
478
+ // -----------------------------------------------------------------------
479
+ if (
480
+ autoUpgradeToCanvas &&
481
+ options.measurementProvider === undefined &&
482
+ typeof document !== "undefined" &&
483
+ typeof HTMLCanvasElement !== "undefined"
484
+ ) {
485
+ // Swallow errors silently — staying on empirical is correct behavior
486
+ // when the upgrade fails. Perf probe increments the fallback counter
487
+ // through the emitted event (listeners observe fidelity).
488
+ const readCachedRevision = (): number => cachedGraph?.revision ?? 0;
489
+ void (async () => {
490
+ try {
491
+ const mod = await import("./measurement-backend-canvas.ts");
492
+ const canvasProvider = mod.createCanvasBackend();
493
+ measurementProvider = canvasProvider;
494
+ // Invalidate the cached graph/formatting/mapper so the next read
495
+ // recomputes with canvas-measured font metrics. Without this,
496
+ // the first render after the async import still uses empirical
497
+ // numbers and the chrome shifts by a few pixels on the next
498
+ // real invalidation.
499
+ cachedKey = null;
500
+ cachedGraph = null;
501
+ cachedFormatting = null;
502
+ cachedMapper = null;
503
+ emit({
504
+ kind: "measurement_backend_ready",
505
+ revision: readCachedRevision(),
506
+ fidelity: canvasProvider.fidelity,
507
+ });
508
+ } catch {
509
+ // Stay on empirical. No-op.
510
+ }
511
+ })();
512
+ }
513
+
430
514
  return {
431
515
  get measurementFidelity() {
432
516
  return measurementProvider.fidelity;
@@ -543,13 +627,19 @@ function buildNavigationFromGraph(
543
627
  ): DocumentNavigationSnapshot {
544
628
  const pages = deriveDocumentPageSnapshots(graph);
545
629
  const sections = graph.sections;
630
+ const mainSurface = createEditorSurfaceSnapshot(
631
+ document,
632
+ createSelectionSnapshot(0, 0),
633
+ MAIN_STORY_TARGET,
634
+ );
635
+ const headings = buildHeadingOutline(document, mainSurface, sections, pages);
546
636
 
547
637
  if (activeStory.kind === "main") {
548
638
  const activePageIndex = deriveActivePageIndex(graph, selectionHead);
549
639
  return {
550
640
  pageCount: pages.length,
551
641
  pages,
552
- headings: buildHeadings(graph),
642
+ headings,
553
643
  activePageIndex,
554
644
  activeSectionIndex: deriveActiveSectionIndex(graph, selectionHead),
555
645
  };
@@ -564,24 +654,19 @@ function buildNavigationFromGraph(
564
654
  return {
565
655
  pageCount: pages.length,
566
656
  pages,
567
- headings: buildHeadings(graph),
657
+ headings,
568
658
  activePageIndex: firstPage >= 0 ? firstPage : 0,
569
659
  activeSectionIndex: sectionIndex,
570
660
  };
571
661
  }
572
662
 
573
663
  if (activeStory.kind === "footnote" || activeStory.kind === "endnote") {
574
- const mainSurface = createEditorSurfaceSnapshot(
575
- document,
576
- createSelectionSnapshot(0, 0),
577
- MAIN_STORY_TARGET,
578
- );
579
664
  const referencePosition = findNoteReferencePosition(mainSurface, activeStory);
580
665
  const activePageIndex = deriveActivePageIndex(graph, referencePosition);
581
666
  return {
582
667
  pageCount: pages.length,
583
668
  pages,
584
- headings: buildHeadings(graph),
669
+ headings,
585
670
  activePageIndex,
586
671
  activeSectionIndex:
587
672
  graph.pages[activePageIndex]?.sectionIndex ??
@@ -592,20 +677,12 @@ function buildNavigationFromGraph(
592
677
  return {
593
678
  pageCount: pages.length,
594
679
  pages,
595
- headings: buildHeadings(graph),
680
+ headings,
596
681
  activePageIndex: 0,
597
682
  activeSectionIndex: 0,
598
683
  };
599
684
  }
600
685
 
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
686
  // ---------------------------------------------------------------------------
610
687
  // Convenience: find the active page node directly
611
688
  // ---------------------------------------------------------------------------
@@ -93,12 +93,21 @@ export function analyzeInvalidation(
93
93
  return analyzeSectionChange(reason, graph);
94
94
 
95
95
  case "numbering-change":
96
- // Numbering changes can affect indentation and spacing globally,
97
- // but could be bounded to sections using that numbering instance.
98
- // For now, full recompute.
96
+ if (!reason.numberingInstanceId) {
97
+ return {
98
+ scope: "full",
99
+ requiresFullRecompute: true,
100
+ dirtyFieldFamilies: [],
101
+ };
102
+ }
99
103
  return {
100
- scope: "full",
101
- requiresFullRecompute: true,
104
+ scope: "bounded",
105
+ requiresFullRecompute: false,
106
+ dirtyPageRange: {
107
+ firstPageIndex: 0,
108
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
109
+ },
110
+ dirtySectionRange: null,
102
111
  dirtyFieldFamilies: [],
103
112
  };
104
113
 
@@ -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
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Page-format catalog — named page sizes that back the section page-size
3
+ * picker and the real-dimension page frame.
4
+ *
5
+ * The canonical storage of page geometry remains
6
+ * `SectionProperties.pageSize` in twips. This catalog adds a layer of
7
+ * semantic naming on top so the UI can render "A4" or "US Letter" and
8
+ * the render kernel can pick a sensible default per locale.
9
+ *
10
+ * Unit reference (OOXML):
11
+ * - 1 inch = 1440 twips
12
+ * - 1 mm = 56.6929 twips
13
+ *
14
+ * The catalog deliberately ships a single default match tolerance (1 twip)
15
+ * so legacy documents whose page sizes were round-tripped through third-
16
+ * party tools still identify as the named format they were authored at.
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Public types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type PageFormatId =
24
+ | "letter"
25
+ | "legal"
26
+ | "tabloid"
27
+ | "executive"
28
+ | "a3"
29
+ | "a4"
30
+ | "a5"
31
+ | "b4-iso"
32
+ | "b5-iso"
33
+ | "custom";
34
+
35
+ export type PageFormatRegion = "us" | "iso" | "jp" | "custom";
36
+
37
+ export type PageFormatLocaleDefault = "en-us" | "en-gb" | "eu" | "jp";
38
+
39
+ export interface PageFormatDisplay {
40
+ inches?: string;
41
+ millimeters?: string;
42
+ }
43
+
44
+ export interface PageFormatDefinition {
45
+ /** Stable identifier. */
46
+ id: PageFormatId;
47
+ /** Human-facing label used in pickers. */
48
+ label: string;
49
+ /** Locale or region hint. */
50
+ region: PageFormatRegion;
51
+ /** Width in twips at portrait orientation. */
52
+ portraitWidthTwips: number;
53
+ /** Height in twips at portrait orientation. */
54
+ portraitHeightTwips: number;
55
+ /** Default-for-locale hint so consumers can pick a sensible default. */
56
+ localeDefault?: PageFormatLocaleDefault;
57
+ /** Pre-computed inches/millimeters strings for the picker. */
58
+ display: PageFormatDisplay;
59
+ }
60
+
61
+ export interface ActivePageFormat {
62
+ sectionIndex: number;
63
+ format: PageFormatDefinition;
64
+ orientation: "portrait" | "landscape";
65
+ /** True when the section matches a catalog format within the match tolerance. */
66
+ matchesCatalog: boolean;
67
+ /** When matchesCatalog is false, the raw twips (custom format). */
68
+ customTwips?: { width: number; height: number };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Catalog
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Pre-defined named page formats. Ordering intentionally puts the most
77
+ * common sizes (Letter, A4) first because UI dropdowns render in list order.
78
+ */
79
+ export const PAGE_FORMAT_CATALOG: readonly PageFormatDefinition[] = Object.freeze([
80
+ {
81
+ id: "letter",
82
+ label: "US Letter",
83
+ region: "us",
84
+ portraitWidthTwips: 12240,
85
+ portraitHeightTwips: 15840,
86
+ localeDefault: "en-us",
87
+ display: { inches: "8.5 × 11 in", millimeters: "215.9 × 279.4 mm" },
88
+ },
89
+ {
90
+ id: "a4",
91
+ label: "A4",
92
+ region: "iso",
93
+ portraitWidthTwips: 11906,
94
+ portraitHeightTwips: 16838,
95
+ localeDefault: "eu",
96
+ display: { inches: "8.27 × 11.69 in", millimeters: "210 × 297 mm" },
97
+ },
98
+ {
99
+ id: "legal",
100
+ label: "US Legal",
101
+ region: "us",
102
+ portraitWidthTwips: 12240,
103
+ portraitHeightTwips: 20160,
104
+ display: { inches: "8.5 × 14 in", millimeters: "215.9 × 355.6 mm" },
105
+ },
106
+ {
107
+ id: "tabloid",
108
+ label: "Tabloid / Ledger",
109
+ region: "us",
110
+ portraitWidthTwips: 15840,
111
+ portraitHeightTwips: 24480,
112
+ display: { inches: "11 × 17 in", millimeters: "279.4 × 431.8 mm" },
113
+ },
114
+ {
115
+ id: "executive",
116
+ label: "Executive",
117
+ region: "us",
118
+ portraitWidthTwips: 10440,
119
+ portraitHeightTwips: 15120,
120
+ display: { inches: "7.25 × 10.5 in", millimeters: "184.1 × 266.7 mm" },
121
+ },
122
+ {
123
+ id: "a3",
124
+ label: "A3",
125
+ region: "iso",
126
+ portraitWidthTwips: 16838,
127
+ portraitHeightTwips: 23811,
128
+ display: { inches: "11.69 × 16.54 in", millimeters: "297 × 420 mm" },
129
+ },
130
+ {
131
+ id: "a5",
132
+ label: "A5",
133
+ region: "iso",
134
+ portraitWidthTwips: 8391,
135
+ portraitHeightTwips: 11906,
136
+ display: { inches: "5.83 × 8.27 in", millimeters: "148 × 210 mm" },
137
+ },
138
+ {
139
+ id: "b4-iso",
140
+ label: "B4 (ISO)",
141
+ region: "iso",
142
+ portraitWidthTwips: 14173,
143
+ portraitHeightTwips: 20016,
144
+ display: { inches: "9.84 × 13.90 in", millimeters: "250 × 353 mm" },
145
+ },
146
+ {
147
+ id: "b5-iso",
148
+ label: "B5 (ISO)",
149
+ region: "iso",
150
+ portraitWidthTwips: 9977,
151
+ portraitHeightTwips: 14173,
152
+ display: { inches: "6.93 × 9.84 in", millimeters: "176 × 250 mm" },
153
+ },
154
+ {
155
+ id: "custom",
156
+ label: "Custom",
157
+ region: "custom",
158
+ portraitWidthTwips: 0,
159
+ portraitHeightTwips: 0,
160
+ display: {},
161
+ },
162
+ ]);
163
+
164
+ /**
165
+ * Match tolerance in twips. Historic round-trips through third-party tools
166
+ * sometimes drift by sub-twip amounts; a 1-twip window catches those while
167
+ * keeping intentionally custom sizes genuinely custom.
168
+ */
169
+ const MATCH_TOLERANCE_TWIPS = 1;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Matching
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export interface MatchPageFormatInput {
176
+ sectionIndex: number;
177
+ widthTwips: number;
178
+ heightTwips: number;
179
+ }
180
+
181
+ /**
182
+ * Match a section's page dimensions against the catalog.
183
+ *
184
+ * The matcher tolerates either orientation — if the supplied (width, height)
185
+ * match a catalog entry rotated by 90°, the active format is reported as
186
+ * `landscape`. Custom sizes fall through to the `custom` entry.
187
+ */
188
+ export function matchPageFormat(input: MatchPageFormatInput): ActivePageFormat {
189
+ const { sectionIndex, widthTwips, heightTwips } = input;
190
+
191
+ for (const format of PAGE_FORMAT_CATALOG) {
192
+ if (format.id === "custom") continue;
193
+
194
+ const portraitMatch =
195
+ near(widthTwips, format.portraitWidthTwips) &&
196
+ near(heightTwips, format.portraitHeightTwips);
197
+ const landscapeMatch =
198
+ near(widthTwips, format.portraitHeightTwips) &&
199
+ near(heightTwips, format.portraitWidthTwips);
200
+
201
+ if (portraitMatch || landscapeMatch) {
202
+ return {
203
+ sectionIndex,
204
+ format,
205
+ orientation: portraitMatch ? "portrait" : "landscape",
206
+ matchesCatalog: true,
207
+ };
208
+ }
209
+ }
210
+
211
+ const custom = PAGE_FORMAT_CATALOG.find((f) => f.id === "custom")!;
212
+ return {
213
+ sectionIndex,
214
+ format: custom,
215
+ orientation: widthTwips > heightTwips ? "landscape" : "portrait",
216
+ matchesCatalog: false,
217
+ customTwips: { width: widthTwips, height: heightTwips },
218
+ };
219
+ }
220
+
221
+ function near(a: number, b: number): boolean {
222
+ return Math.abs(a - b) <= MATCH_TOLERANCE_TWIPS;
223
+ }
224
+
225
+ /**
226
+ * Look up a format by id. Returns the `custom` entry for unknown ids so
227
+ * callers can chain `.format` reads without null-checking.
228
+ */
229
+ export function getPageFormatById(id: string): PageFormatDefinition {
230
+ const found = PAGE_FORMAT_CATALOG.find((f) => f.id === id);
231
+ if (found) return found;
232
+ return PAGE_FORMAT_CATALOG.find((f) => f.id === "custom")!;
233
+ }