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
Expand Up @@ -6,6 +6,7 @@ import { marginLeftTranslator } from '../left/index.js';
import { marginRightTranslator } from '../right/index.js';
import { marginStartTranslator } from '../start/index.js';
import { marginTopTranslator } from '../top/index.js';
import { CT_TC_MAR_CHILD_ORDER } from '../tcMar/tcMar-translator.js';

const propertyTranslators = [
marginBottomTranslator,
Expand All @@ -16,6 +17,23 @@ const propertyTranslators = [
marginTopTranslator,
];

export const translator = NodeTranslator.from(
createNestedPropertiesTranslator('w:tblCellMar', 'cellMargins', propertyTranslators),
);
// CT_TblCellMar has identical child sequence to CT_TcMar per ECMA-376 §A.1.
const baseConfig = createNestedPropertiesTranslator('w:tblCellMar', 'cellMargins', propertyTranslators);

const orderedConfig = {
...baseConfig,
decode: function (params) {
const result = baseConfig.decode.call(this, params);
if (!result || !Array.isArray(result.elements)) return result;
const rank = (name) => {
const i = CT_TC_MAR_CHILD_ORDER.indexOf(name);
return i === -1 ? CT_TC_MAR_CHILD_ORDER.length : i;
};
return {
...result,
elements: [...result.elements].sort((a, b) => rank(a.name) - rank(b.name)),
};
},
};

export const translator = NodeTranslator.from(orderedConfig);
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,16 @@ describe('w:tblCellMar translator', () => {
});

describe('decode', () => {
it('decodes a cellMargins object by calling its property translators', () => {
it('decodes a cellMargins object in CT_TblCellMar sequence order', () => {
// CT_TblCellMar has identical sequence to CT_TcMar per ECMA-376 §A.1:
// top, start, left, bottom, end, right. Insertion order is scrambled
// here to prove the decoder sorts rather than emitting in attr order.
const params = {
node: {
attrs: {
cellMargins: {
marginTop: { value: 100 },
marginRight: { value: 120 },
marginTop: { value: 100 },
marginBottom: { value: 140 },
},
},
Expand All @@ -118,7 +121,29 @@ describe('w:tblCellMar translator', () => {
const result = translator.decode(params);

expect(result.name).toBe('w:tblCellMar');
expect(result.elements).toEqual([{ name: 'w:top' }, { name: 'w:right' }, { name: 'w:bottom' }]);
// Sequence: top (0), bottom (3), right (5). Position-by-position.
expect(result.elements).toEqual([{ name: 'w:top' }, { name: 'w:bottom' }, { name: 'w:right' }]);
});

it('emits all six children in CT_TblCellMar sequence order when present', () => {
const params = {
node: {
attrs: {
cellMargins: {
marginEnd: { value: 60 },
marginRight: { value: 70 },
marginBottom: { value: 40 },
marginLeft: { value: 30 },
marginStart: { value: 20 },
marginTop: { value: 10 },
},
},
},
};

const result = translator.decode(params);
const names = result.elements.map((el) => el.name);
expect(names).toEqual(['w:top', 'w:start', 'w:left', 'w:bottom', 'w:end', 'w:right']);
});

it('returns undefined for an empty cellMargins object', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,4 +686,115 @@ describe('legacy-handle-table-cell-node', () => {
expect(blockNode.type).toBe('customBlock');
expect(blockNode.content?.[0]).toEqual(bookmarkStart);
});

// SuperDoc exposes two views of cell margins by design:
// 1) attrs.tableCellProperties.cellMargins — raw OOXML-shaped, preserves
// the source key family (marginStart/marginEnd OR marginLeft/marginRight).
// 2) attrs.cellMargins — LTR-default physical-only {top, bottom, left, right},
// consumed by pm-adapter/painter, mirrored at paint time for RTL.
// These tests lock that contract so a future change can't quietly collapse
// the dual view (e.g. by promoting attrs.cellMargins to a polymorphic shape).
describe('cellMargins dual-view contract', () => {
it('logical-only w:tcMar: source shape preserved on tableCellProperties.cellMargins; attrs.cellMargins is physical', () => {
const cellNode = {
name: 'w:tc',
elements: [
{
name: 'w:tcPr',
elements: [
{
name: 'w:tcMar',
elements: [
{ name: 'w:top', attributes: { 'w:w': '120', 'w:type': 'dxa' } },
{ name: 'w:start', attributes: { 'w:w': '480', 'w:type': 'dxa' } },
{ name: 'w:bottom', attributes: { 'w:w': '120', 'w:type': 'dxa' } },
{ name: 'w:end', attributes: { 'w:w': '60', 'w:type': 'dxa' } },
],
},
],
},
{ name: 'w:p' },
],
};
const row = { name: 'w:tr', elements: [cellNode] };
const table = { name: 'w:tbl', elements: [row] };

const out = handleTableCellNode({
params: { docx: {}, nodeListHandler: { handler: vi.fn(() => []) }, path: [], editor: createEditorStub() },
node: cellNode,
table,
row,
columnIndex: 0,
columnWidth: null,
allColumnWidths: [],
_referencedStyles: null,
});

// View 1: raw OOXML shape preserved.
const tcProps = out.attrs.tableCellProperties.cellMargins;
expect(tcProps).toMatchObject({
marginTop: { value: 120, type: 'dxa' },
marginStart: { value: 480, type: 'dxa' },
marginBottom: { value: 120, type: 'dxa' },
marginEnd: { value: 60, type: 'dxa' },
});
expect(tcProps.marginLeft).toBeUndefined();
expect(tcProps.marginRight).toBeUndefined();

// View 2: LTR-default physical projection only. start→left, end→right.
// No logical aliases on attrs.cellMargins (would re-introduce the
// dual-shape divergence SD-3134 removed).
expect(Object.keys(out.attrs.cellMargins).sort()).toEqual(['bottom', 'left', 'right', 'top']);
expect(out.attrs.cellMargins.marginStart).toBeUndefined();
expect(out.attrs.cellMargins.marginEnd).toBeUndefined();
});

it('physical-only w:tcMar: source shape preserved; attrs.cellMargins is physical', () => {
const cellNode = {
name: 'w:tc',
elements: [
{
name: 'w:tcPr',
elements: [
{
name: 'w:tcMar',
elements: [
{ name: 'w:top', attributes: { 'w:w': '120', 'w:type': 'dxa' } },
{ name: 'w:left', attributes: { 'w:w': '480', 'w:type': 'dxa' } },
{ name: 'w:bottom', attributes: { 'w:w': '120', 'w:type': 'dxa' } },
{ name: 'w:right', attributes: { 'w:w': '60', 'w:type': 'dxa' } },
],
},
],
},
{ name: 'w:p' },
],
};
const row = { name: 'w:tr', elements: [cellNode] };
const table = { name: 'w:tbl', elements: [row] };

const out = handleTableCellNode({
params: { docx: {}, nodeListHandler: { handler: vi.fn(() => []) }, path: [], editor: createEditorStub() },
node: cellNode,
table,
row,
columnIndex: 0,
columnWidth: null,
allColumnWidths: [],
_referencedStyles: null,
});

const tcProps = out.attrs.tableCellProperties.cellMargins;
expect(tcProps).toMatchObject({
marginTop: { value: 120, type: 'dxa' },
marginLeft: { value: 480, type: 'dxa' },
marginBottom: { value: 120, type: 'dxa' },
marginRight: { value: 60, type: 'dxa' },
});
expect(tcProps.marginStart).toBeUndefined();
expect(tcProps.marginEnd).toBeUndefined();

expect(Object.keys(out.attrs.cellMargins).sort()).toEqual(['bottom', 'left', 'right', 'top']);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Fixture-backed end-to-end round-trip test.
// Loads a real Word-authored docx containing every CT_TcMar / CT_TblCellMar
// sibling pair under test, runs the v3 translators + generateTableCellProperties,
// and asserts the imported and re-exported shapes preserve the source key
// family per side (logical-only stays logical, physical-only stays physical)
// and respect the schema sequence order.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { xml2js } from 'xml-js';

import { translator as tcMarTranslator } from '../../tcMar/index.js';
import { translator as tblCellMarTranslator } from '../../tblCellMar/index.js';
import { generateTableCellProperties } from './translate-table-cell.js';
import { twipsToPixels } from '@converter/helpers.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.resolve(
__dirname,
'../../../../../../../../../../../../tests/behavior/tests/tables/fixtures/sd-3152-tcmar-key-family.docx',
);

const findTblCellMar = (container) => container?.elements?.find((e) => e.name === 'w:tblCellMar');

describe('w:tcMar / w:tblCellMar fixture round-trip', () => {
it('preserves logical and physical key families through import + export', async () => {
const buf = fs.readFileSync(FIXTURE);
const zip = await JSZip.loadAsync(buf);
const docXml = await zip.file('word/document.xml').async('string');
const parsed = xml2js(docXml, { compact: false });

const doc = parsed.elements.find((e) => e.name === 'w:document');
const body = doc.elements.find((e) => e.name === 'w:body');
const tbl = body.elements.find((e) => e.name === 'w:tbl');
const tblPr = tbl.elements.find((e) => e.name === 'w:tblPr');
const rows = tbl.elements.filter((e) => e.name === 'w:tr');
// tblCellMar may sit in tblPr (§17.4.42) or be moved to tblPrEx (§17.4.41)
// by Word repair on save. Both share CT_TblCellMar and the same translator.
const tblPrEx = rows[0].elements.find((e) => e.name === 'w:tblPrEx');
const tblCellMar = findTblCellMar(tblPr) ?? findTblCellMar(tblPrEx);
expect(tblCellMar).toBeTruthy();

const cells = rows[0].elements.filter((e) => e.name === 'w:tc');
const tcMar1 = cells[0].elements.find((e) => e.name === 'w:tcPr').elements.find((e) => e.name === 'w:tcMar');
const tcMar2 = cells[1].elements.find((e) => e.name === 'w:tcPr').elements.find((e) => e.name === 'w:tcMar');

// --- IMPORT side ---
const tblMargins = tblCellMarTranslator.encode({ nodes: [tblCellMar] });
const cell1Margins = tcMarTranslator.encode({ nodes: [tcMar1] });
const cell2Margins = tcMarTranslator.encode({ nodes: [tcMar2] });

// tblCellMar in fixture: logical-only.
expect(tblMargins).toMatchObject({
marginTop: { value: 120, type: 'dxa' },
marginStart: { value: 240, type: 'dxa' },
marginBottom: { value: 120, type: 'dxa' },
marginEnd: { value: 180, type: 'dxa' },
});
expect(tblMargins.marginLeft).toBeUndefined();
expect(tblMargins.marginRight).toBeUndefined();

// Cell 1 tcMar: logical-only.
expect(cell1Margins).toMatchObject({
marginTop: { value: 120, type: 'dxa' },
marginStart: { value: 480, type: 'dxa' },
marginBottom: { value: 120, type: 'dxa' },
marginEnd: { value: 60, type: 'dxa' },
});
expect(cell1Margins.marginLeft).toBeUndefined();
expect(cell1Margins.marginRight).toBeUndefined();

// Cell 2 tcMar: physical-only.
expect(cell2Margins).toMatchObject({
marginTop: { value: 120, type: 'dxa' },
marginLeft: { value: 480, type: 'dxa' },
marginBottom: { value: 120, type: 'dxa' },
marginRight: { value: 60, type: 'dxa' },
});
expect(cell2Margins.marginStart).toBeUndefined();
expect(cell2Margins.marginEnd).toBeUndefined();

// --- EXPORT side ---
// Mirror what legacy-handle-table-cell-node.js produces for attrs.cellMargins
// (LTR-default physical, painter mirrors for RTL).
const cell1Node = {
attrs: {
colwidth: [50],
widthUnit: 'px',
tableCellProperties: { cellMargins: cell1Margins },
tableCellPropertiesInlineKeys: ['cellMargins'],
cellMargins: {
top: twipsToPixels(120),
bottom: twipsToPixels(120),
left: twipsToPixels(480),
right: twipsToPixels(60),
},
},
};
const cell2Node = {
attrs: {
colwidth: [50],
widthUnit: 'px',
tableCellProperties: { cellMargins: cell2Margins },
tableCellPropertiesInlineKeys: ['cellMargins'],
cellMargins: {
top: twipsToPixels(120),
bottom: twipsToPixels(120),
left: twipsToPixels(480),
right: twipsToPixels(60),
},
},
};
const tcPr1 = generateTableCellProperties(cell1Node);
const tcPr2 = generateTableCellProperties(cell2Node);
const marNames = (tcPr) => {
const mar = tcPr.elements.find((e) => e.name === 'w:tcMar');
return mar.elements.map((e) => e.name);
};
// Logical-only export stays logical-only, in CT_TcMar sequence.
expect(marNames(tcPr1)).toEqual(['w:top', 'w:start', 'w:bottom', 'w:end']);
// Physical-only export stays physical-only, in CT_TcMar sequence.
expect(marNames(tcPr2)).toEqual(['w:top', 'w:left', 'w:bottom', 'w:right']);

// tblCellMar decode emits in CT_TblCellMar sequence.
const tblOut = tblCellMarTranslator.decode({ node: { attrs: { cellMargins: tblMargins } } });
expect(tblOut.elements.map((e) => e.name)).toEqual(['w:top', 'w:start', 'w:bottom', 'w:end']);
});
});
Loading
Loading