@beyondwork/docx-react-component 1.0.37 → 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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  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/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Logical grid topology helper for merge-aware table structural operations.
3
+ *
4
+ * A `TableNode` carries cells in row-major order, but horizontal (`gridSpan`)
5
+ * and vertical (`verticalMerge`) merges mean cell index and logical column
6
+ * index are not the same. Every structural command that has to reason about
7
+ * "the cell at logical column C in row R" reads through this module.
8
+ *
9
+ * The core type is `LogicalGrid`: a rectangular 2D array where each entry
10
+ * describes either the origin of a spanning region or a covered slot that
11
+ * points back to its origin.
12
+ */
13
+ import type {
14
+ TableCellNode,
15
+ TableNode,
16
+ TableRowNode,
17
+ } from "../../model/canonical-document.ts";
18
+
19
+ export interface GridOriginSlot {
20
+ kind: "origin";
21
+ /** Row index of this origin slot in the table. */
22
+ rowIndex: number;
23
+ /** Logical column index of the left edge of the spanning region. */
24
+ columnIndex: number;
25
+ /** Index of the cell within its row's `cells` array. */
26
+ cellIndex: number;
27
+ /** Horizontal span in logical columns (== gridSpan, default 1). */
28
+ columnSpan: number;
29
+ /** Vertical span in rows, resolved from verticalMerge chains. */
30
+ rowSpan: number;
31
+ /** The cell this origin points to. */
32
+ cell: TableCellNode;
33
+ }
34
+
35
+ export interface GridCoveredSlot {
36
+ kind: "covered";
37
+ /** The origin this slot is covered by. */
38
+ origin: GridOriginSlot;
39
+ }
40
+
41
+ export type GridSlot = GridOriginSlot | GridCoveredSlot;
42
+
43
+ export interface LogicalGrid {
44
+ table: TableNode;
45
+ rowCount: number;
46
+ columnCount: number;
47
+ /** 2D matrix [rowIndex][logicalColumnIndex] → slot. */
48
+ slots: GridSlot[][];
49
+ /**
50
+ * Rows carried over via gridBefore/gridAfter spacers. Each entry lists
51
+ * the logical columns that were marked as "before" or "after" padding
52
+ * rather than cell content. Used by commands that must preserve ragged
53
+ * row geometry.
54
+ */
55
+ rowPadding: Array<{ beforeColumns: number[]; afterColumns: number[] }>;
56
+ /** Invariant violations discovered while building the grid. */
57
+ warnings: GridWarning[];
58
+ }
59
+
60
+ export interface GridWarning {
61
+ kind:
62
+ | "row-span-mismatch"
63
+ | "vmerge-continue-without-restart"
64
+ | "column-span-zero";
65
+ rowIndex: number;
66
+ columnIndex?: number;
67
+ message: string;
68
+ }
69
+
70
+ /**
71
+ * Build the logical grid for a canonical table.
72
+ *
73
+ * The result is tolerant: malformed inputs (e.g. a `verticalMerge: "continue"`
74
+ * with no matching restart above) produce `warnings` rather than throwing,
75
+ * so callers can still make best-effort structural decisions on imported
76
+ * documents. The grid is always rectangular — covered slots are filled in
77
+ * to match the widest effective row width.
78
+ */
79
+ export function buildLogicalGrid(table: TableNode): LogicalGrid {
80
+ const rowCount = table.rows.length;
81
+ const columnCount = computeLogicalColumnCount(table);
82
+ const slots: GridSlot[][] = Array.from({ length: rowCount }, () =>
83
+ Array.from({ length: columnCount }) as GridSlot[],
84
+ );
85
+ const rowPadding: LogicalGrid["rowPadding"] = [];
86
+ const warnings: GridWarning[] = [];
87
+
88
+ // Track active vMerge chains keyed by their logical start column. Each
89
+ // entry holds the origin and the trailing column covered by the chain.
90
+ const activeVMerges = new Map<number, GridOriginSlot>();
91
+
92
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
93
+ const row = table.rows[rowIndex]!;
94
+ const gridBefore = Math.max(0, row.gridBefore ?? 0);
95
+ const gridAfter = Math.max(0, row.gridAfter ?? 0);
96
+ const beforeColumns: number[] = [];
97
+ const afterColumns: number[] = [];
98
+ const endColumn = columnCount - gridAfter;
99
+
100
+ // Mark gridBefore columns as padding (they don't participate in the
101
+ // cell grid but we still need to know they're reserved so column
102
+ // counts stay accurate for subsequent vMerge math).
103
+ for (let padIndex = 0; padIndex < gridBefore && padIndex < columnCount; padIndex += 1) {
104
+ beforeColumns.push(padIndex);
105
+ }
106
+
107
+ let columnCursor = gridBefore;
108
+
109
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
110
+ const cell = row.cells[cellIndex]!;
111
+ const rawSpan = cell.gridSpan ?? 1;
112
+ if (rawSpan <= 0) {
113
+ warnings.push({
114
+ kind: "column-span-zero",
115
+ rowIndex,
116
+ columnIndex: columnCursor,
117
+ message: `Cell ${cellIndex} has non-positive gridSpan; treating as 1`,
118
+ });
119
+ }
120
+ const columnSpan = Math.max(1, rawSpan);
121
+
122
+ if (cell.verticalMerge === "continue") {
123
+ const owner = findVMergeOwner(activeVMerges, columnCursor, columnSpan);
124
+ if (owner) {
125
+ // Extend the owner's rowspan to cover this row.
126
+ owner.rowSpan += 1;
127
+ const covered: GridCoveredSlot = { kind: "covered", origin: owner };
128
+ for (let offset = 0; offset < columnSpan; offset += 1) {
129
+ const column = columnCursor + offset;
130
+ if (column < columnCount) {
131
+ slots[rowIndex]![column] = covered;
132
+ }
133
+ }
134
+ // Note: a vMerge:continue cell can still break the chain later;
135
+ // we keep the owner entry keyed by its original column so
136
+ // subsequent rows can extend it.
137
+ columnCursor += columnSpan;
138
+ continue;
139
+ }
140
+
141
+ warnings.push({
142
+ kind: "vmerge-continue-without-restart",
143
+ rowIndex,
144
+ columnIndex: columnCursor,
145
+ message: `Cell ${cellIndex} has verticalMerge=continue but no matching restart above`,
146
+ });
147
+ // Fall through and treat as a regular cell origin.
148
+ }
149
+
150
+ const origin: GridOriginSlot = {
151
+ kind: "origin",
152
+ rowIndex,
153
+ columnIndex: columnCursor,
154
+ cellIndex,
155
+ columnSpan,
156
+ rowSpan: 1,
157
+ cell,
158
+ };
159
+
160
+ for (let offset = 0; offset < columnSpan; offset += 1) {
161
+ const column = columnCursor + offset;
162
+ if (column >= columnCount) break;
163
+ if (offset === 0) {
164
+ slots[rowIndex]![column] = origin;
165
+ } else {
166
+ slots[rowIndex]![column] = { kind: "covered", origin };
167
+ }
168
+ }
169
+
170
+ if (cell.verticalMerge === "restart") {
171
+ // Close out any prior chain starting at this column (stale).
172
+ activeVMerges.set(columnCursor, origin);
173
+ } else {
174
+ // Any non-merge origin breaks prior chains crossing this column.
175
+ clearChainsBetween(activeVMerges, columnCursor, columnCursor + columnSpan);
176
+ }
177
+
178
+ columnCursor += columnSpan;
179
+ }
180
+
181
+ // Any columns the cells did not cover fall into gridAfter padding. If
182
+ // the row was too short to reach endColumn, record the trailing gap so
183
+ // downstream code knows the row is ragged rather than broken.
184
+ if (columnCursor < endColumn) {
185
+ warnings.push({
186
+ kind: "row-span-mismatch",
187
+ rowIndex,
188
+ message:
189
+ `Row cells reached column ${columnCursor}, expected ${endColumn}` +
190
+ ` (gridBefore=${gridBefore}, gridAfter=${gridAfter})`,
191
+ });
192
+ for (let column = columnCursor; column < endColumn; column += 1) {
193
+ beforeColumns.push(column);
194
+ }
195
+ }
196
+
197
+ for (let padIndex = 0; padIndex < gridAfter; padIndex += 1) {
198
+ const column = columnCount - gridAfter + padIndex;
199
+ if (column >= 0 && column < columnCount) {
200
+ afterColumns.push(column);
201
+ }
202
+ }
203
+
204
+ rowPadding.push({ beforeColumns, afterColumns });
205
+
206
+ // Prune chains that were not extended by a continue in this row.
207
+ for (const [column, owner] of activeVMerges) {
208
+ const slot = slots[rowIndex]![column];
209
+ if (!slot || slot.kind !== "covered" || slot.origin !== owner) {
210
+ if (owner.rowIndex !== rowIndex) {
211
+ // Chain did not continue; drop it so later rows don't match.
212
+ activeVMerges.delete(column);
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ return {
219
+ table,
220
+ rowCount,
221
+ columnCount,
222
+ slots,
223
+ rowPadding,
224
+ warnings,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Logical column count across all rows: the widest effective row width,
230
+ * honoring `gridBefore + Σ(gridSpan) + gridAfter` and `gridColumns` length.
231
+ */
232
+ export function computeLogicalColumnCount(table: TableNode): number {
233
+ const fromGrid = table.gridColumns.length;
234
+ const fromRows = table.rows.reduce((max, row) => {
235
+ const rowWidth =
236
+ (row.gridBefore ?? 0) +
237
+ row.cells.reduce((sum, cell) => sum + Math.max(1, cell.gridSpan ?? 1), 0) +
238
+ (row.gridAfter ?? 0);
239
+ return rowWidth > max ? rowWidth : max;
240
+ }, 0);
241
+ return Math.max(fromGrid, fromRows);
242
+ }
243
+
244
+ /**
245
+ * Look up the slot at a logical position. Returns null when the row or
246
+ * column is out of bounds.
247
+ */
248
+ export function slotAt(
249
+ grid: LogicalGrid,
250
+ rowIndex: number,
251
+ columnIndex: number,
252
+ ): GridSlot | null {
253
+ if (rowIndex < 0 || rowIndex >= grid.rowCount) return null;
254
+ if (columnIndex < 0 || columnIndex >= grid.columnCount) return null;
255
+ return grid.slots[rowIndex]![columnIndex] ?? null;
256
+ }
257
+
258
+ /**
259
+ * Returns the origin slot covering `(rowIndex, columnIndex)`. If the slot
260
+ * is covered (by hspan or vMerge), follows the pointer back to the origin.
261
+ */
262
+ export function originAt(
263
+ grid: LogicalGrid,
264
+ rowIndex: number,
265
+ columnIndex: number,
266
+ ): GridOriginSlot | null {
267
+ const slot = slotAt(grid, rowIndex, columnIndex);
268
+ if (!slot) return null;
269
+ return slot.kind === "origin" ? slot : slot.origin;
270
+ }
271
+
272
+ /**
273
+ * Walk the vertical-merge chain rooted at `(rowIndex, columnIndex)`.
274
+ * Returns the origin's row indices in order. An un-merged cell yields a
275
+ * single-element array. A continue cell returns the full chain from its
276
+ * origin through every covered row.
277
+ */
278
+ export function collectVMergeChain(
279
+ grid: LogicalGrid,
280
+ rowIndex: number,
281
+ columnIndex: number,
282
+ ): number[] {
283
+ const origin = originAt(grid, rowIndex, columnIndex);
284
+ if (!origin) return [];
285
+ const rows: number[] = [];
286
+ for (let r = origin.rowIndex; r < grid.rowCount; r += 1) {
287
+ const slot: GridSlot | undefined = grid.slots[r]?.[origin.columnIndex];
288
+ if (!slot) break;
289
+ const slotOrigin: GridOriginSlot = slot.kind === "origin" ? slot : slot.origin;
290
+ if (slotOrigin !== origin) break;
291
+ rows.push(r);
292
+ }
293
+ return rows;
294
+ }
295
+
296
+ /**
297
+ * Determine whether a rectangular selection rect contains only whole
298
+ * spanning regions (no torn spans). Merge-aware commands use this to
299
+ * decide whether a bulk op can proceed cleanly.
300
+ */
301
+ export interface RectCoverage {
302
+ /** Rect is clean: every origin fully inside the rect, no partial covers. */
303
+ clean: boolean;
304
+ /** Origins that were only partially covered by the rect. */
305
+ straddlingOrigins: GridOriginSlot[];
306
+ /** Origins fully enclosed by the rect. */
307
+ fullyCoveredOrigins: GridOriginSlot[];
308
+ }
309
+
310
+ export interface GridRect {
311
+ /** Inclusive. */
312
+ top: number;
313
+ /** Inclusive. */
314
+ left: number;
315
+ /** Exclusive (one past the last row). */
316
+ bottom: number;
317
+ /** Exclusive (one past the last column). */
318
+ right: number;
319
+ }
320
+
321
+ export function analyzeRect(grid: LogicalGrid, rect: GridRect): RectCoverage {
322
+ const seen = new Set<GridOriginSlot>();
323
+ const straddling = new Set<GridOriginSlot>();
324
+ for (let r = rect.top; r < rect.bottom; r += 1) {
325
+ for (let c = rect.left; c < rect.right; c += 1) {
326
+ const origin = originAt(grid, r, c);
327
+ if (!origin) continue;
328
+ seen.add(origin);
329
+ const originFitsHorizontally =
330
+ origin.columnIndex >= rect.left &&
331
+ origin.columnIndex + origin.columnSpan <= rect.right;
332
+ const originFitsVertically =
333
+ origin.rowIndex >= rect.top &&
334
+ origin.rowIndex + origin.rowSpan <= rect.bottom;
335
+ if (!originFitsHorizontally || !originFitsVertically) {
336
+ straddling.add(origin);
337
+ }
338
+ }
339
+ }
340
+ return {
341
+ clean: straddling.size === 0,
342
+ straddlingOrigins: [...straddling],
343
+ fullyCoveredOrigins: [...seen].filter((origin) => !straddling.has(origin)),
344
+ };
345
+ }
346
+
347
+ function findVMergeOwner(
348
+ activeVMerges: ReadonlyMap<number, GridOriginSlot>,
349
+ columnCursor: number,
350
+ columnSpan: number,
351
+ ): GridOriginSlot | null {
352
+ const exact = activeVMerges.get(columnCursor);
353
+ if (exact && exact.columnSpan === columnSpan) {
354
+ return exact;
355
+ }
356
+ // Fall back to any owner whose origin lies inside the cursor range —
357
+ // Word tolerates mismatched column spans in a vMerge chain and resolves
358
+ // to the nearest matching origin.
359
+ for (const owner of activeVMerges.values()) {
360
+ if (
361
+ owner.columnIndex >= columnCursor &&
362
+ owner.columnIndex < columnCursor + columnSpan
363
+ ) {
364
+ return owner;
365
+ }
366
+ }
367
+ return exact ?? null;
368
+ }
369
+
370
+ function clearChainsBetween(
371
+ activeVMerges: Map<number, GridOriginSlot>,
372
+ leftColumn: number,
373
+ rightColumn: number,
374
+ ): void {
375
+ for (const column of [...activeVMerges.keys()]) {
376
+ if (column >= leftColumn && column < rightColumn) {
377
+ activeVMerges.delete(column);
378
+ }
379
+ }
380
+ }
381
+
382
+ // ─── Structural invariants exposed for tests and capability snapshots ─────────
383
+
384
+ export interface StructuralInvariantReport {
385
+ ok: boolean;
386
+ rowWidthMismatches: number[];
387
+ vMergeDangling: Array<{ rowIndex: number; columnIndex: number }>;
388
+ zeroColumnSpans: Array<{ rowIndex: number; columnIndex: number }>;
389
+ }
390
+
391
+ export function evaluateInvariants(grid: LogicalGrid): StructuralInvariantReport {
392
+ const rowWidthMismatches: number[] = [];
393
+ const vMergeDangling: StructuralInvariantReport["vMergeDangling"] = [];
394
+ const zeroColumnSpans: StructuralInvariantReport["zeroColumnSpans"] = [];
395
+ for (const warning of grid.warnings) {
396
+ if (warning.kind === "row-span-mismatch") {
397
+ rowWidthMismatches.push(warning.rowIndex);
398
+ } else if (warning.kind === "vmerge-continue-without-restart" && warning.columnIndex !== undefined) {
399
+ vMergeDangling.push({ rowIndex: warning.rowIndex, columnIndex: warning.columnIndex });
400
+ } else if (warning.kind === "column-span-zero" && warning.columnIndex !== undefined) {
401
+ zeroColumnSpans.push({ rowIndex: warning.rowIndex, columnIndex: warning.columnIndex });
402
+ }
403
+ }
404
+ return {
405
+ ok: rowWidthMismatches.length === 0 && vMergeDangling.length === 0 && zeroColumnSpans.length === 0,
406
+ rowWidthMismatches,
407
+ vMergeDangling,
408
+ zeroColumnSpans,
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Helper: find the cell-array index + cell reference for a logical column
414
+ * in a specific row. Mirrors the `findCellAtColumn` helper previously
415
+ * embedded in `table-structure-commands.ts` but routes through the grid.
416
+ */
417
+ export function findCellIndexAtColumn(
418
+ row: TableRowNode,
419
+ logicalColumn: number,
420
+ ): { cellIndex: number; cell: TableCellNode } | null {
421
+ let cursor = row.gridBefore ?? 0;
422
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
423
+ const cell = row.cells[cellIndex]!;
424
+ const width = Math.max(1, cell.gridSpan ?? 1);
425
+ if (logicalColumn >= cursor && logicalColumn < cursor + width) {
426
+ return { cellIndex, cell };
427
+ }
428
+ cursor += width;
429
+ }
430
+ return null;
431
+ }