Skip to content

Commit c01a888

Browse files
committed
fix(table): collapsed cell-border rendering with tblPrEx and gridAfter
Render w:tblPrEx row-level borders (merged per edge over the table borders) and resolve collapsed shared edges with single-owner ownership plus the §17.4.66 winner, so adjacent cell borders draw exactly once (no doubling) and asymmetric or borderless edges keep their line (no drop). Handle trailing w:gridAfter columns: the rightmost real cell owns the right border, the resize overlay skips the degenerate spacer, and a cell spanning past the next row's coverage owns its full-width bottom while that row suppresses its top. (SD-3345, SD-2969)
1 parent a8e71ee commit c01a888

7 files changed

Lines changed: 642 additions & 24 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,14 @@ export type TableRowAttrs = {
760760
value: number;
761761
rule?: 'auto' | 'atLeast' | 'exact' | string;
762762
};
763+
/**
764+
* Row-level border override from OOXML `w:tblPrEx/w:tblBorders` (§17.4.61).
765+
* Table property exceptions override the table-level borders for this row
766+
* only. Rows without a `tblPrEx` border block leave this undefined and fall
767+
* through to the table's borders. Resolved (eighth-points → px) by the v1
768+
* layout-adapter; the painter merges it over the table borders per edge.
769+
*/
770+
borders?: TableBorders;
763771
};
764772

765773
export type TableRow = {

packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,61 @@ describe('renderTableFragment', () => {
577577
});
578578
});
579579

580+
// Build a one-row measure whose single cell occupies only column 0, leaving
581+
// the last grid column as an empty trailing gridAfter spacer.
582+
const spacerMeasure = (spacerWidth: number): TableMeasure => ({
583+
kind: 'table',
584+
rows: [
585+
{
586+
cells: [
587+
{
588+
paragraph: { kind: 'paragraph', lines: [], totalHeight: 20 },
589+
width: 100,
590+
height: 20,
591+
gridColumnStart: 0,
592+
colSpan: 1,
593+
},
594+
],
595+
height: 20,
596+
},
597+
],
598+
columnWidths: [100, spacerWidth],
599+
totalWidth: 100 + spacerWidth,
600+
totalHeight: 20,
601+
});
602+
603+
const renderSpacerTable = (spacerWidth: number): Element =>
604+
renderTableFragment({
605+
doc,
606+
fragment: createTestTableFragment([
607+
{ index: 0, x: 0, width: 100, minWidth: 25, resizable: true },
608+
{ index: 1, x: 100, width: spacerWidth, minWidth: 25, resizable: true },
609+
]),
610+
context,
611+
block: createTestTableBlock(),
612+
measure: spacerMeasure(spacerWidth),
613+
cellSpacingPx: 0,
614+
effectiveColumnWidths: [100, spacerWidth],
615+
renderLine: () => doc.createElement('div'),
616+
applyFragmentFrame: () => {},
617+
applySdtDataset: () => {},
618+
applyStyles: () => {},
619+
});
620+
621+
it('omits the resize boundary for a degenerate trailing gridAfter spacer column (SD-3345)', () => {
622+
// Spacer is narrower than its own min width → degenerate → its left-edge
623+
// resize boundary is suppressed so it does not crowd the table-edge handle.
624+
const parsed = JSON.parse(renderSpacerTable(7).getAttribute('data-table-boundaries')!);
625+
expect(parsed.segments[1]).toEqual([]);
626+
});
627+
628+
it('keeps the resize boundary for a normal-width trailing column (control)', () => {
629+
// Same shape but the trailing column meets its min width → not degenerate →
630+
// the boundary is kept. Proves the suppression is gated on degeneracy.
631+
const parsed = JSON.parse(renderSpacerTable(40).getAttribute('data-table-boundaries')!);
632+
expect(parsed.segments[1].length).toBeGreaterThan(0);
633+
});
634+
580635
it('should embed row boundary metadata when rowBoundaries are present', () => {
581636
const block = createTestTableBlock();
582637
const measure = createTestTableMeasure();

packages/layout-engine/painters/dom/src/table/renderTableFragment.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,28 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement
313313
// Track which column boundaries exist in this row
314314
const boundariesInRow = new Set<number>();
315315

316+
// Columns occupied by a cell in this row. Used to detect a trailing
317+
// w:gridAfter spacer column this row leaves empty.
318+
const occupiedCols = new Set<number>();
319+
for (const cellMeasure of rowMeasure.cells) {
320+
const s = cellMeasure.gridColumnStart ?? 0;
321+
const sp = cellMeasure.colSpan ?? 1;
322+
for (let c = s; c < s + sp; c++) occupiedCols.add(c);
323+
}
324+
// A degenerate trailing gridAfter spacer (last column, unoccupied this row,
325+
// narrower than its own min width) sits a few px from the table edge. Emitting
326+
// a resize boundary at its left edge crowds the table-edge handle and reads as a
327+
// doubled border on hover, so skip that boundary for this row (SD-3345).
328+
const lastColIndex = columnCount - 1;
329+
const lastColMeta = fragment.metadata.columnBoundaries[lastColIndex];
330+
const skipTrailingSpacerBoundary =
331+
lastColIndex > 0 &&
332+
!occupiedCols.has(lastColIndex) &&
333+
!!lastColMeta &&
334+
typeof lastColMeta.width === 'number' &&
335+
typeof lastColMeta.minWidth === 'number' &&
336+
lastColMeta.width < lastColMeta.minWidth;
337+
316338
for (const cellMeasure of rowMeasure.cells) {
317339
const startCol = cellMeasure.gridColumnStart ?? 0;
318340
const colSpan = cellMeasure.colSpan ?? 1;
@@ -323,8 +345,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement
323345
if (startCol > 0) {
324346
boundariesInRow.add(startCol);
325347
}
326-
// End boundary (right edge of cell)
327-
if (endCol < columnCount) {
348+
// End boundary (right edge of cell), unless it lands on a degenerate
349+
// trailing gridAfter spacer (its left edge is the table edge for practical
350+
// purposes, handled by the table-edge handle).
351+
if (endCol < columnCount && !(skipTrailingSpacerBoundary && endCol === lastColIndex)) {
328352
boundariesInRow.add(endCol);
329353
}
330354
}
@@ -428,6 +452,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement
428452
y,
429453
rowMeasure,
430454
row: block.rows[r],
455+
prevRow: r > 0 ? block.rows[r - 1] : undefined,
456+
prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined,
457+
nextRowMeasure: r < block.rows.length - 1 ? measure.rows[r + 1] : undefined,
431458
totalRows: block.rows.length,
432459
tableBorders,
433460
columnWidths: effectiveColumnWidths,
@@ -595,6 +622,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement
595622
y,
596623
rowMeasure,
597624
row: block.rows[r],
625+
prevRow: r > 0 ? block.rows[r - 1] : undefined,
626+
prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined,
627+
nextRowMeasure: r < block.rows.length - 1 ? measure.rows[r + 1] : undefined,
598628
totalRows: block.rows.length,
599629
tableBorders,
600630
columnWidths: effectiveColumnWidths,

0 commit comments

Comments
 (0)