Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 `<w:sdt><w:sdtContent><w:tc/></w:sdtContent></w:sdt>`
// 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <w:tc> child of <w:tr>.
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] };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @ts-check

/**
* Normalize a `<w:tr>` element's children into the cell stream the row encoder
* iterates. Direct `<w:tc>` children pass through unchanged. A cell-level
* `<w:sdt>` (ECMA-376 §17.5.2.32, CT_SdtCell) is unwrapped: its inner `<w:tc>`
* 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 `<w:sdt>` 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;
};
Original file line number Diff line number Diff line change
@@ -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 `<w:tbl>` containing `<w:tr><w:sdt><w:sdtContent><w:tc/></w:sdtContent></w:sdt></w:tr>`
* through the real v3 table importer (no translator mocks), then exports the
* resulting PM tree via the production `exportSchemaToJson` router, and asserts
* the `<w:sdt>` 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 <w:tc> (the label cell) AND a <w:sdt> 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 <w:tc> 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 <w:tc>
// 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);
});
});
Loading
Loading