feat: a table keeps the cell margins its file gave it

The cell padding was a constant in three places that disagreed with each
other, so a narrow column lost 0.38cm it did not have: a bibliography
marker column of 0.71cm wrapped "[2] " onto three lines and every entry
grew with it, costing a fixture a whole page.

The table now carries its own margins (w:tblCellMar, direct or from the
table style; fo:padding on the ODF cell style) and both exports write
them back. A side within a twentieth of a millimetre of the default is
the default, or Word's 108 twips would tag every table.
This commit is contained in:
Steffen Becker committed 2026-08-08 12:18:16 +00:00
1 parent e4b679ad8b
commit db0fe71cdb
11 files changed
+174 -16

No files matched your search

+3 -1
View File
@@ -20,6 +20,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops';
import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
@@ -805,6 +806,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0);
let mr = Math.max(0, Number(node.attrs?.marginRight) || 0);
if (ml + mr > contentWidthCm - 1) { ml = 0; mr = 0; }
const pad = parseCellPadding(node.attrs?.cellPadding) ?? DEFAULT_CELL_PADDING;
const colsCm = columnWidthsCm(node, contentWidthCm - ml - mr);
const colsTwip = colsCm?.map(cmToTwip);
const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm - ml - mr);
@@ -856,7 +858,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
width: { size: totalTwip, type: WidthType.DXA },
indent: ml ? { size: cmToTwip(ml), type: WidthType.DXA } : undefined,
layout: TableLayoutType.FIXED,
margins: { marginUnitType: WidthType.DXA, top: cmToTwip(0.05), bottom: cmToTwip(0.05), left: cmToTwip(0.1), right: cmToTwip(0.1) },
margins: { marginUnitType: WidthType.DXA, top: cmToTwip(pad[0]), right: cmToTwip(pad[1]), bottom: cmToTwip(pad[2]), left: cmToTwip(pad[3]) },
borders: {
top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder,
insideHorizontal: cellBorder, insideVertical: cellBorder,
+14 -11
View File
@@ -14,6 +14,7 @@ import {
type TableStyleRef = { name: string; look: TableLook };
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { parseTabStops } from '../editor/extensions/tabStops';
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
@@ -40,10 +41,6 @@ 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';
// 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.
@@ -764,18 +761,21 @@ function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]):
}
// 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).
// LibreOffice drops a multi-value form. The cells carry their table's margins as a
// CSS-style TRBL shorthand, which this expands per side (scoped to cell properties).
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(
/(<style:table-cell-properties\b[^>]*?)fo:padding="[^"]*"/g,
(_m, head: string) => head + perSide,
/(<style:table-cell-properties\b[^>]*?)fo:padding="([^"]*)"/g,
(_m, head: string, value: string) => {
const [top, right, bottom, left] = value.trim().split(/\s+/);
if (!left) return head + `fo:padding="${value}"`;
return head + `fo:padding-top="${top}" fo:padding-right="${right}"`
+ ` fo:padding-bottom="${bottom}" fo:padding-left="${left}"`;
},
);
files['content.xml'] = strToU8(content);
@@ -2489,6 +2489,9 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
const look = parseTableLook(node.attrs?.tableLook);
tableStyleNames.push(tableStyle && styleName ? { name: styleName, look } : null);
const columnWidths = tableColumnWidthsCm(node, contentWidthCm - (margins ? margins.ml + margins.mr : 0));
// The table's cell margins ride on every cell as a TRBL shorthand; expandCellPadding
// splits it, since odf-kit only writes fo:padding and ODF only allows one length.
const cellPad = (parseCellPadding(node.attrs?.cellPadding) ?? DEFAULT_CELL_PADDING).map(n => `${n}cm`).join(' ');
doc.addTable((t: TableBuilder) => {
for (const row of rows) {
t.addRow((r: RowBuilder) => {
@@ -2497,7 +2500,7 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
// Emit the cell's runs (SEG-separated) and record its descriptor; addCell is
// synchronous, so the push stays in document order, matching applyCellBlocks'
// walk. Spans make odf-kit emit covered cells, which its cell regex never matches.
const opts: CellOptions = { padding: CELL_PADDING };
const opts: CellOptions = { padding: cellPad };
const colspan = (cell.attrs?.colspan as number) ?? 1;
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
if (colspan > 1) opts.colSpan = colspan;