@beyondwork/docx-react-component 1.0.36 → 1.0.38

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 (107) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Centralized formatting resolution for the layout engine.
3
+ *
4
+ * Takes a surface block snapshot and resolves the effective formatting
5
+ * properties needed for measurement and page composition. This includes:
6
+ *
7
+ * - Paragraph spacing (before, after, line height, line rule)
8
+ * - Indentation (left, right, firstLine, hanging)
9
+ * - Tab stops with numbering geometry override
10
+ * - Font size for line height calculation
11
+ * - Pagination properties (keepNext, keepLines, widowControl, pageBreakBefore)
12
+ * - Contextual spacing suppression
13
+ *
14
+ * The resolver produces a flat, inspectable structure so that the measurement
15
+ * engine never needs to reach back into multiple sources.
16
+ */
17
+
18
+ import type {
19
+ SurfaceBlockSnapshot,
20
+ } from "../../api/public-types";
21
+ import type {
22
+ ParagraphSpacing,
23
+ } from "../../model/canonical-document.ts";
24
+
25
+ /** Tab stop as it appears on the surface snapshot. */
26
+ interface SurfaceTabStop {
27
+ pos: number;
28
+ val?: string;
29
+ leader?: string;
30
+ }
31
+
32
+ /** Normalized tab stop for layout computation. */
33
+ export interface LayoutTabStop {
34
+ position: number;
35
+ align: string;
36
+ leader?: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Font metric tables — common fonts used in CCEP/legal contracts
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Average character width in twips per half-point of font size.
45
+ * These are empirical values from measuring common document fonts.
46
+ * Key insight: Word uses font metrics from the OS font table; we use
47
+ * bounded approximations that are close enough for page composition.
48
+ */
49
+ const FONT_AVG_CHAR_WIDTH: Record<string, number> = {
50
+ // Proportional serif
51
+ "times new roman": 9.2,
52
+ "georgia": 10.0,
53
+ "garamond": 8.8,
54
+ "book antiqua": 9.6,
55
+ "palatino linotype": 9.8,
56
+ "cambria": 9.6,
57
+ // Proportional sans-serif
58
+ "arial": 10.4,
59
+ "calibri": 9.0,
60
+ "helvetica": 10.4,
61
+ "verdana": 12.0,
62
+ "tahoma": 10.6,
63
+ "segoe ui": 9.8,
64
+ "trebuchet ms": 10.2,
65
+ // Monospace
66
+ "courier new": 12.0,
67
+ "consolas": 11.0,
68
+ "lucida console": 12.0,
69
+ };
70
+
71
+ const DEFAULT_FONT_AVG_CHAR_WIDTH = 10.0; // reasonable fallback
72
+ const DEFAULT_FONT_SIZE_HALF_POINTS = 24; // 12pt
73
+ const DEFAULT_LINE_HEIGHT_FACTOR = 1.15; // Word default for Calibri body
74
+ const TWIPS_PER_POINT = 20;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Resolved types
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export interface ResolvedParagraphFormatting {
81
+ /** Effective spacing before in twips. */
82
+ spacingBefore: number;
83
+ /** Effective spacing after in twips. */
84
+ spacingAfter: number;
85
+ /** Effective line height in twips. */
86
+ lineHeight: number;
87
+ /** Line rule governing how lineHeight is interpreted. */
88
+ lineRule: "auto" | "exact" | "atLeast";
89
+ /** Left indentation in twips. */
90
+ indentLeft: number;
91
+ /** Right indentation in twips. */
92
+ indentRight: number;
93
+ /** First-line indent in twips (positive = indent, negative = outdent). */
94
+ firstLineIndent: number;
95
+ /** Hanging indent in twips (mutually exclusive with firstLineIndent). */
96
+ hangingIndent: number;
97
+ /** Resolved font size in half-points for the dominant run. */
98
+ fontSizeHalfPoints: number;
99
+ /** Average character width in twips for the dominant font. */
100
+ averageCharWidthTwips: number;
101
+ /** Effective tab stops sorted by position. */
102
+ tabStops: LayoutTabStop[];
103
+ /** Keep with next paragraph. */
104
+ keepNext: boolean;
105
+ /** Keep all lines of this paragraph on the same page. */
106
+ keepLines: boolean;
107
+ /** Force page break before this paragraph. */
108
+ pageBreakBefore: boolean;
109
+ /** Widow/orphan control (default true in Word). */
110
+ widowControl: boolean;
111
+ /** Contextual spacing — suppress before/after when adjacent styles match. */
112
+ contextualSpacing: boolean;
113
+ }
114
+
115
+ export interface ResolvedTableRowFormatting {
116
+ /** Explicit row height in twips, 0 if auto. */
117
+ explicitHeight: number;
118
+ /** Height rule. */
119
+ heightRule: "auto" | "exact" | "atLeast";
120
+ /** Whether this row is a header row (repeats on page breaks). */
121
+ isHeader: boolean;
122
+ /** Whether this row can split across pages. */
123
+ cantSplit: boolean;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Paragraph formatting resolution
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export function resolveBlockFormatting(
131
+ block: SurfaceBlockSnapshot,
132
+ ): ResolvedParagraphFormatting | null {
133
+ if (block.kind !== "paragraph") {
134
+ return null;
135
+ }
136
+
137
+ const spacing = resolveSpacing(block);
138
+ const indent = resolveIndentation(block);
139
+ const fontInfo = resolveDominantFont(block);
140
+ const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
141
+
142
+ return {
143
+ spacingBefore: spacing.before ?? 0,
144
+ spacingAfter: spacing.after ?? 0,
145
+ lineHeight: lineHeight.height,
146
+ lineRule: lineHeight.rule,
147
+ indentLeft: indent.left,
148
+ indentRight: indent.right,
149
+ firstLineIndent: indent.firstLine,
150
+ hangingIndent: indent.hanging,
151
+ fontSizeHalfPoints: fontInfo.fontSizeHalfPoints,
152
+ averageCharWidthTwips: fontInfo.avgCharWidth,
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),
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Spacing resolution
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function resolveSpacing(
167
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
168
+ ): ParagraphSpacing {
169
+ // Numbering geometry spacing overrides direct paragraph spacing
170
+ const numbering = block.resolvedNumbering?.geometry.spacing;
171
+ const direct = block.spacing;
172
+
173
+ const normalizeLineRule = (
174
+ rule: string | undefined,
175
+ ): ParagraphSpacing["lineRule"] => {
176
+ if (rule === "auto" || rule === "exact" || rule === "atLeast") return rule;
177
+ return undefined;
178
+ };
179
+
180
+ if (numbering) {
181
+ 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),
195
+ };
196
+ }
197
+
198
+ return {};
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Indentation resolution
203
+ // ---------------------------------------------------------------------------
204
+
205
+ function resolveIndentation(
206
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
207
+ ): { left: number; right: number; firstLine: number; hanging: number } {
208
+ // Numbering geometry provides precise text column placement for legal numbering
209
+ const numGeometry = block.resolvedNumbering?.geometry;
210
+
211
+ if (numGeometry?.textColumn) {
212
+ const tc = numGeometry.textColumn;
213
+ return {
214
+ left: tc.start,
215
+ right: tc.right ?? 0,
216
+ firstLine: tc.firstLine ?? 0,
217
+ hanging: tc.hanging ?? 0,
218
+ };
219
+ }
220
+
221
+ if (numGeometry?.indentation) {
222
+ const ni = numGeometry.indentation;
223
+ return {
224
+ left: ni.left ?? 0,
225
+ right: ni.right ?? 0,
226
+ firstLine: ni.firstLine ?? 0,
227
+ hanging: ni.hanging ?? 0,
228
+ };
229
+ }
230
+
231
+ const ind = block.indentation;
232
+ if (!ind) {
233
+ return { left: 0, right: 0, firstLine: 0, hanging: 0 };
234
+ }
235
+
236
+ 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),
241
+ };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Font / character width resolution
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function resolveDominantFont(
249
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
250
+ ): { fontSizeHalfPoints: number; avgCharWidth: number; fontFamily: string | undefined } {
251
+ // Find the dominant font from the segments (most common or first text run)
252
+ let fontFamily: string | undefined;
253
+ let fontSizeHalfPoints: number | undefined;
254
+ let maxTextLength = 0;
255
+
256
+ for (const segment of block.segments) {
257
+ if (segment.kind !== "text") continue;
258
+ const segFontFamily = segment.markAttrs?.fontFamily;
259
+ const segFontSize = segment.markAttrs?.fontSize;
260
+ const textLength = segment.text.length;
261
+
262
+ if (textLength > maxTextLength) {
263
+ maxTextLength = textLength;
264
+ if (typeof segFontFamily === "string") {
265
+ fontFamily = segFontFamily;
266
+ }
267
+ if (typeof segFontSize === "number") {
268
+ fontSizeHalfPoints = segFontSize;
269
+ }
270
+ }
271
+ }
272
+
273
+ const effectiveSize = fontSizeHalfPoints ?? DEFAULT_FONT_SIZE_HALF_POINTS;
274
+ const normalizedFamily = fontFamily?.toLowerCase();
275
+ const charWidthFactor = normalizedFamily !== undefined
276
+ ? (FONT_AVG_CHAR_WIDTH[normalizedFamily] ?? DEFAULT_FONT_AVG_CHAR_WIDTH)
277
+ : DEFAULT_FONT_AVG_CHAR_WIDTH;
278
+
279
+ // Average char width in twips = factor * (fontSize in half-points)
280
+ const avgCharWidth = Math.max(96, Math.round(charWidthFactor * effectiveSize));
281
+
282
+ return { fontSizeHalfPoints: effectiveSize, avgCharWidth, fontFamily };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Line height resolution
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function resolveLineHeight(
290
+ spacing: ParagraphSpacing,
291
+ fontSizeHalfPoints: number,
292
+ ): { height: number; rule: "auto" | "exact" | "atLeast" } {
293
+ const lineRule = spacing.lineRule ?? "auto";
294
+
295
+ if (lineRule === "exact" && typeof spacing.line === "number" && spacing.line > 0) {
296
+ return { height: spacing.line, rule: "exact" };
297
+ }
298
+
299
+ if (lineRule === "atLeast" && typeof spacing.line === "number" && spacing.line > 0) {
300
+ // atLeast: use the larger of the specified height or the auto-calculated height
301
+ const autoHeight = calculateAutoLineHeight(fontSizeHalfPoints);
302
+ return { height: Math.max(spacing.line, autoHeight), rule: "atLeast" };
303
+ }
304
+
305
+ // Auto: line is in 240ths of a line (240 = single space, 480 = double)
306
+ if (typeof spacing.line === "number" && spacing.line > 0) {
307
+ // Convert from 240ths: line/240 * fontSize * 20 (twips per pt)
308
+ const fontSizePoints = fontSizeHalfPoints / 2;
309
+ const lineSpacingFactor = spacing.line / 240;
310
+ const height = Math.round(fontSizePoints * TWIPS_PER_POINT * lineSpacingFactor * DEFAULT_LINE_HEIGHT_FACTOR);
311
+ return { height: Math.max(height, 200), rule: "auto" };
312
+ }
313
+
314
+ // Default: single space at current font size
315
+ return { height: calculateAutoLineHeight(fontSizeHalfPoints), rule: "auto" };
316
+ }
317
+
318
+ function calculateAutoLineHeight(fontSizeHalfPoints: number): number {
319
+ const fontSizePoints = fontSizeHalfPoints / 2;
320
+ return Math.max(200, Math.round(fontSizePoints * TWIPS_PER_POINT * DEFAULT_LINE_HEIGHT_FACTOR));
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Tab stop resolution
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function resolveTabStops(
328
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
329
+ ): LayoutTabStop[] {
330
+ // Surface tab stops use { pos, val, leader } format
331
+ const surfaceTabs: SurfaceTabStop[] | undefined = block.tabStops;
332
+ // Numbering geometry tab stops are on the geometry, not the snapshot root
333
+ const numSurfaceTabs: SurfaceTabStop[] | undefined = block.resolvedNumbering?.geometry.tabStops;
334
+
335
+ const normalize = (tab: SurfaceTabStop): LayoutTabStop => ({
336
+ position: tab.pos,
337
+ align: tab.val ?? "left",
338
+ leader: tab.leader,
339
+ });
340
+
341
+ if (numSurfaceTabs && numSurfaceTabs.length > 0 && surfaceTabs && surfaceTabs.length > 0) {
342
+ const numPositions = new Set(numSurfaceTabs.map((t) => t.pos));
343
+ const merged = [
344
+ ...numSurfaceTabs.map(normalize),
345
+ ...surfaceTabs.filter((t) => !numPositions.has(t.pos)).map(normalize),
346
+ ];
347
+ return merged.sort((a, b) => a.position - b.position);
348
+ }
349
+
350
+ if (numSurfaceTabs && numSurfaceTabs.length > 0) {
351
+ return numSurfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
352
+ }
353
+
354
+ if (surfaceTabs && surfaceTabs.length > 0) {
355
+ return surfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
356
+ }
357
+
358
+ return [];
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Precise measurement helpers
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /**
366
+ * Calculate the usable text width for a paragraph line, accounting for
367
+ * indentation and column width.
368
+ */
369
+ export function resolveTextWidth(
370
+ formatting: ResolvedParagraphFormatting,
371
+ columnWidth: number,
372
+ isFirstLine: boolean,
373
+ ): number {
374
+ const baseWidth = columnWidth - formatting.indentLeft - formatting.indentRight;
375
+
376
+ if (isFirstLine) {
377
+ if (formatting.hangingIndent > 0) {
378
+ // Hanging indent: first line starts further left (wider available)
379
+ return Math.max(1, baseWidth + formatting.hangingIndent);
380
+ }
381
+ if (formatting.firstLineIndent !== 0) {
382
+ return Math.max(1, baseWidth - formatting.firstLineIndent);
383
+ }
384
+ }
385
+
386
+ return Math.max(1, baseWidth);
387
+ }
388
+
389
+ /**
390
+ * Calculate characters per line for a given text width and font.
391
+ */
392
+ export function resolveCharsPerLine(
393
+ textWidth: number,
394
+ avgCharWidth: number,
395
+ ): number {
396
+ return Math.max(1, Math.floor(textWidth / avgCharWidth));
397
+ }
398
+
399
+ /**
400
+ * Calculate the total height of a paragraph from its resolved formatting.
401
+ */
402
+ export function calculateParagraphHeight(
403
+ formatting: ResolvedParagraphFormatting,
404
+ lineCount: number,
405
+ ): number {
406
+ const contentHeight = formatting.lineHeight * lineCount;
407
+ return Math.max(240, contentHeight + formatting.spacingBefore + formatting.spacingAfter);
408
+ }
409
+
410
+ /**
411
+ * Estimate the number of prefix characters added by numbering.
412
+ */
413
+ export function resolveNumberingPrefixLength(
414
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
415
+ ): number {
416
+ if (block.resolvedNumbering?.geometry.textColumn) {
417
+ // When text column is resolved, the numbering marker is placed in its
418
+ // own lane — it doesn't consume characters from the text line.
419
+ return 0;
420
+ }
421
+
422
+ const prefix = block.numberingPrefix ?? "";
423
+ const suffix =
424
+ block.numberingSuffix === "space"
425
+ ? 1
426
+ : block.numberingSuffix === "tab"
427
+ ? 4
428
+ : 0;
429
+ return prefix.length + suffix;
430
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Table render plan (P3e).
3
+ *
4
+ * Per `docs/reference/docx/runtime-rendering-and-chrome-phase.md` §1, the
5
+ * render kernel consumes a `TableRenderPlan` per table per page so the
6
+ * chrome can:
7
+ * - render band-aware cell styling without pre-flattening into inline
8
+ * CSS strings,
9
+ * - position column-resize grips at logical column edges without
10
+ * re-measuring the DOM,
11
+ * - show repeated header rows on continuation pages (when row-level
12
+ * pagination lands; today the field is empty),
13
+ * - walk vertical-merge chains for merged-cell hit-testing.
14
+ *
15
+ * The plan is a pure function of the table block + resolved style
16
+ * resolution + gridColumns + the effective page column width. It does
17
+ * not read DOM or mutate state.
18
+ */
19
+
20
+ import type {
21
+ SurfaceBlockSnapshot,
22
+ SurfaceTableCellSnapshot,
23
+ SurfaceTableRowSnapshot,
24
+ } from "../../api/public-types";
25
+ import type { TableStyleConditionalRegion } from "../../model/canonical-document.ts";
26
+ import type { ResolvedTableStyleResolution } from "../table-style-resolver.ts";
27
+
28
+ // ─── Public shapes ───────────────────────────────────────────────────────────
29
+
30
+ export type TableBandRegion = TableStyleConditionalRegion;
31
+
32
+ export interface TableCellBandAssignment {
33
+ rowIndex: number;
34
+ columnIndex: number;
35
+ regions: readonly TableBandRegion[];
36
+ }
37
+
38
+ export interface TableRowBandAssignment {
39
+ rowIndex: number;
40
+ regions: readonly TableBandRegion[];
41
+ }
42
+
43
+ export interface TableBandClasses {
44
+ /** Active regions per row (firstRow, lastRow, band1Horz, band2Horz). */
45
+ rows: readonly TableRowBandAssignment[];
46
+ /** Active regions per cell (row regions ∪ column regions). */
47
+ cells: readonly TableCellBandAssignment[];
48
+ }
49
+
50
+ export interface VerticalMergeRef {
51
+ /** Logical column the chain lives in. */
52
+ columnIndex: number;
53
+ /** Row where the chain starts (verticalMerge: "restart"). */
54
+ startRowIndex: number;
55
+ /** Inclusive row where the chain ends. */
56
+ endRowIndex: number;
57
+ /** Horizontal span of the merged cell. */
58
+ columnSpan: number;
59
+ }
60
+
61
+ export interface RepeatedHeaderRowRef {
62
+ /** Row index in the source table whose header is repeated. */
63
+ sourceRowIndex: number;
64
+ /** Virtual fragment id the render kernel uses to address this repeat. */
65
+ virtualFragmentId: string;
66
+ }
67
+
68
+ export interface ColumnResizeHandle {
69
+ /** Logical column index the handle sits at (0-based, right edge of column i). */
70
+ columnIndex: number;
71
+ /** X-origin of the handle in twips, measured from the table's left edge. */
72
+ originTwips: number;
73
+ /** Handle height in twips (table visual height). */
74
+ heightTwips: number;
75
+ }
76
+
77
+ export interface TableRenderPlan {
78
+ blockId: string;
79
+ pageIndex: number;
80
+ /** Logical column widths in twips (may be scaled from canonical). */
81
+ columnsTwips: readonly number[];
82
+ /** Band-class assignments derived from resolved table style. */
83
+ bandClasses: TableBandClasses;
84
+ /** Vertical-merge chains in the visible table range. */
85
+ verticalMerges: readonly VerticalMergeRef[];
86
+ /** Header rows repeated on this page (empty until row-level split lands). */
87
+ repeatedHeaderRows: readonly RepeatedHeaderRowRef[];
88
+ /** Column-resize handle origins for chrome grip placement. */
89
+ columnResizeHandles: readonly ColumnResizeHandle[];
90
+ }
91
+
92
+ // ─── Builder ────────────────────────────────────────────────────────────────
93
+
94
+ export interface BuildTableRenderPlanInput {
95
+ blockId: string;
96
+ pageIndex: number;
97
+ block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
98
+ resolved: ResolvedTableStyleResolution;
99
+ /** Total height of the table in twips (for grip geometry). */
100
+ tableHeightTwips: number;
101
+ /** If `columnsTwips` was scaled to the canvas width, pass the scale
102
+ * factor here; otherwise leave undefined. */
103
+ columnsTwipsScale?: number;
104
+ }
105
+
106
+ export function buildTableRenderPlan(
107
+ input: BuildTableRenderPlanInput,
108
+ ): TableRenderPlan {
109
+ const { blockId, pageIndex, block, resolved, tableHeightTwips } = input;
110
+ const scale = input.columnsTwipsScale ?? 1;
111
+ const columnsTwips = block.gridColumns.map((w) => Math.round(w * scale));
112
+
113
+ const bandClasses = buildBandClasses(block.rows, resolved);
114
+ const verticalMerges = collectVerticalMerges(block.rows);
115
+ const repeatedHeaderRows: RepeatedHeaderRowRef[] = [];
116
+ const columnResizeHandles = buildColumnResizeHandles(columnsTwips, tableHeightTwips);
117
+
118
+ return {
119
+ blockId,
120
+ pageIndex,
121
+ columnsTwips,
122
+ bandClasses,
123
+ verticalMerges,
124
+ repeatedHeaderRows,
125
+ columnResizeHandles,
126
+ };
127
+ }
128
+
129
+ // ─── Internals ───────────────────────────────────────────────────────────────
130
+
131
+ function buildBandClasses(
132
+ rows: readonly SurfaceTableRowSnapshot[],
133
+ resolved: ResolvedTableStyleResolution,
134
+ ): TableBandClasses {
135
+ const rowAssignments: TableRowBandAssignment[] = [];
136
+ const cellAssignments: TableCellBandAssignment[] = [];
137
+
138
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
139
+ const resolvedRow = resolved.rows[rowIndex];
140
+ const rowRegions = resolvedRow?.style.activeConditionalRegions ?? [];
141
+ rowAssignments.push({ rowIndex, regions: rowRegions });
142
+
143
+ const resolvedCells = resolvedRow?.cells ?? [];
144
+ const sourceRow = rows[rowIndex]!;
145
+ let columnCursor = sourceRow.gridBefore ?? 0;
146
+ for (let cellIndex = 0; cellIndex < sourceRow.cells.length; cellIndex += 1) {
147
+ const cell = sourceRow.cells[cellIndex]!;
148
+ const columnSpan = Math.max(1, cell.colspan ?? 1);
149
+ const resolvedCell = resolvedCells[cellIndex];
150
+ const regions = resolvedCell?.activeConditionalRegions ?? [];
151
+ cellAssignments.push({
152
+ rowIndex,
153
+ columnIndex: columnCursor,
154
+ regions,
155
+ });
156
+ columnCursor += columnSpan;
157
+ }
158
+ }
159
+
160
+ return { rows: rowAssignments, cells: cellAssignments };
161
+ }
162
+
163
+ function collectVerticalMerges(
164
+ rows: readonly SurfaceTableRowSnapshot[],
165
+ ): VerticalMergeRef[] {
166
+ const merges: VerticalMergeRef[] = [];
167
+ // Track active chains by column: maps startColumn → { startRow, span }.
168
+ const active = new Map<number, { startRow: number; span: number }>();
169
+
170
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
171
+ const row = rows[rowIndex]!;
172
+ let columnCursor = row.gridBefore ?? 0;
173
+ const seenThisRow = new Set<number>();
174
+
175
+ for (const cell of row.cells) {
176
+ const span = Math.max(1, cell.colspan ?? 1);
177
+ if (cell.verticalMerge === "restart") {
178
+ active.set(columnCursor, { startRow: rowIndex, span });
179
+ seenThisRow.add(columnCursor);
180
+ } else if (cell.verticalMerge === "continue") {
181
+ seenThisRow.add(columnCursor);
182
+ // chain stays open; ended rowIndex extends via the flush below
183
+ }
184
+ columnCursor += span;
185
+ }
186
+
187
+ // Flush chains that did NOT appear as a continue in this row.
188
+ for (const [startColumn, chain] of [...active.entries()]) {
189
+ if (!seenThisRow.has(startColumn)) {
190
+ merges.push({
191
+ columnIndex: startColumn,
192
+ startRowIndex: chain.startRow,
193
+ endRowIndex: rowIndex - 1,
194
+ columnSpan: chain.span,
195
+ });
196
+ active.delete(startColumn);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Flush any still-open chains at end of table.
202
+ for (const [startColumn, chain] of active.entries()) {
203
+ merges.push({
204
+ columnIndex: startColumn,
205
+ startRowIndex: chain.startRow,
206
+ endRowIndex: rows.length - 1,
207
+ columnSpan: chain.span,
208
+ });
209
+ }
210
+
211
+ return merges;
212
+ }
213
+
214
+ function buildColumnResizeHandles(
215
+ columnsTwips: readonly number[],
216
+ heightTwips: number,
217
+ ): ColumnResizeHandle[] {
218
+ const handles: ColumnResizeHandle[] = [];
219
+ let cursor = 0;
220
+ for (let i = 0; i < columnsTwips.length - 1; i += 1) {
221
+ cursor += columnsTwips[i] ?? 0;
222
+ handles.push({
223
+ columnIndex: i,
224
+ originTwips: cursor,
225
+ heightTwips,
226
+ });
227
+ }
228
+ return handles;
229
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Block fragment projection (P4b).
3
+ *
4
+ * Classifies each `PublicBlockFragment` produced by the layout facet into
5
+ * a `RenderBlock["kind"]` so the render kernel's output is accurate across
6
+ * paragraph / table / opaque / synthetic blocks. Previously the kernel's
7
+ * `classifyBlockKind` stub always returned `"paragraph"`.
8
+ *
9
+ * The classification is driven by the deterministic blockId prefix scheme
10
+ * emitted by `src/runtime/surface-projection.ts`:
11
+ * - `paragraph-*` → "paragraph"
12
+ * - `table-*` → "table"
13
+ * - `opaque-*` → "opaque"
14
+ * - `section-break-*` → "opaque" (read-only boundary marker)
15
+ * - `sdt-*`, `sdt-wrapper-*` → "opaque"
16
+ * - `custom-xml-*` → "opaque"
17
+ * - `alt-chunk-*` → "opaque"
18
+ * - `synthetic-*` → "synthetic"
19
+ * Unknown prefixes fall back to `"paragraph"` so the render frame stays
20
+ * usable while a new surface node type is in development.
21
+ */
22
+
23
+ import type { RenderBlock } from "./render-frame-types.ts";
24
+
25
+ export function classifyBlockKind(blockId: string): RenderBlock["kind"] {
26
+ if (blockId.startsWith("paragraph")) return "paragraph";
27
+ if (blockId.startsWith("table")) return "table";
28
+ if (blockId.startsWith("opaque")) return "opaque";
29
+ if (blockId.startsWith("section-break")) return "opaque";
30
+ if (blockId.startsWith("sdt")) return "opaque";
31
+ if (blockId.startsWith("custom-xml")) return "opaque";
32
+ if (blockId.startsWith("alt-chunk")) return "opaque";
33
+ if (blockId.startsWith("synthetic")) return "synthetic";
34
+ return "paragraph";
35
+ }