diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index a00a666dc4..df3ab3e21e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -1,6 +1,7 @@ import { twipsToPixels, resolveShadingFillColor } from '@converter/helpers'; import { translator as tcPrTranslator } from '../../tcPr'; import { isInlineNode } from '../../../helpers/is-inline-node.js'; +import { normalizeRowCellChildren } from '../../tr/row-cell-children.js'; /** * @param {Object} options @@ -258,7 +259,13 @@ const getTableCellGridSpan = (node) => { }; const findTableCellAtColumn = (row, targetColumn) => { - const cells = row.elements?.filter((el) => el.name === 'w:tc') ?? []; + // Use the SDT-aware row-child normalizer so cell-level structured document + // tags (ECMA-376 §17.5.2.32) are unwrapped here too. Without this, a vMerge + // continuation cell inside `` + // would be invisible to the merge pre-pass and never marked `_vMergeConsumed`, + // breaking the merge on import. The wrapper metadata (cellSdt) is not + // relevant for merge tracking and is discarded here. + const cells = normalizeRowCellChildren(row).map((entry) => entry.node); let currentColumn = getGridBefore(row); for (const cell of cells) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js index b640190ece..4af309e4aa 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js @@ -282,6 +282,61 @@ describe('legacy-handle-table-cell-node', () => { expect(row2Cells.every((tc) => tc._vMergeConsumed)).toBe(true); }); + it('marks an SDT-wrapped vMerge continuation cell as consumed (SD-3289 / IT-1119)', () => { + // Row 1: a normal restart cell. + const restartCell = { + name: 'w:tc', + elements: [ + { name: 'w:tcPr', elements: [{ name: 'w:vMerge', attributes: { 'w:val': 'restart' } }] }, + { name: 'w:p' }, + ], + }; + const row1 = { name: 'w:tr', elements: [restartCell] }; + + // Row 2: the continuation cell is wrapped in a cell-level SDT + // (ECMA-376 §17.5.2.32). Without the SDT-aware row-cell lookup, the + // vMerge pre-pass would skip this cell because it isn't a direct + // child of . + const continuationCell = { + name: 'w:tc', + elements: [{ name: 'w:tcPr', elements: [{ name: 'w:vMerge' }] }, { name: 'w:p' }], + }; + const row2 = { + name: 'w:tr', + elements: [ + { + name: 'w:sdt', + elements: [ + { name: 'w:sdtPr', elements: [{ name: 'w:id', attributes: { 'w:val': '12345' } }] }, + { name: 'w:sdtContent', elements: [continuationCell] }, + ], + }, + ], + }; + + const table = { name: 'w:tbl', elements: [row1, row2] }; + const params = { + docx: {}, + nodeListHandler: { handler: vi.fn(() => 'CONTENT') }, + path: [], + editor: createEditorStub(), + }; + + const out = handleTableCellNode({ + params, + node: restartCell, + table, + row: row1, + columnIndex: 0, + columnWidth: null, + allColumnWidths: [90], + _referencedStyles: null, + }); + + expect(out.attrs.rowspan).toBe(2); + expect(continuationCell._vMergeConsumed).toBe(true); + }); + it('blends percentage table shading into a solid background color', () => { const cellNode = { name: 'w:tc', elements: [{ name: 'w:p' }] }; const row = { name: 'w:tr', elements: [cellNode] }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js new file mode 100644 index 0000000000..484385093c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js @@ -0,0 +1,57 @@ +// @ts-check + +/** + * Normalize a `` element's children into the cell stream the row encoder + * iterates. Direct `` children pass through unchanged. A cell-level + * `` (ECMA-376 §17.5.2.32, CT_SdtCell) is unwrapped: its inner `` + * is emitted in document order, and when the wrapper contains exactly one cell + * the wrapper's `w:sdtPr` / `w:sdtEndPr` are attached as metadata so export + * can rebuild the `` envelope. + * + * Multi-cell SDT wrappers (legal under `CT_SdtContentCell`/EG_ContentCellContent + * but rare in practice; the spec prose at §17.5.2.33 describes a single cell) + * are imported defensively: every inner cell is emitted in order, but wrapper + * metadata is dropped because exact multi-cell grouping needs a representation + * SuperDoc does not currently model. + * + * Other legal `w:tr` children (`w:customXml`, run-level markup) are skipped + * silently, matching the prior behavior of the cell-only filter. + * + * Pure helper: no dependencies. Shared between `tr-translator.js` (row encode) + * and `legacy-handle-table-cell-node.js` (vMerge continuation lookup) so both + * see the same set of importable cells. + * + * @param {any} row + * @returns {Array<{ node: any, cellSdt: any }>} + */ +export const normalizeRowCellChildren = (row) => { + /** @type {Array<{ node: any, cellSdt: any }>} */ + const out = []; + const children = Array.isArray(row?.elements) ? row.elements : []; + for (const child of children) { + if (!child || typeof child.name !== 'string') continue; + if (child.name === 'w:tc') { + out.push({ node: child, cellSdt: null }); + continue; + } + if (child.name === 'w:sdt') { + const sdtPr = child.elements?.find((/** @type {any} */ el) => el?.name === 'w:sdtPr') ?? null; + const sdtEndPr = child.elements?.find((/** @type {any} */ el) => el?.name === 'w:sdtEndPr') ?? null; + const sdtContent = child.elements?.find((/** @type {any} */ el) => el?.name === 'w:sdtContent'); + const innerCells = sdtContent?.elements?.filter((/** @type {any} */ el) => el?.name === 'w:tc') ?? []; + if (innerCells.length === 1 && sdtPr) { + out.push({ + node: innerCells[0], + cellSdt: { scope: 'cell', sdtPr, sdtEndPr }, + }); + } else { + // Multi-cell wrapper or wrapper without sdtPr: import inner cells without + // wrapper metadata so the row is not dropped. + for (const innerTc of innerCells) { + out.push({ node: innerTc, cellSdt: null }); + } + } + } + } + return out; +}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.integration.test.js new file mode 100644 index 0000000000..0768b4ad0d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.integration.test.js @@ -0,0 +1,241 @@ +// @ts-check +/** + * End-to-end round-trip integration test for cell-level SDT (CT_SdtCell) + * preservation. SD-3289 / IT-1119. + * + * Drives a real `` containing `` + * through the real v3 table importer (no translator mocks), then exports the + * resulting PM tree via the production `exportSchemaToJson` router, and asserts + * the `` wrapper plus its `w:date` metadata survive both legs. + * + * Distinct from `tr-translator.cell-sdt.test.js`, which mocks `tcTranslator` + * and `translateChildNodes` to unit-test the row translator's logic. This file + * runs the real chain so we catch schema-level losses (PM attr persistence, + * cross-translator wiring) that unit mocks would miss. + */ +import { describe, it, expect } from 'vitest'; +import { translator as tblTranslator } from '../tbl/tbl-translator.js'; +import { exportSchemaToJson } from '../../../../exporter.js'; +import { defaultNodeListHandler } from '../../../../v2/importer/docxImporter.js'; + +const DATE_TEXT = '18 January 2025'; +const LABEL_TEXT = 'COMMENCEMENT DATE'; + +const SDT_PR = { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '849213029' } }, + { + name: 'w:date', + attributes: { 'w:fullDate': '2025-01-18T00:00:00Z' }, + elements: [ + { name: 'w:dateFormat', attributes: { 'w:val': 'd MMMM yyyy' } }, + { name: 'w:lid', attributes: { 'w:val': 'en-AU' } }, + { name: 'w:storeMappedDataAs', attributes: { 'w:val': 'dateTime' } }, + { name: 'w:calendar', attributes: { 'w:val': 'gregorian' } }, + ], + }, + ], +}; + +const buildIT1119Table = () => ({ + name: 'w:tbl', + elements: [ + { + name: 'w:tblPr', + elements: [ + { name: 'w:tblW', attributes: { 'w:w': '9180', 'w:type': 'dxa' } }, + { name: 'w:tblLayout', attributes: { 'w:type': 'fixed' } }, + ], + }, + { + name: 'w:tblGrid', + elements: [ + { name: 'w:gridCol', attributes: { 'w:w': '3260' } }, + { name: 'w:gridCol', attributes: { 'w:w': '5920' } }, + ], + }, + { + name: 'w:tr', + elements: [ + { + name: 'w:tc', + elements: [ + { + name: 'w:tcPr', + elements: [{ name: 'w:tcW', attributes: { 'w:w': '3260', 'w:type': 'dxa' } }], + }, + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: LABEL_TEXT }] }], + }, + ], + }, + ], + }, + { + name: 'w:sdt', + elements: [ + SDT_PR, + { + name: 'w:sdtContent', + elements: [ + { + name: 'w:tc', + elements: [ + { + name: 'w:tcPr', + elements: [{ name: 'w:tcW', attributes: { 'w:w': '5920', 'w:type': 'dxa' } }], + }, + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: DATE_TEXT }] }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +}); + +const minimalDocx = { + 'word/styles.xml': { elements: [{ name: 'w:styles', elements: [] }] }, +}; + +const editorStub = { + schema: { + nodes: { + doc: { spec: { group: 'block' } }, + paragraph: { spec: { group: 'block' } }, + run: { isInline: true, spec: { group: 'inline' } }, + text: { isInline: true, spec: { group: 'inline' } }, + table: { spec: { group: 'block' } }, + tableRow: { spec: { group: 'block' } }, + tableCell: { spec: { group: 'block' } }, + }, + }, + converter: { addedMediaFiles: {} }, +}; + +const findFirst = (xml, name) => { + if (!xml) return null; + if (xml.name === name) return xml; + for (const child of xml.elements || []) { + const hit = findFirst(child, name); + if (hit) return hit; + } + return null; +}; + +const findAll = (xml, name) => { + if (!xml) return []; + const acc = []; + if (xml.name === name) acc.push(xml); + for (const child of xml.elements || []) acc.push(...findAll(child, name)); + return acc; +}; + +const collectText = (pmNode) => { + if (!pmNode) return ''; + if (pmNode.type === 'text') return pmNode.text || ''; + return (pmNode.content || []).map(collectText).join(''); +}; + +const collectXmlText = (xml) => { + if (!xml) return ''; + if (xml.type === 'text') return xml.text || ''; + return (xml.elements || []).map(collectXmlText).join(''); +}; + +describe('cell-level SDT round-trip (SD-3289 / IT-1119)', () => { + it('imports and exports a CT_SdtCell-wrapped table cell with date metadata intact', () => { + const tbl = buildIT1119Table(); + const { handler, handlerEntities } = defaultNodeListHandler(); + + const tablePm = tblTranslator.encode( + { + nodes: [tbl], + docx: minimalDocx, + nodeListHandler: { handler, handlerEntities }, + editor: editorStub, + path: [], + }, + {}, + ); + + // Import-side assertions --------------------------------------------------- + expect(tablePm).toBeTruthy(); + expect(tablePm.type).toBe('table'); + const rows = (tablePm.content || []).filter((n) => n.type === 'tableRow'); + expect(rows).toHaveLength(1); + const cells = (rows[0].content || []).filter((n) => n.type === 'tableCell'); + expect(cells.length).toBeGreaterThanOrEqual(2); + + const labelCell = cells[0]; + const dateCell = cells[1]; + + // First cell: no cellSdt; carries the label text. + expect(labelCell.attrs?.cellSdt ?? null).toBeNull(); + expect(collectText(labelCell)).toContain(LABEL_TEXT); + + // Second cell: cellSdt populated, date text preserved. + expect(dateCell.attrs?.cellSdt).toBeTruthy(); + expect(dateCell.attrs.cellSdt.scope).toBe('cell'); + expect(dateCell.attrs.cellSdt.sdtPr?.name).toBe('w:sdtPr'); + expect(collectText(dateCell)).toContain(DATE_TEXT); + + // The preserved sdtPr carries the `w:date` element with the original + // fullDate attribute and child elements (dateFormat / lid / calendar). + const dateEl = (dateCell.attrs.cellSdt.sdtPr.elements || []).find((el) => el.name === 'w:date'); + expect(dateEl).toBeTruthy(); + expect(dateEl.attributes['w:fullDate']).toBe('2025-01-18T00:00:00Z'); + const dateFormat = (dateEl.elements || []).find((el) => el.name === 'w:dateFormat'); + expect(dateFormat?.attributes['w:val']).toBe('d MMMM yyyy'); + + // Export-side assertions --------------------------------------------------- + const exported = exportSchemaToJson({ node: tablePm }); + expect(exported).toBeTruthy(); + + const trEl = findFirst(exported, 'w:tr'); + expect(trEl).toBeTruthy(); + + // Row must contain a direct (the label cell) AND a wrapper + // (the date cell). Order must be preserved. + const trChildren = (trEl.elements || []).filter((el) => el?.name === 'w:tc' || el?.name === 'w:sdt'); + expect(trChildren.map((el) => el.name)).toEqual(['w:tc', 'w:sdt']); + + const sdtEl = trChildren[1]; + const sdtChildNames = (sdtEl.elements || []).map((el) => el?.name); + expect(sdtChildNames).toContain('w:sdtPr'); + expect(sdtChildNames).toContain('w:sdtContent'); + + // Reconstructed sdtPr preserves the date metadata. + const reSdtPr = (sdtEl.elements || []).find((el) => el.name === 'w:sdtPr'); + const reDateEl = findFirst(reSdtPr, 'w:date'); + expect(reDateEl?.attributes?.['w:fullDate']).toBe('2025-01-18T00:00:00Z'); + + // sdtContent wraps a single carrying the date text. + const reSdtContent = (sdtEl.elements || []).find((el) => el.name === 'w:sdtContent'); + const wrappedTc = (reSdtContent.elements || []).find((el) => el.name === 'w:tc'); + expect(wrappedTc).toBeTruthy(); + expect(collectXmlText(wrappedTc)).toContain(DATE_TEXT); + + // The exported tree must not also emit the date cell as a bare + // sibling — exactly one cell carries the date text. + const allCellsInRow = findAll(trEl, 'w:tc'); + const cellsWithDate = allCellsInRow.filter((tc) => collectXmlText(tc).includes(DATE_TEXT)); + expect(cellsWithDate).toHaveLength(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js new file mode 100644 index 0000000000..fdf3244d7c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js @@ -0,0 +1,334 @@ +// @ts-check +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies in the same shape as tr-translator.test.js so the row +// translator's helpers resolve consistently across test files. +vi.mock('@core/super-converter/helpers.js', () => ({ + twipsToPixels: vi.fn((val) => (val ? parseInt(val, 10) / 20 : 0)), + pixelsToTwips: vi.fn((val) => (val ? Math.round(val * 20) : 0)), + eighthPointsToPixels: vi.fn((val) => (val != null ? parseInt(val, 10) / 8 : 0)), +})); + +vi.mock('@core/super-converter/v2/exporter/helpers/index.js', () => ({ + translateChildNodes: vi.fn(), +})); + +vi.mock('../tc', () => ({ + translator: { + encode: vi.fn((params) => ({ + type: 'tableCell', + attrs: { + from: 'tcTranslator', + columnIndex: params.extraParams.columnIndex, + columnWidth: params.extraParams.columnWidth, + colspan: 1, + }, + })), + }, +})); + +vi.mock('../trPr', () => ({ + translator: { + encode: vi.fn(() => ({})), + decode: vi.fn(() => null), + }, +})); + +vi.mock('../tblBorders', () => ({ + translator: { + encode: vi.fn(() => null), + decode: vi.fn(() => null), + }, +})); + +import { translator } from './tr-translator.js'; +import { translator as tcTranslator } from '../tc'; +import { translateChildNodes } from '@core/super-converter/v2/exporter/helpers/index.js'; + +const COMMENCEMENT_CELL = { + name: 'w:tc', + elements: [ + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'COMMENCEMENT DATE' }] }] }], + }, + ], +}; + +const DATE_CELL = { + name: 'w:tc', + elements: [ + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '18 January 2025' }] }] }], + }, + ], +}; + +const SDT_PR = { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '849213029' } }, + { + name: 'w:date', + attributes: { 'w:fullDate': '2025-01-18T00:00:00Z' }, + elements: [ + { name: 'w:dateFormat', attributes: { 'w:val': 'd MMMM yyyy' } }, + { name: 'w:lid', attributes: { 'w:val': 'en-AU' } }, + { name: 'w:storeMappedDataAs', attributes: { 'w:val': 'dateTime' } }, + { name: 'w:calendar', attributes: { 'w:val': 'gregorian' } }, + ], + }, + ], +}; + +const SDT_END_PR = { name: 'w:sdtEndPr', elements: [] }; + +describe('w:tr translator — cell-level SDT (SD-3289 / IT-1119)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('encode (import): cell-level SDT (CT_SdtCell) unwrap', () => { + it('imports a direct w:tc + a w:sdt > w:sdtContent > w:tc as two cells', () => { + const row = { + name: 'w:tr', + elements: [ + COMMENCEMENT_CELL, + { + name: 'w:sdt', + elements: [SDT_PR, { name: 'w:sdtContent', elements: [DATE_CELL] }], + }, + ], + }; + const params = { + nodes: [row], + extraParams: { row, columnWidths: [163, 296] }, + }; + + const result = translator.encode(params, {}); + + expect(tcTranslator.encode).toHaveBeenCalledTimes(2); + expect(result.content).toHaveLength(2); + // first cell: regular, no cellSdt + expect(result.content[0].attrs.cellSdt).toBeUndefined(); + // second cell: wrapped, carries cellSdt + expect(result.content[1].attrs.cellSdt).toEqual({ + scope: 'cell', + sdtPr: SDT_PR, + sdtEndPr: null, + }); + }); + + it('preserves w:sdtEndPr when present on the wrapper', () => { + const row = { + name: 'w:tr', + elements: [ + { + name: 'w:sdt', + elements: [SDT_PR, SDT_END_PR, { name: 'w:sdtContent', elements: [DATE_CELL] }], + }, + ], + }; + const params = { nodes: [row], extraParams: { row, columnWidths: [296] } }; + + const result = translator.encode(params, {}); + + expect(result.content[0].attrs.cellSdt).toEqual({ + scope: 'cell', + sdtPr: SDT_PR, + sdtEndPr: SDT_END_PR, + }); + }); + + it('routes the inner w:tc through the existing tc-translator', () => { + const row = { + name: 'w:tr', + elements: [{ name: 'w:sdt', elements: [SDT_PR, { name: 'w:sdtContent', elements: [DATE_CELL] }] }], + }; + const params = { nodes: [row], extraParams: { row, columnWidths: [296] } }; + + translator.encode(params, {}); + + expect(tcTranslator.encode).toHaveBeenCalledWith( + expect.objectContaining({ + extraParams: expect.objectContaining({ node: DATE_CELL, columnIndex: 0, columnWidth: 296 }), + }), + ); + }); + + it('defensively imports multi-cell SDT wrappers without wrapper metadata', () => { + const secondInnerCell = { + name: 'w:tc', + elements: [ + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'B' }] }] }], + }, + ], + }; + const row = { + name: 'w:tr', + elements: [ + { + name: 'w:sdt', + elements: [SDT_PR, { name: 'w:sdtContent', elements: [DATE_CELL, secondInnerCell] }], + }, + ], + }; + const params = { nodes: [row], extraParams: { row, columnWidths: [148, 148] } }; + + const result = translator.encode(params, {}); + + expect(result.content).toHaveLength(2); + expect(result.content[0].attrs.cellSdt).toBeUndefined(); + expect(result.content[1].attrs.cellSdt).toBeUndefined(); + }); + + it('skips other legal row children silently (w:customXml, run-level markup)', () => { + const row = { + name: 'w:tr', + elements: [ + COMMENCEMENT_CELL, + { name: 'w:customXml', elements: [DATE_CELL] }, // not unwrapped in v1 + { name: 'w:bookmarkStart', attributes: { 'w:id': '1' } }, // run-level markup + ], + }; + const params = { nodes: [row], extraParams: { row, columnWidths: [163] } }; + + const result = translator.encode(params, {}); + + expect(tcTranslator.encode).toHaveBeenCalledTimes(1); + expect(result.content).toHaveLength(1); + }); + + it('does not crash when the SDT wrapper has no sdtContent', () => { + const row = { + name: 'w:tr', + elements: [{ name: 'w:sdt', elements: [SDT_PR] }], + }; + const params = { nodes: [row], extraParams: { row, columnWidths: [296] } }; + + expect(() => translator.encode(params, {})).not.toThrow(); + }); + }); + + describe('decode (export): re-wrap cells with cellSdt metadata', () => { + it('wraps a cell carrying cellSdt in ', () => { + const exportedTc = { name: 'w:tc', comment: 'cell xml' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([exportedTc]); + + const row = { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableCell', + attrs: { cellSdt: { scope: 'cell', sdtPr: SDT_PR, sdtEndPr: null } }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: row, extraParams: {} }, {}); + + expect(result.elements).toHaveLength(1); + const wrapped = result.elements[0]; + expect(wrapped.name).toBe('w:sdt'); + expect(wrapped.elements).toHaveLength(2); + expect(wrapped.elements[0]).toBe(SDT_PR); + expect(wrapped.elements[1]).toEqual({ name: 'w:sdtContent', elements: [exportedTc] }); + }); + + it('emits in the wrapper when preserved', () => { + const exportedTc = { name: 'w:tc', comment: 'cell xml' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([exportedTc]); + + const row = { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableCell', + attrs: { cellSdt: { scope: 'cell', sdtPr: SDT_PR, sdtEndPr: SDT_END_PR } }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: row, extraParams: {} }, {}); + + const wrapped = result.elements[0]; + expect(wrapped.elements).toHaveLength(3); + expect(wrapped.elements[0]).toBe(SDT_PR); + expect(wrapped.elements[1]).toBe(SDT_END_PR); + expect(wrapped.elements[2]).toEqual({ name: 'w:sdtContent', elements: [exportedTc] }); + }); + + it('does not wrap cells without cellSdt metadata', () => { + const tc1 = { name: 'w:tc', comment: 'first' }; + const tc2 = { name: 'w:tc', comment: 'second' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([tc1, tc2]); + + const row = { + type: 'tableRow', + attrs: {}, + content: [ + { type: 'tableCell', attrs: {}, content: [] }, + { type: 'tableCell', attrs: { cellSdt: null }, content: [] }, + ], + }; + + const result = translator.decode({ node: row, extraParams: {} }, {}); + + expect(result.elements).toEqual([tc1, tc2]); + }); + + it('wraps only the SDT cell when a row has mixed bare and SDT-wrapped cells', () => { + const bareTc = { name: 'w:tc', comment: 'bare' }; + const wrappedTc = { name: 'w:tc', comment: 'date cell xml' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([bareTc, wrappedTc]); + + const row = { + type: 'tableRow', + attrs: {}, + content: [ + { type: 'tableCell', attrs: {}, content: [] }, + { + type: 'tableCell', + attrs: { cellSdt: { scope: 'cell', sdtPr: SDT_PR, sdtEndPr: null } }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: row, extraParams: {} }, {}); + + expect(result.elements[0]).toBe(bareTc); + expect(result.elements[1].name).toBe('w:sdt'); + expect(result.elements[1].elements[1].elements[0]).toBe(wrappedTc); + }); + + it('ignores cellSdt with the wrong scope discriminator', () => { + const exportedTc = { name: 'w:tc', comment: 'cell xml' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([exportedTc]); + + const row = { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableCell', + // scope is 'row' (hypothetical future variant) — should not wrap as cell-level + attrs: { cellSdt: { scope: 'row', sdtPr: SDT_PR, sdtEndPr: null } }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: row, extraParams: {} }, {}); + + expect(result.elements).toEqual([exportedTc]); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js index e8c5d813bc..bfb967b014 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js @@ -8,6 +8,7 @@ import { translator as tcTranslator } from '../tc'; import { translator as tblBordersTranslator } from '../tblBorders'; import { translator as trPrTranslator } from '../trPr'; import { advancePastRowSpans, fillPlaceholderColumns, isPlaceholderCell } from './tr-helpers.js'; +import { normalizeRowCellChildren } from './row-cell-children.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:tr'; @@ -75,7 +76,7 @@ const encode = (params, encodedAttrs) => { const totalColumns = Array.isArray(gridColumnWidths) ? gridColumnWidths.length : 0; const pendingRowSpans = Array.isArray(activeRowSpans) ? activeRowSpans.slice() : []; while (pendingRowSpans.length < totalColumns) pendingRowSpans.push(0); - const cellNodes = row.elements.filter((el) => el.name === 'w:tc'); + const cellEntries = normalizeRowCellChildren(row); const content = []; let currentColumnIndex = 0; @@ -98,7 +99,7 @@ const encode = (params, encodedAttrs) => { fillUntil(safeGridBefore, 'gridBefore'); skipOccupiedColumns(); - cellNodes?.forEach((node) => { + cellEntries.forEach(({ node, cellSdt }) => { skipOccupiedColumns(); const startColumn = currentColumnIndex; @@ -122,6 +123,13 @@ const encode = (params, encodedAttrs) => { }, }); + // Attach cell-level SDT metadata after encode. The `NodeTranslator` wrapper + // ignores any second arg to `encode`, so we cannot piggyback `cellSdt` on + // the standard `encodedAttrs` channel; the cell loop owns this merge. + if (result && cellSdt) { + result.attrs = { ...(result.attrs || {}), cellSdt }; + } + if (result) { content.push(result); const colspan = Math.max(1, result.attrs?.colspan || 1); @@ -214,6 +222,26 @@ const decode = (params, decodedAttrs) => { const elements = translateChildNodes(translateParams); + // Re-wrap cells that were originally imported as cell-level SDT + // (ECMA-376 §17.5.2.32, CT_SdtCell). The decoder above emits a plain `` + // for each cell; we wrap it back in `` + // using the preserved `sdtPr` (and `sdtEndPr` if present) on the source cell. + // Done here (not inside `tc-translator.decode`) so callers checking + // `el.name === 'w:tc'` on the decoder result remain correct. + let cellCursor = 0; + for (let i = 0; i < elements.length; i += 1) { + const exportedEl = elements[i]; + if (!exportedEl || exportedEl.name !== 'w:tc') continue; + const sourceCell = trimmedContent[cellCursor]; + cellCursor += 1; + const cellSdt = sourceCell?.attrs?.cellSdt; + if (!cellSdt || cellSdt.scope !== 'cell' || !cellSdt.sdtPr) continue; + const sdtChildren = [cellSdt.sdtPr]; + if (cellSdt.sdtEndPr) sdtChildren.push(cellSdt.sdtEndPr); + sdtChildren.push({ name: 'w:sdtContent', elements: [exportedEl] }); + elements[i] = { name: 'w:sdt', elements: sdtChildren }; + } + if (node.attrs?.tableRowProperties) { const tableRowProperties = { ...node.attrs.tableRowProperties }; if (leadingPlaceholders > 0) { 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 3999d7ea40..c865363bed 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 @@ -249,6 +249,17 @@ export const TableCell = Node.create({ default: null, rendered: false, }, + + /** + * @private + * Cell-level structured document tag metadata (ECMA-376 §17.5.2.32, CT_SdtCell). + * Set when the source OOXML wrapped this cell in ``; reconstructed on export. + * Shape: `{ scope: 'cell', sdtPr, sdtEndPr }`. + */ + cellSdt: { + default: null, + rendered: false, + }, }; }, 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 329134e110..3ff7fdb997 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 @@ -164,6 +164,17 @@ export const TableHeader = Node.create({ rendered: false, }, + /** + * @private + * Cell-level structured document tag metadata (ECMA-376 §17.5.2.32, CT_SdtCell). + * Set when the source OOXML wrapped this cell in ``; reconstructed on export. + * Shape: `{ scope: 'cell', sdtPr, sdtEndPr }`. + */ + cellSdt: { + default: null, + rendered: false, + }, + __placeholder: { default: null, parseDOM: (element) => { diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index e791e51fda..0a16e250b5 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -366,6 +366,22 @@ export interface CellBackground { color: string; } +/** + * Cell-level structured document tag metadata, preserved on a `tableCell` when + * the source OOXML wrapped the cell in `` (ECMA-376 §17.5.2.32, CT_SdtCell). + * + * The wrapper is reconstructed on export. Cells carrying this metadata are not + * exposed through the content-controls Document API in v1. + */ +export interface CellSdtMetadata { + /** Discriminator for future SDT scope variants (row, block) on the same slot. */ + scope: 'cell'; + /** Raw `` element preserved from import for opaque round-trip. */ + sdtPr: unknown; + /** Raw `` element if present, otherwise null. */ + sdtEndPr: unknown | null; +} + /** Table cell node attributes */ export interface TableCellAttrs extends TableNodeAttributes { /** Legacy imported identity preserved for backwards compatibility */ @@ -396,6 +412,11 @@ export interface TableCellAttrs extends TableNodeAttributes { widthUnit: string; /** Placeholder key for temporary cells */ __placeholder: string | null; + /** + * Cell-level structured document tag metadata preserved from OOXML import + * when the source `` was wrapped in ``. Reconstructed on export. + */ + cellSdt?: CellSdtMetadata | null; } /** Table header cell attributes (same as TableCellAttrs) */ diff --git a/tests/doc-api-stories/tests/tables/cell-sdt-roundtrip.ts b/tests/doc-api-stories/tests/tables/cell-sdt-roundtrip.ts new file mode 100644 index 0000000000..29c05cce9a --- /dev/null +++ b/tests/doc-api-stories/tests/tables/cell-sdt-roundtrip.ts @@ -0,0 +1,202 @@ +import { execFile } from 'node:child_process'; +import { writeFile, readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +type JsZipConstructor = typeof import('jszip').default; + +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const REPO_ROOT = path.resolve(import.meta.dirname, '../../../..'); +const TEMPLATE_DOCX = path.join(REPO_ROOT, 'packages/super-editor/src/editors/v1/tests/data/basic-list.docx'); + +const DATE_TEXT = '18 January 2025'; +const LABEL_TEXT = 'COMMENCEMENT DATE'; +const DATE_FULL = '2025-01-18T00:00:00Z'; + +// Build a synthetic minimal document.xml carrying the IT-1119 OOXML shape: +// with a direct followed by a cell-level +// (ECMA-376 §17.5.2.32, CT_SdtCell) whose wraps a single . +// When `includeSdtEndPr` is true, the wrapper also carries +// (CT_SdtCell schema: sdtEndPr is 0..1 and Word emits it for end-marker +// formatting on some controls). +function buildSyntheticDocumentXml(options: { includeSdtEndPr: boolean }): string { + const sdtEndPrFragment = options.includeSdtEndPr ? '' : ''; + return ` + + + Cell-level SDT round-trip fixture for SD-3289 + + + + + + + + + + + + + ${LABEL_TEXT} + + + + + + + + + + + + ${sdtEndPrFragment} + + + + ${DATE_TEXT} + + + + + + + + + + +`; +} + +// Synthetic docProps/core.xml so the generated fixture carries no third-party +// metadata from the base template. Public-repo safety net. +const SYNTHETIC_CORE_XML = ` +SD-3289 cell-level SDT fixtureSuperDoc testsSuperDoc tests1`; + +let jsZipPromise: Promise | null = null; +async function loadJsZip(): Promise { + if (jsZipPromise) return jsZipPromise; + jsZipPromise = (async () => { + const entry = require.resolve('jszip', { paths: [path.join(REPO_ROOT, 'packages/super-editor')] }); + const mod = await import(pathToFileURL(entry).href); + return (mod.default ?? mod) as JsZipConstructor; + })(); + return jsZipPromise; +} + +async function buildFixture(outputPath: string, options: { includeSdtEndPr: boolean }): Promise { + const JSZip = await loadJsZip(); + const sourceBytes = await readFile(TEMPLATE_DOCX); + const zip = await JSZip.loadAsync(sourceBytes); + zip.file('word/document.xml', buildSyntheticDocumentXml(options)); + zip.file('docProps/core.xml', SYNTHETIC_CORE_XML); + const outputBytes = await zip.generateAsync({ type: 'nodebuffer' }); + await writeFile(outputPath, outputBytes); + return outputPath; +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +describe('document-api story: cell-level SDT round-trip (SD-3289 / IT-1119)', () => { + const { client, outPath } = useStoryHarness('tables/cell-sdt-roundtrip', { preserveResults: true }); + + it('imports cell-level SDT, finds the date text, and preserves the wrapper on export', async () => { + const fixturePath = outPath('cell-sdt-fixture.docx'); + await buildFixture(fixturePath, { includeSdtEndPr: false }); + + const sessionId = sid('cell-sdt-roundtrip'); + await client.doc.open({ sessionId, doc: fixturePath }); + + // 1. query.match finds the date text after open. Customer's primary symptom + // (IT-1119): before the fix, this returned 0 hits. + const matchResult = unwrap( + await client.doc.query.match({ + sessionId, + select: { type: 'text', pattern: DATE_TEXT, mode: 'contains', caseSensitive: false }, + require: 'any', + mode: 'strict', + limit: 5, + }), + ); + expect(matchResult?.total).toBeGreaterThanOrEqual(1); + + // 2. save/export succeeds. + const exportedPath = outPath('cell-sdt-roundtrip.docx'); + const saveResult = unwrap(await client.doc.save({ sessionId, out: exportedPath, force: true })); + expect(saveResult?.saved).toBe(true); + + // 3. Exported document.xml has the row shape with a bare + // followed by a wrapper (the SDT-wrapped cell). + const documentXml = await readDocxPart(exportedPath, 'word/document.xml'); + expect(documentXml).toMatch(/]*>[\s\S]*?]*>[\s\S]*?<\/w:tc>[\s\S]*? survives the round-trip. + expect(documentXml).toMatch( + /]*>[\s\S]*?]*>[\s\S]*?]*\bw:fullDate="2025-01-18T00:00:00Z"/, + ); + + // 5. The inner inside contains the date text exactly once. + const sdtBlockMatch = documentXml.match(//); + expect(sdtBlockMatch).not.toBeNull(); + const wrappedTcMatch = sdtBlockMatch![0].match( + /]*>[\s\S]*?[\s\S]*?<\/w:sdtContent>/, + ); + expect(wrappedTcMatch).not.toBeNull(); + const dateOccurrencesInWrappedCell = wrappedTcMatch![0].split(DATE_TEXT).length - 1; + expect(dateOccurrencesInWrappedCell).toBe(1); + }); + + it('preserves every sdtPr child (id, full date subtree, sdtEndPr) on round-trip', async () => { + const fixturePath = outPath('cell-sdt-fixture-full-fidelity.docx'); + await buildFixture(fixturePath, { includeSdtEndPr: true }); + + const sessionId = sid('cell-sdt-full-fidelity'); + await client.doc.open({ sessionId, doc: fixturePath }); + + const exportedPath = outPath('cell-sdt-roundtrip-full-fidelity.docx'); + const saveResult = unwrap(await client.doc.save({ sessionId, out: exportedPath, force: true })); + expect(saveResult?.saved).toBe(true); + + const documentXml = await readDocxPart(exportedPath, 'word/document.xml'); + const sdtBlockMatch = documentXml.match(//); + expect(sdtBlockMatch).not.toBeNull(); + const sdtBlock = sdtBlockMatch![0]; + + // Every sdtPr child from the source fixture must survive byte-equivalently + // in the exported wrapper. Opaque sdtPr preservation is what the v1 fix + // promises; this guards against any narrowing of that contract. + expect(sdtBlock).toMatch(/]*\bw:val="849213029"/); + expect(sdtBlock).toMatch(/]*\bw:fullDate="2025-01-18T00:00:00Z"/); + expect(sdtBlock).toMatch(/]*\bw:val="d MMMM yyyy"/); + expect(sdtBlock).toMatch(/]*\bw:val="en-AU"/); + expect(sdtBlock).toMatch(/]*\bw:val="dateTime"/); + expect(sdtBlock).toMatch(/]*\bw:val="gregorian"/); + + // sdtEndPr from the source fixture must also survive (CT_SdtCell allows + // 0..1 sdtEndPr; the fix preserves it opaquely). + expect(sdtBlock).toMatch(/'); + const sdtEndPrStart = sdtBlock.indexOf('