From e2179b75fb4795ed8e8233e1548f3c97cdeb7590 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 14:05:08 +0000 Subject: [PATCH] feat: a table keeps the space its style puts above and below it ODF puts fo:margin-top/-bottom on the table style and LibreOffice honours them like a paragraph's; we dropped both, so every callout table in a fixture sat half a line too tight and the document ran a page short. With them the Math Guide paginates to LibreOffice's 63 pages exactly. --- docs/architecture/tables.md | 6 ++++++ .../editor/extensions/tableColumnResize.ts | 21 +++++++++++++++++-- src/lib/editor/extensions/tableView.ts | 4 ++++ src/lib/export/odt.ts | 19 ++++++++++------- src/lib/import/odt.ts | 15 ++++++++++++- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index ed4b903..d5b9084 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -89,6 +89,12 @@ We deliberately keep TipTap's `resizable: false` (so its own columnResizing plug without it an emphasis-only area such as first/last column or the total row is **invisible** in a tile, since the tiles hold no real text — only fills and borders would show. +- **A table's own space above/below** (`marginTop`/`marginBottom`, cm, beside the two horizontal + ones) — ODF `fo:margin-top`/`-bottom` on the table style, which LibreOffice honours like a + paragraph's. Rendered on the `.tableWrapper` (`TableView`, plus the inline style for the static + paths). Word has no table-level spacing, so it round-trips through ODF only. the Math Guide's callout + tables carry 0.109/0.409cm and lost half a line each without it. + ## Cell metrics measured against LibreOffice - **No space above a cell's first block.** LibreOffice adds none there (its diff --git a/src/lib/editor/extensions/tableColumnResize.ts b/src/lib/editor/extensions/tableColumnResize.ts index 503cf2a..3afd455 100644 --- a/src/lib/editor/extensions/tableColumnResize.ts +++ b/src/lib/editor/extensions/tableColumnResize.ts @@ -44,8 +44,13 @@ function marginCm(value: string): number { function tableMarginStyle(attrs: Record): Record { const ml = Number(attrs.marginLeft) || 0; const mr = Number(attrs.marginRight) || 0; - if (!ml && !mr) return {}; - return { style: `margin-left: ${ml}cm; margin-right: ${mr}cm; width: calc(100% - ${round3(ml + mr)}cm)` }; + const mt = Number(attrs.marginTop) || 0; + const mb = Number(attrs.marginBottom) || 0; + const decls: string[] = []; + if (ml || mr) decls.push(`margin-left: ${ml}cm`, `margin-right: ${mr}cm`, `width: calc(100% - ${round3(ml + mr)}cm)`); + if (mt) decls.push(`margin-top: ${mt}cm`); + if (mb) decls.push(`margin-bottom: ${mb}cm`); + return decls.length ? { style: decls.join('; ') } : {}; } export const tableColumnResizeKey = new PluginKey('tableColumnResize'); @@ -373,6 +378,18 @@ export const TableColumnResize = Extension.create({ parseHTML: (element: HTMLElement) => marginCm(element.style.marginRight), renderHTML: () => ({}), }, + // The space above/below the table (ODF fo:margin-top/-bottom on the table + // style). Word has no equivalent, so it only round-trips through ODF. + marginTop: { + default: 0, + parseHTML: (element: HTMLElement) => marginCm(element.style.marginTop), + renderHTML: () => ({}), + }, + marginBottom: { + default: 0, + parseHTML: (element: HTMLElement) => marginCm(element.style.marginBottom), + renderHTML: () => ({}), + }, }, }, ]; diff --git a/src/lib/editor/extensions/tableView.ts b/src/lib/editor/extensions/tableView.ts index 0522c62..943a23d 100644 --- a/src/lib/editor/extensions/tableView.ts +++ b/src/lib/editor/extensions/tableView.ts @@ -42,8 +42,12 @@ export function columnPercents(weights: (number | null)[]): number[] { function applyMargins(node: PMNode, wrapper: HTMLElement): void { const ml = (node.attrs.marginLeft as number) || 0; const mr = (node.attrs.marginRight as number) || 0; + const mt = (node.attrs.marginTop as number) || 0; + const mb = (node.attrs.marginBottom as number) || 0; wrapper.style.marginLeft = ml ? `${ml}cm` : ''; wrapper.style.marginRight = mr ? `${mr}cm` : ''; + wrapper.style.marginTop = mt ? `${mt}cm` : ''; + wrapper.style.marginBottom = mb ? `${mb}cm` : ''; } // The node view never calls the node's renderHTML, so the table-style hook the generated diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 13f3dca..1cca7bd 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -808,7 +808,7 @@ type CellBlock = | { kind: 'heading'; level: number; style: ParaStyle } | CellListBlock; // A table's own margins in cm (0/0 tables are recorded as null). -type TableMargins = { ml: number; mr: number }; +type TableMargins = { ml: number; mr: number; mt: number; mb: number }; // Collect the alignment + paragraph spacing of each listItem's first paragraph, // in DFS order — matching the order that odf-kit emits elements @@ -930,9 +930,12 @@ function applyTableMargins(odtBytes: Uint8Array, margins: (TableMargins | null)[ margins.forEach((m, i) => { if (!m) return; const width = Math.round((contentWidthCm - m.ml - m.mr) * 1000) / 1000; + const horiz = m.ml || m.mr + ? ` style:width="${width}cm" fo:margin-left="${m.ml}cm" fo:margin-right="${m.mr}cm"` : ''; + const vert = `${m.mt ? ` fo:margin-top="${m.mt}cm"` : ''}${m.mb ? ` fo:margin-bottom="${m.mb}cm"` : ''}`; content = content.replace( new RegExp(`(]*style:name="Table${i + 1}"[^>]*>\\s* Math.round(v * 1000) / 1000; - const ml = Math.max(0, Number(node.attrs?.marginLeft) || 0); - const mr = Math.max(0, Number(node.attrs?.marginRight) || 0); - if (!ml && !mr) return null; - if (ml + mr > contentWidthCm - 1) return null; - return { ml: round3(ml), mr: round3(mr) }; + let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0); + let mr = Math.max(0, Number(node.attrs?.marginRight) || 0); + const mt = Math.max(0, Number(node.attrs?.marginTop) || 0); + const mb = Math.max(0, Number(node.attrs?.marginBottom) || 0); + if (ml + mr > contentWidthCm - 1) ml = mr = 0; + if (!ml && !mr && !mt && !mb) return null; + return { ml: round3(ml), mr: round3(mr), mt: round3(mt), mb: round3(mb) }; } // Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index f0c3a33..425e297 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1995,7 +1995,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null { // name comes back — the look rides on the cell attrs above, and the editor re-derives // the regions from the registry (refreshTableStyles). const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table'); - const attrs: Record = { ...(tableMargins(el, ctx) ?? {}) }; + const attrs: Record = { ...(tableMargins(el, ctx) ?? {}), ...tableSpacing(el, ctx) }; if (cellPad) attrs.cellPadding = cellPad; if (named) { attrs.tableStyle = displayStyleName(named); @@ -2051,6 +2051,19 @@ function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight: return { marginLeft: round2(left), marginRight: round2(right) }; } +// The space a table's own style puts above and below it (fo:margin-top/-bottom on +// the table, which LibreOffice honours like a paragraph's). +function tableSpacing(el: Element, ctx: Ctx): { marginTop?: number; marginBottom?: number } { + const props = ctx.resolver.tableProps(el.getAttributeNS(NS.table, 'style-name')); + const round3 = (v: number) => Math.round(v * 1000) / 1000; + const out: { marginTop?: number; marginBottom?: number } = {}; + const top = lengthToCm(props['fo:margin-top']); + const bottom = lengthToCm(props['fo:margin-bottom']); + if (top && top > 0) out.marginTop = round3(top); + if (bottom && bottom > 0) out.marginBottom = round3(bottom); + return out; +} + // Per-column proportional weights for the colwidth cell attr (tableView.ts uses // ratios only). Prefers LibreOffice's relative widths (rel-column-width) and // falls back to absolute cm. null when the document declares no usable widths.