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
View File
@@ -41,6 +41,7 @@ import { TableRowResize } from './extensions/tableRowResize';
import { TableSplit } from './extensions/tableSplit';
import { TableCellBackground } from './extensions/tableCellBackground';
import { TableCellBorders } from './extensions/tableCellBorders';
import { TableCellPadding } from './extensions/tableCellPadding';
import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TableStyle } from './extensions/tableStyle';
import { TrailingNode } from './extensions/trailingNode';
@@ -134,6 +135,8 @@ export const extensions = [
TableCellBackground,
// Border presets: per-side border attrs on cells (→ fo:border-*).
TableCellBorders,
// The table's cell margins (→ w:tblCellMar / fo:padding on the cell style).
TableCellPadding,
// "Header row" styling preset: bold + light shading on the first row (toggle).
TableHeaderRow,
// Named table style (Word table style / LibreOffice AutoFormat): paints the table's
@@ -0,0 +1,58 @@
import { Extension } from '@tiptap/core';
// A table's cell margins in cm, [top, right, bottom, left] — Word's w:tblCellMar,
// ODF's fo:padding on the cell style. `null` = the default below, which is Word's
// own and what editor.css falls back to, so an ordinary table carries no attr.
export type CellPadding = [number, number, number, number];
export const DEFAULT_CELL_PADDING: CellPadding = [0, 0.19, 0, 0.19];
const round3 = (n: number) => Math.round(n * 1000) / 1000;
// Word's 108 twips are 0.1905cm and LibreOffice writes 0.19cm back as 0.191, so a
// side this close to the default is the default — otherwise every table carries one.
const SAME_CM = 0.005;
// Clamped, rounded, and collapsed to null when every side is the default.
export function cellPaddingAttr(sides: (number | null | undefined)[]): CellPadding | null {
const p = DEFAULT_CELL_PADDING.map((d, i) => {
const v = sides[i];
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return d;
return Math.abs(v - d) <= SAME_CM ? d : round3(Math.min(v, 5));
}) as CellPadding;
return p.every((v, i) => v === DEFAULT_CELL_PADDING[i]) ? null : p;
}
export function parseCellPadding(value: unknown): CellPadding | null {
if (Array.isArray(value) && value.length === 4) return cellPaddingAttr(value as number[]);
if (typeof value !== 'string') return null;
const nums = value.trim().split(/\s+/).map((s) => parseFloat(s));
return nums.length === 4 && nums.every((n) => Number.isFinite(n)) ? cellPaddingAttr(nums) : null;
}
// One custom property carrying the whole shorthand; editor.css reads it with the
// default as its fallback, so nothing to write for an ordinary table.
export function cellPaddingStyle(value: unknown): string | null {
const p = parseCellPadding(value);
return p ? `--cell-pad:${p.map((n) => `${n}cm`).join(' ')}` : null;
}
export const TableCellPadding = Extension.create({
name: 'tableCellPadding',
addGlobalAttributes() {
return [
{
types: ['table'],
attributes: {
cellPadding: {
default: null,
parseHTML: (element: HTMLElement) => parseCellPadding(element.style.getPropertyValue('--cell-pad')),
renderHTML: (attributes: Record<string, unknown>) => {
const style = cellPaddingStyle(attributes.cellPadding);
return style ? { style } : {};
},
},
},
},
];
},
});
+10
View File
@@ -1,4 +1,5 @@
import type { Node as PMNode } from '@tiptap/pm/model';
import { parseCellPadding } from './tableCellPadding';
import type { ViewMutationRecord } from '@tiptap/pm/view';
// Custom table node view: <colgroup> uses percentage widths and the table stays at
@@ -56,6 +57,13 @@ function applyTableStyleAttr(node: PMNode, table: HTMLElement): void {
else delete table.dataset.tableLook;
}
// Also bypassed by the node view: the cell margins editor.css reads off the table.
function applyCellPadding(node: PMNode, table: HTMLElement): void {
const p = parseCellPadding(node.attrs.cellPadding);
if (p) table.style.setProperty('--cell-pad', p.map((n) => `${n}cm`).join(' '));
else table.style.removeProperty('--cell-pad');
}
function buildColgroup(node: PMNode, colgroup: HTMLElement): void {
while (colgroup.firstChild) colgroup.removeChild(colgroup.firstChild);
const percents = columnPercents(columnWeightsFromRow(node.firstChild));
@@ -86,6 +94,7 @@ export class TableView {
// table the full text width regardless of the stored weights.
this.table.style.width = '100%';
applyTableStyleAttr(node, this.table);
applyCellPadding(node, this.table);
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
buildColgroup(node, this.colgroup);
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
@@ -96,6 +105,7 @@ export class TableView {
this.node = node;
applyMargins(node, this.dom);
applyTableStyleAttr(node, this.table);
applyCellPadding(node, this.table);
buildColgroup(node, this.colgroup);
return true;
}
+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;
+25 -1
View File
@@ -19,6 +19,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/
import type { HfDoc } from '../storage/headerFooter';
import { applyUniformRunFont, type OdtImportResult } from './odt';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, type CellPadding } from '../editor/extensions/tableCellPadding';
// .docx → TipTap JSON, inverting export/docx.ts. Editor-expressible OOXML becomes its
// native node/mark/attr; values matching the editor's defaults are suppressed so round
@@ -1439,6 +1440,26 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null {
}
}
// w:tblCellMar → [top, right, bottom, left] cm, direct before the table style's.
// A side nobody declares stays the editor default (cellPaddingAttr fills it in).
function docxCellPadding(tbl: Element, styleId: string | null, ctx: Ctx): CellPadding | null {
const direct = fc(fc(tbl, 'tblPr'), 'tblCellMar');
const fromStyle = ctx.styles.tableCellMar(styleId);
const side = (...names: string[]): number | null => {
for (const el of [direct, fromStyle]) {
for (const name of names) {
const s = el && fc(el, name);
if (!s) continue;
if (s.getAttributeNS(W, 'type') === 'nil') return 0;
const w = intAttr(s, W, 'w');
if (w != null) return twipToCm(w);
}
}
return null;
};
return cellPaddingAttr([side('top'), side('right', 'end'), side('bottom'), side('left', 'start')]);
}
function buildTable(tbl: Element, ctx: Ctx): Node | null {
const grid = fc(tbl, 'tblGrid');
const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null;
@@ -1507,8 +1528,11 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
// Word's w:tblStyle: only the name comes back — the look rides on the cell attrs, and
// the editor re-derives the regions from its own registry (refreshTableStyles).
const styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle');
const named = ctx.styles.tableStyleName(styleEl ? wVal(styleEl) : null);
const styleId = styleEl ? wVal(styleEl) : null;
const named = ctx.styles.tableStyleName(styleId);
const attrs: Record<string, unknown> = { ...(tableMargins(tbl, useWeights, ctx) ?? {}) };
const pad = docxCellPadding(tbl, styleId, ctx);
if (pad) attrs.cellPadding = pad;
if (named) {
attrs.tableStyle = named;
const look = docxTableLook(fc(tbl, 'tblPr'));
+11
View File
@@ -172,6 +172,7 @@ export class DocxStyles {
private ownKeepNext = new Map<string, boolean>(); // style's own w:pPr/w:keepNext
private ownKeepLines = new Map<string, boolean>(); // style's own w:pPr/w:keepLines
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
private ownCellMar = new Map<string, Element>(); // table style's own w:tblPr/w:tblCellMar
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
private numToAbstract = new Map<string, string>();
private abstractLevels = new Map<string, Map<number, LevelDef>>();
@@ -246,6 +247,8 @@ export class DocxStyles {
const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10);
if (Number.isFinite(left)) this.ownIndentTwip.set(id, left);
}
const cellMar = firstChild(style, 'tblPr') && firstChild(firstChild(style, 'tblPr')!, 'tblCellMar');
if (cellMar) this.ownCellMar.set(id, cellMar);
const kind = style.getAttributeNS(W, 'type');
if (kind === 'paragraph' || kind === 'character' || kind === 'table') {
const nameEl = firstChild(style, 'name');
@@ -447,6 +450,14 @@ export class DocxStyles {
return { ...this.defaultsSpacing, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) };
}
// The table style's own w:tblCellMar along the w:basedOn chain (leaf wins). The
// element itself, so the caller reads whichever sides it declares.
tableCellMar(styleId: string | null | undefined, seen = new Set<string>()): Element | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
return this.ownCellMar.get(styleId) ?? this.tableCellMar(this.basedOn.get(styleId) ?? null, seen);
}
level(numId: number, ilvl: number): LevelDef {
const abs = this.numToAbstract.get(String(numId));
if (!abs) return {};
+6
View File
@@ -17,6 +17,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, type CellPadding } from '../editor/extensions/tableCellPadding';
// .odt → TipTap JSON, inverting export/odt.ts. Editor-expressible content becomes its
// native node/mark/attr; values matching the editor's defaults are suppressed so round
@@ -1536,6 +1537,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const weights = columnWeights(el, ctx.resolver);
const rows: Node[] = [];
let cellPad: CellPadding | null | undefined;
const addRow = (rowEl: Element, header: boolean) => {
const cells: Node[] = [];
let colIndex = 0;
@@ -1551,6 +1553,9 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const colspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1;
const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1;
const cellStyleName = cellEl.getAttributeNS(NS.table, 'style-name');
// ODF has no table-level cell margin — it sits on each cell's style, and every
// producer writes it uniformly, so the first cell's stands for the table.
if (cellPad === undefined) cellPad = cellPaddingAttr(ctx.resolver.cellPadding(cellStyleName));
const rawBg = ctx.resolver.cellBackgroundColor(cellStyleName);
const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null;
// Per-side borders; an undeclared side means no border in ODF → 'none'.
@@ -1603,6 +1608,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
// the regions from the registry (refreshTableStyles).
const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table');
const attrs: Record<string, unknown> = { ...(tableMargins(el, ctx) ?? {}) };
if (cellPad) attrs.cellPadding = cellPad;
if (named) {
attrs.tableStyle = displayStyleName(named);
// Which conditional areas the table opts into (ODF's table template attributes).
+10
View File
@@ -371,6 +371,16 @@ export class StyleResolver {
return out;
}
// A cell style's padding (the cell margin) as [top, right, bottom, left] cm, per-side
// overriding the fo:padding shorthand. A side nobody declares comes back null.
cellPadding(styleName: string | null): (number | null)[] {
if (!styleName) return [null, null, null, null];
const misc = this.merged('table-cell', styleName).misc;
const all = misc['fo:padding'];
return (['top', 'right', 'bottom', 'left'] as const)
.map((side) => lengthToCm(misc[`fo:padding-${side}`] ?? all));
}
listStyle(name: string | null): Element | null {
return name ? this.listStyles.get(name) ?? null : null;
}
+3 -3
View File
@@ -481,8 +481,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 → Word's/LibreOffice's cell margins; export emits them per side
(CELL_PADDING_X/_Y, expanded by expandCellPadding).
• padding → Word's/LibreOffice's cell margins. The fallback below is Word's
own default; a file's own value rides --cell-pad (tableCellPadding.ts).
Tables are wrapped in <div class="tableWrapper"> by TipTap's node view. */
.paper .tiptap .tableWrapper {
/* Default is flush with the text area; a dragged outer edge sets an inline
@@ -504,7 +504,7 @@
.paper .tiptap td,
.paper .tiptap th {
border: 1px solid #000;
padding: 0 0.19cm;
padding: var(--cell-pad, 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). */
+31
View File
@@ -4,6 +4,10 @@
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;
@@ -37,4 +41,31 @@ describe('table cell padding', () => {
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]);
});
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);
}
});
});