@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -42,6 +42,7 @@ import type {
42
42
  LayoutEngineInstance,
43
43
  LayoutEngineQueryInput,
44
44
  } from "./layout-engine-instance.ts";
45
+ import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
45
46
  import type { PageFragmentMapper } from "./page-fragment-mapper.ts";
46
47
  import {
47
48
  PAGE_FORMAT_CATALOG,
@@ -56,6 +57,7 @@ import {
56
57
  type ActiveMarginPreset,
57
58
  } from "./margin-preset-catalog.ts";
58
59
  import {
60
+ attachScopeCardModel,
59
61
  collectScopeRailSegments,
60
62
  type ScopeRailSegment,
61
63
  } from "../workflow-rail-segments.ts";
@@ -64,6 +66,7 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
64
66
  import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
65
67
  import { resolveTableStyleResolution } from "../table-style-resolver.ts";
66
68
  import { buildTableRenderPlan } from "./table-render-plan.ts";
69
+ import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
67
70
  import type {
68
71
  SurfaceBlockSnapshot,
69
72
  } from "../../api/public-types";
@@ -390,12 +393,48 @@ export interface WordReviewEditorLayoutFacet {
390
393
  /** Return every scope rail segment across the document. */
391
394
  getAllScopeRailSegments(): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
392
395
 
396
+ /**
397
+ * Scope-card projection consumed by the chrome overlay's card layer
398
+ * (scope-card-overlay P1). Joins every unique scope segment with
399
+ * its attached issue metadata (R2 `ISSUE_METADATA_ID`), its
400
+ * work-item id, and its `primaryAnchorRect` via the render kernel's
401
+ * anchor index. P2 fields (`suggestionGroupIds`,
402
+ * `reviewActionCount`, `agentPending`) are defaulted to empty /
403
+ * zero / false in P1 and populated by later phases.
404
+ *
405
+ * Returns an empty list when no workflow rail input has been wired
406
+ * or when no scopes are currently on the active story.
407
+ */
408
+ getAllScopeCardModels(): readonly import("../../api/public-types.ts").ScopeCardModel[];
409
+
393
410
  // Measurement exposure -------------------------------------------------
394
411
  getResolvedFormatting(blockId: string): PublicResolvedParagraphFormatting | null;
395
412
  getResolvedRunFormatting(runId: string): PublicResolvedRunFormatting | null;
396
413
  getMeasurement(blockId: string): PublicBlockMeasurement | null;
397
414
  getMeasurementFidelity(): PublicMeasurementFidelity;
398
415
  whenMeasurementReady(): Promise<void>;
416
+ swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
417
+
418
+ // Table render plan (P3e consumed by the render kernel, P4) ------------
419
+ /**
420
+ * 0-based page index where the first fragment of `blockId` lands, or
421
+ * `null` when the block has no fragments in the current page graph.
422
+ * Used by the tables facet to determine which page to request plans for
423
+ * without needing PM document offsets.
424
+ */
425
+ getFirstPageIndexForBlock(blockId: string): number | null;
426
+ /**
427
+ * Build a `TableRenderPlan` for a table block on a given page. Returns
428
+ * `null` when the blockId does not resolve to a table in the current
429
+ * surface. The plan carries columnsTwips, bandClasses, verticalMerges,
430
+ * repeatedHeaderRows, and columnResizeHandles so chrome can render
431
+ * band-aware cell styling and place column-resize grips without
432
+ * walking canonical state.
433
+ */
434
+ getTableRenderPlan(
435
+ blockId: string,
436
+ pageIndex: number,
437
+ ): import("./table-render-plan.ts").TableRenderPlan | null;
399
438
 
400
439
  // Table render plan (P3e consumed by the render kernel, P4) ------------
401
440
  /**
@@ -447,6 +486,17 @@ export interface CreateLayoutFacetInput {
447
486
  >
448
487
  | null
449
488
  | undefined;
489
+ /**
490
+ * Optional workflow-metadata accessor for scope-card issue resolution
491
+ * (R2 / scope-card-overlay P1). Returns the
492
+ * `WorkflowMarkupSnapshot.metadata` array so `getAllScopeCardModels`
493
+ * can attach issue values to their scopes. Omit when the host does
494
+ * not push metadata entries.
495
+ */
496
+ getWorkflowMarkupMetadata?: () =>
497
+ | readonly import("../../api/public-types.ts").WorkflowMetadataMarkup[]
498
+ | null
499
+ | undefined;
450
500
  }
451
501
 
452
502
  export function createLayoutFacet(
@@ -659,8 +709,11 @@ export function createLayoutFacet(
659
709
  hitTest(pointInRoot) {
660
710
  const kernel = input.renderKernel?.();
661
711
  if (!kernel) return null;
712
+ const t0 = typeof performance !== "undefined" ? performance.now() : 0;
662
713
  const frame = kernel.getRenderFrame();
663
- return resolveHitTest(frame, pointInRoot);
714
+ const result = resolveHitTest(frame, pointInRoot);
715
+ if (t0 > 0) recordPerfSample("chrome.hit_test", performance.now() - t0);
716
+ return result;
664
717
  },
665
718
 
666
719
  getAnchorRects(query) {
@@ -684,6 +737,21 @@ export function createLayoutFacet(
684
737
  );
685
738
  },
686
739
 
740
+ getAllScopeCardModels() {
741
+ const railInput = input.getWorkflowRailInput?.();
742
+ const segments = collectScopeRailSegmentsForQuery(railInput, currentGraph());
743
+ if (segments.length === 0) return [];
744
+ const kernel = input.renderKernel?.();
745
+ const anchorIndex = kernel?.getRenderFrame?.()?.anchorIndex ?? null;
746
+ const metadata = input.getWorkflowMarkupMetadata?.();
747
+ return attachScopeCardModel({
748
+ segments,
749
+ scopes: railInput?.scopes ?? [],
750
+ metadata: metadata ?? undefined,
751
+ anchorIndex,
752
+ });
753
+ },
754
+
687
755
  getFragmentsForPage(pageIndex) {
688
756
  const graph = currentGraph();
689
757
  const node = graph.pages[pageIndex];
@@ -736,6 +804,18 @@ export function createLayoutFacet(
736
804
  return engine.whenMeasurementReady();
737
805
  },
738
806
 
807
+ getFirstPageIndexForBlock(blockId) {
808
+ const graph = currentGraph();
809
+ const fragment = graph.fragments.find((f) => f.blockId === blockId);
810
+ if (!fragment) return null;
811
+ const page = graph.pages.find((p) => p.pageId === fragment.pageId);
812
+ return page?.pageIndex ?? null;
813
+ },
814
+
815
+ swapMeasurementProvider(provider) {
816
+ engine.swapMeasurementProvider(provider);
817
+ },
818
+
739
819
  getTableRenderPlan(blockId, pageIndex) {
740
820
  const graph = currentGraph();
741
821
  const fragment = graph.fragments.find((f) => f.blockId === blockId);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Resolve `PAGE` and `NUMPAGES` field values per page.
3
+ *
4
+ * These two field families (now in `SupportedFieldFamily`) compute differently
5
+ * from `REF` / `PAGEREF` because the resolved value depends on the *owning
6
+ * page* of each header/footer instance, not on a single document-level
7
+ * computation. A header part is shared across many pages; the same PAGE
8
+ * field must render "1" on page 1, "2" on page 2, and so on.
9
+ *
10
+ * The resolver is a pure function over the page graph — no DOM, no side
11
+ * effects. Consumers (per-page header/footer bands) call
12
+ * `resolvePageFieldDisplayText` at render time to substitute the cached
13
+ * preserve-only text that currently sits inside the paragraph inline atom.
14
+ */
15
+
16
+ import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
17
+ import type { SupportedFieldFamily } from "../../model/canonical-document.ts";
18
+
19
+ export interface PageFieldContext {
20
+ /** The page this header/footer copy renders on. */
21
+ page: RuntimePageNode;
22
+ /** The graph the page belongs to (for NUMPAGES resolution). */
23
+ graph: RuntimePageGraph;
24
+ }
25
+
26
+ /**
27
+ * Resolve the display text for a PAGE / NUMPAGES field on a specific page.
28
+ *
29
+ * Returns the original cached `displayText` for unsupported families so the
30
+ * caller can use this as a single resolution helper.
31
+ */
32
+ export function resolvePageFieldDisplayText(
33
+ family: SupportedFieldFamily | string,
34
+ cachedDisplayText: string,
35
+ context: PageFieldContext,
36
+ ): string {
37
+ switch (family) {
38
+ case "PAGE":
39
+ return String(context.page.stories.displayPageNumber);
40
+ case "NUMPAGES":
41
+ // Blank fillers (evenPage/oddPage section breaks) don't count toward
42
+ // NUMPAGES — the graph already excludes them from contentPageCount.
43
+ return String(context.graph.contentPageCount);
44
+ default:
45
+ return cachedDisplayText;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Build a lookup keyed by pageId → (fieldFamily → resolvedText). Useful when
51
+ * a renderer wants to bulk-resolve every page's fields before emitting DOM.
52
+ * Absent keys mean no override; the cached field text should be used.
53
+ */
54
+ export function buildPageFieldResolutionTable(
55
+ graph: RuntimePageGraph,
56
+ families: ReadonlySet<string> = new Set(["PAGE", "NUMPAGES"]),
57
+ ): Map<string, Map<string, string>> {
58
+ const result = new Map<string, Map<string, string>>();
59
+ for (const page of graph.pages) {
60
+ const perPage = new Map<string, string>();
61
+ for (const family of families) {
62
+ perPage.set(
63
+ family,
64
+ resolvePageFieldDisplayText(family, "", { page, graph }),
65
+ );
66
+ }
67
+ result.set(page.pageId, perPage);
68
+ }
69
+ return result;
70
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Flatten header/footer sub-part blocks to short text previews with PAGE /
3
+ * NUMPAGES fields resolved for the owning page. Used by the in-flow page
4
+ * chrome widgets so the bands show live content ("Page 3 of 5") rather
5
+ * than a static "Header" / "Footer" placeholder.
6
+ *
7
+ * This is a **preview**, not a full renderer — we flatten to plain text
8
+ * and cap the length. A per-page band is ~32 px tall; anything more
9
+ * elaborate belongs to the full editing path, which is entered by double-
10
+ * clicking the band.
11
+ */
12
+
13
+ import type {
14
+ BlockNode,
15
+ HeaderDocument,
16
+ FooterDocument,
17
+ InlineNode,
18
+ } from "../../model/canonical-document.ts";
19
+ import type {
20
+ RuntimePageGraph,
21
+ RuntimePageNode,
22
+ } from "./page-graph.ts";
23
+ import { resolvePageFieldDisplayText } from "./resolve-page-fields.ts";
24
+
25
+ const MAX_PREVIEW_CHARS = 140;
26
+
27
+ export interface PagePreviewMaps {
28
+ /** pageId → short text preview of the resolved header story (if any). */
29
+ headerPreviewByPageId: Map<string, string>;
30
+ /** pageId → short text preview of the resolved footer story (if any). */
31
+ footerPreviewByPageId: Map<string, string>;
32
+ }
33
+
34
+ export function buildPagePreviewMaps(
35
+ graph: RuntimePageGraph,
36
+ subParts: {
37
+ headers?: ReadonlyArray<HeaderDocument>;
38
+ footers?: ReadonlyArray<FooterDocument>;
39
+ },
40
+ ): PagePreviewMaps {
41
+ const headerPreviewByPageId = new Map<string, string>();
42
+ const footerPreviewByPageId = new Map<string, string>();
43
+
44
+ for (const page of graph.pages) {
45
+ if (page.isBlankFiller) continue;
46
+ const headerStory = page.stories.header;
47
+ if (headerStory && headerStory.kind === "header") {
48
+ const source = findSubPart(subParts.headers, headerStory.relationshipId);
49
+ if (source) {
50
+ headerPreviewByPageId.set(
51
+ page.pageId,
52
+ flattenBlocksToPreview(source.blocks, page, graph),
53
+ );
54
+ }
55
+ }
56
+ const footerStory = page.stories.footer;
57
+ if (footerStory && footerStory.kind === "footer") {
58
+ const source = findSubPart(subParts.footers, footerStory.relationshipId);
59
+ if (source) {
60
+ footerPreviewByPageId.set(
61
+ page.pageId,
62
+ flattenBlocksToPreview(source.blocks, page, graph),
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ return { headerPreviewByPageId, footerPreviewByPageId };
69
+ }
70
+
71
+ function findSubPart<T extends HeaderDocument | FooterDocument>(
72
+ parts: ReadonlyArray<T> | undefined,
73
+ relationshipId: string,
74
+ ): T | undefined {
75
+ if (!parts) return undefined;
76
+ return parts.find((p) => p.relationshipId === relationshipId);
77
+ }
78
+
79
+ function flattenBlocksToPreview(
80
+ blocks: ReadonlyArray<BlockNode>,
81
+ page: RuntimePageNode,
82
+ graph: RuntimePageGraph,
83
+ ): string {
84
+ const parts: string[] = [];
85
+ for (const block of blocks) {
86
+ collectBlockText(block, page, graph, parts);
87
+ if (joinPreview(parts).length >= MAX_PREVIEW_CHARS) break;
88
+ }
89
+ return truncate(joinPreview(parts));
90
+ }
91
+
92
+ function collectBlockText(
93
+ block: BlockNode,
94
+ page: RuntimePageNode,
95
+ graph: RuntimePageGraph,
96
+ out: string[],
97
+ ): void {
98
+ switch (block.type) {
99
+ case "paragraph":
100
+ for (const child of block.children) {
101
+ collectInlineText(child, page, graph, out);
102
+ }
103
+ break;
104
+ case "table":
105
+ for (const row of block.rows) {
106
+ for (const cell of row.cells) {
107
+ for (const childBlock of cell.children) {
108
+ collectBlockText(childBlock, page, graph, out);
109
+ }
110
+ }
111
+ }
112
+ break;
113
+ case "sdt":
114
+ for (const child of block.children) {
115
+ collectBlockText(child, page, graph, out);
116
+ }
117
+ break;
118
+ // opaque_block / section_break / custom_xml / alt_chunk — no preview text.
119
+ default:
120
+ break;
121
+ }
122
+ }
123
+
124
+ function collectInlineText(
125
+ inline: InlineNode,
126
+ page: RuntimePageNode,
127
+ graph: RuntimePageGraph,
128
+ out: string[],
129
+ ): void {
130
+ switch (inline.type) {
131
+ case "text":
132
+ out.push(inline.text);
133
+ break;
134
+ case "tab":
135
+ out.push(" ");
136
+ break;
137
+ case "hard_break":
138
+ out.push(" ");
139
+ break;
140
+ case "field": {
141
+ const family = inline.fieldFamily ?? classifyFieldInstructionLocal(inline.instruction);
142
+ const cached = flattenInline(inline.children);
143
+ if (family === "PAGE" || family === "NUMPAGES") {
144
+ out.push(
145
+ resolvePageFieldDisplayText(family, cached, { page, graph }),
146
+ );
147
+ } else {
148
+ out.push(cached);
149
+ }
150
+ break;
151
+ }
152
+ case "hyperlink":
153
+ for (const child of inline.children) {
154
+ collectInlineText(child, page, graph, out);
155
+ }
156
+ break;
157
+ // bookmark_start/end, opaque_inline, note_ref etc — skip.
158
+ default:
159
+ break;
160
+ }
161
+ }
162
+
163
+ function flattenInline(inlines: ReadonlyArray<InlineNode> | undefined): string {
164
+ if (!inlines) return "";
165
+ let buf = "";
166
+ for (const inline of inlines) {
167
+ if (inline.type === "text") buf += inline.text;
168
+ else if (inline.type === "hard_break" || inline.type === "tab") buf += " ";
169
+ }
170
+ return buf;
171
+ }
172
+
173
+ function classifyFieldInstructionLocal(instr: string): string {
174
+ const match = /^\s*(\w+)/.exec(instr);
175
+ return match ? match[1]!.toUpperCase() : "UNKNOWN";
176
+ }
177
+
178
+ function joinPreview(parts: readonly string[]): string {
179
+ return parts.join("").replace(/\s+/g, " ").trim();
180
+ }
181
+
182
+ function truncate(s: string): string {
183
+ if (s.length <= MAX_PREVIEW_CHARS) return s;
184
+ return s.slice(0, MAX_PREVIEW_CHARS - 1).trimEnd() + "…";
185
+ }
@@ -151,11 +151,11 @@ export function resolveBlockFormatting(
151
151
  fontSizeHalfPoints: fontInfo.fontSizeHalfPoints,
152
152
  averageCharWidthTwips: fontInfo.avgCharWidth,
153
153
  tabStops: resolveTabStops(block),
154
- keepNext: Boolean(block.keepNext),
155
- keepLines: Boolean(block.keepLines),
156
- pageBreakBefore: Boolean(block.pageBreakBefore),
157
- widowControl: block.widowControl !== false, // default true in Word
158
- contextualSpacing: Boolean(block.contextualSpacing),
154
+ keepNext: Boolean(block.keepNext ?? block.resolvedParagraphFormatting?.keepNext),
155
+ keepLines: Boolean(block.keepLines ?? block.resolvedParagraphFormatting?.keepLines),
156
+ pageBreakBefore: Boolean(block.pageBreakBefore ?? block.resolvedParagraphFormatting?.pageBreakBefore),
157
+ widowControl: (block.widowControl ?? block.resolvedParagraphFormatting?.widowControl) !== false, // default true in Word
158
+ contextualSpacing: Boolean(block.contextualSpacing ?? block.resolvedParagraphFormatting?.contextualSpacing),
159
159
  };
160
160
  }
161
161
 
@@ -166,9 +166,10 @@ export function resolveBlockFormatting(
166
166
  function resolveSpacing(
167
167
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
168
168
  ): ParagraphSpacing {
169
- // Numbering geometry spacing overrides direct paragraph spacing
169
+ // Priority: numbering geometry > direct paragraph > style cascade
170
170
  const numbering = block.resolvedNumbering?.geometry.spacing;
171
171
  const direct = block.spacing;
172
+ const cascade = block.resolvedParagraphFormatting?.spacing;
172
173
 
173
174
  const normalizeLineRule = (
174
175
  rule: string | undefined,
@@ -177,21 +178,15 @@ function resolveSpacing(
177
178
  return undefined;
178
179
  };
179
180
 
180
- if (numbering) {
181
+ if (numbering || direct || cascade) {
181
182
  return {
182
- before: numbering.before ?? direct?.before,
183
- after: numbering.after ?? direct?.after,
184
- line: numbering.line ?? direct?.line,
185
- lineRule: normalizeLineRule(numbering.lineRule) ?? normalizeLineRule(direct?.lineRule),
186
- };
187
- }
188
-
189
- if (direct) {
190
- return {
191
- before: direct.before,
192
- after: direct.after,
193
- line: direct.line,
194
- lineRule: normalizeLineRule(direct.lineRule),
183
+ before: numbering?.before ?? direct?.before ?? cascade?.before,
184
+ after: numbering?.after ?? direct?.after ?? cascade?.after,
185
+ line: numbering?.line ?? direct?.line ?? cascade?.line,
186
+ lineRule:
187
+ normalizeLineRule(numbering?.lineRule) ??
188
+ normalizeLineRule(direct?.lineRule) ??
189
+ normalizeLineRule(cascade?.lineRule),
195
190
  };
196
191
  }
197
192
 
@@ -228,16 +223,25 @@ function resolveIndentation(
228
223
  };
229
224
  }
230
225
 
231
- const ind = block.indentation;
232
- if (!ind) {
226
+ const direct = block.indentation;
227
+ const cascade = block.resolvedParagraphFormatting?.indentation;
228
+
229
+ if (!direct && !cascade) {
233
230
  return { left: 0, right: 0, firstLine: 0, hanging: 0 };
234
231
  }
235
232
 
236
233
  return {
237
- left: ind.left ?? 0,
238
- right: ind.right ?? 0,
239
- firstLine: ind.firstLine ?? 0,
240
- hanging: ind.hanging ?? (typeof ind.firstLine === "number" && ind.firstLine < 0 ? Math.abs(ind.firstLine) : 0),
234
+ left: direct?.left ?? cascade?.left ?? 0,
235
+ right: direct?.right ?? cascade?.right ?? 0,
236
+ firstLine: direct?.firstLine ?? cascade?.firstLine ?? 0,
237
+ hanging:
238
+ direct?.hanging ??
239
+ cascade?.hanging ??
240
+ (typeof direct?.firstLine === "number" && direct.firstLine < 0
241
+ ? Math.abs(direct.firstLine)
242
+ : typeof cascade?.firstLine === "number" && cascade.firstLine < 0
243
+ ? Math.abs(cascade.firstLine)
244
+ : 0),
241
245
  };
242
246
  }
243
247
 
@@ -101,6 +101,13 @@ export interface BuildTableRenderPlanInput {
101
101
  /** If `columnsTwips` was scaled to the canvas width, pass the scale
102
102
  * factor here; otherwise leave undefined. */
103
103
  columnsTwipsScale?: number;
104
+ /**
105
+ * R3: true when this plan is for a continuation page of a multi-page
106
+ * table (i.e. a page other than the one the table first appears on).
107
+ * When true, header rows from the source table are emitted in
108
+ * `repeatedHeaderRows` so the consumer can prepend them on render.
109
+ */
110
+ isContinuationPage?: boolean;
104
111
  }
105
112
 
106
113
  export function buildTableRenderPlan(
@@ -112,7 +119,20 @@ export function buildTableRenderPlan(
112
119
 
113
120
  const bandClasses = buildBandClasses(block.rows, resolved);
114
121
  const verticalMerges = collectVerticalMerges(block.rows);
115
- const repeatedHeaderRows: RepeatedHeaderRowRef[] = [];
122
+ // R3: header rows are repeated on every continuation page. When the
123
+ // render-plan builder is invoked for a page with `isContinuationPage`,
124
+ // every row with `isHeader === true` gets an entry in repeatedHeaderRows.
125
+ // First-page plans (or tables that fit on a single page) return an empty
126
+ // array. `virtualFragmentId` is deterministic so consumers can key off it.
127
+ const repeatedHeaderRows: RepeatedHeaderRowRef[] = input.isContinuationPage
128
+ ? block.rows
129
+ .map((row, rowIndex) => ({ row, rowIndex }))
130
+ .filter(({ row }) => row.isHeader === true)
131
+ .map(({ rowIndex }) => ({
132
+ sourceRowIndex: rowIndex,
133
+ virtualFragmentId: `fragment-${blockId}-vheader-p${pageIndex}-r${rowIndex}`,
134
+ }))
135
+ : [];
116
136
  const columnResizeHandles = buildColumnResizeHandles(columnsTwips, tableHeightTwips);
117
137
 
118
138
  return {
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CanonicalRunFormatting,
2
3
  NumberingCatalog,
3
4
  NumberingLevelDefinition,
4
5
  ParagraphNode,
@@ -23,6 +24,7 @@ export interface NumberingPrefixResult {
23
24
  paragraphStyleId?: string;
24
25
  isLegalNumbering?: boolean;
25
26
  geometry: ResolvedNumberingGeometry;
27
+ markerRunProperties?: CanonicalRunFormatting;
26
28
  }
27
29
 
28
30
  export interface NumberingPrefixResolver {
@@ -90,6 +92,9 @@ export function createNumberingPrefixResolver(
90
92
  ? { paragraphStyleId: resolved.effectiveLevel.paragraphStyleId }
91
93
  : {}),
92
94
  ...(resolved.effectiveLevel.isLegalNumbering ? { isLegalNumbering: true } : {}),
95
+ ...(resolved.geometry.markerRunProperties
96
+ ? { markerRunProperties: resolved.geometry.markerRunProperties }
97
+ : {}),
93
98
  geometry: resolved.geometry,
94
99
  };
95
100
  }