@beyondwork/docx-react-component 1.0.102 → 1.0.104

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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +63 -1
  3. package/src/api/v3/_runtime-handle.ts +2 -0
  4. package/src/api/v3/ai/outline.ts +2 -7
  5. package/src/api/v3/runtime/geometry.ts +79 -0
  6. package/src/core/commands/formatting-commands.ts +8 -7
  7. package/src/core/commands/paragraph-layout-commands.ts +11 -10
  8. package/src/core/commands/section-layout-commands.ts +7 -6
  9. package/src/core/commands/style-commands.ts +3 -2
  10. package/src/io/normalize/normalize-text.ts +6 -5
  11. package/src/io/ooxml/parse-anchor.ts +15 -15
  12. package/src/io/ooxml/parse-drawing.ts +103 -5
  13. package/src/io/ooxml/parse-fields.ts +43 -21
  14. package/src/io/ooxml/parse-font-table.ts +2 -1
  15. package/src/io/ooxml/parse-footnotes.ts +3 -2
  16. package/src/io/ooxml/parse-headers-footers.ts +7 -6
  17. package/src/io/ooxml/parse-main-document.ts +41 -40
  18. package/src/io/ooxml/parse-numbering.ts +3 -2
  19. package/src/io/ooxml/parse-object.ts +6 -6
  20. package/src/io/ooxml/parse-paragraph-formatting.ts +12 -11
  21. package/src/io/ooxml/parse-picture.ts +16 -16
  22. package/src/io/ooxml/parse-run-formatting.ts +11 -10
  23. package/src/io/ooxml/parse-settings.ts +2 -1
  24. package/src/io/ooxml/parse-shapes.ts +148 -17
  25. package/src/io/ooxml/parse-styles.ts +16 -16
  26. package/src/io/ooxml/parse-theme.ts +5 -4
  27. package/src/model/canonical-document.ts +869 -836
  28. package/src/model/canonical-layout-inputs.ts +979 -0
  29. package/src/model/layout/index.ts +6 -0
  30. package/src/model/layout/page-graph-types.ts +61 -0
  31. package/src/model/layout/runtime-page-graph-types.ts +10 -0
  32. package/src/runtime/collab/runtime-collab-sync.ts +3 -3
  33. package/src/runtime/debug/build-debug-inspector-snapshot.ts +17 -4
  34. package/src/runtime/document-runtime.ts +30 -14
  35. package/src/runtime/event-refresh-hints.ts +3 -0
  36. package/src/runtime/formatting/document-lookup.ts +3 -2
  37. package/src/runtime/formatting/formatting-context.ts +176 -34
  38. package/src/runtime/formatting/index.ts +20 -0
  39. package/src/runtime/formatting/layout-inputs.ts +320 -0
  40. package/src/runtime/formatting/numbering/geometry.ts +13 -12
  41. package/src/runtime/formatting/style-cascade.ts +2 -1
  42. package/src/runtime/formatting/table-style-resolver.ts +8 -7
  43. package/src/runtime/geometry/caret-geometry.ts +82 -10
  44. package/src/runtime/geometry/geometry-facet.ts +36 -0
  45. package/src/runtime/geometry/geometry-index.ts +891 -0
  46. package/src/runtime/geometry/geometry-types.ts +221 -1
  47. package/src/runtime/geometry/index.ts +26 -0
  48. package/src/runtime/geometry/inert-geometry-facet.ts +3 -0
  49. package/src/runtime/geometry/replacement-envelope.ts +41 -2
  50. package/src/runtime/layout/layout-engine-version.ts +16 -1
  51. package/src/runtime/layout/page-graph.ts +191 -1
  52. package/src/runtime/prerender/graph-canonicalize.ts +30 -0
  53. package/src/runtime/surface-projection.ts +74 -39
  54. package/src/runtime/workflow/coordinator.ts +57 -11
  55. package/src/session/import/normalize.ts +2 -1
  56. package/src/session/import/source-package-evidence.ts +612 -1
  57. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +3 -0
@@ -59,6 +59,12 @@
59
59
  export type {
60
60
  RuntimePageRegions,
61
61
  RuntimePageRegion,
62
+ RuntimeTwipsRect,
63
+ RuntimeResolvedRegions,
64
+ RuntimeExclusionZone,
65
+ RuntimeLayoutDivergenceKind,
66
+ RuntimeLayoutDivergence,
67
+ RuntimePageFrame,
62
68
  RuntimeBlockFragment,
63
69
  RuntimeLineBox,
64
70
  RuntimeNoteAllocation,
@@ -48,6 +48,17 @@ export interface RuntimePageRegions {
48
48
  footnotes?: RuntimePageRegion[];
49
49
  }
50
50
 
51
+ export interface RuntimeTwipsRect {
52
+ /** Twips offset from the physical page left edge. */
53
+ xTwips: number;
54
+ /** Twips offset from the physical page top edge. */
55
+ yTwips: number;
56
+ /** Width in twips. */
57
+ widthTwips: number;
58
+ /** Height in twips. */
59
+ heightTwips: number;
60
+ }
61
+
51
62
  export interface RuntimePageRegion {
52
63
  kind: "body" | "header" | "footer" | "column" | "footnote-area";
53
64
  /** Twips offset from page top (header) or similar region-specific origin. */
@@ -56,10 +67,60 @@ export interface RuntimePageRegion {
56
67
  widthTwips: number;
57
68
  /** Height in twips. */
58
69
  heightTwips: number;
70
+ /**
71
+ * PE2 Slice 2 — explicit twips-space frame for this region.
72
+ * This is semantic layout geometry, not rendered pixel geometry.
73
+ */
74
+ rectTwips?: RuntimeTwipsRect;
59
75
  /** IDs of block fragments rendered in this region, in order. */
60
76
  fragmentIds: string[];
61
77
  }
62
78
 
79
+ export interface RuntimeResolvedRegions {
80
+ body: RuntimePageRegion;
81
+ header?: RuntimePageRegion;
82
+ footer?: RuntimePageRegion;
83
+ columns?: RuntimePageRegion[];
84
+ footnotes?: RuntimePageRegion[];
85
+ exclusionZones: RuntimeExclusionZone[];
86
+ }
87
+
88
+ export interface RuntimeExclusionZone {
89
+ rectTwips: RuntimeTwipsRect;
90
+ objectId: string;
91
+ wrapMode: string;
92
+ layoutInCell?: boolean;
93
+ }
94
+
95
+ export type RuntimeLayoutDivergenceKind =
96
+ | "frame-collision"
97
+ | "intentional-overflow"
98
+ | "unsupported-wrap"
99
+ | "stale-field-label"
100
+ | "preserve-only-placeholder";
101
+
102
+ export interface RuntimeLayoutDivergence {
103
+ divergenceId: string;
104
+ kind: RuntimeLayoutDivergenceKind;
105
+ source: "runtime";
106
+ severity: "info" | "warning" | "error";
107
+ message: string;
108
+ regionKinds?: RuntimePageRegion["kind"][];
109
+ fragmentIds?: string[];
110
+ }
111
+
112
+ export interface RuntimePageFrame {
113
+ frameId: string;
114
+ pageId: string;
115
+ pageIndex: number;
116
+ sectionIndex: number;
117
+ displayPageNumber: number;
118
+ physicalBoundsTwips: RuntimeTwipsRect;
119
+ regions: RuntimeResolvedRegions;
120
+ divergenceIds: string[];
121
+ signature: string;
122
+ }
123
+
63
124
  // ---------------------------------------------------------------------------
64
125
  // Fragment types
65
126
  // ---------------------------------------------------------------------------
@@ -16,9 +16,11 @@
16
16
 
17
17
  import type {
18
18
  RuntimeBlockFragment,
19
+ RuntimeLayoutDivergence,
19
20
  RuntimeLineBox,
20
21
  RuntimeNoteAllocation,
21
22
  RuntimePageAnchor,
23
+ RuntimePageFrame,
22
24
  RuntimePageRegions,
23
25
  } from "./page-graph-types.ts";
24
26
  import type {
@@ -63,6 +65,14 @@ export interface RuntimePageNode {
63
65
  stories: ResolvedPageStories;
64
66
  /** Sub-regions on the page. */
65
67
  regions: RuntimePageRegions;
68
+ /**
69
+ * PE2 Slice 2 — page-level frame record for consumers that need a
70
+ * single immutable twips-space layout payload instead of reconstructing
71
+ * it from `layout` + `regions`.
72
+ */
73
+ frame?: RuntimePageFrame;
74
+ /** Typed layout limitations or overlaps detected while building this page. */
75
+ divergences?: RuntimeLayoutDivergence[];
66
76
  /** Line boxes rendered in the body region. */
67
77
  lineBoxes: RuntimeLineBox[];
68
78
  /** Footnote allocations reserved at the bottom of the page. */
@@ -473,11 +473,11 @@ export function createRuntimeCollabSync(
473
473
  // For a late joiner it may already be populated — the seed ensures the
474
474
  // runtime reflects pre-existing shared state at attach time (i.e. if a
475
475
  // peer already set `lockedMode`, the new peer starts out locked).
476
- runtime.setSharedWorkflowState(workflowShared.get());
476
+ runtime.setSharedWorkflowState(workflowShared.get(), { source: "collab" });
477
477
 
478
478
  const workflowUnsub = workflowShared.subscribe((state) => {
479
479
  if (readOnly) return; // don't propagate while in read-only (post-mismatch)
480
- runtime.setSharedWorkflowState(state);
480
+ runtime.setSharedWorkflowState(state, { source: "collab" });
481
481
  });
482
482
 
483
483
  const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
@@ -607,7 +607,7 @@ export function createRuntimeCollabSync(
607
607
  yCheckpoints.unobserve(onCheckpointsChange);
608
608
  workflowUnsub();
609
609
  workflowShared.destroy();
610
- runtime.setSharedWorkflowState(null);
610
+ runtime.setSharedWorkflowState(null, { source: "runtime" });
611
611
  listeners.clear();
612
612
  // R3 — abort the current remote-activity signal so any lingering
613
613
  // idle consumers detect the shutdown.
@@ -576,13 +576,23 @@ function projectLayout(
576
576
  runtime: DocumentRuntime,
577
577
  fallbacks: string[],
578
578
  ): DebugInspectorLayoutSection {
579
- const pageLayout = safeCall(
579
+ const graphPageCount = safeCall(
580
580
  runtime,
581
- (r) => r.getPageLayoutSnapshot(),
581
+ (r) => r.getLayoutFacet().getPageCount(),
582
582
  null,
583
583
  fallbacks,
584
- "page-layout-summary",
584
+ "layout-page-count",
585
585
  );
586
+ const pageLayout =
587
+ graphPageCount === null
588
+ ? safeCall(
589
+ runtime,
590
+ (r) => r.getPageLayoutSnapshot(),
591
+ null,
592
+ fallbacks,
593
+ "page-layout-summary",
594
+ )
595
+ : null;
586
596
  const sections = safeCall(runtime, (r) => r.getSections(), [], fallbacks, "sections");
587
597
  const stories = safeCall(
588
598
  runtime,
@@ -592,7 +602,10 @@ function projectLayout(
592
602
  "document-text-stream",
593
603
  );
594
604
  return {
595
- pageCount: (pageLayout as { pages?: unknown[] } | null)?.pages?.length ?? null,
605
+ pageCount:
606
+ graphPageCount ??
607
+ (pageLayout as { pages?: unknown[] } | null)?.pages?.length ??
608
+ null,
596
609
  sectionCount: sections.length,
597
610
  storyCount: stories.length,
598
611
  };
@@ -82,6 +82,7 @@ import type {
82
82
  WorkflowCandidateRange,
83
83
  WorkflowCandidateRangeOptions,
84
84
  WorkflowBlockedCommandReason,
85
+ WorkflowEventOrigin,
85
86
  WorkflowMetadataDefinition,
86
87
  OverlayKind,
87
88
  OverlayVisibilityPolicy,
@@ -764,7 +765,10 @@ export interface DocumentRuntime {
764
765
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
765
766
  clearWorkflowOverlay(): void;
766
767
  getWorkflowOverlay(): WorkflowOverlay | null;
767
- setSharedWorkflowState(state: SharedWorkflowState | null): void;
768
+ setSharedWorkflowState(
769
+ state: SharedWorkflowState | null,
770
+ origin?: WorkflowEventOrigin,
771
+ ): void;
768
772
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
769
773
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
770
774
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -1880,6 +1884,19 @@ export function createDocumentRuntime(
1880
1884
  nextActiveStory: EditorStoryTarget,
1881
1885
  ): DocumentNavigationSnapshot {
1882
1886
  const activeStoryKey = storyTargetKey(nextActiveStory);
1887
+ const buildSnapshot = (): DocumentNavigationSnapshot =>
1888
+ layoutEngine.getNavigationSnapshot(
1889
+ {
1890
+ document: nextState.document,
1891
+ viewState: {
1892
+ activeStory: nextActiveStory,
1893
+ workspaceMode: viewState.workspaceMode,
1894
+ zoomLevel: viewState.zoomLevel,
1895
+ },
1896
+ },
1897
+ nextState.selection,
1898
+ nextActiveStory,
1899
+ );
1883
1900
  if (
1884
1901
  cachedNavigation &&
1885
1902
  cachedNavigation.revisionToken === nextState.revisionToken &&
@@ -1889,11 +1906,7 @@ export function createDocumentRuntime(
1889
1906
  return cachedNavigation.snapshot;
1890
1907
  }
1891
1908
 
1892
- const snapshot = createDocumentNavigationSnapshot(
1893
- nextState.document,
1894
- nextState.selection.head,
1895
- nextActiveStory,
1896
- );
1909
+ const snapshot = buildSnapshot();
1897
1910
  if (
1898
1911
  snapshot.activePageIndex === cachedNavigation.snapshot.activePageIndex &&
1899
1912
  snapshot.activeSectionIndex === cachedNavigation.snapshot.activeSectionIndex
@@ -1915,11 +1928,7 @@ export function createDocumentRuntime(
1915
1928
  return snapshot;
1916
1929
  }
1917
1930
 
1918
- const snapshot = createDocumentNavigationSnapshot(
1919
- nextState.document,
1920
- nextState.selection.head,
1921
- nextActiveStory,
1922
- );
1931
+ const snapshot = buildSnapshot();
1923
1932
  recordPerfSample("snapshot.navigation");
1924
1933
  incrementInvalidationCounter("runtime.snapshot.navigationMisses");
1925
1934
  cachedNavigation = {
@@ -4560,6 +4569,7 @@ export function createDocumentRuntime(
4560
4569
  // P5 — TOC entries print Word's display number (honors page-
4561
4570
  // number restarts), not the raw 0-based pageIndex+1.
4562
4571
  (pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
4572
+ getCachedDocumentNavigationSnapshot(state, activeStory),
4563
4573
  );
4564
4574
  if (refreshed.changed) {
4565
4575
  this.dispatch({
@@ -4709,8 +4719,8 @@ export function createDocumentRuntime(
4709
4719
  getWorkflowOverlay() {
4710
4720
  return workflowCoordinator.getWorkflowOverlay();
4711
4721
  },
4712
- setSharedWorkflowState(sharedState) {
4713
- workflowCoordinator.setSharedWorkflowState(sharedState);
4722
+ setSharedWorkflowState(sharedState, origin) {
4723
+ workflowCoordinator.setSharedWorkflowState(sharedState, origin);
4714
4724
  },
4715
4725
  getWorkflowScopeSnapshot() {
4716
4726
  return workflowCoordinator.getWorkflowScopeSnapshot();
@@ -5935,6 +5945,7 @@ export function createDocumentRuntime(
5935
5945
  activeStory,
5936
5946
  undefined,
5937
5947
  (pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
5948
+ getCachedDocumentNavigationSnapshot(state, activeStory),
5938
5949
  );
5939
5950
  perfCounters.increment("toc.autoRefresh.us", Math.round((performance.now() - t) * 1000));
5940
5951
  if (!refreshed.changed) {
@@ -6144,6 +6155,9 @@ export function createDocumentRuntime(
6144
6155
  case "story_changed":
6145
6156
  case "workflow_overlay_changed":
6146
6157
  case "workflow_active_work_item_changed":
6158
+ case "workflow_shared_state_changed":
6159
+ case "workflow_visibility_policy_changed":
6160
+ case "workflow_markup_mode_policy_changed":
6147
6161
  case "change_authored":
6148
6162
  case "change_accepted":
6149
6163
  case "change_rejected":
@@ -7482,6 +7496,7 @@ function refreshDocumentTableOfContents(
7482
7496
  activeStory: EditorStoryTarget,
7483
7497
  options?: TocRefreshOptions,
7484
7498
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7499
+ navigationSnapshot?: DocumentNavigationSnapshot,
7485
7500
  ): {
7486
7501
  document: CanonicalDocumentEnvelope;
7487
7502
  result: TocRefreshResult;
@@ -7511,7 +7526,8 @@ function refreshDocumentTableOfContents(
7511
7526
  };
7512
7527
  }
7513
7528
 
7514
- const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
7529
+ const navigation =
7530
+ navigationSnapshot ?? createDocumentNavigationSnapshot(document, selectionHead, activeStory);
7515
7531
  // Build a single O(N) map from paragraph offset → bookmark name so the
7516
7532
  // per-heading lookup below is O(1) instead of O(N) per heading.
7517
7533
  const bookmarkNameByOffset = new Map<number, string>();
@@ -25,6 +25,9 @@ export function describeEventImpact(
25
25
  };
26
26
  case "workflow_overlay_changed":
27
27
  case "workflow_active_work_item_changed":
28
+ case "workflow_shared_state_changed":
29
+ case "workflow_visibility_policy_changed":
30
+ case "workflow_markup_mode_policy_changed":
28
31
  return {
29
32
  invalidate: [
30
33
  "workflowScope",
@@ -26,6 +26,7 @@ import type {
26
26
  TableNode,
27
27
  TextMark,
28
28
  TextNode,
29
+ Mutable,
29
30
  } from "../../model/canonical-document.ts";
30
31
 
31
32
  export interface FoundParagraph {
@@ -165,7 +166,7 @@ export function canonicalMarksToRunFormatting(
165
166
  marks: readonly TextMark[] | undefined,
166
167
  ): CanonicalRunFormatting | undefined {
167
168
  if (!marks || marks.length === 0) return undefined;
168
- const direct: CanonicalRunFormatting = {};
169
+ const direct: Mutable<CanonicalRunFormatting> = {};
169
170
  for (const mark of marks) {
170
171
  switch (mark.type) {
171
172
  case "bold":
@@ -190,7 +191,7 @@ export function canonicalMarksToRunFormatting(
190
191
  direct.smallCaps = true;
191
192
  break;
192
193
  case "fontFamily":
193
- direct.fontFamily = mark.val;
194
+ (direct as Mutable<typeof direct>).fontFamily = mark.val;
194
195
  direct.fontFamilyAscii = mark.val;
195
196
  break;
196
197
  case "fontSize":
@@ -44,7 +44,9 @@ import type {
44
44
  RevisionRecord,
45
45
  TableNode,
46
46
  TableStyleConditionalRegion,
47
+ Mutable,
47
48
  } from "../../model/canonical-document.ts";
49
+ import { collectCanonicalFieldRegionIdentities } from "../../model/canonical-layout-inputs.ts";
48
50
  import type { FieldPageGraph, FieldResolver, ResolvedField } from "./field/resolver.ts";
49
51
  import { createFieldResolver } from "./field/resolver.ts";
50
52
  import { ThemeColorResolver, concretizeThemeColors } from "./theme-color.ts";
@@ -67,7 +69,22 @@ import type {
67
69
  EffectiveNumbering,
68
70
  EffectiveParagraphFormatting,
69
71
  EffectiveRunFormatting,
72
+ RevisionDisplayFlags,
70
73
  } from "./formatting-types.ts";
74
+ import {
75
+ buildEffectiveLayoutFormatting,
76
+ buildRevisionLayoutPosture,
77
+ collectFieldLayoutInputs,
78
+ mergeLayoutTabStops,
79
+ toFieldLayoutInput,
80
+ toLayoutTabStops,
81
+ toNumberingLayoutInput,
82
+ type EffectiveLayoutFormatting,
83
+ type FieldLayoutInput,
84
+ type LayoutTabStopInput,
85
+ type NumberingLayoutInput,
86
+ type RevisionLayoutPosture,
87
+ } from "./layout-inputs.ts";
71
88
 
72
89
  /**
73
90
  * Direct-text-mark direct formatting. Mirrors (and replaces) the
@@ -242,6 +259,16 @@ export interface FormattingContext {
242
259
  options?: { advance?: boolean; emitGeometry?: boolean },
243
260
  ): NumberingResolution | null;
244
261
 
262
+ /**
263
+ * PE2 layout-ready numbering input. Normalizes the existing prefix result
264
+ * into marker/text-column/tab-stop fields L04 can measure without
265
+ * reinterpreting numbering semantics.
266
+ */
267
+ resolveNumberingLayoutInput(
268
+ para: ParagraphNode,
269
+ options?: { advance?: boolean; emitGeometry?: boolean },
270
+ ): NumberingLayoutInput | undefined;
271
+
245
272
  /**
246
273
  * Per-paragraph numbering-marker rPr cascade (ECMA-376 §17.9).
247
274
  * Mirrors `resolveNumberingMarkerRunFormatting` with the layer's
@@ -318,6 +345,29 @@ export interface FormattingContext {
318
345
  * context has no page-graph (so the resolver was never built). */
319
346
  resolveField(entry: FieldRegistryEntry): ResolvedField | undefined;
320
347
 
348
+ /** Resolve one registered field into the PE2 field-layout input shape. */
349
+ resolveFieldLayoutInput(entry: FieldRegistryEntry): FieldLayoutInput;
350
+
351
+ /** Resolve every field/TOC registry entry into PE2 field-layout inputs. */
352
+ collectFieldLayoutInputs(): readonly FieldLayoutInput[];
353
+
354
+ /** Resolve paragraph tab stops into L04-ready twip/leader inputs. */
355
+ resolveParagraphTabStops(para: ParagraphNode): readonly LayoutTabStopInput[];
356
+
357
+ /**
358
+ * Consolidated PE2 paragraph formatting bundle. This is the L03-owned
359
+ * handoff shape for L04 measurement: paragraph cascade, run formatting,
360
+ * numbering input, paragraph/numbering tabs, compatibility flags, revision
361
+ * posture, and a stable structural hash are resolved in one context call.
362
+ */
363
+ resolveParagraphLayoutFormatting(input: {
364
+ readonly paragraph: ParagraphNode;
365
+ readonly runs?: readonly EffectiveRunFormatting[];
366
+ readonly revisionDisplays?: readonly RevisionDisplayFlags[];
367
+ readonly revisionMode?: RevisionLayoutPosture["mode"];
368
+ readonly compatFlags?: readonly string[];
369
+ }): EffectiveLayoutFormatting;
370
+
321
371
  /** Resolve the effective font family for a run following
322
372
  * ECMA-376 §17.3.2.26 precedence + theme-minor fallback. */
323
373
  resolveFontFamily(input: RunResolveInput, themeMinorFont?: string): string | undefined;
@@ -489,6 +539,13 @@ class FormattingContextImpl implements FormattingContext {
489
539
  return this.numbering.resolveDetailed(effectiveNumbering, para);
490
540
  }
491
541
 
542
+ resolveNumberingLayoutInput(
543
+ para: ParagraphNode,
544
+ options: { advance?: boolean; emitGeometry?: boolean } = {},
545
+ ): NumberingLayoutInput | undefined {
546
+ return toNumberingLayoutInput(this.resolveParagraphNumbering(para, options));
547
+ }
548
+
492
549
  resolveNumberingMarkerRunFormatting(
493
550
  paragraphStyleId: string | undefined,
494
551
  levelRunProperties: CanonicalRunFormatting | undefined,
@@ -599,6 +656,61 @@ class FormattingContextImpl implements FormattingContext {
599
656
  return this.field.resolve(entry);
600
657
  }
601
658
 
659
+ resolveFieldLayoutInput(entry: FieldRegistryEntry): FieldLayoutInput {
660
+ return toFieldLayoutInput(entry, this.resolveField(entry));
661
+ }
662
+
663
+ collectFieldLayoutInputs(): readonly FieldLayoutInput[] {
664
+ return collectFieldLayoutInputs(this.doc.fieldRegistry, (entry) =>
665
+ this.resolveField(entry),
666
+ collectCanonicalFieldRegionIdentities(this.doc),
667
+ );
668
+ }
669
+
670
+ resolveParagraphTabStops(para: ParagraphNode): readonly LayoutTabStopInput[] {
671
+ return toLayoutTabStops(this.resolveParagraphCascade(para).tabStops, "paragraph");
672
+ }
673
+
674
+ resolveParagraphLayoutFormatting(input: {
675
+ readonly paragraph: ParagraphNode;
676
+ readonly runs?: readonly EffectiveRunFormatting[];
677
+ readonly revisionDisplays?: readonly RevisionDisplayFlags[];
678
+ readonly revisionMode?: RevisionLayoutPosture["mode"];
679
+ readonly compatFlags?: readonly string[];
680
+ }): EffectiveLayoutFormatting {
681
+ const numberingDetail = this.resolveParagraphNumbering(input.paragraph, { advance: true });
682
+ const numbering = toNumberingLayoutInput(numberingDetail);
683
+ const paragraph = buildEffectiveParagraphFormatting(
684
+ this.resolveParagraphCascade(input.paragraph),
685
+ numberingDetail,
686
+ this.theme,
687
+ );
688
+ const runs = input.runs ?? [];
689
+ const revisionDisplays =
690
+ input.revisionDisplays ??
691
+ runs
692
+ .map((run) => run.revisionDisplay)
693
+ .filter((display): display is RevisionDisplayFlags => display !== undefined);
694
+ const revisionMode = input.revisionMode ?? revisionDisplays[0]?.markupMode;
695
+ const revisionPosture = revisionMode
696
+ ? buildRevisionLayoutPosture(revisionMode, revisionDisplays)
697
+ : undefined;
698
+ return buildEffectiveLayoutFormatting({
699
+ paragraph,
700
+ runs,
701
+ ...(numbering ? { numbering } : {}),
702
+ tabs: mergeLayoutTabStops(
703
+ this.resolveParagraphTabStops(input.paragraph),
704
+ numbering?.associatedTabStops,
705
+ ),
706
+ compatFlags: mergeCompatFlags(
707
+ collectParagraphCompatFlags(paragraph),
708
+ input.compatFlags ?? [],
709
+ ),
710
+ ...(revisionPosture ? { revisionPosture } : {}),
711
+ });
712
+ }
713
+
602
714
  resolveFontFamily(
603
715
  input: RunResolveInput,
604
716
  themeMinorFont?: string,
@@ -614,17 +726,11 @@ class FormattingContextImpl implements FormattingContext {
614
726
 
615
727
  resolveParagraph(para: ParagraphNode): EffectiveParagraphFormatting {
616
728
  const cascade = this.resolveParagraphCascade(para);
617
- const numbering = buildEffectiveNumbering(
729
+ return buildEffectiveParagraphFormatting(
730
+ cascade,
618
731
  this.resolveParagraphNumbering(para, { advance: true }),
732
+ this.theme,
619
733
  );
620
- const paragraphMarkRun = cascade.paragraphMarkRunProperties && this.theme
621
- ? (concretizeThemeColors(cascade.paragraphMarkRunProperties, this.theme) as EffectiveRunFormatting)
622
- : (cascade.paragraphMarkRunProperties as EffectiveRunFormatting | undefined);
623
- return {
624
- ...cascade,
625
- ...(numbering ? { numbering } : {}),
626
- ...(paragraphMarkRun ? { paragraphMarkRun } : {}),
627
- };
628
734
  }
629
735
 
630
736
  resolveRunWithProvenance(input: {
@@ -635,7 +741,7 @@ class FormattingContextImpl implements FormattingContext {
635
741
  // Walk each tier in priority-ascending order, recording which tier
636
742
  // last set each field. Highest-priority writer wins — same order
637
743
  // as `resolveEffectiveRunFormatting` but with provenance tracked.
638
- const properties: { [K in keyof CanonicalRunFormatting]?: RunResolvedProperty } = {};
744
+ const properties: { -readonly [K in keyof CanonicalRunFormatting]?: RunResolvedProperty } = {};
639
745
  const applyTier = (
640
746
  tierRecord: CanonicalRunFormatting | undefined,
641
747
  source: RunResolvedProperty["source"],
@@ -755,25 +861,25 @@ function buildDirectRunFormattingFromProjected(
755
861
  projected: ProjectedRunMarks | undefined,
756
862
  ): CanonicalRunFormatting | undefined {
757
863
  if (!projected) return undefined;
758
- const direct: CanonicalRunFormatting = {};
864
+ const direct: Mutable<CanonicalRunFormatting> = {};
759
865
  const marks = projected.marks;
760
866
  if (marks) {
761
- if (marks.includes("bold")) direct.bold = true;
762
- if (marks.includes("italic")) direct.italic = true;
763
- if (marks.includes("underline")) direct.underline = "single";
764
- if (marks.includes("strikethrough")) direct.strikethrough = true;
765
- if (marks.includes("doubleStrikethrough")) direct.doubleStrikethrough = true;
766
- if (marks.includes("vanish")) direct.vanish = true;
767
- if (marks.includes("allCaps")) direct.allCaps = true;
867
+ if (marks.includes("bold")) (direct as Mutable<typeof direct>).bold = true;
868
+ if (marks.includes("italic")) (direct as Mutable<typeof direct>).italic = true;
869
+ if (marks.includes("underline")) (direct as Mutable<typeof direct>).underline = "single";
870
+ if (marks.includes("strikethrough")) (direct as Mutable<typeof direct>).strikethrough = true;
871
+ if (marks.includes("doubleStrikethrough")) (direct as Mutable<typeof direct>).doubleStrikethrough = true;
872
+ if (marks.includes("vanish")) (direct as Mutable<typeof direct>).vanish = true;
873
+ if (marks.includes("allCaps")) (direct as Mutable<typeof direct>).allCaps = true;
768
874
  if (marks.includes("smallCaps")) direct.smallCaps = true;
769
875
  }
770
876
  const markAttrs = projected.markAttrs;
771
877
  if (markAttrs) {
772
878
  if (markAttrs.fontFamily) {
773
- direct.fontFamily = markAttrs.fontFamily;
879
+ (direct as Mutable<typeof direct>).fontFamily = markAttrs.fontFamily;
774
880
  direct.fontFamilyAscii = markAttrs.fontFamily;
775
881
  }
776
- if (typeof markAttrs.fontSize === "number") {
882
+ if (typeof (markAttrs as Mutable<typeof markAttrs>).fontSize === "number") {
777
883
  direct.fontSizeHalfPoints = markAttrs.fontSize;
778
884
  }
779
885
  if (markAttrs.textColor) {
@@ -789,20 +895,20 @@ function buildDirectRunFormattingFromProjected(
789
895
  function extractDirectParagraphFormatting(
790
896
  para: ParagraphNode,
791
897
  ): CanonicalParagraphFormatting {
792
- const direct: CanonicalParagraphFormatting = {};
793
- if (para.alignment !== undefined) direct.alignment = para.alignment;
794
- if (para.spacing !== undefined) direct.spacing = para.spacing;
795
- if (para.contextualSpacing !== undefined) direct.contextualSpacing = para.contextualSpacing;
796
- if (para.indentation !== undefined) direct.indentation = para.indentation;
797
- if (para.tabStops !== undefined) direct.tabStops = para.tabStops;
798
- if (para.keepNext !== undefined) direct.keepNext = para.keepNext;
799
- if (para.keepLines !== undefined) direct.keepLines = para.keepLines;
800
- if (para.outlineLevel !== undefined) direct.outlineLevel = para.outlineLevel;
801
- if (para.pageBreakBefore !== undefined) direct.pageBreakBefore = para.pageBreakBefore;
802
- if (para.widowControl !== undefined) direct.widowControl = para.widowControl;
803
- if (para.borders !== undefined) direct.borders = para.borders;
804
- if (para.shading !== undefined) direct.shading = para.shading;
805
- if (para.bidi !== undefined) direct.bidi = para.bidi;
898
+ const direct: Mutable<CanonicalParagraphFormatting> = {};
899
+ if (para.alignment !== undefined) (direct as Mutable<typeof direct>).alignment = para.alignment;
900
+ if (para.spacing !== undefined) (direct as Mutable<typeof direct>).spacing = para.spacing;
901
+ if (para.contextualSpacing !== undefined) (direct as Mutable<typeof direct>).contextualSpacing = para.contextualSpacing;
902
+ if (para.indentation !== undefined) (direct as Mutable<typeof direct>).indentation = para.indentation;
903
+ if (para.tabStops !== undefined) (direct as Mutable<typeof direct>).tabStops = para.tabStops;
904
+ if (para.keepNext !== undefined) (direct as Mutable<typeof direct>).keepNext = para.keepNext;
905
+ if (para.keepLines !== undefined) (direct as Mutable<typeof direct>).keepLines = para.keepLines;
906
+ if (para.outlineLevel !== undefined) (direct as Mutable<typeof direct>).outlineLevel = para.outlineLevel;
907
+ if (para.pageBreakBefore !== undefined) (direct as Mutable<typeof direct>).pageBreakBefore = para.pageBreakBefore;
908
+ if (para.widowControl !== undefined) (direct as Mutable<typeof direct>).widowControl = para.widowControl;
909
+ if (para.borders !== undefined) (direct as Mutable<typeof direct>).borders = para.borders;
910
+ if (para.shading !== undefined) (direct as Mutable<typeof direct>).shading = para.shading;
911
+ if (para.bidi !== undefined) (direct as Mutable<typeof direct>).bidi = para.bidi;
806
912
  if (para.suppressLineNumbers !== undefined) direct.suppressLineNumbers = para.suppressLineNumbers;
807
913
  return direct;
808
914
  }
@@ -869,6 +975,42 @@ function buildEffectiveNumbering(
869
975
  return result;
870
976
  }
871
977
 
978
+ function buildEffectiveParagraphFormatting(
979
+ cascade: CanonicalParagraphFormatting,
980
+ numberingDetail: NumberingResolution | null,
981
+ theme: ThemeColorResolver | undefined,
982
+ ): EffectiveParagraphFormatting {
983
+ const numbering = buildEffectiveNumbering(numberingDetail);
984
+ const paragraphMarkRun = cascade.paragraphMarkRunProperties && theme
985
+ ? (concretizeThemeColors(cascade.paragraphMarkRunProperties, theme) as EffectiveRunFormatting)
986
+ : (cascade.paragraphMarkRunProperties as EffectiveRunFormatting | undefined);
987
+ return {
988
+ ...cascade,
989
+ ...(numbering ? { numbering } : {}),
990
+ ...(paragraphMarkRun ? { paragraphMarkRun } : {}),
991
+ };
992
+ }
993
+
994
+ function collectParagraphCompatFlags(
995
+ paragraph: EffectiveParagraphFormatting,
996
+ ): readonly string[] {
997
+ const flags: string[] = [];
998
+ if (paragraph.keepNext === true) flags.push("keepNext");
999
+ if (paragraph.keepLines === true) flags.push("keepLines");
1000
+ if (paragraph.pageBreakBefore === true) flags.push("pageBreakBefore");
1001
+ if (paragraph.contextualSpacing === true) flags.push("contextualSpacing");
1002
+ if (paragraph.widowControl === false) flags.push("widowControlDisabled");
1003
+ if (paragraph.bidi === true) flags.push("bidi");
1004
+ if (paragraph.suppressLineNumbers === true) flags.push("suppressLineNumbers");
1005
+ return flags;
1006
+ }
1007
+
1008
+ function mergeCompatFlags(
1009
+ ...groups: readonly (readonly string[] | undefined)[]
1010
+ ): readonly string[] {
1011
+ return [...new Set(groups.flatMap((group) => group ?? []))].sort();
1012
+ }
1013
+
872
1014
  // Type-only re-export of HyperlinkNode so callers can type their walk.
873
1015
  export type { HyperlinkNode, InlineNode };
874
1016
 
@@ -103,6 +103,26 @@ export { formatPageNumber } from "./field/page-number-format.ts";
103
103
 
104
104
  export { rebuildFieldRegistry } from "./field/registry.ts";
105
105
 
106
+ // ── PE2 layout-ready formatting inputs ───────────────────────────────────
107
+ export {
108
+ buildEffectiveLayoutFormatting,
109
+ buildRevisionLayoutPosture,
110
+ collectFieldLayoutInputs,
111
+ createStructuralHash,
112
+ mergeLayoutTabStops,
113
+ normalizeNumberingMarkerSuffix,
114
+ toMeasuredRevisionLayoutPosture,
115
+ toFieldLayoutInput,
116
+ toLayoutTabStops,
117
+ toNumberingLayoutInput,
118
+ type EffectiveLayoutFormatting,
119
+ type FieldLayoutInput,
120
+ type LayoutMarkerSuffix,
121
+ type LayoutTabStopInput,
122
+ type NumberingLayoutInput,
123
+ type RevisionLayoutPosture,
124
+ } from "./layout-inputs.ts";
125
+
106
126
  // ── Debug projector (Slice 3) ─────────────────────────────────────────────
107
127
  export {
108
128
  buildFormattingDebugEntry,