From 2678e5439dc841cd360c0c8dc3cf981fc05c2090 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 29 Jan 2026 23:45:49 +0200 Subject: [PATCH 1/5] fix: support cell spacing --- packages/layout-engine/contracts/src/index.ts | 24 +++++- .../layout-engine/layout-bridge/src/index.ts | 19 +++-- .../layout-engine/src/layout-table.ts | 20 ++++- .../layout-engine/measuring/dom/src/index.ts | 74 ++++++++++++++++++- .../painters/dom/src/renderer.ts | 3 +- .../dom/src/table/renderTableFragment.ts | 59 ++++++++++++--- .../painters/dom/src/table/renderTableRow.ts | 25 ++++--- .../pm-adapter/src/converters/table.ts | 4 + .../v3/handlers/w/tbl/tbl-translator.js | 2 +- .../helpers/legacy-handle-table-cell-node.js | 11 ++- .../src/extensions/table/table.js | 18 ++++- 11 files changed, 221 insertions(+), 38 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 943aa4ab95..dc41cb5a30 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -448,7 +448,7 @@ export type TableCellAttrs = { export type TableAttrs = { borders?: TableBorders; borderCollapse?: 'collapse' | 'separate'; - cellSpacing?: number; + cellSpacing?: CellSpacing; sdt?: SdtMetadata; containerSdt?: SdtMetadata; [key: string]: unknown; @@ -1405,12 +1405,34 @@ export type TableRowMeasure = { height: number; }; +/** Outer table border widths in pixels (top, right, bottom, left). Used for total dimensions and content offset. */ +export type TableBorderWidths = { + top: number; + right: number; + bottom: number; + left: number; +}; + export type TableMeasure = { kind: 'table'; rows: TableRowMeasure[]; columnWidths: number[]; totalWidth: number; totalHeight: number; + /** + * Cell spacing in pixels (border-spacing between cells). + * Used for total table dimensions and cell x/y positioning when border-collapse is 'separate'. + */ + cellSpacingPx?: number; + /** + * Outer table border widths in pixels. Included in totalWidth/totalHeight; content is offset by (left, top). + */ + tableBorderWidths?: TableBorderWidths; +}; + +export type CellSpacing = { + type: string; + value: number; }; export type SectionBreakMeasure = { diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index edc8deff28..86ab851ab1 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -1539,11 +1539,16 @@ export function selectionToRects( return rowMeasure?.height ?? 0; }); + const cellSpacingPx = tableMeasure.cellSpacingPx ?? 0; + const tableBorderWidths = tableMeasure.tableBorderWidths; + const contentOffsetX = tableBorderWidths?.left ?? 0; + const contentOffsetY = tableBorderWidths?.top ?? 0; + const calculateCellX = (cellIdx: number, cellMeasure: TableCellMeasure) => { const gridStart = cellMeasure.gridColumnStart ?? cellIdx; - let x = 0; + let x = cellSpacingPx; // space before first column for (let i = 0; i < gridStart && i < tableMeasure.columnWidths.length; i += 1) { - x += tableMeasure.columnWidths[i]; + x += tableMeasure.columnWidths[i] + cellSpacingPx; } return x; }; @@ -1674,14 +1679,15 @@ export function selectionToRects( wordLayout: cellWordLayout, }); - const rectX = fragment.x + cellX + padding.left + textIndentAdjust + Math.min(startX, endX); + const rectX = + fragment.x + contentOffsetX + cellX + padding.left + textIndentAdjust + Math.min(startX, endX); const rectWidth = Math.max( 1, Math.min(Math.abs(endX - startX), line.width), // clamp to line width to prevent runaway widths ); const lineOffset = lineHeightBeforeIndex(info.measure, index) - lineHeightBeforeIndex(info.measure, info.startLine); - const rectY = fragment.y + rowOffset + blockTopCursor + lineOffset; + const rectY = fragment.y + contentOffsetY + rowOffset + blockTopCursor + lineOffset; rects.push({ x: rectX, @@ -1699,15 +1705,18 @@ export function selectionToRects( return rowOffset + rowHeight; }; - let rowCursor = 0; + // First row starts after space before table content (space between table border and first row) + let rowCursor = cellSpacingPx; const repeatHeaderCount = tableFragment.repeatHeaderCount ?? 0; for (let r = 0; r < repeatHeaderCount && r < tableMeasure.rows.length; r += 1) { rowCursor = processRow(r, rowCursor); + rowCursor += cellSpacingPx; // spacing after every row (including last) for outer spacing } for (let r = tableFragment.fromRow; r < tableFragment.toRow && r < tableMeasure.rows.length; r += 1) { rowCursor = processRow(r, rowCursor); + rowCursor += cellSpacingPx; // spacing after every row (including last) for outer spacing } return; diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 4fe6a0233a..d7ed6dc1aa 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -205,7 +205,8 @@ function calculateColumnMinWidth(): number { */ function generateColumnBoundaries(measure: TableMeasure): TableColumnBoundary[] { const boundaries: TableColumnBoundary[] = []; - let xPosition = 0; + const cellSpacingPx = measure.cellSpacingPx ?? 0; + let xPosition = cellSpacingPx; // space before first column for (let i = 0; i < measure.columnWidths.length; i++) { const width = measure.columnWidths[i]; @@ -221,7 +222,8 @@ function generateColumnBoundaries(measure: TableMeasure): TableColumnBoundary[] boundaries.push(boundary); - xPosition += width; + // Next boundary is after this column plus spacing (border-spacing between columns) + xPosition += width + cellSpacingPx; } return boundaries; @@ -285,14 +287,28 @@ function calculateFragmentHeight( _headerCount: number, ): number { let height = 0; + let rowCount = 0; // Add header height if continuation with repeated headers if (fragment.repeatHeaderCount && fragment.repeatHeaderCount > 0) { height += sumRowHeights(measure.rows, 0, fragment.repeatHeaderCount); + rowCount += fragment.repeatHeaderCount; } // Add body row heights (fromRow to toRow, exclusive) + const bodyRowCount = fragment.toRow - fragment.fromRow; height += sumRowHeights(measure.rows, fragment.fromRow, fragment.toRow); + rowCount += bodyRowCount; + + // Add vertical gaps: space before first row, between rows, after last row (outer spacing) + const cellSpacingPx = measure.cellSpacingPx ?? 0; + if (rowCount > 0 && cellSpacingPx > 0) { + height += (rowCount + 1) * cellSpacingPx; + } + if (rowCount > 0 && measure.tableBorderWidths) { + const borderWidthV = measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; + height += borderWidthV; + } return height; } diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 20f39c3daf..49b7fcca21 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -60,6 +60,9 @@ import { type DrawingGeometry, type DropCapDescriptor, type TableWidthAttr, + type CellSpacing, + type TableBorders, + type TableBorderValue, } from '@superdoc/contracts'; import type { WordParagraphLayoutOutput } from '@superdoc/word-layout'; import { @@ -68,7 +71,6 @@ import { DEFAULT_LIST_INDENT_BASE_PX as DEFAULT_LIST_INDENT_BASE, DEFAULT_LIST_INDENT_STEP_PX as DEFAULT_LIST_INDENT_STEP, DEFAULT_LIST_HANGING_PX as DEFAULT_LIST_HANGING, - SPACE_SUFFIX_GAP_PX, } from '@superdoc/common/layout-constants'; import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils'; import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils'; @@ -147,6 +149,52 @@ const _PX_PER_PT = 96 / 72; // Reserved for future pt↔px conversions const twipsToPx = (twips: number): number => twips / TWIPS_PER_PX; const pxToTwips = (px: number): number => Math.round(px * TWIPS_PER_PX); +/** + * Resolves table cell spacing to pixels (for border-spacing). + * Handles number (px) or { type, value }. The editor/DOCX decoder often stores value + * already in pixels (twipsToPixels), so we use value as px. If value is in twips (raw OOXML), + * type is 'dxa' and we convert; otherwise value is treated as px. + */ +function getCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number { + if (cellSpacing == null) return 0; + if (typeof cellSpacing === 'number') return Math.max(0, cellSpacing); + const v = cellSpacing.value; + if (typeof v !== 'number' || !Number.isFinite(v)) return 0; + const t = (cellSpacing.type ?? '').toLowerCase(); + // Editor/store often has value already in px; raw OOXML has twips (dxa). Only convert when value looks like twips (large). + const asPx = t === 'dxa' && v >= 20 ? twipsToPx(v) : v; + return Math.max(0, asPx); +} + +/** + * Returns the border width in pixels for a table border value (matches painter border-utils logic). + * Used so total table dimensions include outer border sizes and there is enough space for last row/column spacing. + */ +function getTableBorderWidthPx(value: TableBorderValue | null | undefined): number { + if (value == null) return 0; + if (typeof value === 'object' && 'none' in value && value.none) return 0; + const raw = value as { style?: string; width?: number; size?: number }; + const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; + const width = Math.max(0, w); + if (raw.style === 'none') return 0; + if (raw.style === 'thick') return Math.max(width * 2, 3); + return width; +} + +/** Computes outer table border widths in px from table attrs (for total dimensions and content offset). */ +function getTableBorderWidths(borders: TableBorders | null | undefined): { + top: number; + right: number; + bottom: number; + left: number; +} { + const top = getTableBorderWidthPx(borders?.top); + const right = getTableBorderWidthPx(borders?.right); + const bottom = getTableBorderWidthPx(borders?.bottom); + const left = getTableBorderWidthPx(borders?.left); + return { top, right, bottom, left }; +} + const DEFAULT_TAB_INTERVAL_PX = twipsToPx(DEFAULT_TAB_INTERVAL_TWIPS); const TAB_EPSILON = 0.1; const DEFAULT_DECIMAL_SEPARATOR = '.'; @@ -2781,14 +2829,34 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai rows[i].height = Math.max(0, rowHeights[i]); } - const totalHeight = rowHeights.reduce((sum, h) => sum + h, 0); - const totalWidth = columnWidths.reduce((a, b) => a + b, 0); + const contentHeight = rowHeights.reduce((sum, h) => sum + h, 0); + const contentWidth = columnWidths.reduce((a, b) => a + b, 0); + + // Cell margins (OOXML cellMargins) are applied as cell padding (attrs.padding) and are already + // included in row heights and content width: row height = content + paddingTop + paddingBottom, + // and content width per cell = cellWidth - paddingLeft - paddingRight. + + // Cell spacing (border-spacing): gaps between cells plus space before first and after last row/column + const cellSpacingPx = getCellSpacingPx(block.attrs?.cellSpacing); + const numRows = block.rows.length; + const horizontalGaps = gridColumnCount > 0 ? (gridColumnCount + 1) * cellSpacingPx : 0; + const verticalGaps = numRows > 0 ? (numRows + 1) * cellSpacingPx : 0; + + // Outer table border widths: include in total dimensions so there is enough space for last row/column spacing + const tableBorderWidths = getTableBorderWidths(block.attrs?.borders); + const borderWidthH = tableBorderWidths.left + tableBorderWidths.right; + const borderWidthV = tableBorderWidths.top + tableBorderWidths.bottom; + const totalWidth = contentWidth + horizontalGaps + borderWidthH; + const totalHeight = contentHeight + verticalGaps + borderWidthV; + return { kind: 'table', rows, columnWidths, totalWidth, totalHeight, + cellSpacingPx: cellSpacingPx > 0 ? cellSpacingPx : undefined, + tableBorderWidths: borderWidthH > 0 || borderWidthV > 0 ? tableBorderWidths : undefined, }; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 42774924a6..49016f4133 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5768,7 +5768,8 @@ const deriveBlockVersion = (block: FlowBlock): string => { hash = hashString(hash, tblAttrs.borderCollapse); } if (tblAttrs.cellSpacing !== undefined) { - hash = hashNumber(hash, tblAttrs.cellSpacing); + const cs = tblAttrs.cellSpacing; + hash = typeof cs === 'number' ? hashNumber(hash, cs) : hashString(hash, JSON.stringify(cs)); } } diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 55c64f4c6b..34ea74a302 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -1,4 +1,5 @@ import type { + CellSpacing, DrawingBlock, Fragment, Line, @@ -12,9 +13,27 @@ import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import type { FragmentRenderContext, BlockLookup } from '../renderer.js'; import { renderTableRow } from './renderTableRow.js'; import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { applyBorder, borderValueToSpec } from './border-utils'; type ApplyStylesFn = (el: HTMLElement, styles: Partial) => void; +/** 15 twips per pixel (96 dpi). Used when resolving raw dxa values in painter fallback. */ +const TWIPS_PER_PX = 15; + +/** + * Resolves table cell spacing to pixels from block attrs (painter fallback when measure has no cellSpacingPx). + * Editor/store often has value already in px; raw OOXML has twips (dxa). Only convert when value looks like twips. + */ +function resolveCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number { + if (cellSpacing == null) return 0; + if (typeof cellSpacing === 'number') return Math.max(0, cellSpacing); + const v = cellSpacing.value; + if (typeof v !== 'number' || !Number.isFinite(v)) return 0; + const t = (cellSpacing.type ?? '').toLowerCase(); + const asPx = t === 'dxa' && v >= 20 ? v / TWIPS_PER_PX : v; + return Math.max(0, asPx); +} + /** * Dependencies required for rendering a table fragment. * @@ -174,12 +193,23 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement container.style.height = `${fragment.height}px`; applySdtDataset(container, block.attrs?.sdt); + // Outer table border widths: reserve space so border is inside fragment size; content is offset + const tableBorderWidths = measure.tableBorderWidths; + if (tableBorderWidths) { + container.style.boxSizing = 'border-box'; + } + const contentLeft = tableBorderWidths?.left ?? 0; + const contentTop = tableBorderWidths?.top ?? 0; + // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); // Add table-specific class for resize overlay targeting container.classList.add('superdoc-table-fragment'); + // Cell spacing in px (border-spacing). Use measure when present, else resolve from block attrs (e.g. stale/cached measure). + const cellSpacingPx = measure.cellSpacingPx ?? resolveCellSpacingPx(block.attrs?.cellSpacing) ?? 0; + // Add metadata for interactive table resizing if (fragment.metadata?.columnBoundaries) { // Build row-aware boundary segments @@ -195,7 +225,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // For each row, determine which grid columns have cell boundaries // A boundary exists at column X if there's a cell that ENDS at column X (gridColumnStart + colSpan = X) - let rowY = 0; + // rowY includes outer spacing (before first row, between rows, after last) so segment positions match rendered cells + let rowY = cellSpacingPx; for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { const rowMeasure = measure.rows[rowIndex]; if (!rowMeasure) continue; @@ -239,13 +270,13 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } } - rowY += rowMeasure.height; + rowY += rowMeasure.height + cellSpacingPx; } const metadata = { columns: fragment.metadata.columnBoundaries.map((boundary) => ({ i: boundary.index, - x: boundary.x, + x: boundary.x + contentLeft, w: boundary.width, min: boundary.minWidth, r: boundary.resizable ? 1 : 0, @@ -254,7 +285,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement segments: boundarySegments.map((segs, colIndex) => segs.map((seg) => ({ c: colIndex, // column index - y: seg.y, // y position + y: seg.y + contentTop, // y position (relative to table container) h: seg.height, // height of segment })), ), @@ -268,9 +299,12 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement container.setAttribute('data-sd-block-id', block.id); } - const borderCollapse = block.attrs?.borderCollapse || 'collapse'; - if (borderCollapse === 'separate' && block.attrs?.cellSpacing) { - container.style.borderSpacing = `${block.attrs.cellSpacing}px`; + const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + if (borderCollapse === 'separate' && block.attrs?.cellSpacing && tableBorders) { + applyBorder(container, 'Top', borderValueToSpec(tableBorders.top)); + applyBorder(container, 'Right', borderValueToSpec(tableBorders.right)); + applyBorder(container, 'Bottom', borderValueToSpec(tableBorders.bottom)); + applyBorder(container, 'Left', borderValueToSpec(tableBorders.left)); } // Pre-calculate all row heights for rowspan calculations @@ -285,7 +319,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement return r?.height ?? 0; }); - let y = 0; + // First row starts after space before table content (space between table border and first row) + let y = cellSpacingPx; // If this is a continuation fragment with repeated headers, render headers first if (fragment.repeatHeaderCount && fragment.repeatHeaderCount > 0) { @@ -312,8 +347,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, + cellSpacingPx, }); - y += rowMeasure.height; + // Add row height + spacing after every row (including last) for outer spacing after last row + y += rowMeasure.height + cellSpacingPx; } } @@ -353,8 +390,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement continuesOnNext: isLastRenderedBodyRow && fragment.continuesOnNext === true, // Pass partial row data for mid-row splits partialRow: partialRowData, + cellSpacingPx, }); - y += actualRowHeight; + // Add row height + spacing after every row (including last) for outer spacing after last row + y += actualRowHeight + cellSpacingPx; } return container; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index cc8cf221fe..b41f17891a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -78,6 +78,12 @@ type TableRowRenderDependencies = { * only a portion of the row's content. */ partialRow?: PartialRowInfo; + + /** + * Cell spacing in pixels (border-spacing between cells). + * Applied to cell x positions and row y advancement. + */ + cellSpacingPx?: number; }; /** @@ -135,14 +141,15 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { continuesFromPrev, continuesOnNext, partialRow, + cellSpacingPx = 0, } = deps; /** * Calculates the horizontal position (x-coordinate) for a cell based on its grid column index. * - * Sums the widths of all columns preceding the given column index to determine - * the left edge position of a cell. This handles both normal cells and cells - * offset by rowspans from previous rows. + * Sums the widths of all columns preceding the given column index plus spacing between + * columns (border-spacing). When cellSpacingPx > 0, each column after the first is + * offset by one spacing unit, so x = sum(columnWidths[0..gridColumnStart-1]) + gridColumnStart * cellSpacingPx. * * **Bounds Safety:** * Loop terminates at the minimum of `gridColumnStart` and `columnWidths.length` @@ -153,17 +160,15 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { * * @example * ```typescript - * // columnWidths = [100, 150, 200] - * calculateXPosition(0) // Returns: 0 (first column) - * calculateXPosition(1) // Returns: 100 (after first column) - * calculateXPosition(2) // Returns: 250 (after first two columns) - * calculateXPosition(10) // Returns: 450 (safe - stops at array length) + * // columnWidths = [100, 150, 200], cellSpacingPx = 4 + * calculateXPosition(0) // Returns: cellSpacingPx (space before first column) + * calculateXPosition(1) // Returns: cellSpacingPx + columnWidths[0] + cellSpacingPx * ``` */ const calculateXPosition = (gridColumnStart: number): number => { - let x = 0; + let x = cellSpacingPx; // space before first column for (let i = 0; i < gridColumnStart && i < columnWidths.length; i++) { - x += columnWidths[i]; + x += columnWidths[i] + cellSpacingPx; } return x; }; diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index 1534d15c1d..30a7b14056 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -742,6 +742,10 @@ export function tableNodeToBlock( tableAttrs.tableIndent = { ...node.attrs.tableIndent }; } + if (defaultCellPadding && typeof defaultCellPadding === 'object') { + tableAttrs.defaultCellPadding = { ...defaultCellPadding }; + } + // Pass tableLayout through (extracted by tblLayout-translator.js) const tableLayout = node.attrs?.tableLayout; if (tableLayout) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index baf6d52d50..51593514e7 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -87,7 +87,7 @@ const encode = (params, encodedAttrs) => { 'justification', 'tableLayout', ['tableIndent', ({ value, type }) => ({ width: twipsToPixels(value), type })], - ['tableCellSpacing', ({ value, type }) => ({ w: String(value), type })], + ['tableCellSpacing', ({ value, type }) => ({ value: twipsToPixels(value), type })], ].forEach((prop) => { /** @type {string} */ let key; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index 6ad889fbbc..a2860e71a3 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -55,6 +55,7 @@ export function handleTableCellNode({ isLastColumn, tableCellProperties, referencedStyles, + hasBorderSpacing: !!tableProperties?.tableCellSpacing, }); // Colspan if (colspan > 1) attributes['colspan'] = colspan; @@ -304,19 +305,20 @@ const processCellBorders = ({ isLastColumn, tableCellProperties, referencedStyles, + hasBorderSpacing, }) => { let cellBorders = {}; if (baseTableBorders) { - if (isFirstRow && baseTableBorders.top) { + if ((isFirstRow || hasBorderSpacing) && baseTableBorders.top) { cellBorders.top = baseTableBorders.top; } - if (isLastRow && baseTableBorders.bottom) { + if ((isLastRow || hasBorderSpacing) && baseTableBorders.bottom) { cellBorders.bottom = baseTableBorders.bottom; } - if (isFirstColumn && baseTableBorders.left) { + if ((isFirstColumn || hasBorderSpacing) && baseTableBorders.left) { cellBorders.left = baseTableBorders.left; } - if (isLastColumn && baseTableBorders.right) { + if ((isLastColumn || hasBorderSpacing) && baseTableBorders.right) { cellBorders.right = baseTableBorders.right; } } @@ -387,6 +389,7 @@ const processCellBorders = ({ // Process inline cell borders (cell-level overrides) const inlineBorders = processInlineCellBorders(tableCellProperties.borders, cellBorders); if (inlineBorders) cellBorders = Object.assign(cellBorders, inlineBorders); + return cellBorders; }; diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index cc47ea8f1f..c6d036d0d6 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -354,6 +354,17 @@ export const Table = Node.create({ */ borders: { default: {}, + renderDOM({ borders, borderCollapse, tableCellSpacing }) { + if (!borders && borderCollapse !== 'separate' && !tableCellSpacing) return {}; + + const style = Object.entries(borders).reduce((acc, [key, { size, color }]) => { + return `${acc}border-${key}: ${Math.ceil(size)}px solid ${color || 'black'};`; + }, ''); + + return { + style, + }; + }, }, /** @@ -413,7 +424,12 @@ export const Table = Node.create({ */ tableCellSpacing: { default: null, - rendered: false, + renderDOM({ tableCellSpacing }) { + if (!tableCellSpacing?.value) return {}; + return { + style: `border-spacing: ${tableCellSpacing.value}px`, + }; + }, }, /** From cc9d3e709d9ca92954ed9108e14bbaebe47d663e Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 29 Jan 2026 23:54:04 +0200 Subject: [PATCH 2/5] fix: update test --- .../core/super-converter/v3/handlers/w/tbl/tbl-translator.js | 2 +- .../super-converter/v3/handlers/w/tbl/tbl-translator.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index 51593514e7..49786ff214 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -87,7 +87,7 @@ const encode = (params, encodedAttrs) => { 'justification', 'tableLayout', ['tableIndent', ({ value, type }) => ({ width: twipsToPixels(value), type })], - ['tableCellSpacing', ({ value, type }) => ({ value: twipsToPixels(value), type })], + ['tableCellSpacing', ({ value }) => ({ value: twipsToPixels(value), type: 'px' })], ].forEach((prop) => { /** @type {string} */ let key; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js index 3e3406fac4..242b89e9dc 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js @@ -151,7 +151,7 @@ describe('w:tbl translator', () => { expect(result.attrs.justification).toBe('center'); expect(result.attrs.tableIndent).toEqual({ width: 7.2, type: 'dxa' }); expect(result.attrs.tableLayout).toBe('fixed'); - expect(result.attrs.tableCellSpacing).toEqual({ w: '10', type: 'dxa' }); + expect(result.attrs.tableCellSpacing).toEqual({ value: 0.5, type: 'px' }); expect(result.attrs.borderCollapse).toBe('separate'); // Check borders (merged from style and inline) From 876ede5760035f13a73ad5598bd44ac83ac2d4b0 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 30 Jan 2026 18:32:43 +0200 Subject: [PATCH 3/5] fix: pr comments --- packages/layout-engine/layout-bridge/src/index.ts | 4 ++-- .../core/super-converter/v3/handlers/w/tbl/tbl-translator.js | 2 +- .../super-converter/v3/handlers/w/tbl/tbl-translator.test.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 86ab851ab1..5b1c4ad988 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -1541,8 +1541,8 @@ export function selectionToRects( const cellSpacingPx = tableMeasure.cellSpacingPx ?? 0; const tableBorderWidths = tableMeasure.tableBorderWidths; - const contentOffsetX = tableBorderWidths?.left ?? 0; - const contentOffsetY = tableBorderWidths?.top ?? 0; + const contentOffsetX = tableBlock.attrs?.borderCollapse === 'separate' ? (tableBorderWidths?.left ?? 0) : 0; + const contentOffsetY = tableBlock.attrs?.borderCollapse === 'separate' ? (tableBorderWidths?.top ?? 0) : 0; const calculateCellX = (cellIdx: number, cellMeasure: TableCellMeasure) => { const gridStart = cellMeasure.gridColumnStart ?? cellIdx; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index 49786ff214..51593514e7 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -87,7 +87,7 @@ const encode = (params, encodedAttrs) => { 'justification', 'tableLayout', ['tableIndent', ({ value, type }) => ({ width: twipsToPixels(value), type })], - ['tableCellSpacing', ({ value }) => ({ value: twipsToPixels(value), type: 'px' })], + ['tableCellSpacing', ({ value, type }) => ({ value: twipsToPixels(value), type })], ].forEach((prop) => { /** @type {string} */ let key; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js index 242b89e9dc..9222e0a874 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js @@ -151,7 +151,7 @@ describe('w:tbl translator', () => { expect(result.attrs.justification).toBe('center'); expect(result.attrs.tableIndent).toEqual({ width: 7.2, type: 'dxa' }); expect(result.attrs.tableLayout).toBe('fixed'); - expect(result.attrs.tableCellSpacing).toEqual({ value: 0.5, type: 'px' }); + expect(result.attrs.tableCellSpacing).toEqual({ value: 0.5, type: 'dxa' }); expect(result.attrs.borderCollapse).toBe('separate'); // Check borders (merged from style and inline) From 616c2029a4412d1959461964fcd4cc6a88946bba Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 5 Feb 2026 13:33:35 +0200 Subject: [PATCH 4/5] fix: review comments and tests --- packages/layout-engine/contracts/src/index.ts | 2 +- .../layout-engine/src/layout-table.test.ts | 186 +++++++++++++++++- .../layout-engine/src/layout-table.ts | 24 ++- .../layout-engine/measuring/dom/src/index.ts | 2 +- .../layout-engine/painters/dom/package.json | 1 + .../dom/src/table/renderTableFragment.ts | 21 +- .../src/extensions/table/table.js | 2 +- 7 files changed, 206 insertions(+), 32 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index dc41cb5a30..e7e41f659a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1431,7 +1431,7 @@ export type TableMeasure = { }; export type CellSpacing = { - type: string; + type: 'dxa' | 'px'; value: number; }; diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 9a286b75c2..3fc2e1f35c 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -82,14 +82,17 @@ function createMockTableBlock( * Format: lineHeightsPerRow[rowIndex] = [lineHeight1, lineHeight2, ...] * If omitted, cells will have no lines. This parameter enables testing of mid-row * splitting behavior where rows are split at line boundaries. + * @param cellSpacingPx - Optional cell spacing in pixels (border-spacing). When set, + * column boundary x positions and fragment height include spacing. * @returns A TableMeasure object with mocked cell, row, and line data */ function createMockTableMeasure( columnWidths: number[], rowHeights: number[], lineHeightsPerRow?: number[][], + cellSpacingPx?: number, ): TableMeasure { - return { + const base = { kind: 'table', rows: rowHeights.map((height, rowIdx) => ({ cells: columnWidths.map((width) => ({ @@ -116,6 +119,10 @@ function createMockTableMeasure( totalWidth: columnWidths.reduce((sum, w) => sum + w, 0), totalHeight: rowHeights.reduce((sum, h) => sum + h, 0), }; + if (cellSpacingPx !== undefined) { + return { ...base, cellSpacingPx }; + } + return base; } describe('layoutTableBlock', () => { @@ -335,6 +342,183 @@ describe('layoutTableBlock', () => { }); }); + describe('cellSpacing', () => { + it('should position column boundaries with cellSpacingPx (space before first column and between columns)', () => { + const block = createMockTableBlock(1); + const measure = createMockTableMeasure([100, 150, 200], [20], undefined, 4); + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 458, // 4 + 100 + 4 + 150 + 4 + 200 + 4 + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + const boundaries = fragments[0].metadata?.columnBoundaries; + expect(boundaries).toBeDefined(); + expect(boundaries!.length).toBe(3); + // First column: x = cellSpacingPx + expect(boundaries![0].x).toBe(4); + expect(boundaries![0].width).toBe(100); + // Second column: x = cellSpacingPx + col0 + cellSpacingPx + expect(boundaries![1].x).toBe(108); // 4 + 100 + 4 + expect(boundaries![1].width).toBe(150); + // Third column: x = prev + col1 + cellSpacingPx + expect(boundaries![2].x).toBe(262); // 108 + 150 + 4 + expect(boundaries![2].width).toBe(200); + }); + + it('should use zero column boundary offset when cellSpacingPx is 0', () => { + const block = createMockTableBlock(1); + const measure = createMockTableMeasure([100, 150], [20], undefined, 0); + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 250, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + const boundaries = fragments[0].metadata?.columnBoundaries; + expect(boundaries).toBeDefined(); + expect(boundaries![0].x).toBe(0); + expect(boundaries![1].x).toBe(100); + }); + + it('should include vertical cell spacing in fragment height', () => { + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([100, 150], [20, 25], undefined, 4); + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 250, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 50, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 10, + }); + + expect(fragments).toHaveLength(1); + // Row heights 20 + 25 = 45; vertical gaps (rowCount+1)*cellSpacingPx = 3*4 = 12 + expect(fragments[0].height).toBe(57); // 45 + 12 + }); + + it('should not add vertical spacing when cellSpacingPx is 0', () => { + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([100, 150], [20, 25], undefined, 0); + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 250, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 50, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 10, + }); + + expect(fragments).toHaveLength(1); + expect(fragments[0].height).toBe(45); // 20 + 25 only + }); + + it('should not add vertical spacing when measure.cellSpacingPx is undefined', () => { + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([100, 150], [20, 25]); + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 250, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 50, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 10, + }); + + expect(fragments).toHaveLength(1); + expect(fragments[0].height).toBe(45); + }); + + it('should include cell spacing in fragment height when table splits across pages', () => { + const block = createMockTableBlock(4); + const measure = createMockTableMeasure([100], [20, 20, 20, 20], undefined, 2); + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY, + contentBottom: 50, // Fits 2 rows + spacing (2+20+2+20+2 = 46), not 3 rows (68) + }), + advanceColumn: (state) => { + cursorY = 0; + return { + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 50, + }; + }, + columnX: () => 0, + }); + + expect(fragments.length).toBeGreaterThan(1); + // First fragment: 2 rows => height = 20+20 + (2+1)*2 = 46 + expect(fragments[0].height).toBe(46); + // Second fragment: 2 rows => height = 46 + expect(fragments[1].height).toBe(46); + }); + }); + describe('justification alignment', () => { it('positions the table based on justification', () => { const measure = createMockTableMeasure([100, 100], [20]); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index d7ed6dc1aa..5acd16b444 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -168,18 +168,24 @@ function resolveTableFrame( return applyTableIndent(baseX, width, tableIndent); } +const COLUMN_MIN_WIDTH_PX = 25; +const COLUMN_MAX_WIDTH_PX = 200; + /** - * Calculate minimum width for a table column. + * Calculate minimum width for a table column from its measured width. * - * Uses a conservative minimum of 10px per column to match PM's - * columnResizing behavior. + * Clamps the measured width to [COLUMN_MIN_WIDTH_PX, COLUMN_MAX_WIDTH_PX] + * so that resize handles enforce a sensible range (min 25px, max 200px). + * Invalid/negative/zero measured widths are treated as the minimum. * - * @returns Minimum width in pixels (10px) + * @param measuredWidth - Measured width in pixels (may be invalid) + * @returns Clamped minimum width in pixels */ -function calculateColumnMinWidth(): number { - const DEFAULT_MIN_WIDTH = 10; // Minimum usable column width in pixels - - return DEFAULT_MIN_WIDTH; +function calculateColumnMinWidth(measuredWidth: number): number { + if (!Number.isFinite(measuredWidth) || measuredWidth <= 0) { + return COLUMN_MIN_WIDTH_PX; + } + return Math.max(COLUMN_MIN_WIDTH_PX, Math.min(COLUMN_MAX_WIDTH_PX, measuredWidth)); } /** @@ -210,7 +216,7 @@ function generateColumnBoundaries(measure: TableMeasure): TableColumnBoundary[] for (let i = 0; i < measure.columnWidths.length; i++) { const width = measure.columnWidths[i]; - const minWidth = calculateColumnMinWidth(); + const minWidth = calculateColumnMinWidth(width); const boundary = { index: i, diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 49b7fcca21..8fb47092ff 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -155,7 +155,7 @@ const pxToTwips = (px: number): number => Math.round(px * TWIPS_PER_PX); * already in pixels (twipsToPixels), so we use value as px. If value is in twips (raw OOXML), * type is 'dxa' and we convert; otherwise value is treated as px. */ -function getCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number { +export function getCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number { if (cellSpacing == null) return 0; if (typeof cellSpacing === 'number') return Math.max(0, cellSpacing); const v = cellSpacing.value; diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index c61ced9133..efa053218e 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -19,6 +19,7 @@ "dependencies": { "@superdoc/contracts": "workspace:*", "@superdoc/font-utils": "workspace:*", + "@superdoc/measuring-dom": "workspace:*", "@superdoc/pm-adapter": "workspace:*", "@superdoc/preset-geometry": "workspace:*", "@superdoc/url-validation": "workspace:*" diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 34ea74a302..7563c86cf6 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -9,6 +9,7 @@ import type { TableFragment, TableMeasure, } from '@superdoc/contracts'; +import { getCellSpacingPx } from '@superdoc/measuring-dom'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import type { FragmentRenderContext, BlockLookup } from '../renderer.js'; import { renderTableRow } from './renderTableRow.js'; @@ -16,24 +17,6 @@ import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt- import { applyBorder, borderValueToSpec } from './border-utils'; type ApplyStylesFn = (el: HTMLElement, styles: Partial) => void; - -/** 15 twips per pixel (96 dpi). Used when resolving raw dxa values in painter fallback. */ -const TWIPS_PER_PX = 15; - -/** - * Resolves table cell spacing to pixels from block attrs (painter fallback when measure has no cellSpacingPx). - * Editor/store often has value already in px; raw OOXML has twips (dxa). Only convert when value looks like twips. - */ -function resolveCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number { - if (cellSpacing == null) return 0; - if (typeof cellSpacing === 'number') return Math.max(0, cellSpacing); - const v = cellSpacing.value; - if (typeof v !== 'number' || !Number.isFinite(v)) return 0; - const t = (cellSpacing.type ?? '').toLowerCase(); - const asPx = t === 'dxa' && v >= 20 ? v / TWIPS_PER_PX : v; - return Math.max(0, asPx); -} - /** * Dependencies required for rendering a table fragment. * @@ -208,7 +191,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement container.classList.add('superdoc-table-fragment'); // Cell spacing in px (border-spacing). Use measure when present, else resolve from block attrs (e.g. stale/cached measure). - const cellSpacingPx = measure.cellSpacingPx ?? resolveCellSpacingPx(block.attrs?.cellSpacing) ?? 0; + const cellSpacingPx = measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing); // Add metadata for interactive table resizing if (fragment.metadata?.columnBoundaries) { diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index c6d036d0d6..57ae7b201f 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -355,7 +355,7 @@ export const Table = Node.create({ borders: { default: {}, renderDOM({ borders, borderCollapse, tableCellSpacing }) { - if (!borders && borderCollapse !== 'separate' && !tableCellSpacing) return {}; + if (!Object.keys(borders).length && borderCollapse !== 'separate' && !tableCellSpacing) return {}; const style = Object.entries(borders).reduce((acc, [key, { size, color }]) => { return `${acc}border-${key}: ${Math.ceil(size)}px solid ${color || 'black'};`; From ac681e786a0ed65697565d8837acae1daf28b724 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 6 Feb 2026 15:04:34 +0200 Subject: [PATCH 5/5] fix: update deps --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de8e483eb2..28ac2eadb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -784,6 +784,9 @@ importers: '@superdoc/font-utils': specifier: workspace:* version: link:../../../../shared/font-utils + '@superdoc/measuring-dom': + specifier: workspace:* + version: link:../../measuring/dom '@superdoc/pm-adapter': specifier: workspace:* version: link:../../pm-adapter