@beyondwork/docx-react-component 1.0.38 → 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 (76) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +183 -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/io/docx-session.ts +1 -0
  6. package/src/io/export/serialize-numbering.ts +42 -8
  7. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  8. package/src/io/export/serialize-run-formatting.ts +90 -0
  9. package/src/io/export/serialize-styles.ts +212 -0
  10. package/src/io/ooxml/parse-fields.ts +10 -3
  11. package/src/io/ooxml/parse-numbering.ts +41 -1
  12. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  13. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  14. package/src/io/ooxml/parse-styles.ts +31 -0
  15. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  16. package/src/io/ooxml/xml-element.ts +19 -0
  17. package/src/model/canonical-document.ts +83 -3
  18. package/src/runtime/collab/event-types.ts +165 -0
  19. package/src/runtime/collab/index.ts +22 -0
  20. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  22. package/src/runtime/document-runtime.ts +134 -18
  23. package/src/runtime/layout/index.ts +2 -0
  24. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  25. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  26. package/src/runtime/layout/layout-invalidation.ts +14 -5
  27. package/src/runtime/layout/page-graph.ts +36 -0
  28. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  29. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  30. package/src/runtime/layout/project-block-fragments.ts +154 -20
  31. package/src/runtime/layout/public-facet.ts +40 -1
  32. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  33. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  34. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  35. package/src/runtime/layout/table-render-plan.ts +21 -1
  36. package/src/runtime/numbering-prefix.ts +5 -0
  37. package/src/runtime/paragraph-style-resolver.ts +194 -0
  38. package/src/runtime/render/render-kernel.ts +5 -1
  39. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  40. package/src/runtime/surface-projection.ts +129 -9
  41. package/src/runtime/table-schema.ts +11 -0
  42. package/src/ui/WordReviewEditor.tsx +285 -5
  43. package/src/ui/editor-command-bag.ts +4 -0
  44. package/src/ui/editor-runtime-boundary.ts +16 -0
  45. package/src/ui/editor-shell-view.tsx +4 -0
  46. package/src/ui/editor-surface-controller.tsx +9 -1
  47. package/src/ui/headless/chrome-registry.ts +34 -5
  48. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  49. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  50. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  51. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  52. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  53. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  54. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  55. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  56. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  57. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  58. package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
  59. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
  60. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  61. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  62. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  63. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  64. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  65. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  66. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  67. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  68. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  69. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  70. package/src/ui-tailwind/index.ts +1 -5
  71. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  72. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  73. package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
  74. package/src/runtime/collab-review-sync.ts +0 -254
  75. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  76. 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,
@@ -64,6 +65,7 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
64
65
  import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
65
66
  import { resolveTableStyleResolution } from "../table-style-resolver.ts";
66
67
  import { buildTableRenderPlan } from "./table-render-plan.ts";
68
+ import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
67
69
  import type {
68
70
  SurfaceBlockSnapshot,
69
71
  } from "../../api/public-types";
@@ -396,6 +398,28 @@ export interface WordReviewEditorLayoutFacet {
396
398
  getMeasurement(blockId: string): PublicBlockMeasurement | null;
397
399
  getMeasurementFidelity(): PublicMeasurementFidelity;
398
400
  whenMeasurementReady(): Promise<void>;
401
+ swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
402
+
403
+ // Table render plan (P3e consumed by the render kernel, P4) ------------
404
+ /**
405
+ * 0-based page index where the first fragment of `blockId` lands, or
406
+ * `null` when the block has no fragments in the current page graph.
407
+ * Used by the tables facet to determine which page to request plans for
408
+ * without needing PM document offsets.
409
+ */
410
+ getFirstPageIndexForBlock(blockId: string): number | null;
411
+ /**
412
+ * Build a `TableRenderPlan` for a table block on a given page. Returns
413
+ * `null` when the blockId does not resolve to a table in the current
414
+ * surface. The plan carries columnsTwips, bandClasses, verticalMerges,
415
+ * repeatedHeaderRows, and columnResizeHandles so chrome can render
416
+ * band-aware cell styling and place column-resize grips without
417
+ * walking canonical state.
418
+ */
419
+ getTableRenderPlan(
420
+ blockId: string,
421
+ pageIndex: number,
422
+ ): import("./table-render-plan.ts").TableRenderPlan | null;
399
423
 
400
424
  // Table render plan (P3e consumed by the render kernel, P4) ------------
401
425
  /**
@@ -659,8 +683,11 @@ export function createLayoutFacet(
659
683
  hitTest(pointInRoot) {
660
684
  const kernel = input.renderKernel?.();
661
685
  if (!kernel) return null;
686
+ const t0 = typeof performance !== "undefined" ? performance.now() : 0;
662
687
  const frame = kernel.getRenderFrame();
663
- return resolveHitTest(frame, pointInRoot);
688
+ const result = resolveHitTest(frame, pointInRoot);
689
+ if (t0 > 0) recordPerfSample("chrome.hit_test", performance.now() - t0);
690
+ return result;
664
691
  },
665
692
 
666
693
  getAnchorRects(query) {
@@ -736,6 +763,18 @@ export function createLayoutFacet(
736
763
  return engine.whenMeasurementReady();
737
764
  },
738
765
 
766
+ getFirstPageIndexForBlock(blockId) {
767
+ const graph = currentGraph();
768
+ const fragment = graph.fragments.find((f) => f.blockId === blockId);
769
+ if (!fragment) return null;
770
+ const page = graph.pages.find((p) => p.pageId === fragment.pageId);
771
+ return page?.pageIndex ?? null;
772
+ },
773
+
774
+ swapMeasurementProvider(provider) {
775
+ engine.swapMeasurementProvider(provider);
776
+ },
777
+
739
778
  getTableRenderPlan(blockId, pageIndex) {
740
779
  const graph = currentGraph();
741
780
  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
  }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Resolve style chains for paragraph and character styles by walking the
3
+ * `basedOn` reference, with cycle detection.
4
+ *
5
+ * Returned chains are leaf-first (most-specific style at index 0, root at the
6
+ * end). Cycles break at the first re-encounter. Dangling `basedOn` references
7
+ * stop the walk silently (the leaf is still returned).
8
+ *
9
+ * Tasks 9 and 10 extend this module with `resolveEffective*Formatting`
10
+ * helpers that cascade the chain into a flat formatting record.
11
+ */
12
+
13
+ import type {
14
+ CanonicalParagraphFormatting,
15
+ CanonicalRunFormatting,
16
+ CharacterStyleDefinition,
17
+ ParagraphStyleDefinition,
18
+ StylesCatalog,
19
+ } from "../model/canonical-document.ts";
20
+
21
+ export function resolveParagraphStyleChain(
22
+ styleId: string,
23
+ catalog: StylesCatalog | undefined,
24
+ ): string[] {
25
+ const chain: string[] = [];
26
+ if (!catalog) return chain;
27
+ const visited = new Set<string>();
28
+ let current: string | undefined = styleId;
29
+ while (current && !visited.has(current)) {
30
+ visited.add(current);
31
+ const def: ParagraphStyleDefinition | undefined = catalog.paragraphs[current];
32
+ if (!def) break;
33
+ chain.push(current);
34
+ current = def.basedOn;
35
+ }
36
+ return chain;
37
+ }
38
+
39
+ export function resolveCharacterStyleChain(
40
+ styleId: string,
41
+ catalog: StylesCatalog | undefined,
42
+ ): string[] {
43
+ const chain: string[] = [];
44
+ if (!catalog) return chain;
45
+ const visited = new Set<string>();
46
+ let current: string | undefined = styleId;
47
+ while (current && !visited.has(current)) {
48
+ visited.add(current);
49
+ const def: CharacterStyleDefinition | undefined = catalog.characters[current];
50
+ if (!def) break;
51
+ chain.push(current);
52
+ current = def.basedOn;
53
+ }
54
+ return chain;
55
+ }
56
+
57
+ function mergeRun(
58
+ base: CanonicalRunFormatting | undefined,
59
+ over: CanonicalRunFormatting | undefined,
60
+ ): CanonicalRunFormatting | undefined {
61
+ if (!base && !over) return undefined;
62
+ return { ...(base ?? {}), ...(over ?? {}) };
63
+ }
64
+
65
+ function mergeParagraph(
66
+ base: CanonicalParagraphFormatting | undefined,
67
+ over: CanonicalParagraphFormatting | undefined,
68
+ ): CanonicalParagraphFormatting | undefined {
69
+ if (!base && !over) return undefined;
70
+ const merged: CanonicalParagraphFormatting = { ...(base ?? {}), ...(over ?? {}) };
71
+ if (base?.spacing || over?.spacing) {
72
+ merged.spacing = { ...(base?.spacing ?? {}), ...(over?.spacing ?? {}) };
73
+ }
74
+ if (base?.indentation || over?.indentation) {
75
+ merged.indentation = { ...(base?.indentation ?? {}), ...(over?.indentation ?? {}) };
76
+ }
77
+ if (base?.borders || over?.borders) {
78
+ merged.borders = { ...(base?.borders ?? {}), ...(over?.borders ?? {}) };
79
+ }
80
+ if (base?.shading || over?.shading) {
81
+ merged.shading = { ...(base?.shading ?? {}), ...(over?.shading ?? {}) };
82
+ }
83
+ if (base?.paragraphMarkRunProperties || over?.paragraphMarkRunProperties) {
84
+ merged.paragraphMarkRunProperties = mergeRun(
85
+ base?.paragraphMarkRunProperties,
86
+ over?.paragraphMarkRunProperties,
87
+ );
88
+ }
89
+ return merged;
90
+ }
91
+
92
+ export interface ParagraphResolveInput {
93
+ styleId: string | undefined;
94
+ direct: CanonicalParagraphFormatting | undefined;
95
+ }
96
+
97
+ /**
98
+ * Resolve paragraph formatting cascade: docDefaults.paragraph → basedOn chain
99
+ * (root-to-leaf) → direct. Returns `{}` when nothing is present.
100
+ */
101
+ export function resolveEffectiveParagraphFormatting(
102
+ input: ParagraphResolveInput,
103
+ catalog: StylesCatalog | undefined,
104
+ ): CanonicalParagraphFormatting {
105
+ let acc: CanonicalParagraphFormatting | undefined = catalog?.docDefaults?.paragraph;
106
+ if (catalog && input.styleId) {
107
+ const chain = resolveParagraphStyleChain(input.styleId, catalog);
108
+ // Walk root-to-leaf (most-general first) so the most-specific style wins
109
+ for (let i = chain.length - 1; i >= 0; i -= 1) {
110
+ const styleId = chain[i]!;
111
+ acc = mergeParagraph(acc, catalog.paragraphs[styleId]?.paragraphProperties);
112
+ }
113
+ }
114
+ acc = mergeParagraph(acc, input.direct);
115
+ return acc ?? {};
116
+ }
117
+
118
+ export interface RunResolveInput {
119
+ paragraphStyleId: string | undefined;
120
+ characterStyleId: string | undefined;
121
+ direct: CanonicalRunFormatting | undefined;
122
+ }
123
+
124
+ /**
125
+ * Resolve run formatting cascade: docDefaults.run → paragraph-style-chain rPr
126
+ * (root-to-leaf) → character-style-chain (root-to-leaf) → direct. Returns
127
+ * `{}` when nothing is present.
128
+ */
129
+ export function resolveEffectiveRunFormatting(
130
+ input: RunResolveInput,
131
+ catalog: StylesCatalog | undefined,
132
+ ): CanonicalRunFormatting {
133
+ let acc: CanonicalRunFormatting | undefined = catalog?.docDefaults?.run;
134
+ if (catalog && input.paragraphStyleId) {
135
+ const chain = resolveParagraphStyleChain(input.paragraphStyleId, catalog);
136
+ for (let i = chain.length - 1; i >= 0; i -= 1) {
137
+ const styleId = chain[i]!;
138
+ acc = mergeRun(acc, catalog.paragraphs[styleId]?.runProperties);
139
+ }
140
+ }
141
+ if (catalog && input.characterStyleId) {
142
+ const chain = resolveCharacterStyleChain(input.characterStyleId, catalog);
143
+ for (let i = chain.length - 1; i >= 0; i -= 1) {
144
+ const styleId = chain[i]!;
145
+ acc = mergeRun(acc, catalog.characters[styleId]?.runProperties);
146
+ }
147
+ }
148
+ acc = mergeRun(acc, input.direct);
149
+ return acc ?? {};
150
+ }
151
+
152
+ export interface MarkerResolveInput {
153
+ paragraphStyleId: string | undefined;
154
+ levelRunProperties: CanonicalRunFormatting | undefined;
155
+ }
156
+
157
+ /**
158
+ * Resolve the numbering marker's character formatting — the rPr that styles
159
+ * the number/bullet glyph itself (bold, color, font, size, etc.).
160
+ *
161
+ * Cascade order (lowest to highest priority):
162
+ * 1. `docDefaults.run` — Word's baseline run formatting.
163
+ * 2. Paragraph style chain's `runProperties` — walked root-to-leaf; the
164
+ * numbered paragraph's styleId contributes each ancestor's rPr.
165
+ * 3. The leaf paragraph style's `paragraphProperties.paragraphMarkRunProperties`
166
+ * — Word stores the paragraph-mark rPr separately from the body rPr,
167
+ * and the mark's formatting is what the numbering marker inherits from.
168
+ * 4. `levelRunProperties` — `<w:lvl><w:rPr>` on the numbering level. Per
169
+ * ECMA-376, this is the most specific formatting for the marker and
170
+ * overrides the paragraph's run formatting entirely for the marker.
171
+ */
172
+ export function resolveNumberingMarkerRunFormatting(
173
+ input: MarkerResolveInput,
174
+ catalog: StylesCatalog | undefined,
175
+ ): CanonicalRunFormatting {
176
+ // Start with docDefaults baseline
177
+ let acc: CanonicalRunFormatting | undefined = catalog?.docDefaults?.run;
178
+
179
+ // Walk the paragraph style chain's rPr (root-to-leaf, most-specific wins)
180
+ if (catalog && input.paragraphStyleId) {
181
+ const chain = resolveParagraphStyleChain(input.paragraphStyleId, catalog);
182
+ for (let i = chain.length - 1; i >= 0; i -= 1) {
183
+ const styleId = chain[i]!;
184
+ acc = mergeRun(acc, catalog.paragraphs[styleId]?.runProperties);
185
+ }
186
+ // Leaf paragraph style's paragraph-mark rPr layers on top of the chain
187
+ const leaf = catalog.paragraphs[input.paragraphStyleId];
188
+ acc = mergeRun(acc, leaf?.paragraphProperties?.paragraphMarkRunProperties);
189
+ }
190
+
191
+ // Level rPr has the final say (per ECMA-376 17.9)
192
+ acc = mergeRun(acc, input.levelRunProperties);
193
+ return acc ?? {};
194
+ }