Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,14 @@ export type TableRowAttrs = {
value: number;
rule?: 'auto' | 'atLeast' | 'exact' | string;
};
/**
* Row-level border override from OOXML `w:tblPrEx/w:tblBorders` (§17.4.61).
* Table property exceptions override the table-level borders for this row
* only. Rows without a `tblPrEx` border block leave this undefined and fall
* through to the table's borders. Resolved (eighth-points → px) by the v1
* layout-adapter; the painter merges it over the table borders per edge.
*/
borders?: TableBorders;
};

export type TableRow = {
Expand Down
62 changes: 62 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5825,6 +5825,68 @@ describe('requirePageBoundary edge cases', () => {
expect(pageContainsBlock(layout.pages[0], 'body')).toBe(true);
});

it('uses first ROW height (not full table) for a splittable table anchor so the chain starts and the table splits (SD-3345)', () => {
// A heading (keepNext) immediately followed by a tall splittable table. The
// heading + table FIRST ROW fits in the remaining space, but heading + the WHOLE
// table does not. Word starts the table here and splits it; reserving the full
// table height would push the heading + table wholly to the next page (large gap).
const filler: FlowBlock = {
kind: 'paragraph',
id: 'filler',
runs: [{ text: 'filler', fontFamily: 'Arial', fontSize: 12 }],
attrs: {},
};
const heading: FlowBlock = {
kind: 'paragraph',
id: 'heading',
runs: [{ text: 'Heading', fontFamily: 'Arial', fontSize: 24 }],
attrs: { keepNext: true },
};
const table = {
kind: 'table',
id: 'tbl',
rows: Array.from({ length: 4 }, (_unused, i) => ({
id: `r${i}`,
cells: [
{
id: `c${i}`,
blocks: [{ kind: 'paragraph', id: `p${i}`, runs: [{ text: 'x', fontFamily: 'Arial', fontSize: 10 }] }],
},
],
})),
} as unknown as TableBlock;

const fillerMeasure: ParagraphMeasure = { kind: 'paragraph', lines: [makeLine(50)], totalHeight: 50 };
const headingMeasure: ParagraphMeasure = { kind: 'paragraph', lines: [makeLine(20)], totalHeight: 20 };
// 4 rows × 15px = 60px total; first row 15px. Cells carry a single measured line
// so the table-start preflight can render at least one row on the current page.
const tableMeasure = {
kind: 'table',
rows: Array.from({ length: 4 }, () => ({
cells: [{ paragraph: { kind: 'paragraph', lines: [makeLine(15)], totalHeight: 15 }, width: 100, height: 15 }],
height: 15,
})),
columnWidths: [100],
totalWidth: 100,
totalHeight: 60,
} as unknown as TableMeasure;

// Content area = 100px. After filler(50), 50px remain.
// - heading(20) + firstRow(15) = 35 <= 50 → chain fits → start on this page.
// - heading(20) + fullTable(60) = 80 > 50 → would advance without the fix.
// (80 <= 100 content height, so the blank-page guard does not suppress the advance.)
const options: LayoutOptions = {
pageSize: { w: 400, h: 160 },
margins: { top: 30, right: 30, bottom: 30, left: 30 },
};

const layout = layoutDocument([filler, heading, table], [fillerMeasure, headingMeasure, tableMeasure], options);

// Heading and the table both start on page 0 (the table then splits across pages).
expect(pageContainsBlock(layout.pages[0], 'heading')).toBe(true);
expect(pageContainsBlock(layout.pages[0], 'tbl')).toBe(true);
});

it('reclaims trailing spacing when both filler and chain starter have contextualSpacing', () => {
// Both filler and chain starter have contextualSpacing + same style.
// The trailing spacing should be reclaimed, making room for the chain.
Expand Down
20 changes: 17 additions & 3 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,13 +438,27 @@ function calculateChainHeight(

totalHeight += interParagraphSpacing + anchorHeight;
} else {
// Non-paragraph anchor (table, image, etc.): use full height
// No contextual spacing applies to non-paragraph blocks
// Non-paragraph anchor (table, image, etc.).
// No contextual spacing applies to non-paragraph blocks.
// Skip anchored tables - they're positioned out of flow and don't consume flow height
// (consistent with shouldSkipAnchoredTable guard in legacy keepNext path)
const isAnchoredTable = anchorBlock.kind === 'table' && (anchorBlock as TableBlock).anchor?.isAnchored === true;
if (!isAnchoredTable) {
totalHeight += prevSpacingAfter + getMeasureHeight(anchorBlock, anchorMeasure);
// For a table anchor, only require the FIRST ROW to stay with the chain, not
// the full table. The keepNext contract keeps the heading with the table's
// start; the table itself splits across pages (SD-3345). Reserving the full
// height pushed a heading + tall splittable table wholly to the next page,
// leaving a large gap, where Word starts the table here and splits it. This
// mirrors the paragraph anchor's first-line optimization (SD-1282). A table
// whose first row cannot split is still handled by the table-start preflight.
let anchorHeight = getMeasureHeight(anchorBlock, anchorMeasure);
if (anchorBlock.kind === 'table' && anchorMeasure.kind === 'table' && anchorMeasure.rows.length > 0) {
const firstRowHeight = anchorMeasure.rows[0]?.height;
if (typeof firstRowHeight === 'number' && Number.isFinite(firstRowHeight) && firstRowHeight > 0) {
anchorHeight = firstRowHeight;
}
}
totalHeight += prevSpacingAfter + anchorHeight;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
hasExplicitCellBorders,
swapTableBordersLR,
swapCellBordersLR,
resolveBorderConflict,
} from './border-utils.js';

describe('applyBorder', () => {
Expand Down Expand Up @@ -522,3 +523,42 @@ describe('swapCellBordersLR', () => {
expect(swapCellBordersLR(undefined)).toBeUndefined();
});
});

describe('resolveBorderConflict (ECMA-376 §17.4.66)', () => {
const D9 = { style: 'single' as const, width: 1.333, color: '#BDD7EE' };

it('collapses two identical borders to one (symmetric → no doubling)', () => {
// The M&A checklist case: adjacent cells both specify the same border.
const winner = resolveBorderConflict(D9, { ...D9 });
expect(winner).toMatchObject({ style: 'single', color: '#BDD7EE' });
});

it('keeps the present border when the opposing side is none (asymmetric → no dropped border)', () => {
// The it1007 case: header has a bottom border, the body cell below has no top.
const headerBottom = { style: 'single' as const, width: 1, color: '#000000' };
expect(resolveBorderConflict(undefined, headerBottom)).toEqual(headerBottom);
expect(resolveBorderConflict({ style: 'none' }, headerBottom)).toEqual(headerBottom);
expect(resolveBorderConflict(headerBottom, undefined)).toEqual(headerBottom);
});

it('returns undefined when neither side has a border', () => {
expect(resolveBorderConflict(undefined, undefined)).toBeUndefined();
expect(resolveBorderConflict({ style: 'none' }, { style: 'none' })).toBeUndefined();
expect(resolveBorderConflict({ style: 'single', width: 0, color: '#000' }, undefined)).toBeUndefined();
});

it('the heavier-weight border wins (double over single)', () => {
const single = { style: 'single' as const, width: 1, color: '#000000' };
const dbl = { style: 'double' as const, width: 1, color: '#000000' };
// weight: single = 1×1 = 1, double = 2×3 = 6 → double wins
expect(resolveBorderConflict(single, dbl)).toEqual(dbl);
expect(resolveBorderConflict(dbl, single)).toEqual(dbl);
});

it('on equal weight + identical style, the darker color wins', () => {
const dark = { style: 'single' as const, width: 1, color: '#000000' };
const light = { style: 'single' as const, width: 1, color: '#FFFFFF' };
// brightness(R+B+2G): dark=0 < light=1020 → dark wins
expect(resolveBorderConflict(light, dark)).toEqual(dark);
});
});
96 changes: 96 additions & 0 deletions packages/layout-engine/painters/dom/src/table/border-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,102 @@ export const resolveTableBorderValue = (
return borderValueToSpec(fallback);
};

// Border "number" per ECMA-376 §17.4.66 (only the realistic styles; unknown → 1).
const BORDER_STYLE_NUMBER: Partial<Record<BorderStyle, number>> = {
single: 1,
thick: 2,
double: 3,
dotted: 4,
dashed: 5,
dotDash: 6,
dotDotDash: 7,
triple: 8,
wave: 18,
doubleWave: 19,
};
// Number of drawn lines per style (single=1, double=2, triple=3, …).
const BORDER_STYLE_LINES: Partial<Record<BorderStyle, number>> = {
single: 1,
thick: 1,
double: 2,
dotted: 1,
dashed: 1,
dotDash: 1,
dotDotDash: 1,
triple: 3,
wave: 1,
doubleWave: 2,
};

export const isPresentBorder = (b?: BorderSpec): b is BorderSpec =>
!!b && b.style !== undefined && b.style !== 'none' && (b.width === undefined || b.width > 0);

/**
* True when a border is EXPLICITLY set to none/nil (`w:val="nil"`/`"none"`), as opposed to
* simply unset/absent. The distinction matters for shared interior edges (§17.4.66): an
* explicit none on BOTH adjacent cells suppresses the divider, while an unset side inherits
* the table's insideH/insideV. Accepts either a CellBorders BorderSpec (`{style:'none'}`) or
* a TableBorderValue (`{none:true}`).
*/
export const isExplicitNoneBorder = (b?: unknown): boolean => {
if (!b || typeof b !== 'object') return false;
const r = b as Record<string, unknown>;
return r.style === 'none' || r.none === true;
};

const borderWeight = (b: BorderSpec): number =>
(BORDER_STYLE_LINES[b.style as BorderStyle] ?? 1) * (BORDER_STYLE_NUMBER[b.style as BorderStyle] ?? 1);

const colorBrightness = (color: string | undefined, formula: (r: number, g: number, bl: number) => number): number => {
const hex = (color ?? '#000000').replace('#', '');
if (hex.length < 6) return 0;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const bl = parseInt(hex.slice(4, 6), 16);
return formula(r, g, bl);
};

/**
* OOXML cell-border conflict resolution (ECMA-376 §17.4.66).
*
* With zero cell spacing, two cells sharing an edge each specify a border; the spec
* collapses them to a SINGLE displayed border:
* 1. If either side is nil/none/absent, the opposing (present) border is displayed.
* 2. Otherwise the border with greater weight wins, where
* weight = (#lines in the style) × (style number).
* 3. Equal weight → the style higher on the precedence list (single first) wins.
* 4. Identical style → the color with the smaller brightness (R+B+2G, then B+2G, then
* G) wins; finally the first border (reading order) wins.
*
* @param a - One side's border (the owning cell's, e.g. the lower/right cell)
* @param b - The opposing side's border (e.g. the upper/left neighbor)
* @returns The single BorderSpec to display, or undefined if neither is present.
*/
export const resolveBorderConflict = (a?: BorderSpec, b?: BorderSpec): BorderSpec | undefined => {
const pa = isPresentBorder(a);
const pb = isPresentBorder(b);
if (!pa && !pb) return undefined;
if (!pa) return b;
if (!pb) return a;
const wa = borderWeight(a);
const wb = borderWeight(b);
if (wa !== wb) return wa > wb ? a : b;
const na = BORDER_STYLE_NUMBER[a.style as BorderStyle] ?? 99;
const nb = BORDER_STYLE_NUMBER[b.style as BorderStyle] ?? 99;
if (na !== nb) return na < nb ? a : b;
const formulas: Array<(r: number, g: number, bl: number) => number> = [
(r, g, bl) => r + bl + 2 * g,
(_r, g, bl) => bl + 2 * g,
(_r, g) => g,
];
for (const f of formulas) {
const ba = colorBrightness(a.color, f);
const bb = colorBrightness(b.color, f);
if (ba !== bb) return ba < bb ? a : b;
}
return a;
};

/**
* Creates a border overlay element for a table fragment.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,61 @@ describe('renderTableFragment', () => {
});
});

// Build a one-row measure whose single cell occupies only column 0, leaving
// the last grid column as an empty trailing gridAfter spacer.
const spacerMeasure = (spacerWidth: number): TableMeasure => ({
kind: 'table',
rows: [
{
cells: [
{
paragraph: { kind: 'paragraph', lines: [], totalHeight: 20 },
width: 100,
height: 20,
gridColumnStart: 0,
colSpan: 1,
},
],
height: 20,
},
],
columnWidths: [100, spacerWidth],
totalWidth: 100 + spacerWidth,
totalHeight: 20,
});

const renderSpacerTable = (spacerWidth: number): Element =>
renderTableFragment({
doc,
fragment: createTestTableFragment([
{ index: 0, x: 0, width: 100, minWidth: 25, resizable: true },
{ index: 1, x: 100, width: spacerWidth, minWidth: 25, resizable: true },
]),
context,
block: createTestTableBlock(),
measure: spacerMeasure(spacerWidth),
cellSpacingPx: 0,
effectiveColumnWidths: [100, spacerWidth],
renderLine: () => doc.createElement('div'),
applyFragmentFrame: () => {},
applySdtDataset: () => {},
applyStyles: () => {},
});

it('omits the resize boundary for a degenerate trailing gridAfter spacer column (SD-3345)', () => {
// Spacer is narrower than its own min width → degenerate → its left-edge
// resize boundary is suppressed so it does not crowd the table-edge handle.
const parsed = JSON.parse(renderSpacerTable(7).getAttribute('data-table-boundaries')!);
expect(parsed.segments[1]).toEqual([]);
});

it('keeps the resize boundary for a normal-width trailing column (control)', () => {
// Same shape but the trailing column meets its min width → not degenerate →
// the boundary is kept. Proves the suppression is gated on degeneracy.
const parsed = JSON.parse(renderSpacerTable(40).getAttribute('data-table-boundaries')!);
expect(parsed.segments[1].length).toBeGreaterThan(0);
});

it('should embed row boundary metadata when rowBoundaries are present', () => {
const block = createTestTableBlock();
const measure = createTestTableMeasure();
Expand Down
Loading
Loading