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.
This commit is contained in:
Steffen Becker committed 2026-08-10 14:05:08 +00:00
1 parent 0712d44ebf
commit e2179b75fb
5 files changed
+55 -10

No files matched your search

+6
View File
@@ -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
+19 -2
View File
@@ -44,8 +44,13 @@ function marginCm(value: string): number {
function tableMarginStyle(attrs: Record<string, unknown>): Record<string, string> {
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<ResizeState>('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: () => ({}),
},
},
},
];
+4
View File
@@ -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
+12 -7
View File
@@ -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 <text:list-item> 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:style[^>]*style:name="Table${i + 1}"[^>]*>\\s*<style:table-properties)`),
`$1 style:width="${width}cm" fo:margin-left="${m.ml}cm" fo:margin-right="${m.mr}cm"`,
`$1${horiz}${vert}`,
);
});
@@ -2636,11 +2639,13 @@ function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[]
// text width. null = the table spans the full text width (the common case).
function tableMarginsCm(node: TiptapNode, contentWidthCm: number): TableMargins | null {
const round3 = (v: number) => 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
+14 -1
View File
@@ -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<string, unknown> = { ...(tableMargins(el, ctx) ?? {}) };
const attrs: Record<string, unknown> = { ...(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.