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('