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
38 changes: 37 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TabStop } from './engines/tabs.js';
import type { DeepReadonly, DeepRequired } from '@superdoc/common';
export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/tabs.js';

// Re-export TabStop for external consumers
Expand Down Expand Up @@ -437,10 +438,30 @@ export type TableCellAttrs = {
borders?: CellBorders;
padding?: BoxSpacing;
verticalAlign?: 'top' | 'middle' | 'center' | 'bottom';
background?: string;
background?: string | null;
tableCellProperties?: Record<string, unknown>;
};

export type CompleteTableCellAttrs = DeepRequired<TableCellAttrs>;

export const defaultTableCellAttrs: DeepReadonly<CompleteTableCellAttrs> = {
borders: {
top: { style: 'single', width: 1, color: 'auto', space: 0 },
right: { style: 'single', width: 1, color: 'auto', space: 0 },
bottom: { style: 'single', width: 1, color: 'auto', space: 0 },
left: { style: 'single', width: 1, color: 'auto', space: 0 },
},
padding: {
top: 2,
left: 4,
right: 4,
bottom: 2,
},
verticalAlign: 'top',
background: null,
tableCellProperties: {},
};

export type TableAttrs = {
borders?: TableBorders;
borderCollapse?: 'collapse' | 'separate';
Expand All @@ -461,6 +482,15 @@ export type TableCell = {
attrs?: TableCellAttrs;
};

export type CompleteTableCell = DeepRequired<TableCell>;

export const defaultTableCell: DeepReadonly<Omit<CompleteTableCell, 'id' | 'paragraph'>> = {
blocks: [],
rowSpan: 1,
colSpan: 1,
attrs: defaultTableCellAttrs,
};

export type TableRowAttrs = {
tableRowProperties?: Record<string, unknown>;
rowHeight?: {
Expand Down Expand Up @@ -1310,11 +1340,17 @@ export type TableCellMeasure = {
colSpan?: number;
/** Number of rows this cell spans */
rowSpan?: number;
/** Bottom border */
borderBottom: number;
/** Top border */
borderTop: number;
};

export type TableRowMeasure = {
cells: TableCellMeasure[];
height: number;
borderTop: number;
borderBottom: number;
};

export type TableMeasure = {
Expand Down
41 changes: 27 additions & 14 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
DrawingGeometry,
DropCapDescriptor,
} from '@superdoc/contracts';
import { defaultTableCell } from '@superdoc/contracts';
import type { WordParagraphLayoutOutput } from '@superdoc/word-layout';
import { Engines } from '@superdoc/contracts';
import {
Expand All @@ -73,7 +74,9 @@ import { toCssFontFamily } from '@superdoc/font-utils';
export { installNodeCanvasPolyfill } from './setup.js';
import { clearMeasurementCache, getMeasuredTextWidth, setCacheSize } from './measurementCache.js';
import { getFontMetrics, clearFontMetricsCache, type FontInfo } from './fontMetricsCache.js';
import { getBorderWidth } from './table-utils';

export { getBorderWidth };
export { clearFontMetricsCache };

const { computeTabStops } = Engines;
Expand Down Expand Up @@ -688,6 +691,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
leaders?: Line['leaders'];
/** Count of breakable spaces already included on this line (for justify-aware fitting) */
spaceCount: number;
naturalWidth?: number;
} | null = null;

// Helper to calculate effective available width based on current line count.
Expand Down Expand Up @@ -865,8 +869,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lineToTrim.width = roundValue(Math.max(0, lineToTrim.width - delta));
lineToTrim.spaceCount = Math.max(0, lineToTrim.spaceCount - trimCount);

if ((lineToTrim as any).naturalWidth != null && typeof (lineToTrim as any).naturalWidth === 'number') {
(lineToTrim as any).naturalWidth = roundValue(Math.max(0, (lineToTrim as any).naturalWidth - delta));
if (lineToTrim.naturalWidth != undefined && typeof lineToTrim.naturalWidth === 'number') {
lineToTrim.naturalWidth = roundValue(Math.max(0, lineToTrim.naturalWidth - delta));
}
};

Expand Down Expand Up @@ -1745,7 +1749,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
(shouldIncludeDelimiterSpace ? ((run as TextRun).letterSpacing ?? 0) : 0);
// Preserve natural width when compression is applied for justify calculations
if (compressedWidth != null) {
(currentLine as any).naturalWidth = roundValue(totalWidthWithWord);
currentLine.naturalWidth = roundValue(totalWidthWithWord);
}
currentLine.width = roundValue(targetWidth);
currentLine.maxFontInfo = updateMaxFontInfo(currentLine.maxFontSize, currentLine.maxFontInfo, run);
Expand Down Expand Up @@ -2135,8 +2139,8 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai
let gridColIndex = 0; // Track position in the grid

for (const cell of row.cells) {
const colspan = cell.colSpan ?? 1;
const rowspan = cell.rowSpan ?? 1;
const colspan = cell.colSpan ?? defaultTableCell.colSpan;
const rowspan = cell.rowSpan ?? defaultTableCell.rowSpan;

// Skip grid columns that are occupied by rowspans from previous rows
// before processing this cell
Expand All @@ -2159,12 +2163,14 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai
}
}

// Get cell padding for height calculation
const cellPadding = cell.attrs?.padding ?? { top: 2, left: 4, right: 4, bottom: 2 };
const paddingTop = cellPadding.top ?? 2;
const paddingBottom = cellPadding.bottom ?? 2;
const paddingLeft = cellPadding.left ?? 4;
const paddingRight = cellPadding.right ?? 4;
// Get cell padding and borders for width and height calculations
const paddingTop = cell.attrs?.padding?.top ?? defaultTableCell.attrs.padding.top;
const paddingBottom = cell.attrs?.padding?.bottom ?? defaultTableCell.attrs.padding.bottom;
const paddingLeft = cell.attrs?.padding?.left ?? defaultTableCell.attrs.padding.left;
const paddingRight = cell.attrs?.padding?.right ?? defaultTableCell.attrs.padding.right;

const borderTop = getBorderWidth(cell.attrs?.borders?.top ?? defaultTableCell.attrs.borders.top);
const borderBottom = getBorderWidth(cell.attrs?.borders?.bottom ?? defaultTableCell.attrs.borders.bottom);

// Content width accounts for horizontal padding
const contentWidth = Math.max(1, cellWidth - paddingLeft - paddingRight);
Expand All @@ -2184,7 +2190,7 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai
*
* Height Calculation:
* - contentHeight = sum of all block.totalHeight values
* - totalCellHeight = contentHeight + paddingTop + paddingBottom
* - totalCellHeight = contentHeight + paddingTop + paddingBottom + borderTop
*
* Example:
* ```
Expand Down Expand Up @@ -2217,7 +2223,7 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai
}

// Total cell height includes vertical padding
const totalCellHeight = contentHeight + paddingTop + paddingBottom;
const totalCellHeight = contentHeight + paddingTop + paddingBottom + borderTop;

cellMeasures.push({
blocks: blockMeasures,
Expand All @@ -2228,6 +2234,8 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai
gridColumnStart: gridColIndex,
colSpan: colspan,
rowSpan: rowspan,
borderTop,
borderBottom,
});

if (rowspan === 1) {
Expand All @@ -2247,7 +2255,12 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai
}
}

rows.push({ cells: cellMeasures, height: 0 });
rows.push({
cells: cellMeasures,
height: 0,
borderTop: Math.max(0, ...cellMeasures.map((cm) => cm.borderTop)),
borderBottom: Math.max(0, ...cellMeasures.map((cm) => cm.borderBottom)),
});
}

const rowHeights = [...rowBaseHeights];
Expand Down
9 changes: 9 additions & 0 deletions packages/layout-engine/measuring/dom/src/table-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BorderSpec } from '@superdoc/contracts';

export function getBorderWidth(border: BorderSpec | null | undefined): number {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does measuring/dom seem like the right place for this? It means that painters/dom is now dependent on measuring/dom, which it wasn't before. Open to other suggestions of which package to put it in.

if (!border || border.style === 'none') {
return 0;
}

return border.width ?? 1;
}
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@superdoc/contracts": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/measuring-dom": "workspace:*",
"@superdoc/preset-geometry": "workspace:*",
"@superdoc/url-validation": "workspace:*"
},
Expand Down
34 changes: 17 additions & 17 deletions packages/layout-engine/painters/dom/src/table/border-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,91 +30,91 @@ describe('applyBorder', () => {

it('should apply border with single style (converts to solid)', () => {
const border: BorderSpec = { style: 'single', width: 2, color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
// Browsers may normalize colors to rgb() format
expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i);
});

it('should apply border with double style', () => {
const border: BorderSpec = { style: 'double', width: 2, color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/2px double (#FF0000|rgb\(255,\s*0,\s*0\))/i);
});

it('should apply border with dashed style', () => {
const border: BorderSpec = { style: 'dashed', width: 1, color: '#00FF00' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/1px dashed (#00FF00|rgb\(0,\s*255,\s*0\))/i);
});

it('should apply border with dotted style', () => {
const border: BorderSpec = { style: 'dotted', width: 1, color: '#0000FF' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/1px dotted (#0000FF|rgb\(0,\s*0,\s*255\))/i);
});

it('should convert triple to solid CSS', () => {
const border: BorderSpec = { style: 'triple', width: 2, color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i);
});

it('should handle thick border with width multiplier', () => {
const border: BorderSpec = { style: 'thick', width: 1, color: '#000000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
// Thick borders use max(width * 2, 3)
expect(element.style.borderTop).toMatch(/3px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
});

it('should handle thick border with larger width', () => {
const border: BorderSpec = { style: 'thick', width: 3, color: '#000000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/6px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
});

it('should set border to none for none style', () => {
const border: BorderSpec = { style: 'none', width: 2, color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
// Setting border to 'none' results in empty string or 'none' depending on browser
expect(element.style.borderTop === 'none' || element.style.borderTop === '').toBe(true);
});

it('should set border to none for zero width', () => {
const border: BorderSpec = { style: 'single', width: 0, color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
// Setting border to 'none' results in empty string or 'none' depending on browser
expect(element.style.borderTop === 'none' || element.style.borderTop === '').toBe(true);
});

it('should sanitize invalid hex color to black', () => {
const border: BorderSpec = { style: 'single', width: 1, color: 'invalid' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/1px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
});

it('should default width to 1 if missing', () => {
const border: BorderSpec = { style: 'single', color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/1px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i);
});

it('should default color to black if missing', () => {
const border: BorderSpec = { style: 'single', width: 2 };
applyBorder(element, 'Top', border);
applyBorder(element, 'top', border);
expect(element.style.borderTop).toMatch(/2px solid (#000000|rgb\(0,\s*0,\s*0\))/i);
});

it('should do nothing if border is undefined', () => {
applyBorder(element, 'Top', undefined);
applyBorder(element, 'top', undefined);
expect(element.style.borderTop).toBe('');
});

it('should apply to all four sides', () => {
const border: BorderSpec = { style: 'single', width: 1, color: '#FF0000' };
applyBorder(element, 'Top', border);
applyBorder(element, 'Right', border);
applyBorder(element, 'Bottom', border);
applyBorder(element, 'Left', border);
applyBorder(element, 'top', border);
applyBorder(element, 'right', border);
applyBorder(element, 'bottom', border);
applyBorder(element, 'left', border);
const pattern = /1px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i;
expect(element.style.borderTop).toMatch(pattern);
expect(element.style.borderRight).toMatch(pattern);
Expand Down
33 changes: 20 additions & 13 deletions packages/layout-engine/painters/dom/src/table/border-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ const borderStyleToCSS = (style?: BorderStyle): string => {

const isValidHexColor = (color: string): boolean => /^#[0-9A-Fa-f]{6}$/.test(color);

const BORDER_CSS_ATTRS = {
top: 'borderTop',
right: 'borderRight',
bottom: 'borderBottom',
left: 'borderLeft',
} as const;

/**
* Applies a border specification to one side of an HTML element.
*
Expand All @@ -57,24 +64,24 @@ const isValidHexColor = (color: string): boolean => /^#[0-9A-Fa-f]{6}$/.test(col
* and special cases like 'thick' borders which use doubled width.
*
* @param element - The HTML element to apply the border to
* @param side - Which side of the element to apply the border ('Top', 'Right', 'Bottom', or 'Left')
* @param side - Which side of the element to apply the border ('top', 'right', 'bottom', or 'left')
* @param border - The border specification to apply, or undefined to skip
*
* @example
* ```typescript
* const cell = document.createElement('td');
* applyBorder(cell, 'Top', { style: 'single', width: 2, color: '#FF0000' });
* applyBorder(cell, 'top', { style: 'single', width: 2, color: '#FF0000' });
* // Sets cell.style.borderTop = '2px solid #FF0000'
* ```
*/
export const applyBorder = (
element: HTMLElement,
side: 'Top' | 'Right' | 'Bottom' | 'Left',
side: 'top' | 'right' | 'bottom' | 'left',
border?: BorderSpec,
): void => {
if (!border) return;
if (border.style === 'none' || border.width === 0) {
element.style[`border${side}`] = 'none';
element.style[BORDER_CSS_ATTRS[side]] = 'none';
return;
}

Expand All @@ -83,7 +90,7 @@ export const applyBorder = (
const color = border.color ?? '#000000';
const safeColor = isValidHexColor(color) ? color : '#000000';
const actualWidth = border.style === 'thick' ? Math.max(width * 2, 3) : width;
element.style[`border${side}`] = `${actualWidth}px ${style} ${safeColor}`;
element.style[BORDER_CSS_ATTRS[side]] = `${actualWidth}px ${style} ${safeColor}`;
};

/**
Expand All @@ -107,10 +114,10 @@ export const applyBorder = (
*/
export const applyCellBorders = (element: HTMLElement, borders?: CellBorders): void => {
if (!borders) return;
applyBorder(element, 'Top', borders.top);
applyBorder(element, 'Right', borders.right);
applyBorder(element, 'Bottom', borders.bottom);
applyBorder(element, 'Left', borders.left);
applyBorder(element, 'top', borders.top);
applyBorder(element, 'right', borders.right);
applyBorder(element, 'bottom', borders.bottom);
applyBorder(element, 'left', borders.left);
};

/**
Expand Down Expand Up @@ -228,10 +235,10 @@ export const createTableBorderOverlay = (
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '1';

applyBorder(overlay, 'Top', top);
applyBorder(overlay, 'Right', right);
applyBorder(overlay, 'Bottom', bottom);
applyBorder(overlay, 'Left', left);
applyBorder(overlay, 'top', top);
applyBorder(overlay, 'right', right);
applyBorder(overlay, 'bottom', bottom);
applyBorder(overlay, 'left', left);

return overlay;
};
Expand Down
Loading
Loading