@beyondwork/docx-react-component 1.0.103 → 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 (33) 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/io/ooxml/parse-drawing.ts +99 -1
  7. package/src/io/ooxml/parse-fields.ts +27 -6
  8. package/src/io/ooxml/parse-shapes.ts +130 -0
  9. package/src/model/canonical-document.ts +34 -3
  10. package/src/model/canonical-layout-inputs.ts +979 -0
  11. package/src/model/layout/index.ts +6 -0
  12. package/src/model/layout/page-graph-types.ts +61 -0
  13. package/src/model/layout/runtime-page-graph-types.ts +10 -0
  14. package/src/runtime/collab/runtime-collab-sync.ts +3 -3
  15. package/src/runtime/debug/build-debug-inspector-snapshot.ts +17 -4
  16. package/src/runtime/document-runtime.ts +30 -14
  17. package/src/runtime/event-refresh-hints.ts +3 -0
  18. package/src/runtime/formatting/formatting-context.ts +110 -9
  19. package/src/runtime/formatting/index.ts +2 -0
  20. package/src/runtime/formatting/layout-inputs.ts +67 -3
  21. package/src/runtime/geometry/caret-geometry.ts +82 -10
  22. package/src/runtime/geometry/geometry-facet.ts +36 -0
  23. package/src/runtime/geometry/geometry-index.ts +891 -0
  24. package/src/runtime/geometry/geometry-types.ts +221 -1
  25. package/src/runtime/geometry/index.ts +26 -0
  26. package/src/runtime/geometry/inert-geometry-facet.ts +3 -0
  27. package/src/runtime/geometry/replacement-envelope.ts +41 -2
  28. package/src/runtime/layout/layout-engine-version.ts +16 -1
  29. package/src/runtime/layout/page-graph.ts +191 -1
  30. package/src/runtime/prerender/graph-canonicalize.ts +30 -0
  31. package/src/runtime/surface-projection.ts +43 -3
  32. package/src/runtime/workflow/coordinator.ts +57 -11
  33. 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",
@@ -46,6 +46,7 @@ import type {
46
46
  TableStyleConditionalRegion,
47
47
  Mutable,
48
48
  } from "../../model/canonical-document.ts";
49
+ import { collectCanonicalFieldRegionIdentities } from "../../model/canonical-layout-inputs.ts";
49
50
  import type { FieldPageGraph, FieldResolver, ResolvedField } from "./field/resolver.ts";
50
51
  import { createFieldResolver } from "./field/resolver.ts";
51
52
  import { ThemeColorResolver, concretizeThemeColors } from "./theme-color.ts";
@@ -68,13 +69,21 @@ import type {
68
69
  EffectiveNumbering,
69
70
  EffectiveParagraphFormatting,
70
71
  EffectiveRunFormatting,
72
+ RevisionDisplayFlags,
71
73
  } from "./formatting-types.ts";
72
74
  import {
75
+ buildEffectiveLayoutFormatting,
76
+ buildRevisionLayoutPosture,
73
77
  collectFieldLayoutInputs,
78
+ mergeLayoutTabStops,
74
79
  toFieldLayoutInput,
80
+ toLayoutTabStops,
75
81
  toNumberingLayoutInput,
82
+ type EffectiveLayoutFormatting,
76
83
  type FieldLayoutInput,
84
+ type LayoutTabStopInput,
77
85
  type NumberingLayoutInput,
86
+ type RevisionLayoutPosture,
78
87
  } from "./layout-inputs.ts";
79
88
 
80
89
  /**
@@ -342,6 +351,23 @@ export interface FormattingContext {
342
351
  /** Resolve every field/TOC registry entry into PE2 field-layout inputs. */
343
352
  collectFieldLayoutInputs(): readonly FieldLayoutInput[];
344
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
+
345
371
  /** Resolve the effective font family for a run following
346
372
  * ECMA-376 §17.3.2.26 precedence + theme-minor fallback. */
347
373
  resolveFontFamily(input: RunResolveInput, themeMinorFont?: string): string | undefined;
@@ -637,9 +663,54 @@ class FormattingContextImpl implements FormattingContext {
637
663
  collectFieldLayoutInputs(): readonly FieldLayoutInput[] {
638
664
  return collectFieldLayoutInputs(this.doc.fieldRegistry, (entry) =>
639
665
  this.resolveField(entry),
666
+ collectCanonicalFieldRegionIdentities(this.doc),
640
667
  );
641
668
  }
642
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
+
643
714
  resolveFontFamily(
644
715
  input: RunResolveInput,
645
716
  themeMinorFont?: string,
@@ -655,17 +726,11 @@ class FormattingContextImpl implements FormattingContext {
655
726
 
656
727
  resolveParagraph(para: ParagraphNode): EffectiveParagraphFormatting {
657
728
  const cascade = this.resolveParagraphCascade(para);
658
- const numbering = buildEffectiveNumbering(
729
+ return buildEffectiveParagraphFormatting(
730
+ cascade,
659
731
  this.resolveParagraphNumbering(para, { advance: true }),
732
+ this.theme,
660
733
  );
661
- const paragraphMarkRun = cascade.paragraphMarkRunProperties && this.theme
662
- ? (concretizeThemeColors(cascade.paragraphMarkRunProperties, this.theme) as EffectiveRunFormatting)
663
- : (cascade.paragraphMarkRunProperties as EffectiveRunFormatting | undefined);
664
- return {
665
- ...cascade,
666
- ...(numbering ? { numbering } : {}),
667
- ...(paragraphMarkRun ? { paragraphMarkRun } : {}),
668
- };
669
734
  }
670
735
 
671
736
  resolveRunWithProvenance(input: {
@@ -910,6 +975,42 @@ function buildEffectiveNumbering(
910
975
  return result;
911
976
  }
912
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
+
913
1014
  // Type-only re-export of HyperlinkNode so callers can type their walk.
914
1015
  export type { HyperlinkNode, InlineNode };
915
1016
 
@@ -109,7 +109,9 @@ export {
109
109
  buildRevisionLayoutPosture,
110
110
  collectFieldLayoutInputs,
111
111
  createStructuralHash,
112
+ mergeLayoutTabStops,
112
113
  normalizeNumberingMarkerSuffix,
114
+ toMeasuredRevisionLayoutPosture,
113
115
  toFieldLayoutInput,
114
116
  toLayoutTabStops,
115
117
  toNumberingLayoutInput,
@@ -7,6 +7,10 @@ import type {
7
7
  FieldRegistryEntry,
8
8
  TabStop,
9
9
  } from "../../model/canonical-document.ts";
10
+ import {
11
+ MAIN_STORY_KEY,
12
+ type CanonicalFieldRegionIdentity,
13
+ } from "../../model/canonical-layout-inputs.ts";
10
14
  import type { ResolvedField } from "./field/resolver.ts";
11
15
  import type {
12
16
  EffectiveParagraphFormatting,
@@ -43,7 +47,11 @@ export interface NumberingLayoutInput {
43
47
 
44
48
  export interface FieldLayoutInput {
45
49
  readonly fieldId: string;
50
+ readonly regionId: string;
51
+ readonly regionKind: "field" | "toc-region";
52
+ readonly storyKey: string;
46
53
  readonly fieldIndex: number;
54
+ readonly paragraphIndex: number;
47
55
  readonly family: FieldFamily;
48
56
  readonly instruction: string;
49
57
  readonly cachedText: string;
@@ -58,6 +66,11 @@ export interface FieldLayoutInput {
58
66
 
59
67
  export interface RevisionLayoutPosture {
60
68
  readonly mode: "clean" | "simple" | "all" | "original" | "no-markup";
69
+ /**
70
+ * True only when the active revision display changes measured text
71
+ * for the paragraph. Paint-only redline posture is compositor data,
72
+ * not an L04 measurement input.
73
+ */
61
74
  readonly affectsMeasuredText: boolean;
62
75
  readonly hiddenRevisionIds: readonly string[];
63
76
  readonly visibleRevisionIds: readonly string[];
@@ -100,6 +113,27 @@ export function toLayoutTabStops(
100
113
  .sort((a, b) => a.positionTwips - b.positionTwips);
101
114
  }
102
115
 
116
+ export function mergeLayoutTabStops(
117
+ ...groups: readonly (readonly LayoutTabStopInput[] | undefined)[]
118
+ ): readonly LayoutTabStopInput[] {
119
+ const byKey = new Map<string, LayoutTabStopInput>();
120
+ for (const group of groups) {
121
+ for (const tabStop of group ?? []) {
122
+ const key = [
123
+ tabStop.source,
124
+ tabStop.positionTwips,
125
+ tabStop.align,
126
+ tabStop.leader ?? "",
127
+ ].join(":");
128
+ byKey.set(key, tabStop);
129
+ }
130
+ }
131
+ return [...byKey.values()].sort((a, b) => {
132
+ if (a.positionTwips !== b.positionTwips) return a.positionTwips - b.positionTwips;
133
+ return a.source.localeCompare(b.source);
134
+ });
135
+ }
136
+
103
137
  export function toNumberingLayoutInput(
104
138
  numbering: NumberingPrefixResult | null | undefined,
105
139
  ): NumberingLayoutInput | undefined {
@@ -138,10 +172,15 @@ export function toNumberingLayoutInput(
138
172
  export function toFieldLayoutInput(
139
173
  entry: FieldRegistryEntry,
140
174
  resolved?: ResolvedField,
175
+ region?: CanonicalFieldRegionIdentity,
141
176
  ): FieldLayoutInput {
142
177
  return {
143
178
  fieldId: `field-${entry.fieldIndex}`,
179
+ regionId: region?.regionId ?? `field:${entry.fieldIndex}`,
180
+ regionKind: region?.kind ?? "field",
181
+ storyKey: region?.storyKey ?? entry.storyKey ?? MAIN_STORY_KEY,
144
182
  fieldIndex: entry.fieldIndex,
183
+ paragraphIndex: region?.paragraphIndex ?? entry.paragraphIndex,
145
184
  family: entry.fieldFamily,
146
185
  instruction: entry.instruction,
147
186
  cachedText: entry.displayText,
@@ -156,20 +195,32 @@ export function toFieldLayoutInput(
156
195
  export function collectFieldLayoutInputs(
157
196
  registry: FieldRegistry | undefined,
158
197
  resolve?: (entry: FieldRegistryEntry) => ResolvedField | undefined,
198
+ regions: readonly CanonicalFieldRegionIdentity[] = [],
159
199
  ): readonly FieldLayoutInput[] {
160
200
  if (!registry) return [];
161
201
  const inputs: FieldLayoutInput[] = [];
162
202
  const entries = [...registry.supported, ...registry.preserveOnly];
203
+ const fieldRegions = new Map(
204
+ regions
205
+ .filter((region) => region.kind === "field")
206
+ .map((region) => [region.fieldIndex, region]),
207
+ );
208
+ const tocRegions = new Map(
209
+ regions
210
+ .filter((region) => region.kind === "toc-region" && region.tocId !== undefined)
211
+ .map((region) => [region.tocId!, region]),
212
+ );
163
213
  for (const entry of entries) {
164
- inputs.push(toFieldLayoutInput(entry, resolve?.(entry)));
214
+ inputs.push(toFieldLayoutInput(entry, resolve?.(entry), fieldRegions.get(entry.fieldIndex)));
165
215
  }
166
216
  for (const region of registry.tocRegions ?? []) {
167
217
  const source = registry.supported.find(
168
218
  (entry) => entry.fieldIndex === region.sourceFieldIndex,
169
219
  );
170
220
  if (!source) continue;
221
+ const identity = tocRegions.get(region.tocId);
171
222
  inputs.push({
172
- ...toFieldLayoutInput(source, resolve?.(source)),
223
+ ...toFieldLayoutInput(source, resolve?.(source), identity),
173
224
  fieldId: `toc-${region.tocId}`,
174
225
  family: "TOC",
175
226
  instruction: region.instruction.raw,
@@ -200,6 +251,18 @@ export function buildRevisionLayoutPosture(
200
251
  };
201
252
  }
202
253
 
254
+ export function toMeasuredRevisionLayoutPosture(
255
+ posture: RevisionLayoutPosture | undefined,
256
+ ): RevisionLayoutPosture | undefined {
257
+ if (!posture?.affectsMeasuredText || posture.hiddenRevisionIds.length === 0) {
258
+ return undefined;
259
+ }
260
+ return {
261
+ ...posture,
262
+ visibleRevisionIds: [],
263
+ };
264
+ }
265
+
203
266
  export function buildEffectiveLayoutFormatting(input: {
204
267
  readonly paragraph?: EffectiveParagraphFormatting;
205
268
  readonly runs?: readonly EffectiveRunFormatting[];
@@ -213,6 +276,7 @@ export function buildEffectiveLayoutFormatting(input: {
213
276
  readonly compatFlags?: readonly string[];
214
277
  readonly revisionPosture?: RevisionLayoutPosture;
215
278
  }): EffectiveLayoutFormatting {
279
+ const revisionPosture = toMeasuredRevisionLayoutPosture(input.revisionPosture);
216
280
  const withoutHash = {
217
281
  ...(input.paragraph ? { paragraph: input.paragraph } : {}),
218
282
  runs: input.runs ?? [],
@@ -221,7 +285,7 @@ export function buildEffectiveLayoutFormatting(input: {
221
285
  ...(input.numbering ? { numbering: input.numbering } : {}),
222
286
  tabs: input.tabs ?? [],
223
287
  compatFlags: input.compatFlags ?? [],
224
- ...(input.revisionPosture ? { revisionPosture: input.revisionPosture } : {}),
288
+ ...(revisionPosture ? { revisionPosture } : {}),
225
289
  };
226
290
  return {
227
291
  ...withoutHash,