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"/); + } + }); +});