diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts
index 868fec2ef2..4c1661ced4 100644
--- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts
+++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts
@@ -126,6 +126,89 @@ describe('renderTableRow', () => {
expect(call.borders?.right).toBeDefined();
});
+ it('does not paint interior right border for explicit cell borders in collapsed mode', () => {
+ renderTableRow(
+ createDeps({
+ rowIndex: 0,
+ totalRows: 1,
+ cellSpacingPx: 0,
+ columnWidths: [100, 100],
+ rowMeasure: {
+ height: 20,
+ cells: [
+ { width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 },
+ { width: 100, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 },
+ ],
+ },
+ row: {
+ id: 'row-1',
+ cells: [
+ {
+ id: 'cell-1',
+ attrs: {
+ borders: {
+ top: { style: 'single', width: 2, color: '#123456' },
+ right: { style: 'single', width: 2, color: '#123456' },
+ bottom: { style: 'single', width: 2, color: '#123456' },
+ left: { style: 'single', width: 2, color: '#123456' },
+ },
+ },
+ blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }],
+ },
+ {
+ id: 'cell-2',
+ blocks: [{ kind: 'paragraph', id: 'p2', runs: [] }],
+ },
+ ],
+ },
+ }) as never,
+ );
+
+ expect(renderTableCellMock).toHaveBeenCalledTimes(2);
+ const firstCall = renderTableCellMock.mock.calls[0][0] as {
+ borders?: { right?: unknown; left?: unknown };
+ };
+
+ expect(firstCall.borders?.right).toBeUndefined();
+ expect(firstCall.borders?.left).toBeDefined();
+ });
+
+ it('does not paint interior bottom border for explicit cell borders in collapsed mode on non-final row', () => {
+ const explicit = {
+ top: { style: 'single' as const, width: 2, color: '#123456' },
+ right: { style: 'single' as const, width: 2, color: '#123456' },
+ bottom: { style: 'single' as const, width: 2, color: '#123456' },
+ left: { style: 'single' as const, width: 2, color: '#123456' },
+ };
+ renderTableRow(
+ createDeps({
+ rowIndex: 2,
+ totalRows: 5,
+ cellSpacingPx: 0,
+ columnWidths: [100],
+ rowMeasure: {
+ height: 20,
+ cells: [{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }],
+ },
+ row: {
+ id: 'row-1',
+ cells: [
+ {
+ id: 'cell-1',
+ attrs: { borders: explicit },
+ blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }],
+ },
+ ],
+ },
+ }) as never,
+ );
+
+ expect(renderTableCellMock).toHaveBeenCalledTimes(1);
+ const call = getRenderedCellCall();
+ expect(call.borders?.bottom).toBeUndefined();
+ expect(call.borders?.top).toBeDefined();
+ });
+
it('applies the table bottom border to a rowspan cell that reaches the final row', () => {
renderTableRow(
createDeps({
diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts
index 1f335b7f92..764f5873d2 100644
--- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts
+++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts
@@ -74,6 +74,22 @@ const resolveRenderedCellBorders = ({
const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext;
if (hasExplicitBorders) {
+ if (cellSpacingPx === 0) {
+ // Collapsed model: avoid double interior borders by using single-owner sides.
+ // Keep explicit top/left (or table fallbacks), and only render right/bottom on table edges.
+ // Assumes shared interior edges specify the same border on both adjacent cells (e.g. Google Docs
+ // round-trips this way). Asymmetric (only one cell’s side set) may miss a line until we add conflict resolution.
+ return {
+ top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH),
+ right: cellBounds.touchesRightEdge ? resolveTableBorderValue(cellBorders.right, tableBorders.right) : undefined,
+ bottom: touchesBottomBoundary ? resolveTableBorderValue(cellBorders.bottom, tableBorders.bottom) : undefined,
+ left: resolveTableBorderValue(
+ cellBorders.left,
+ cellBounds.touchesLeftEdge ? tableBorders.left : tableBorders.insideV,
+ ),
+ };
+ }
+
return {
top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH),
right: resolveTableBorderValue(cellBorders.right, cellBounds.touchesRightEdge ? tableBorders.right : undefined),
diff --git a/packages/super-editor/src/editors/v1/extensions/shared/parseCellBorders.js b/packages/super-editor/src/editors/v1/extensions/shared/parseCellBorders.js
new file mode 100644
index 0000000000..f7f0e0b51c
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/shared/parseCellBorders.js
@@ -0,0 +1,139 @@
+// @ts-check
+import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js';
+import { cssColorToHex } from '@core/utilities/cssColorToHex.js';
+import { halfPointToPixels } from '@core/super-converter/helpers.js';
+
+/**
+ * Parsed cell border shape used by table cell / header parseDOM.
+ * @typedef {Object} ParsedCellBorder
+ * @property {'none' | 'single' | 'dashed' | 'dotted'} val
+ * @property {number} size
+ * @property {string} color
+ * @property {string} style
+ */
+
+/**
+ * Parsed borders object for each side.
+ * @typedef {Object} ParsedCellBorders
+ * @property {ParsedCellBorder} [top]
+ * @property {ParsedCellBorder} [right]
+ * @property {ParsedCellBorder} [bottom]
+ * @property {ParsedCellBorder} [left]
+ */
+
+const STYLE_TOKEN_SET = new Set([
+ 'none',
+ 'hidden',
+ 'dotted',
+ 'dashed',
+ 'solid',
+ 'double',
+ 'groove',
+ 'ridge',
+ 'inset',
+ 'outset',
+]);
+
+const STYLE_TOKEN_PATTERN = Array.from(STYLE_TOKEN_SET).join('|');
+
+/**
+ * Parse border width token into pixel number.
+ *
+ * @param {string} value
+ * @returns {number | null}
+ */
+const parseBorderWidth = (value) => {
+ const widthMatch = value.match(/(?:^|\s)(-?\d*\.?\d+(?:px|pt))(?=\s|$)/i);
+ if (!widthMatch?.[1]) return null;
+
+ const [widthValue, widthUnit] = parseSizeUnit(widthMatch[1]);
+
+ const numericWidth = Number(widthValue);
+ const size = widthUnit === 'pt' ? halfPointToPixels(numericWidth) : numericWidth;
+ return size;
+};
+
+/**
+ * Parse border style token.
+ *
+ * @param {string} value
+ * @returns {string | null}
+ */
+const parseBorderStyle = (value) => {
+ const styleMatch = value.match(new RegExp(`(?:^|\\s)(${STYLE_TOKEN_PATTERN})(?=\\s|$)`, 'i'));
+ return styleMatch?.[1] ? styleMatch[1].toLowerCase() : null;
+};
+
+/**
+ * Parse border color token.
+ *
+ * @param {string} value
+ * @returns {string | null}
+ */
+const parseBorderColor = (value) => {
+ const directColorMatch = value.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8}|var\([^)]+\))/i);
+ if (directColorMatch?.[1]) return directColorMatch[1];
+
+ const tokenColorMatch = value
+ .split(/\s+/)
+ .find((part) => /^[a-z]+$/i.test(part) && !STYLE_TOKEN_SET.has(part.toLowerCase()));
+ return tokenColorMatch || null;
+};
+
+/**
+ * Parse a single CSS border declaration.
+ *
+ * @param {string | undefined | null} rawValue
+ * @returns {ParsedCellBorder | null}
+ */
+const parseBorderValue = (rawValue) => {
+ if (!rawValue || typeof rawValue !== 'string') return null;
+ const value = rawValue.trim();
+ if (!value) return null;
+
+ if (value === 'none') {
+ return { val: 'none', size: 0, color: 'auto', style: 'none' };
+ }
+
+ const size = parseBorderWidth(value);
+ const style = parseBorderStyle(value);
+ const color = parseBorderColor(value);
+
+ const hexColor = cssColorToHex(color);
+ if (style === 'none') {
+ return { val: 'none', size: 0, color: 'auto', style: 'none' };
+ }
+
+ if (size == null && !hexColor && !style) return null;
+
+ return {
+ val: style === 'dashed' || style === 'dotted' ? style : 'single',
+ size: size ?? 1,
+ color: hexColor || 'auto',
+ style: style || 'solid',
+ };
+};
+
+/**
+ * Parse cell borders from inline TD/TH styles.
+ *
+ * @param {HTMLElement} element
+ * @returns {ParsedCellBorders | null}
+ */
+export const parseCellBorders = (element) => {
+ const { style } = element;
+
+ const top = parseBorderValue(style?.borderTop || style?.border);
+ const right = parseBorderValue(style?.borderRight || style?.border);
+ const bottom = parseBorderValue(style?.borderBottom || style?.border);
+ const left = parseBorderValue(style?.borderLeft || style?.border);
+
+ if (!top && !right && !bottom && !left) return null;
+
+ return {
+ ...(top ? { top } : {}),
+ ...(right ? { right } : {}),
+ ...(bottom ? { bottom } : {}),
+ ...(left ? { left } : {}),
+ };
+};
diff --git a/packages/super-editor/src/editors/v1/extensions/shared/parseCellMargins.js b/packages/super-editor/src/editors/v1/extensions/shared/parseCellMargins.js
new file mode 100644
index 0000000000..634961d818
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/shared/parseCellMargins.js
@@ -0,0 +1,52 @@
+// @ts-check
+import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js';
+import { halfPointToPixels } from '@core/super-converter/helpers.js';
+
+/**
+ * Cell margins configuration in pixels.
+ * @typedef {Object} CellMargins
+ * @property {number} [top] - Top margin in pixels
+ * @property {number} [right] - Right margin in pixels
+ * @property {number} [bottom] - Bottom margin in pixels
+ * @property {number} [left] - Left margin in pixels
+ */
+
+/**
+ * Parse one CSS padding side into pixels.
+ *
+ * @param {string} sideValue
+ * @returns {number | undefined}
+ */
+const parseSide = (sideValue) => {
+ if (!sideValue) return undefined;
+ const [rawValue, unit] = parseSizeUnit(sideValue);
+ const numericValue = Number(rawValue);
+ const calculatedValue = unit === 'pt' ? halfPointToPixels(numericValue) : numericValue;
+ return calculatedValue;
+};
+
+/**
+ * Parse cell margins from inline TD/TH padding styles.
+ *
+ * @param {HTMLElement} element
+ * @returns {CellMargins | null}
+ */
+export const parseCellMargins = (element) => {
+ const { style } = element;
+
+ const top = parseSide(style?.paddingTop);
+ const right = parseSide(style?.paddingRight);
+ const bottom = parseSide(style?.paddingBottom);
+ const left = parseSide(style?.paddingLeft);
+
+ if (top == null && right == null && bottom == null && left == null) {
+ return null;
+ }
+
+ return {
+ ...(top != null ? { top } : {}),
+ ...(right != null ? { right } : {}),
+ ...(bottom != null ? { bottom } : {}),
+ ...(left != null ? { left } : {}),
+ };
+};
diff --git a/packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js b/packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js
new file mode 100644
index 0000000000..64e25e56a7
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/shared/parseCellVerticalAlign.js
@@ -0,0 +1,16 @@
+// @ts-check
+
+/**
+ * Parse `vertical-align` from inline styles on table cells (TD/TH), e.g. pasted HTML.
+ * Maps CSS `middle` to the schema value `center`.
+ *
+ * @param {HTMLElement} element
+ * @returns {string | null}
+ */
+export function parseCellVerticalAlignFromStyle(element) {
+ const value = element.style?.verticalAlign;
+ if (!value || typeof value !== 'string') return null;
+ const normalized = value.trim().toLowerCase();
+ if (normalized === 'middle') return 'center';
+ return normalized;
+}
diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js b/packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js
similarity index 88%
rename from packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js
rename to packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js
index f55bf32b7f..cd1e7eeff0 100644
--- a/packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js
+++ b/packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js
@@ -6,7 +6,7 @@
* Shared by both `tableCell` and `tableHeader` node `renderDOM` methods
* so the border-rendering logic stays in one place.
*
- * @param {import('./createCellBorders.js').CellBorders | null | undefined} borders
+ * @param {import('../table-cell/helpers/createCellBorders.js').CellBorders | null | undefined} borders
* @returns {{ style: string } | {}}
*/
export const renderCellBorderStyle = (borders) => {
diff --git a/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js b/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js
new file mode 100644
index 0000000000..b884ec1612
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/shared/table-cell-header-paste.integration.test.js
@@ -0,0 +1,345 @@
+/**
+ * Integration: HTML paste → ProseMirror JSON for `tableCell` (`
`) and `tableHeader` (` | `).
+ *
+ * ParseDOM fills `attrs.borders`; the table extension then migrates them to
+ * `attrs.tableCellProperties.borders` (OOXML, eighths of a point) and sets `attrs.borders` to null.
+ * Border assertions read that migrated shape.
+ */
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { handleHtmlPaste } from '@core/InputRule.js';
+import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js';
+
+let docData;
+let editor;
+
+/** Same as legacyBorderMigration: px → OOXML eighths of a point. */
+const pxToEighthPt = (px) => Math.round((px / (96 / 72)) * 8);
+
+beforeAll(async () => {
+ docData = await loadTestDataForEditorTests('blank-doc.docx');
+});
+
+afterEach(() => {
+ editor?.destroy();
+ editor = null;
+});
+
+/**
+ * Fresh editor from blank doc, paste HTML, return `tableCell` / `tableHeader` nodes (depth-first)
+ * from the first `table` in the document.
+ */
+function pasteTableCells(html) {
+ ({ editor } = initTestEditor({
+ content: docData.docx,
+ media: docData.media,
+ mediaFiles: docData.mediaFiles,
+ fonts: docData.fonts,
+ mode: 'docx',
+ }));
+
+ expect(handleHtmlPaste(html, editor)).toBe(true);
+
+ const table = editor.getJSON().content?.find((n) => n?.type === 'table');
+ expect(table).toBeTruthy();
+
+ const cells = [];
+ const collectTableCellNodesInOrder = (node) => {
+ if (node?.type === 'tableCell' || node?.type === 'tableHeader') {
+ cells.push(node);
+ }
+ for (const child of node?.content ?? []) {
+ collectTableCellNodesInOrder(child);
+ }
+ };
+ collectTableCellNodesInOrder(table);
+
+ return cells;
+}
+
+describe('tableCell & tableHeader HTML paste integration', () => {
+ it('parses td border shorthand: solid, dashed, dotted, widths and colors', () => {
+ const cells = pasteTableCells(`
+
+
+ | A |
+ B |
+
+
+ | C |
+ D |
+
+
+ `);
+
+ expect(cells).toHaveLength(4);
+
+ const b0 = cells[0].attrs?.tableCellProperties?.borders;
+ expect(b0?.top).toMatchObject({ val: 'single', color: '#ff0000' });
+ expect(b0?.top?.size).toBe(pxToEighthPt(2));
+ for (const side of ['top', 'right', 'bottom', 'left']) {
+ expect(b0?.[side]).toMatchObject({ val: 'single', color: '#ff0000' });
+ }
+
+ const b1 = cells[1].attrs?.tableCellProperties?.borders;
+ expect(b1?.top).toMatchObject({ val: 'dashed', color: '#0000ff' });
+ expect(b1?.top?.size).toBe(pxToEighthPt(1));
+
+ const b2 = cells[2].attrs?.tableCellProperties?.borders;
+ expect(b2?.top).toMatchObject({ val: 'dotted', color: '#00aa00' });
+ expect(b2?.top?.size).toBe(pxToEighthPt(3));
+
+ expect(cells[3].attrs?.tableCellProperties?.borders).toBeUndefined();
+ expect(cells[3].attrs?.borders).toBeNull();
+ });
+
+ it('parses per-side borders when sides differ', () => {
+ const cells = pasteTableCells(`
+
+ `);
+
+ expect(cells).toHaveLength(1);
+ const b = cells[0].attrs?.tableCellProperties?.borders;
+ expect(b?.top).toMatchObject({ val: 'dashed', color: '#ff0000' });
+ expect(b?.top?.size).toBe(pxToEighthPt(2));
+ expect(b?.right).toMatchObject({ val: 'dotted', color: '#0000ff' });
+ expect(b?.bottom).toMatchObject({ val: 'single', color: 'auto' });
+ expect(b?.left).toMatchObject({ val: 'single', color: 'auto' });
+ });
+
+ it('parses different background colors per row and cell', () => {
+ const cells = pasteTableCells(`
+
+
+ | R0C0 |
+ R0C1 |
+
+
+ | R1C0 |
+ R1C1 plain |
+
+
+ `);
+
+ expect(cells).toHaveLength(4);
+ expect(cells[0].attrs?.background).toEqual({ color: 'ffff00' });
+ expect(cells[1].attrs?.background).toEqual({ color: '00ffff' });
+ expect(cells[2].attrs?.background).toEqual({ color: '800080' });
+ expect(cells[3].attrs?.background == null).toBe(true);
+ });
+
+ it('parses vertical-align from td style', () => {
+ const cells = pasteTableCells(`
+
+ `);
+
+ expect(cells).toHaveLength(3);
+ expect(cells[0].attrs?.verticalAlign).toBe('center');
+ expect(cells[1].attrs?.verticalAlign).toBe('bottom');
+ expect(cells[2].attrs?.verticalAlign == null).toBe(true);
+ });
+
+ it('parses padding into cellMargins on pasted cells', () => {
+ const cells = pasteTableCells(`
+
+ `);
+
+ expect(cells[0].attrs?.cellMargins).toEqual({
+ top: 7,
+ right: 7,
+ bottom: 7,
+ left: 7,
+ });
+ });
+
+ it('parses Google Docs–style header row: th keeps padding, background, borders, vertical-align', () => {
+ const cells = pasteTableCells(`
+
+
+
+ | H1 |
+ H2 |
+
+
+
+ | A | B |
+
+
+ `);
+
+ expect(cells).toHaveLength(4);
+ expect(cells[0].type).toBe('tableHeader');
+ expect(cells[1].type).toBe('tableHeader');
+
+ expect(cells[0].attrs?.cellMargins).toEqual({
+ top: 7,
+ right: 7,
+ bottom: 7,
+ left: 7,
+ });
+ expect(cells[0].attrs?.background).toEqual({ color: 'c8dcff' });
+ expect(cells[0].attrs?.verticalAlign).toBe('bottom');
+
+ const hb = cells[0].attrs?.tableCellProperties?.borders;
+ expect(hb?.top).toMatchObject({ val: 'dashed', color: '#0000c8' });
+ expect(hb?.top?.size).toBe(pxToEighthPt(2));
+
+ expect(cells[1].attrs?.cellMargins).toEqual({
+ top: 8,
+ right: 8,
+ bottom: 8,
+ left: 8,
+ });
+
+ expect(cells[2].type).toBe('tableCell');
+ expect(cells[3].type).toBe('tableCell');
+ });
+
+ describe('mixed tableHeader and tableCell layouts', () => {
+ it('parses tbody rows that start with th (row headers) then td', () => {
+ const cells = pasteTableCells(`
+
+
+ | Label1 |
+ A1 |
+ A2 |
+
+
+ | Label2 |
+ B1 |
+ B2 |
+
+
+ `);
+
+ expect(cells).toHaveLength(6);
+ expect(cells.map((c) => c.type)).toEqual([
+ 'tableHeader',
+ 'tableCell',
+ 'tableCell',
+ 'tableHeader',
+ 'tableCell',
+ 'tableCell',
+ ]);
+
+ expect(cells[0].attrs?.background).toEqual({ color: 'e6e6e6' });
+ expect(cells[1].attrs?.cellMargins).toEqual({
+ top: 6,
+ right: 6,
+ bottom: 6,
+ left: 6,
+ });
+ expect(cells[2].attrs?.cellMargins == null).toBe(true);
+
+ expect(cells[3].attrs?.background == null).toBe(true);
+ const bB1 = cells[4].attrs?.tableCellProperties?.borders;
+ expect(bB1?.top).toMatchObject({ val: 'single', color: '#008000' });
+ });
+
+ it('parses a single body row mixing th and td with different inline styles', () => {
+ const cells = pasteTableCells(`
+
+ `);
+
+ expect(cells).toHaveLength(4);
+ expect(cells.map((c) => c.type)).toEqual(['tableHeader', 'tableCell', 'tableHeader', 'tableCell']);
+
+ expect(cells[0].attrs?.verticalAlign).toBe('top');
+ const hBorder = cells[0].attrs?.tableCellProperties?.borders;
+ expect(hBorder?.top).toMatchObject({ val: 'single', color: '#c80000' });
+
+ expect(cells[1].attrs?.cellMargins?.top).toBe(7);
+ expect(cells[1].attrs?.background).toEqual({ color: '00c8c8' });
+
+ expect(cells[2].attrs?.cellMargins).toEqual({
+ top: 2,
+ right: 2,
+ bottom: 2,
+ left: 2,
+ });
+ expect(cells[3].attrs?.background == null).toBe(true);
+ });
+
+ it('parses two-row thead plus body: each header row th, then body td', () => {
+ const cells = pasteTableCells(`
+
+
+
+ | Dept |
+ Code |
+
+
+ | Subtotal |
+
+
+
+ | Eng | E1 |
+ | Sales | S1 |
+
+
+ `);
+
+ // Row0: 2 headers; row1: 1 header (colspan 2); body: 2 rows × 2 cells = 7 cells
+ expect(cells).toHaveLength(7);
+ expect(cells[0].type).toBe('tableHeader');
+ expect(cells[1].type).toBe('tableHeader');
+ expect(cells[0].attrs?.background).toEqual({ color: '6464c8' });
+ expect(cells[1].attrs?.background == null).toBe(true);
+
+ expect(cells[2].type).toBe('tableHeader');
+ expect(cells[2].attrs?.colspan).toBe(2);
+ expect(cells[2].attrs?.cellMargins?.top).toBe(3);
+
+ expect(cells.slice(3).every((c) => c.type === 'tableCell')).toBe(true);
+ expect(cells[3].attrs?.background == null).toBe(true);
+ });
+
+ it('parses thead column headers and tbody with styled td next to plain th row header', () => {
+ const cells = pasteTableCells(`
+
+
+
+ |
+ Jan |
+ Feb |
+
+
+
+
+ | Row A |
+ 10 |
+ 20 |
+
+
+
+ `);
+
+ expect(cells).toHaveLength(6);
+ expect(cells[0].type).toBe('tableHeader');
+ expect(cells[1].type).toBe('tableHeader');
+ expect(cells[2].type).toBe('tableHeader');
+
+ const janBottom = cells[1].attrs?.tableCellProperties?.borders?.bottom;
+ expect(janBottom).toMatchObject({ val: 'single', color: '#0000ff' });
+ expect(janBottom?.size).toBe(pxToEighthPt(2));
+
+ expect(cells[3].type).toBe('tableHeader');
+ expect(cells[4].type).toBe('tableCell');
+ expect(cells[4].attrs?.background).toEqual({ color: 'fff0f0' });
+ expect(cells[5].type).toBe('tableCell');
+ });
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js
index 40fce5b770..3999d7ea40 100644
--- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js
+++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js
@@ -39,7 +39,11 @@
import { Node } from '@core/Node.js';
import { Attribute } from '@core/Attribute.js';
-import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js';
+import { cssColorToHex } from '@core/utilities/cssColorToHex.js';
+import { parseCellBorders } from '@extensions/shared/parseCellBorders.js';
+import { parseCellMargins } from '@extensions/shared/parseCellMargins.js';
+import { parseCellVerticalAlignFromStyle } from '@extensions/shared/parseCellVerticalAlign.js';
+import { renderCellBorderStyle } from '@extensions/shared/renderCellBorderStyle.js';
/**
* Cell margins configuration
@@ -154,6 +158,11 @@ export const TableCell = Node.create({
},
background: {
+ parseDOM: (element) => {
+ const color = cssColorToHex(element.style?.backgroundColor);
+ if (!color) return null;
+ return { color: color.replace(/^#/, '') };
+ },
renderDOM({ background }) {
if (!background) return {};
// @ts-expect-error - background is known to be an object at runtime
@@ -164,6 +173,7 @@ export const TableCell = Node.create({
},
verticalAlign: {
+ parseDOM: parseCellVerticalAlignFromStyle,
renderDOM({ verticalAlign }) {
if (!verticalAlign) return {};
const style = `vertical-align: ${verticalAlign}`;
@@ -172,6 +182,7 @@ export const TableCell = Node.create({
},
cellMargins: {
+ parseDOM: (element) => parseCellMargins(element),
renderDOM({ cellMargins, borders }) {
if (!cellMargins) return {};
const sides = ['top', 'right', 'bottom', 'left'];
@@ -192,6 +203,7 @@ export const TableCell = Node.create({
borders: {
default: null,
+ parseDOM: (element) => parseCellBorders(element),
renderDOM: ({ borders }) => {
if (!borders) return {};
return renderCellBorderStyle(borders);
diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js
index 3492941682..33a01e6c2d 100644
--- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.test.js
@@ -1,18 +1,196 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeAll } from 'vitest';
import { TableCell } from './table-cell.js';
-describe('TableCell verticalAlign renderDOM', () => {
- const attributes = TableCell.config.addAttributes.call(TableCell);
+describe('TableCell', () => {
+ let attributes;
- it('omits style when verticalAlign is not provided', () => {
- expect(attributes.verticalAlign.renderDOM({})).toEqual({});
- expect(attributes.verticalAlign.renderDOM({ verticalAlign: null })).toEqual({});
+ beforeAll(() => {
+ attributes = TableCell.config.addAttributes.call(TableCell);
});
- it('adds vertical-align style when attribute is set', () => {
- expect(attributes.verticalAlign.renderDOM({ verticalAlign: 'bottom' })).toEqual({
- style: 'vertical-align: bottom',
+ describe('background', () => {
+ describe('parseDOM', () => {
+ it('parses rgb() background to hex', () => {
+ const td = document.createElement('td');
+ td.style.backgroundColor = 'rgb(255, 255, 0)';
+ expect(attributes.background.parseDOM(td)).toEqual({ color: 'ffff00' });
+ });
+
+ it('returns null when background is unset', () => {
+ expect(attributes.background.parseDOM(document.createElement('td'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('omits style when background is absent', () => {
+ expect(attributes.background.renderDOM({})).toEqual({});
+ expect(attributes.background.renderDOM({ background: null })).toEqual({});
+ });
+
+ // renderDOM prefixes `#`; store channel hex without leading `#` to avoid `##` in CSS.
+ it('renders background-color for hex digits without leading #', () => {
+ expect(attributes.background.renderDOM({ background: { color: 'ffff00' } })).toEqual({
+ style: 'background-color: #ffff00',
+ });
+ });
+
+ it('uses transparent when color is missing on background object', () => {
+ expect(attributes.background.renderDOM({ background: {} })).toEqual({
+ style: 'background-color: transparent',
+ });
+ });
+ });
+
+ it('parseDOM → renderDOM round-trips to valid CSS', () => {
+ const td = document.createElement('td');
+ td.style.backgroundColor = 'rgb(255, 255, 0)';
+ const parsed = attributes.background.parseDOM(td);
+ const { style } = attributes.background.renderDOM({ background: parsed });
+ expect(style).toBe('background-color: #ffff00');
+ });
+ });
+
+ describe('verticalAlign', () => {
+ describe('parseDOM', () => {
+ it('maps middle to center', () => {
+ const td = document.createElement('td');
+ td.style.verticalAlign = 'middle';
+ expect(attributes.verticalAlign.parseDOM(td)).toBe('center');
+ });
+
+ it('normalizes top and bottom', () => {
+ const topTd = document.createElement('td');
+ topTd.style.verticalAlign = 'TOP';
+ expect(attributes.verticalAlign.parseDOM(topTd)).toBe('top');
+
+ const bottomTd = document.createElement('td');
+ bottomTd.style.verticalAlign = 'bottom';
+ expect(attributes.verticalAlign.parseDOM(bottomTd)).toBe('bottom');
+ });
+
+ it('returns null when vertical-align is missing', () => {
+ expect(attributes.verticalAlign.parseDOM(document.createElement('td'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('omits style when verticalAlign is unset', () => {
+ expect(attributes.verticalAlign.renderDOM({})).toEqual({});
+ expect(attributes.verticalAlign.renderDOM({ verticalAlign: null })).toEqual({});
+ });
+
+ it('emits vertical-align CSS', () => {
+ expect(attributes.verticalAlign.renderDOM({ verticalAlign: 'bottom' })).toEqual({
+ style: 'vertical-align: bottom',
+ });
+ });
+ });
+ });
+
+ describe('cellMargins', () => {
+ describe('parseDOM', () => {
+ it('parses uniform padding in pt', () => {
+ const td = document.createElement('td');
+ td.style.padding = '5pt';
+ expect(attributes.cellMargins.parseDOM(td)).toEqual({
+ top: 7,
+ right: 7,
+ bottom: 7,
+ left: 7,
+ });
+ });
+
+ it('parses per-side padding', () => {
+ const td = document.createElement('td');
+ td.style.paddingTop = '5pt';
+ td.style.paddingRight = '10pt';
+ td.style.paddingBottom = '15pt';
+ expect(attributes.cellMargins.parseDOM(td)).toEqual({
+ top: 7,
+ right: 13,
+ bottom: 20,
+ });
+ });
+
+ it('returns null when no padding is set', () => {
+ expect(attributes.cellMargins.parseDOM(document.createElement('td'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('returns {} when cellMargins is absent', () => {
+ expect(attributes.cellMargins.renderDOM({})).toEqual({});
+ });
+
+ it('subtracts non-none border width from padding', () => {
+ const result = attributes.cellMargins.renderDOM({
+ cellMargins: { top: 12, right: 8 },
+ borders: {
+ top: { val: 'single', size: 3, color: '#000', style: 'solid' },
+ right: { val: 'none', size: 0, color: 'auto', style: 'none' },
+ },
+ });
+ expect(result.style).toContain('padding-top: 9px');
+ expect(result.style).toContain('padding-right: 8px');
+ });
+ });
+ });
+
+ describe('borders', () => {
+ describe('parseDOM', () => {
+ it('applies shorthand border to all sides', () => {
+ const td = document.createElement('td');
+ td.style.border = '1px solid rgb(0, 0, 0)';
+ expect(attributes.borders.parseDOM(td)).toEqual({
+ top: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ right: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ bottom: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ left: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ });
+ });
+
+ it('uses per-side rules when they override shorthand', () => {
+ const td = document.createElement('td');
+ td.style.border = '1px solid rgb(0, 0, 0)';
+ td.style.borderTop = '2px dashed rgb(255, 0, 0)';
+ td.style.borderRight = '3px dotted rgb(0, 0, 255)';
+ td.style.borderBottom = '4px double rgb(0, 128, 0)';
+ expect(attributes.borders.parseDOM(td)).toEqual({
+ top: { val: 'dashed', size: 2, color: '#ff0000', style: 'dashed' },
+ right: { val: 'dotted', size: 3, color: '#0000ff', style: 'dotted' },
+ bottom: { val: 'single', size: 4, color: '#008000', style: 'double' },
+ left: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ });
+ });
+
+ it('returns null when no border is set', () => {
+ expect(attributes.borders.parseDOM(document.createElement('td'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('returns {} when borders are absent', () => {
+ expect(attributes.borders.renderDOM({})).toEqual({});
+ });
+
+ it('delegates to renderCellBorderStyle (solid px, auto → black)', () => {
+ const result = attributes.borders.renderDOM({
+ borders: {
+ top: { val: 'single', size: 1, color: 'auto', style: 'solid' },
+ },
+ });
+ expect(result.style).toContain('border-top: 1px solid black');
+ });
+
+ it('renders border none without width', () => {
+ const result = attributes.borders.renderDOM({
+ borders: {
+ top: { val: 'none', size: 0, color: 'auto', style: 'none' },
+ },
+ });
+ expect(result.style).toContain('border-top: none');
+ });
});
});
});
diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js
index 67bc305ea4..329134e110 100644
--- a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js
+++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js
@@ -2,7 +2,11 @@
import { Node } from '@core/Node.js';
import { Attribute } from '@core/Attribute.js';
-import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderStyle.js';
+import { cssColorToHex } from '@core/utilities/cssColorToHex.js';
+import { parseCellBorders } from '@extensions/shared/parseCellBorders.js';
+import { parseCellMargins } from '@extensions/shared/parseCellMargins.js';
+import { parseCellVerticalAlignFromStyle } from '@extensions/shared/parseCellVerticalAlign.js';
+import { renderCellBorderStyle } from '@extensions/shared/renderCellBorderStyle.js';
/**
* Configuration options for TableHeader
@@ -87,6 +91,11 @@ export const TableHeader = Node.create({
},
background: {
+ parseDOM: (element) => {
+ const color = cssColorToHex(element.style?.backgroundColor);
+ if (!color) return null;
+ return { color: color.replace(/^#/, '') };
+ },
renderDOM({ background }) {
if (!background) return {};
// @ts-expect-error - background is known to be an object at runtime
@@ -97,6 +106,7 @@ export const TableHeader = Node.create({
},
verticalAlign: {
+ parseDOM: parseCellVerticalAlignFromStyle,
renderDOM({ verticalAlign }) {
if (!verticalAlign) return {};
const style = `vertical-align: ${verticalAlign}`;
@@ -105,6 +115,7 @@ export const TableHeader = Node.create({
},
cellMargins: {
+ parseDOM: (element) => parseCellMargins(element),
renderDOM({ cellMargins, borders }) {
if (!cellMargins) return {};
const sides = ['top', 'right', 'bottom', 'left'];
@@ -112,6 +123,7 @@ export const TableHeader = Node.create({
.map((side) => {
const margin = cellMargins?.[side] ?? 0;
const border = borders?.[side];
+ // TODO: this should include table-level borders as well for the first/last cell in the row
const borderSize = border && border.val !== 'none' ? Math.ceil(border.size) : 0;
if (margin) return `padding-${side}: ${Math.max(0, margin - borderSize)}px;`;
@@ -124,6 +136,7 @@ export const TableHeader = Node.create({
borders: {
default: null,
+ parseDOM: (element) => parseCellBorders(element),
renderDOM: ({ borders }) => {
if (!borders) return {};
return renderCellBorderStyle(borders);
diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js
new file mode 100644
index 0000000000..73b5f8dce7
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.test.js
@@ -0,0 +1,199 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+
+import { TableHeader } from './table-header.js';
+
+/**
+ * `tableHeader` uses the same DOM attribute specs as `tableCell` for presentation;
+ * parse rules run on ` | ` (e.g. Google Docs header rows).
+ */
+describe('TableHeader', () => {
+ let attributes;
+
+ beforeAll(() => {
+ attributes = TableHeader.config.addAttributes.call(TableHeader);
+ });
+
+ describe('background', () => {
+ describe('parseDOM', () => {
+ it('parses rgb() background to hex', () => {
+ const th = document.createElement('th');
+ th.style.backgroundColor = 'rgb(255, 255, 0)';
+ expect(attributes.background.parseDOM(th)).toEqual({ color: 'ffff00' });
+ });
+
+ it('returns null when background is unset', () => {
+ expect(attributes.background.parseDOM(document.createElement('th'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('omits style when background is absent', () => {
+ expect(attributes.background.renderDOM({})).toEqual({});
+ expect(attributes.background.renderDOM({ background: null })).toEqual({});
+ });
+
+ it('renders background-color for hex digits without leading #', () => {
+ expect(attributes.background.renderDOM({ background: { color: 'ffff00' } })).toEqual({
+ style: 'background-color: #ffff00',
+ });
+ });
+
+ it('uses transparent when color is missing on background object', () => {
+ expect(attributes.background.renderDOM({ background: {} })).toEqual({
+ style: 'background-color: transparent',
+ });
+ });
+ });
+
+ it('parseDOM → renderDOM round-trips to valid CSS', () => {
+ const th = document.createElement('th');
+ th.style.backgroundColor = 'rgb(255, 255, 0)';
+ const parsed = attributes.background.parseDOM(th);
+ const { style } = attributes.background.renderDOM({ background: parsed });
+ expect(style).toBe('background-color: #ffff00');
+ });
+ });
+
+ describe('verticalAlign', () => {
+ describe('parseDOM', () => {
+ it('maps middle to center', () => {
+ const th = document.createElement('th');
+ th.style.verticalAlign = 'middle';
+ expect(attributes.verticalAlign.parseDOM(th)).toBe('center');
+ });
+
+ it('normalizes top and bottom', () => {
+ const topTh = document.createElement('th');
+ topTh.style.verticalAlign = 'TOP';
+ expect(attributes.verticalAlign.parseDOM(topTh)).toBe('top');
+
+ const bottomTh = document.createElement('th');
+ bottomTh.style.verticalAlign = 'bottom';
+ expect(attributes.verticalAlign.parseDOM(bottomTh)).toBe('bottom');
+ });
+
+ it('returns null when vertical-align is missing', () => {
+ expect(attributes.verticalAlign.parseDOM(document.createElement('th'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('omits style when verticalAlign is unset', () => {
+ expect(attributes.verticalAlign.renderDOM({})).toEqual({});
+ expect(attributes.verticalAlign.renderDOM({ verticalAlign: null })).toEqual({});
+ });
+
+ it('emits vertical-align CSS', () => {
+ expect(attributes.verticalAlign.renderDOM({ verticalAlign: 'bottom' })).toEqual({
+ style: 'vertical-align: bottom',
+ });
+ });
+ });
+ });
+
+ describe('cellMargins', () => {
+ describe('parseDOM', () => {
+ it('parses uniform padding in pt', () => {
+ const th = document.createElement('th');
+ th.style.padding = '5pt';
+ expect(attributes.cellMargins.parseDOM(th)).toEqual({
+ top: 7,
+ right: 7,
+ bottom: 7,
+ left: 7,
+ });
+ });
+
+ it('parses per-side padding', () => {
+ const th = document.createElement('th');
+ th.style.paddingTop = '5pt';
+ th.style.paddingRight = '10pt';
+ th.style.paddingBottom = '15pt';
+ expect(attributes.cellMargins.parseDOM(th)).toEqual({
+ top: 7,
+ right: 13,
+ bottom: 20,
+ });
+ });
+
+ it('returns null when no padding is set', () => {
+ expect(attributes.cellMargins.parseDOM(document.createElement('th'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('returns {} when cellMargins is absent', () => {
+ expect(attributes.cellMargins.renderDOM({})).toEqual({});
+ });
+
+ it('subtracts non-none border width from padding', () => {
+ const result = attributes.cellMargins.renderDOM({
+ cellMargins: { top: 12, right: 8 },
+ borders: {
+ top: { val: 'single', size: 3, color: '#000', style: 'solid' },
+ right: { val: 'none', size: 0, color: 'auto', style: 'none' },
+ },
+ });
+ expect(result.style).toContain('padding-top: 9px');
+ expect(result.style).toContain('padding-right: 8px');
+ });
+ });
+ });
+
+ describe('borders', () => {
+ describe('parseDOM', () => {
+ it('applies shorthand border to all sides', () => {
+ const th = document.createElement('th');
+ th.style.border = '1px solid rgb(0, 0, 0)';
+ expect(attributes.borders.parseDOM(th)).toEqual({
+ top: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ right: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ bottom: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ left: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ });
+ });
+
+ it('uses per-side rules when they override shorthand', () => {
+ const th = document.createElement('th');
+ th.style.border = '1px solid rgb(0, 0, 0)';
+ th.style.borderTop = '2px dashed rgb(255, 0, 0)';
+ th.style.borderRight = '3px dotted rgb(0, 0, 255)';
+ th.style.borderBottom = '4px double rgb(0, 128, 0)';
+ expect(attributes.borders.parseDOM(th)).toEqual({
+ top: { val: 'dashed', size: 2, color: '#ff0000', style: 'dashed' },
+ right: { val: 'dotted', size: 3, color: '#0000ff', style: 'dotted' },
+ bottom: { val: 'single', size: 4, color: '#008000', style: 'double' },
+ left: { val: 'single', size: 1, color: '#000000', style: 'solid' },
+ });
+ });
+
+ it('returns null when no border is set', () => {
+ expect(attributes.borders.parseDOM(document.createElement('th'))).toBeNull();
+ });
+ });
+
+ describe('renderDOM', () => {
+ it('returns {} when borders are absent', () => {
+ expect(attributes.borders.renderDOM({})).toEqual({});
+ });
+
+ it('delegates to renderCellBorderStyle (solid px, auto → black)', () => {
+ const result = attributes.borders.renderDOM({
+ borders: {
+ top: { val: 'single', size: 1, color: 'auto', style: 'solid' },
+ },
+ });
+ expect(result.style).toContain('border-top: 1px solid black');
+ });
+
+ it('renders border none without width', () => {
+ const result = attributes.borders.renderDOM({
+ borders: {
+ top: { val: 'none', size: 0, color: 'auto', style: 'none' },
+ },
+ });
+ expect(result.style).toContain('border-top: none');
+ });
+ });
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js b/packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js
new file mode 100644
index 0000000000..8965fa9560
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/table-row/helpers/parseRowHeight.js
@@ -0,0 +1,82 @@
+// @ts-check
+import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js';
+import { halfPointToPixels } from '@core/super-converter/helpers.js';
+
+/**
+ * Parse CSS length-like value into pixels.
+ * Supports pt and px (or unitless numbers interpreted as px).
+ *
+ * @param {unknown} length
+ * @returns {number | undefined}
+ */
+const parseLengthPx = (length) => {
+ if (length == null) return undefined;
+
+ if (typeof length === 'string') {
+ const trimmed = length.trim().toLowerCase();
+ if (!trimmed || trimmed === 'auto') return undefined;
+ }
+
+ const [value, unit] = parseSizeUnit(String(length));
+ const numericValue = Number(value);
+
+ const calculatedLength = unit === 'pt' ? halfPointToPixels(numericValue) : numericValue;
+ return calculatedLength;
+};
+
+/**
+ * Parse row height from explicit row-level style/attributes.
+ *
+ * @param {HTMLElement} element
+ * @returns {number | undefined}
+ */
+const parseExplicitRowHeight = (element) => {
+ const fromHeightStyle = parseLengthPx(element?.style?.height);
+ if (fromHeightStyle != null) return fromHeightStyle;
+
+ const fromMinHeightStyle = parseLengthPx(element?.style?.minHeight);
+ if (fromMinHeightStyle != null) return fromMinHeightStyle;
+
+ if (element?.hasAttribute?.('height')) {
+ const fromHeightAttr = parseLengthPx(element.getAttribute('height'));
+ if (fromHeightAttr != null) return fromHeightAttr;
+ }
+
+ return undefined;
+};
+
+/**
+ * Parse row height by promoting the tallest explicit td/th height.
+ *
+ * @param {HTMLElement} element
+ * @returns {number | undefined}
+ */
+const parseTallestCellHeight = (element) => {
+ const cells = element?.querySelectorAll?.('td,th');
+ if (!cells?.length) return undefined;
+
+ let maxCellHeight;
+ for (const cellNode of Array.from(cells)) {
+ const cell = /** @type {HTMLElement} */ (cellNode);
+ const fromCellHeight = parseLengthPx(cell?.style?.height);
+ const fromCellMinHeight = parseLengthPx(cell?.style?.minHeight);
+ const fromCellHeightAttr = cell?.hasAttribute?.('height') ? parseLengthPx(cell.getAttribute('height')) : undefined;
+
+ const candidate = fromCellHeight ?? fromCellMinHeight ?? fromCellHeightAttr;
+ if (candidate == null) continue;
+ if (maxCellHeight == null || candidate > maxCellHeight) maxCellHeight = candidate;
+ }
+
+ return maxCellHeight;
+};
+
+/**
+ * Parse row height from a | element.
+ * Priority: row height/min-height/height attr, then tallest td/th height.
+ *
+ * @param {HTMLElement} element
+ * @returns {number | undefined}
+ */
+export const parseRowHeight = (element) => {
+ return parseExplicitRowHeight(element) ?? parseTallestCellHeight(element);
+};
diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js
index 035e9b6740..623a5ca113 100644
--- a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js
+++ b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js
@@ -1,6 +1,8 @@
// @ts-nocheck
import { Node } from '@core/Node.js';
import { Attribute } from '@core/Attribute.js';
+import { pixelsToTwips } from '@core/super-converter/helpers.js';
+import { parseRowHeight } from './helpers/parseRowHeight.js';
/**
* @typedef {Object} CnfStyle
@@ -118,7 +120,20 @@ export const TableRow = Node.create({
/**
* @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 377-482
*/
- tableRowProperties: { rendered: false },
+ tableRowProperties: {
+ rendered: false,
+ parseDOM: (element) => {
+ const parsedHeightPx = parseRowHeight(element);
+ if (parsedHeightPx == null) return undefined;
+
+ return {
+ rowHeight: {
+ value: pixelsToTwips(parsedHeightPx),
+ rule: 'atLeast',
+ },
+ };
+ },
+ },
/**
* @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 472
*/
diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js
new file mode 100644
index 0000000000..a7cc57fe47
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.test.js
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest';
+
+import { TableRow } from './table-row.js';
+
+describe('TableRow attributes', () => {
+ const attributes = TableRow.config.addAttributes.call(TableRow);
+
+ it('omits row height style when value is missing', () => {
+ expect(attributes.rowHeight.renderDOM({})).toEqual({});
+ expect(attributes.rowHeight.renderDOM({ rowHeight: null })).toEqual({});
+ });
+
+ it('renders row height style in pixels', () => {
+ expect(attributes.rowHeight.renderDOM({ rowHeight: 20 })).toEqual({
+ style: 'height: 20px',
+ });
+ });
+
+ it('parses inline row height in pt into tableRowProperties (twips)', () => {
+ const tr = document.createElement('tr');
+ tr.style.height = '15pt';
+
+ expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({
+ rowHeight: {
+ value: expect.closeTo(300, 5),
+ rule: 'atLeast',
+ },
+ });
+ });
+
+ it('parses inline row height in px into tableRowProperties (twips)', () => {
+ const tr = document.createElement('tr');
+ tr.style.height = '30px';
+
+ expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({
+ rowHeight: {
+ value: 450,
+ rule: 'atLeast',
+ },
+ });
+ });
+
+ it('falls back to min-height when height is missing', () => {
+ const tr = document.createElement('tr');
+ tr.style.minHeight = '12pt';
+
+ expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({
+ rowHeight: {
+ value: expect.closeTo(240, 5),
+ rule: 'atLeast',
+ },
+ });
+ });
+
+ it('parses height attribute when styles are missing', () => {
+ const tr = document.createElement('tr');
+ tr.setAttribute('height', '24');
+
+ expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({
+ rowHeight: {
+ value: 360,
+ rule: 'atLeast',
+ },
+ });
+ });
+
+ it('returns undefined when no supported height value is present', () => {
+ const tr = document.createElement('tr');
+ tr.style.height = 'auto';
+
+ expect(attributes.tableRowProperties.parseDOM(tr)).toBeUndefined();
+ });
+
+ it('falls back to tallest td/th height when tr has no explicit height', () => {
+ const tr = document.createElement('tr');
+ const tdA = document.createElement('td');
+ tdA.style.height = '10pt';
+ const tdB = document.createElement('td');
+ tdB.style.minHeight = '22px';
+ tr.append(tdA, tdB);
+
+ expect(attributes.tableRowProperties.parseDOM(tr)).toEqual({
+ rowHeight: {
+ value: 330,
+ rule: 'atLeast',
+ },
+ });
+ });
+});