diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js index ecf0a4dbb1..f90685d244 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js @@ -47,7 +47,7 @@ const decode = (params) => { const grid = Array.isArray(rawGrid) ? rawGrid : []; const { firstRow = {}, preferTableGrid = false, totalColumns: requestedColumns } = params.extraParams || {}; - const cellNodes = firstRow.content?.filter((n) => n.type === 'tableCell') ?? []; + const cellNodes = firstRow.content?.filter((n) => n.type === 'tableCell' || n.type === 'tableHeader') ?? []; let colWidthsFromCellNodes = cellNodes.flatMap((cell) => { const spanCount = Math.max(1, cell?.attrs?.colspan ?? 1); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.test.js index 59738d1fdb..3d6618a929 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.test.js @@ -322,6 +322,28 @@ describe('w:tblGrid translator', () => { expect(widths).toEqual(['2000', '4000']); }); + it('derives grid widths from tableHeader cells in header-only first row', () => { + const params = { + node: { + attrs: {}, + }, + extraParams: { + firstRow: { + content: [ + { type: 'tableHeader', attrs: { colspan: 1, colwidth: [80] } }, + { type: 'tableHeader', attrs: { colspan: 1, colwidth: [120] } }, + ], + }, + }, + }; + + const result = translator.decode(params); + expect(result.name).toBe('w:tblGrid'); + const widths = result.elements.map((el) => el.attributes['w:w']); + // 80 * 20 = 1600, 120 * 20 = 2400 (via mocked pixelsToTwips) + expect(widths).toEqual(['1600', '2400']); + }); + it('preserves narrow grid columns for placeholder cells without inflating width', () => { const params = { node: { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js index 8f411ceb5e..147ff39f3c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js @@ -39,15 +39,28 @@ export function generateTableCellProperties(node) { const { attrs } = node; // Width - const { colwidth = [], cellWidthType = 'dxa', widthUnit } = attrs; - const colwidthSum = colwidth.reduce((acc, curr) => acc + curr, 0); - const propertiesWidthPixels = twipsToPixels(tableCellProperties.cellWidth?.value); - if (propertiesWidthPixels !== colwidthSum) { - // If the value has changed, update it - tableCellProperties['cellWidth'] = { - value: widthUnit === 'px' ? pixelsToTwips(colwidthSum) : inchesToTwips(colwidthSum), - type: cellWidthType, - }; + const { colwidth: rawColwidth, widthUnit = 'px' } = attrs; + const resolvedWidthType = + attrs.cellWidthType ?? + (attrs.widthType !== 'auto' ? attrs.widthType : undefined) ?? + tableCellProperties.cellWidth?.type ?? + 'dxa'; + + // Filter to finite numbers to guard against NaN/Infinity/non-numeric entries + const colwidth = Array.isArray(rawColwidth) ? rawColwidth.filter((v) => Number.isFinite(v)) : []; + + // Skip rewrite when: + // - colwidth is empty (no data to compute from — preserve original cellWidth) + // - resolvedWidthType is 'pct' (colwidth is in pixels but type expects fiftieths-of-percent) + if (colwidth.length > 0 && resolvedWidthType !== 'pct') { + const colwidthSum = colwidth.reduce((acc, curr) => acc + curr, 0); + const propertiesWidthPixels = twipsToPixels(tableCellProperties.cellWidth?.value); + if (propertiesWidthPixels !== colwidthSum) { + tableCellProperties['cellWidth'] = { + value: widthUnit === 'px' ? pixelsToTwips(colwidthSum) : inchesToTwips(colwidthSum), + type: resolvedWidthType, + }; + } } // Colspan diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js index a4d682c56f..10d44cfbcb 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js @@ -96,3 +96,118 @@ describe('translate-table-cell helpers', () => { expect(out.elements[1]).toMatchObject({ name: 'w:p' }); }); }); + +/** Helper: extract w:tcW element from a generateTableCellProperties result */ +function getTcW(tcPr) { + return tcPr.elements?.find((e) => e.name === 'w:tcW') ?? null; +} + +describe('IT-550: tableHeader width export fixes', () => { + it('uses pixelsToTwips when widthUnit is px', () => { + const node = { attrs: { colwidth: [100], widthUnit: 'px' } }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + expect(tcW.attributes['w:w']).toBe(String(pixelsToTwips(100))); + expect(tcW.attributes['w:type']).toBe('dxa'); + }); + + it('defaults widthUnit to px when missing', () => { + // Simulates a tableHeader node before Step 1 fix — no widthUnit attr at all + const node = { attrs: { colwidth: [100] } }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + // Should use pixelsToTwips (not inchesToTwips), producing 1500, not 144000 + expect(tcW.attributes['w:w']).toBe(String(pixelsToTwips(100))); + }); + + it('preserves existing cellWidth when colwidth is null', () => { + const originalCellWidth = { value: 3000, type: 'dxa' }; + const node = { + attrs: { + colwidth: null, + widthUnit: 'px', + tableCellProperties: { cellWidth: originalCellWidth }, + }, + }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + // Should preserve the original value, not write 0 + expect(tcW.attributes['w:w']).toBe('3000'); + expect(tcW.attributes['w:type']).toBe('dxa'); + }); + + it('preserves existing cellWidth when colwidth is empty array', () => { + const originalCellWidth = { value: 3000, type: 'dxa' }; + const node = { + attrs: { + colwidth: [], + widthUnit: 'px', + tableCellProperties: { cellWidth: originalCellWidth }, + }, + }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + expect(tcW.attributes['w:w']).toBe('3000'); + }); + + it('filters non-finite values from colwidth', () => { + const node = { attrs: { colwidth: [100, NaN, 50], widthUnit: 'px' } }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + // Only 100 + 50 = 150 should be summed (NaN filtered out) + expect(tcW.attributes['w:w']).toBe(String(pixelsToTwips(150))); + }); + + it('preserves original cellWidth for pct width type', () => { + // Simulates a pct-imported cell: widthType is 'pct', colwidth is in pixels (from tblGrid fallback) + const originalCellWidth = { value: 5000, type: 'pct' }; + const node = { + attrs: { + colwidth: [200], + widthUnit: 'px', + widthType: 'pct', + tableCellProperties: { cellWidth: originalCellWidth }, + }, + }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + // Should preserve original pct value, NOT rewrite with pixelsToTwips(200) + expect(tcW.attributes['w:w']).toBe('5000'); + expect(tcW.attributes['w:type']).toBe('pct'); + }); + + it('resolves widthType dxa from node attrs', () => { + const node = { attrs: { colwidth: [100], widthUnit: 'px', widthType: 'dxa' } }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + expect(tcW.attributes['w:type']).toBe('dxa'); + }); + + it('falls through auto widthType to tableCellProperties.cellWidth.type', () => { + const node = { + attrs: { + colwidth: [100], + widthUnit: 'px', + widthType: 'auto', + tableCellProperties: { cellWidth: { value: 1500, type: 'dxa' } }, + }, + }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + // 'auto' is the uninformative default — should fall through to tableCellProperties type + expect(tcW.attributes['w:type']).toBe('dxa'); + }); + + it('falls through auto widthType to dxa when no tableCellProperties type', () => { + const node = { + attrs: { + colwidth: [100], + widthUnit: 'px', + widthType: 'auto', + }, + }; + const tcPr = generateTableCellProperties(node); + const tcW = getTcW(tcPr); + expect(tcW.attributes['w:type']).toBe('dxa'); + }); +}); diff --git a/packages/super-editor/src/extensions/table-header/table-header.js b/packages/super-editor/src/extensions/table-header/table-header.js index 21730c3da0..ff903db95f 100644 --- a/packages/super-editor/src/extensions/table-header/table-header.js +++ b/packages/super-editor/src/extensions/table-header/table-header.js @@ -17,8 +17,14 @@ import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderSty * @category Attributes * @property {number} [colspan=1] - Number of columns this header spans * @property {number} [rowspan=1] - Number of rows this header spans - * @property {number[]} [colwidth] - Column widths array in pixels + * @property {number[]} [colwidth=[100]] - Column widths array in pixels + * @property {import('../table-cell/table-cell.js').CellBackground} [background] - Cell background color configuration + * @property {string} [verticalAlign] - Vertical content alignment (top, middle, bottom) + * @property {import('../table-cell/table-cell.js').CellMargins} [cellMargins] - Internal cell padding * @property {import('../table-cell/helpers/createCellBorders.js').CellBorders} [borders] - Cell border configuration + * @property {string} [widthType='auto'] @internal - Internal width type + * @property {string} [widthUnit='px'] @internal - Internal width unit + * @property {import('../table-cell/table-cell.js').TableCellProperties} [tableCellProperties] @internal - Raw OOXML cell properties */ /** @@ -54,7 +60,7 @@ export const TableHeader = Node.create({ }, colwidth: { - default: null, + default: [100], parseDOM: (element) => { const colwidth = element.getAttribute('data-colwidth'); const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null; @@ -69,11 +75,62 @@ export const TableHeader = Node.create({ }, }, + background: { + renderDOM({ background }) { + if (!background) return {}; + // @ts-expect-error - background is known to be an object at runtime + const { color } = background || {}; + const style = `background-color: ${color ? `#${color}` : 'transparent'}`; + return { style }; + }, + }, + + verticalAlign: { + renderDOM({ verticalAlign }) { + if (!verticalAlign) return {}; + const style = `vertical-align: ${verticalAlign}`; + return { style }; + }, + }, + + cellMargins: { + renderDOM({ cellMargins, borders }) { + if (!cellMargins) return {}; + const sides = ['top', 'right', 'bottom', 'left']; + const style = sides + .map((side) => { + const margin = cellMargins?.[side] ?? 0; + const border = borders?.[side]; + const borderSize = border && border.val !== 'none' ? Math.ceil(border.size) : 0; + + if (margin) return `padding-${side}: ${Math.max(0, margin - borderSize)}px;`; + return ''; + }) + .join(' '); + return { style }; + }, + }, + borders: { default: () => createCellBorders(), renderDOM: ({ borders }) => renderCellBorderStyle(borders), }, + widthType: { + default: 'auto', + rendered: false, + }, + + widthUnit: { + default: 'px', + rendered: false, + }, + + tableCellProperties: { + default: null, + rendered: false, + }, + __placeholder: { default: null, parseDOM: (element) => { diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index ecbbe3e566..520f4f5f06 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -506,6 +506,71 @@ describe('Table commands', async () => { }); }); + describe('toggleHeaderRow preserves cell attributes (IT-550)', async () => { + beforeEach(async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + // Create a 2x2 table with explicit cell attributes + const CellType = schema.nodes.tableCell; + const RowType = schema.nodes.tableRow; + const TableType = schema.nodes.table; + + const cellAttrs = { + colspan: 1, + rowspan: 1, + colwidth: [150], + widthUnit: 'px', + widthType: 'dxa', + background: { color: 'FF0000' }, + tableCellProperties: { cellWidth: { value: 2250, type: 'dxa' } }, + }; + + const makeCell = (text) => CellType.create(cellAttrs, schema.nodes.paragraph.create(null, schema.text(text))); + + const row1 = RowType.create(null, [makeCell('A'), makeCell('B')]); + const row2 = RowType.create(null, [makeCell('C'), makeCell('D')]); + table = TableType.create(null, [row1, row2]); + + const doc = schema.nodes.doc.create(null, [table]); + const nextState = EditorState.create({ schema, doc, plugins: editor.state.plugins }); + editor.setState(nextState); + }); + + it('toggleHeaderRow preserves widthUnit, widthType, background, and tableCellProperties', async () => { + const tablePos = findTablePos(editor.state.doc); + expect(tablePos).not.toBeNull(); + + // Position cursor in first row + editor.commands.setTextSelection(tablePos + 3); + + // Toggle first row to header + const didToggle = editor.commands.toggleHeaderRow(); + expect(didToggle).toBe(true); + + const updatedTable = editor.state.doc.nodeAt(tablePos); + const firstRow = updatedTable.child(0); + + // First row cells should now be tableHeader type + firstRow.forEach((cell) => { + expect(cell.type.name).toBe('tableHeader'); + // Critical attrs that were previously dropped + expect(cell.attrs.widthUnit).toBe('px'); + expect(cell.attrs.widthType).toBe('dxa'); + expect(cell.attrs.colwidth).toEqual([150]); + expect(cell.attrs.background).toEqual({ color: 'FF0000' }); + expect(cell.attrs.tableCellProperties).toEqual({ cellWidth: { value: 2250, type: 'dxa' } }); + }); + + // Second row should remain tableCell + const secondRow = updatedTable.child(1); + secondRow.forEach((cell) => { + expect(cell.type.name).toBe('tableCell'); + }); + }); + }); + describe('deleteCellAndTableBorders', async () => { let table, tablePos; diff --git a/packages/super-editor/src/tests/export/tableExporter.test.js b/packages/super-editor/src/tests/export/tableExporter.test.js index 673ef6e231..2c2577b93c 100644 --- a/packages/super-editor/src/tests/export/tableExporter.test.js +++ b/packages/super-editor/src/tests/export/tableExporter.test.js @@ -1,5 +1,5 @@ import { getExportedResult, getExportedResultWithDocContent } from './export-helpers/index.js'; -import { twipsToPixels } from '../../core/super-converter/helpers.js'; +import { twipsToPixels, pixelsToTwips } from '../../core/super-converter/helpers.js'; describe('test table export', async () => { const fileName = 'table-merged-cells.docx'; @@ -153,4 +153,94 @@ describe('tableHeader export', () => { expect(cells.length).toBe(2); }); + + it('IT-550: tableHeader and tableCell emit identical w:tcW for same colwidth', async () => { + const expectedTwips = String(pixelsToTwips(100)); + const tableWithHeaders = { + type: 'table', + attrs: { + grid: [{ col: 1500 }, { col: 1500 }], + tableProperties: {}, + }, + content: [ + { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableHeader', + attrs: { colspan: 1, rowspan: 1, colwidth: [100], widthUnit: 'px' }, + content: [ + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', content: [{ type: 'text', text: 'Header' }] }], + }, + ], + }, + { + type: 'tableHeader', + attrs: { colspan: 1, rowspan: 1, colwidth: [100], widthUnit: 'px' }, + content: [ + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', content: [{ type: 'text', text: 'Header 2' }] }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [100], widthUnit: 'px' }, + content: [ + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', content: [{ type: 'text', text: 'Cell' }] }], + }, + ], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [100], widthUnit: 'px' }, + content: [ + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', content: [{ type: 'text', text: 'Cell 2' }] }], + }, + ], + }, + ], + }, + ], + }; + + const result = await getExportedResultWithDocContent([tableWithHeaders]); + const body = result.elements.find((el) => el.name === 'w:body'); + const tbl = body.elements.find((el) => el.name === 'w:tbl'); + const rows = tbl.elements.filter((el) => el.name === 'w:tr'); + + // Extract w:tcW from both header and body cells + const getTcW = (cell) => { + const tcPr = cell.elements.find((el) => el.name === 'w:tcPr'); + return tcPr?.elements?.find((el) => el.name === 'w:tcW'); + }; + + const headerCell = rows[0].elements.filter((el) => el.name === 'w:tc')[0]; + const bodyCell = rows[1].elements.filter((el) => el.name === 'w:tc')[0]; + + const headerTcW = getTcW(headerCell); + const bodyTcW = getTcW(bodyCell); + + // Assert exact expected value (1500 twips = pixelsToTwips(100)) + expect(headerTcW.attributes['w:w']).toBe(expectedTwips); + expect(bodyTcW.attributes['w:w']).toBe(expectedTwips); + }); });