From 275a77564ef891ca5fb28d7060d633bdf3b0aea3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 6 Aug 2026 19:26:38 +0000 Subject: [PATCH] fix: use Word's asymmetric cell padding for tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cells padded 0.1cm on all sides; Word and LibreOffice use 0.19cm left and right and none top or bottom. Text sat 0.7mm too far left and every row was 2mm too tall, so the offset accumulated down a table. odf-kit only writes the fo:padding shorthand, which ODF defines as a single length — LibreOffice drops a two-value form — so the export expands it per side. --- src/lib/export/odt.ts | 26 +++++++++++++++++++-- src/styles/editor.css | 5 +++-- tests/unit/cell-padding.test.ts | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 tests/unit/cell-padding.test.ts diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 91803a3..3f29ea5 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -38,7 +38,10 @@ const CUST_HF = '__cust_hf__'; // Table export styling. Values mirror the editor's table CSS (src/styles/editor.css) // so the .odt matches the on-screen preview. const TABLE_BORDER = '0.5pt solid #000000'; -const CELL_PADDING = '0.1cm'; // must match td/th padding in editor.css +// Word's/LibreOffice's cell margins, asymmetric. Must match td/th padding in editor.css. +const CELL_PADDING_X = '0.19cm'; +const CELL_PADDING_Y = '0cm'; +const CELL_PADDING = CELL_PADDING_X; // odf-kit emits this as the document's default font (Standard style). The editor // renders the bundled, metric-identical Liberation Serif on screen. @@ -739,6 +742,25 @@ function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): return rezipOdt(files); } +// odf-kit only writes the fo:padding shorthand, which ODF defines as one length — +// LibreOffice drops a two-value form. Cells need Word's asymmetric margins, so the +// shorthand is expanded per side (scoped to cell properties; paragraphs use it too). +function expandCellPadding(odtBytes: Uint8Array): Uint8Array { + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + const perSide = `fo:padding-left="${CELL_PADDING_X}" fo:padding-right="${CELL_PADDING_X}"` + + ` fo:padding-top="${CELL_PADDING_Y}" fo:padding-bottom="${CELL_PADDING_Y}"`; + const content = strFromU8(contentBytes).replace( + /(]*?)fo:padding="[^"]*"/g, + (_m, head: string) => head + perSide, + ); + + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + // A dragged table edge → the table style's fo:margin-left/-right + style:width (with // odf-kit's table:align="margins" the table fills exactly what's left). odf-kit names // table styles Table1, Table2, … in document order, matching the descriptor list. @@ -2856,7 +2878,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const rowHeights: (string | null)[] = []; collectTableRowHeights(raw, rowHeights); - const styledRows = applyTableRowHeights(styledCells, rowHeights); + const styledRows = applyTableRowHeights(expandCellPadding(styledCells), rowHeights); const withTableMargins = applyTableStyleNames( applyTableMargins(styledRows, tableMargins, contentWidthCm), tableStyleNames); diff --git a/src/styles/editor.css b/src/styles/editor.css index 4b6a168..ec428dd 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -460,7 +460,8 @@ and the exported .odt look identical (see export/odt.ts CUST_TABLE handler): • border → export emits 0.5pt solid #000000; 1px solid renders crisply on screen and is the visual equivalent of a thin 0.5pt line. - • padding → export emits fo:padding 0.1cm per cell (CELL_PADDING). + • padding → Word's/LibreOffice's cell margins; export emits them per side + (CELL_PADDING_X/_Y, expanded by expandCellPadding). Tables are wrapped in
by TipTap's node view. */ .paper .tiptap .tableWrapper { /* Default is flush with the text area; a dragged outer edge sets an inline @@ -482,7 +483,7 @@ .paper .tiptap td, .paper .tiptap th { border: 1px solid #000; - padding: 0.1cm; + padding: 0 0.19cm; vertical-align: top; /* Positioning context for the column-resize handle widget (a child div the resize plugin places at the cell's right edge). */ diff --git a/tests/unit/cell-padding.test.ts b/tests/unit/cell-padding.test.ts new file mode 100644 index 0000000..6dfb514 --- /dev/null +++ b/tests/unit/cell-padding.test.ts @@ -0,0 +1,40 @@ +// 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'; + +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(/]*>/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"/); + } + }); +});