(style:width, fo:margin-*, table:align).
+ tableProps(styleName: string | null): PropMap {
+ return styleName ? this.merged('table', styleName).misc : {};
+ }
+
columnWidthCm(styleName: string | null): number | null {
if (!styleName) return null;
return lengthToCm(this.merged('table-column', styleName).misc['style:column-width']);
diff --git a/src/styles/editor.css b/src/styles/editor.css
index f02687b..bef96be 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -459,7 +459,8 @@
• padding → export emits fo:padding 0.1cm per cell (CELL_PADDING).
Tables are wrapped in by TipTap's node view. */
.paper .tiptap .tableWrapper {
- /* The exported table style carries no margin (nor do Word/LO tables). */
+ /* Default is flush with the text area; a dragged outer edge sets an inline
+ margin-left/-right here (tableView.ts), which is what the export writes. */
margin: 0;
overflow-x: auto;
}
@@ -533,6 +534,12 @@
pointer-events: none;
}
+/* The table's outer left edge: same handle, mirrored to the cell's left side. */
+.paper .tiptap .column-resize-handle.left {
+ left: -2px;
+ right: auto;
+}
+
.paper .tiptap .column-resize-dragging {
/* Suppress text-selection flicker while dragging a column border. */
user-select: none;
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index d2cf3bf..ad631c2 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -999,3 +999,29 @@ describe('DOCX named character styles', () => {
expect(res.styles.character['Signal'].text).toEqual({ bold: true, color: '#CC0000' });
});
});
+
+describe('DOCX table margins (dragged outer edges)', () => {
+ // Default Word geometry here: 21 - 2.12 - 2.12 = 16.76cm of text width, so the
+ // table is 16.76 - 2 - 3 = 11.76cm wide and indented 2cm.
+ const fixture = {
+ type: 'doc',
+ content: [
+ { type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [
+ { type: 'tableRow', content: [cell('A', { colwidth: [200] }), cell('B', { colwidth: [100] })] },
+ ] },
+ ],
+ } as any;
+
+ it('writes w:tblInd + the narrower grid and round-trips the margins', async () => {
+ const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait');
+ const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
+ expect(xml).toContain(``);
+
+ const res = await importDocx(bytes);
+ const table = walk(res.content as N, 'table')[0];
+ expect(table.attrs.marginLeft).toBeCloseTo(2, 1);
+ expect(table.attrs.marginRight).toBeCloseTo(3, 1);
+ const weights = (table.content?.[0].content ?? []).map((c: N) => c.attrs.colwidth[0]);
+ expect(weights[0] / weights[1]).toBeCloseTo(2, 2);
+ });
+});
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index 4cd18ae..f6bc72e 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -484,6 +484,43 @@ describe('Leg 1d: table cell borders (per-side, fo:border-*)', () => {
});
});
+describe('Leg 1e: table margins (dragged outer edges)', () => {
+ // Text width here is 21 - 2.5 - 1.5 = 17cm, so the table is 17 - 2 - 3 = 12cm wide.
+ const doc: N = { type: 'doc', content: [
+ { type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [
+ ROW(CELL([200], P(null, T('A'))), CELL([100], P(null, T('B')))),
+ ] },
+ ] };
+
+ it('exports fo:margin-* + style:width and re-imports the attrs', async () => {
+ const bytes = await buildOdt(doc, margins, 'portrait');
+ const xml = strFromU8(unzipSync(bytes)['content.xml']);
+ const tableStyle = xml.match(/]*style:family="table"[^>]*>\s*]*>/)?.[0] ?? '';
+ check('export emits the left margin', tableStyle.includes('fo:margin-left="2cm"'), tableStyle);
+ check('export emits the right margin', tableStyle.includes('fo:margin-right="3cm"'), tableStyle);
+ check('export emits the remaining width', tableStyle.includes('style:width="12cm"'), tableStyle);
+ // Columns keep their 2:1 ratio inside the narrowed table.
+ check('columns fill the narrowed table', xml.includes('style:column-width="8cm"') && xml.includes('style:column-width="4cm"'), xml.match(/style:column-width="[^"]*"/g));
+
+ const res = importOdt(bytes);
+ check('no warnings on own export', res.warnings.length === 0, res.warnings);
+ const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
+ check('left margin round-trips', table?.attrs?.marginLeft === 2, table?.attrs);
+ check('right margin round-trips', table?.attrs?.marginRight === 3, table?.attrs);
+ const weights = (table?.content?.[0]?.content ?? []).map((c: N) => c.attrs?.colwidth?.[0]);
+ check('column ratio survives', weights[0] === 2 * weights[1], weights);
+ });
+
+ it('leaves a full-width table without margin attrs', async () => {
+ const plain: N = { type: 'doc', content: [
+ { type: 'table', content: [ROW(CELL([100], P(null, T('A'))), CELL([100], P(null, T('B'))))] },
+ ] };
+ const res = importOdt(await buildOdt(plain, margins, 'portrait'));
+ const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
+ check('no margins on a full-width table', !table?.attrs?.marginLeft && !table?.attrs?.marginRight, table?.attrs);
+ });
+});
+
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
const stylesXml = `
diff --git a/tests/unit/table-edge-resize.test.ts b/tests/unit/table-edge-resize.test.ts
new file mode 100644
index 0000000..146bd1e
--- /dev/null
+++ b/tests/unit/table-edge-resize.test.ts
@@ -0,0 +1,79 @@
+// The outer-edge drag math (tableColumnResize.ts): Word semantics — only the adjacent
+// column gives up width, every other gridline stays put, and the edge stops at the
+// text margin / the column minimum.
+import { describe, it, expect } from 'vitest';
+import { generateHTML } from '@tiptap/core';
+import Document from '@tiptap/extension-document';
+import Paragraph from '@tiptap/extension-paragraph';
+import Text from '@tiptap/extension-text';
+import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
+import { edgeResize, TableColumnResize } from '../../src/lib/editor/extensions/tableColumnResize';
+
+type Result = { marginLeftPx: number; marginRightPx: number; percents: number[] };
+
+// A 3-column table at 300/200/100 px.
+const PERCENTS = [50, 100 / 3, 100 / 6];
+
+// Resulting column widths in px: the table width changes by whatever the margins gave up.
+const cols = (r: Result, tableWidth: number, ml = 0, mr = 0): number[] => {
+ const width = tableWidth - (r.marginLeftPx - ml) - (r.marginRightPx - mr);
+ return r.percents.map((p) => (p / 100) * width);
+};
+
+describe('edgeResize', () => {
+ it('left edge: only column 1 shrinks, the others keep their width', () => {
+ const r = edgeResize(PERCENTS, 600, 0, 0, 'left', 100);
+ expect(r.marginLeftPx).toBe(100);
+ expect(r.marginRightPx).toBe(0);
+ const [a, b, c] = cols(r, 600);
+ expect(a).toBeCloseTo(200);
+ expect(b).toBeCloseTo(200);
+ expect(c).toBeCloseTo(100);
+ });
+
+ it('right edge: only the last column grows, reclaiming its own margin', () => {
+ const r = edgeResize(PERCENTS, 450, 0, 150, 'right', 100);
+ expect(r.marginRightPx).toBe(50);
+ const [a, b, c] = cols(r, 450, 0, 150);
+ expect(a).toBeCloseTo(225);
+ expect(b).toBeCloseTo(150);
+ expect(c).toBeCloseTo(175);
+ });
+
+ it('stops at the text margin and never goes negative', () => {
+ const r = edgeResize(PERCENTS, 600, 0, 0, 'right', 100); // no right margin to reclaim
+ expect(r.marginRightPx).toBe(0);
+ expect(r.percents).toEqual(PERCENTS);
+ });
+
+ it('keeps the adjacent column above the minimum width', () => {
+ const r = edgeResize(PERCENTS, 600, 0, 0, 'left', 500); // column 1 is only 300px
+ expect(r.marginLeftPx).toBe(270); // 300 - CELL_MIN_PX
+ expect(cols(r, 600)[0]).toBeCloseTo(30);
+ });
+});
+
+// The static HTML path (generateHTML) feeds the PDF export, which has no node view.
+describe('table margins in rendered HTML', () => {
+ const exts = [Document, Paragraph, Text, Table, TableRow, TableCell, TableHeader, TableColumnResize];
+ const table = (attrs: Record) => ({
+ type: 'doc',
+ content: [{ type: 'table', attrs, content: [
+ { type: 'tableRow', content: [{ type: 'tableCell', content: [{ type: 'paragraph' }] }] },
+ ] }],
+ });
+
+ it('renders the margins and the matching width', () => {
+ const html = generateHTML(table({ marginLeft: 2, marginRight: 3 }), exts);
+ expect(html).toContain('margin-left: 2cm');
+ expect(html).toContain('margin-right: 3cm');
+ // jsdom's CSS parser normalizes absolute lengths in `width` to px (browsers don't).
+ const [, value, unit] = html.match(/width: calc\(100% - ([\d.]+)(cm|px)\)/) ?? [];
+ expect(unit).toBeTruthy();
+ expect(unit === 'px' ? Number(value) / (96 / 2.54) : Number(value)).toBeCloseTo(5, 3);
+ });
+
+ it('leaves a full-width table unstyled', () => {
+ expect(generateHTML(table({ marginLeft: 0, marginRight: 0 }), exts)).not.toContain('margin-left');
+ });
+});