diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 23ef014b4c..39ab356902 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -165,6 +165,7 @@ export function exportSchemaToJson(params) { table: wTblNodeTranslator, tableRow: wTrNodeTranslator, tableCell: wTcNodeTranslator, + tableHeader: wTcNodeTranslator, bookmarkStart: wBookmarkStartTranslator, bookmarkEnd: wBookmarkEndTranslator, fieldAnnotation: wSdtNodeTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-helpers.js index 0d74e55460..8402275118 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-helpers.js @@ -85,14 +85,18 @@ export const fillPlaceholderColumns = ({ */ export const isPlaceholderCell = (cell) => { if (!cell) return false; - if (cell.attrs?.__placeholder) return true; + if (cell.attrs?.__placeholder) { + return true; + } const widths = cell.attrs?.colwidth; if (Array.isArray(widths) && widths.length > 0) { const hasMeaningfulWidth = widths.some( (value) => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) > 1, ); - if (!hasMeaningfulWidth) return true; + if (!hasMeaningfulWidth) { + return true; + } } return false; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js index c51403ed9e..61077a44c9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js @@ -214,6 +214,7 @@ const decode = (params, decodedAttrs) => { const { node } = params; const cells = node.content || []; + let leadingPlaceholders = 0; while (leadingPlaceholders < cells.length && isPlaceholderCell(cells[leadingPlaceholders])) { leadingPlaceholders += 1; diff --git a/packages/super-editor/src/tests/export/exporter-utils.test.js b/packages/super-editor/src/tests/export/exporter-utils.test.js index ee0cdec2d2..f0918692e2 100644 --- a/packages/super-editor/src/tests/export/exporter-utils.test.js +++ b/packages/super-editor/src/tests/export/exporter-utils.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isLineBreakOnlyRun, processOutputMarks } from '@converter/exporter.js'; +import { isLineBreakOnlyRun, processOutputMarks, exportSchemaToJson } from '@converter/exporter.js'; describe('isLineBreakOnlyRun', () => { it('returns true for a run containing only line break nodes', () => { @@ -45,3 +45,50 @@ describe('processOutputMarks', () => { ]); }); }); + +describe('exportSchemaToJson', () => { + it('routes tableHeader nodes to the table cell translator (SD-1709)', () => { + const tableHeaderNode = { + type: 'tableHeader', + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [100], + }, + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }; + + const result = exportSchemaToJson({ node: tableHeaderNode }); + + // tableHeader should be exported as w:tc (same as tableCell) + expect(result).not.toBeNull(); + expect(result.name).toBe('w:tc'); + }); + + it('routes tableCell nodes to the table cell translator', () => { + const tableCellNode = { + type: 'tableCell', + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [100], + }, + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }; + + const result = exportSchemaToJson({ node: tableCellNode }); + + expect(result).not.toBeNull(); + expect(result.name).toBe('w:tc'); + }); +}); diff --git a/packages/super-editor/src/tests/export/tableExporter.test.js b/packages/super-editor/src/tests/export/tableExporter.test.js index e25ca16c0f..673ef6e231 100644 --- a/packages/super-editor/src/tests/export/tableExporter.test.js +++ b/packages/super-editor/src/tests/export/tableExporter.test.js @@ -1,4 +1,4 @@ -import { getExportedResult } from './export-helpers/index.js'; +import { getExportedResult, getExportedResultWithDocContent } from './export-helpers/index.js'; import { twipsToPixels } from '../../core/super-converter/helpers.js'; describe('test table export', async () => { @@ -25,3 +25,132 @@ describe('test table export', async () => { expect(gridCol3).toBeCloseTo(176.133, 3); }); }); + +describe('tableHeader export', () => { + it('exports tables with tableHeader nodes to valid OOXML structure', async () => { + 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] }, + content: [ + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', content: [{ type: 'text', text: 'Header 1' }] }], + }, + ], + }, + { + type: 'tableHeader', + attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, + 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] }, + content: [ + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'run', content: [{ type: 'text', text: 'Cell 1' }] }], + }, + ], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, + 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'); + expect(body).toBeDefined(); + + const tbl = body.elements.find((el) => el.name === 'w:tbl'); + expect(tbl).toBeDefined(); + + const tblGrid = tbl.elements.find((el) => el.name === 'w:tblGrid'); + expect(tblGrid).toBeDefined(); + const gridCols = tblGrid.elements.filter((el) => el.name === 'w:gridCol'); + expect(gridCols.length).toBeGreaterThan(0); + + const rows = tbl.elements.filter((el) => el.name === 'w:tr'); + expect(rows.length).toBe(2); + + const firstRowCells = rows[0].elements.filter((el) => el.name === 'w:tc'); + expect(firstRowCells.length).toBe(2); + + const secondRowCells = rows[1].elements.filter((el) => el.name === 'w:tc'); + expect(secondRowCells.length).toBe(2); + }); + + it('exports mixed tableHeader and tableCell in same row', async () => { + const tableWithMixedCells = { + type: 'table', + attrs: { + grid: [{ col: 1500 }, { col: 1500 }], + tableProperties: {}, + }, + content: [ + { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableHeader', + attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, + content: [{ type: 'paragraph', attrs: {}, content: [] }], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, + content: [{ type: 'paragraph', attrs: {}, content: [] }], + }, + ], + }, + ], + }; + + const result = await getExportedResultWithDocContent([tableWithMixedCells]); + + 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'); + const cells = rows[0].elements.filter((el) => el.name === 'w:tc'); + + expect(cells.length).toBe(2); + }); +});