Files
edentext/tests/unit/cell-padding.test.ts
Steffen Becker ca3767f3d4 feat: a cell keeps its own margins where they differ from the table's
w:tcMar and an ODF cell style's own fo:padding were both dropped: the
table's value (in ODF the first cell's) stood for every cell.

cellPadding is now an attr on tableCell/tableHeader too, measured against
the table's rather than the editor default, so only a real disagreement
carries one. --cell-pad inherits, so the cell's own declaration wins for
its own td and the CSS is unchanged.

Checked against LibreOffice: a 1.2cm cell in a 0.3cm table puts its text
at 332.2pt where the grid column starts at 297.6.
2026-08-08 17:47:06 +00:00

97 lines
4.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// odf-kit writes the fo:padding shorthand, which LibreOffice drops when given the
// two-value form Word's asymmetric cell margins need — so the export expands it per
// side. jsdom (vitest `environment`) supplies the global DOMParser.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { importOdt } from '../../src/lib/import/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importDocx } from '../../src/lib/import/docx';
import { cellPaddingAttr } from '../../src/lib/editor/extensions/tableCellPadding';
type N = any;
const P = (text: string): N => ({ type: 'paragraph', content: [{ type: 'text', text }] });
const cell = (text: string): N =>
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [P(text)] });
const doc: N = {
type: 'doc',
content: [
P('Before'),
{ type: 'table', content: [
{ type: 'tableRow', content: [cell('A1'), cell('B1')] },
{ type: 'tableRow', content: [cell('A2'), cell('B2')] },
] },
],
};
describe('table cell padding', () => {
it('exports per-side padding, never the shorthand', async () => {
const bytes = await buildOdt(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
const cellProps = xml.match(/<style:table-cell-properties\b[^>]*>/g) ?? [];
expect(cellProps.length).toBeGreaterThan(0);
for (const props of cellProps) {
expect(props).not.toMatch(/fo:padding="/);
expect(props).toMatch(/fo:padding-left="0\.19cm"/);
expect(props).toMatch(/fo:padding-right="0\.19cm"/);
expect(props).toMatch(/fo:padding-top="0cm"/);
expect(props).toMatch(/fo:padding-bottom="0cm"/);
}
});
it('collapses a producer-rounded default, keeps a real value', () => {
// Word's 108 twips and LibreOffice's 0.191cm are both our 0.19cm default.
expect(cellPaddingAttr([0, 0.1905, 0, 0.191])).toBeNull();
expect(cellPaddingAttr([0.026, 0.026, 0.026, 0.026])).toEqual([0.026, 0.026, 0.026, 0.026]);
// A cell measures against its table's, so the editor default is an override there
// and its own table's value is not.
expect(cellPaddingAttr([0, 0.19, 0, 0.19], [0.3, 0.3, 0.3, 0.3])).toEqual([0, 0.19, 0, 0.19]);
expect(cellPaddingAttr([0.3, 0.3, 0.3, 0.3], [0.3, 0.3, 0.3, 0.3])).toBeNull();
// An undeclared side falls back to the table's, not the editor default.
expect(cellPaddingAttr([0.1, null, null, null], [0.3, 0.3, 0.3, 0.3])).toEqual([0.1, 0.3, 0.3, 0.3]);
});
it('round-trips one cell’s own margins against the table’s', async () => {
const mixed: N = structuredClone(doc);
mixed.content[1].attrs = { cellPadding: [0.3, 0.3, 0.3, 0.3] };
mixed.content[1].content[0].content[1].attrs.cellPadding = [0.02, 0.6, 0.02, 0.6];
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const cellOf = (d: N, row: number, col: number) =>
(d.content!.find((n: N) => n.type === 'table') as N).content[row].content[col];
const odt = await importOdt(await buildOdt(mixed, margins, 'portrait'));
expect(cellOf(odt.content, 0, 0).attrs?.cellPadding ?? null).toBeNull();
expect(cellOf(odt.content, 0, 1).attrs?.cellPadding).toEqual([0.02, 0.6, 0.02, 0.6]);
const docx = importDocx(await buildDocx(mixed, margins, 'portrait'));
expect(cellOf(docx.content, 0, 0).attrs?.cellPadding ?? null).toBeNull();
for (const [i, cm] of [0.02, 0.6, 0.02, 0.6].entries()) {
expect(cellOf(docx.content, 0, 1).attrs?.cellPadding[i]).toBeCloseTo(cm, 2);
}
});
it('round-trips a table’s own cell margins', async () => {
const tight: N = structuredClone(doc);
tight.content[1].attrs = { cellPadding: [0.03, 0.05, 0.03, 0.05] };
const bytes = await buildOdt(tight, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
expect(xml).toMatch(/fo:padding-left="0\.05cm"/);
expect(xml).toMatch(/fo:padding-top="0\.03cm"/);
const back = await importOdt(bytes);
const table = back.content.content!.find((n: N) => n.type === 'table') as N;
expect(table.attrs?.cellPadding).toEqual([0.03, 0.05, 0.03, 0.05]);
const docx = await buildDocx(tight, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait');
const backDocx = importDocx(docx);
const docxTable = backDocx.content.content!.find((n: N) => n.type === 'table') as N;
// Word measures in twips, so 0.05cm comes back a twentieth of a point off.
for (const [i, cm] of [0.03, 0.05, 0.03, 0.05].entries()) {
expect(docxTable.attrs?.cellPadding[i]).toBeCloseTo(cm, 2);
}
});
});