fix: a cell paragraph drops the default style's space below it

Neither word processor passes it into a cell — LibreOffice puts cell text in
its Table Contents style, Word's table styles carry w:spacing after=0 — so the
space filled the cell and vertical alignment had no slack left to move the text
in, which read as the alignment being ignored. The space above is LibreOffice's
alone, so only the add model zeroes that too. Both exporters write the zeros
onto the paragraph, and only when the default style has spacing at all.
This commit is contained in:
Steffen Becker committed 2026-08-13 23:39:31 +02:00
1 parent 102f2c6cac
commit 06c5f2b12a
7 files changed
+117 -12

No files matched your search

+9
View File
@@ -111,6 +111,15 @@ We deliberately keep TipTap's `resizable: false` (so its own columnResizing plug
model only. Measured on the Math Guide: the table row grew 1mm per row without it. `pageBreaks.ts`
excludes cell leaves from its page-top-drop detection, or that missing padding would read
as a decoration to add back.
- **A cell paragraph that names no style has no space below it.** Neither word processor passes
the default style's spacing into a cell: LibreOffice puts cell text in its Table Contents style
(probed — an auto-height row stays exactly as tall as its lines, and a bottom-aligned cell
reaches the row bottom), Word's table styles carry `w:spacing w:after="0"`. The **space above**
is LibreOffice's alone, so the `add` model zeroes that too. `editor.css` renders it, and both
exporters write the zeros onto the paragraph (`cellParaStyle`/`cellSpacingOf`) — only when the
default style has spacing at all, so files without it export unchanged. Without this the space
fills the cell and vertical alignment (`tableCellAlign.ts`) has no slack left to move text in,
which is what makes it look dead.
- **A painted border is always a whole pixel**, whatever width the file declares — Chromium
floors it, LibreOffice reserves the declared width (the Math Guide's cells ask for a 0.05pt hairline).
The excess comes off the cell's `padding-bottom` (`--border-over`, `tableCellBorders.ts`;
+19 -4
View File
@@ -19,6 +19,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import type { SpacingModel } from '../storage/spacingModel';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../storage/noteSettings';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
@@ -29,7 +30,7 @@ import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarke
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
import { builtinStyleSheet, DEFAULT_STYLE, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { parseTableLook, regionText, type TableStyle } from '../styles/tableStyles';
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
import { parseLatex } from '../math/latex';
@@ -41,6 +42,7 @@ let docLangTag = 'en-US';
// The sheet of the export in flight, so the table/cell emitters can resolve a named
// table style without threading it through every helper (mirrors odt.ts).
let exportSheet: StyleSheet = builtinStyleSheet();
let exportSpacingModel: SpacingModel = 'add';
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
// `docx` library instead of odf-kit. Lazy-loaded from App.svelte so neither this
@@ -815,6 +817,17 @@ function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeo
}
}
// Word's table styles carry w:spacing after=0 and LibreOffice's Table Contents zeroes both,
// which is what editor.css renders — written onto the paragraph itself, where a file without
// a table style needs it anyway.
function cellSpacingOf(attrs: TiptapNode['attrs'], node: TiptapNode): ISpacingProperties | undefined {
const s = spacingOf(attrs);
if (attrs?.styleName || node.type !== 'paragraph') return s;
const def = resolveStyle(exportSheet, DEFAULT_STYLE).para;
if (!def.spaceBefore && !def.spaceAfter) return s;
return { ...(exportSpacingModel === 'max' ? { after: 0 } : { before: 0, after: 0 }), ...s };
}
function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
const s: Writable<ISpacingProperties> = {};
if (typeof attrs?.spaceBefore === 'number') s.before = ptToTwip(attrs.spaceBefore);
@@ -834,7 +847,7 @@ function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
}
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; force?: TextProps };
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; force?: TextProps; inCell?: boolean };
// Paragraph background ("colored field") → w:shd; per-side borders ("rule line") → w:pBdr.
function paraShadingOf(attrs: TiptapNode['attrs']) {
@@ -875,7 +888,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
return new Paragraph({
style,
alignment: alignOf(attrs),
spacing: spacingOf(attrs),
spacing: opts.inCell ? cellSpacingOf(attrs, node) : spacingOf(attrs),
indent: Object.keys(indent).length ? indent : undefined,
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
widowControl: attrs.widowControl === false ? false : undefined,
@@ -970,7 +983,7 @@ function cellBlocksToDocx(content: TiptapNode[] = [], force: TextProps, num: Num
} else if (child.type === 'table') {
out.push(tableToDocx(child, contentWidthCm, num));
} else if (child.type === 'paragraph' || child.type === 'heading') {
out.push(paragraphToDocx(child, { force }));
out.push(paragraphToDocx(child, { force, inCell: true }));
}
}
if (out.length === 0) out.push(new Paragraph({}));
@@ -1290,11 +1303,13 @@ export async function buildDocx(
pageFormat: PageFormat = 'A4',
styles: StyleSheet = builtinStyleSheet(),
tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM,
spacingModel: SpacingModel = 'add',
rtl = false,
notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
exportSpacingModel = spacingModel;
docFormulas = [];
const num = new Numbering();
const landscape = orientation === 'landscape';
+16 -2
View File
@@ -2366,6 +2366,7 @@ function linkHrefOf(marks: TiptapNode['marks'] = []): string | undefined {
// The sheet of the export in flight, so the run emitters can resolve character styles
// without threading it through every cell/list helper (mirrors docLangTag in docx.ts).
let exportSheet: StyleSheet = builtinStyleSheet();
let exportSpacingModel: SpacingModel = 'add';
// Emit each text node as an odf-kit run; link-marked runs become <text:a> via addLink.
// `force` bakes formatting onto every run regardless of marks — for header/region cells,
@@ -2651,6 +2652,18 @@ function chapterSentinel(node: TiptapNode): string {
// A segment is one paragraph, heading, or list item's paragraph; exactly one SEG
// between consecutive segments, so splitting yields one piece per segment in DFS order.
// Neither word processor passes the default style's spacing into a cell (LibreOffice's Table
// Contents zeroes both, Word's table styles the space below), and editor.css renders it that
// way — so the file says so too. A paragraph's own spacing still wins.
function cellParaStyle(attrs: TiptapNode['attrs']): ParaStyle {
const style = paraStyleFromAttrs(attrs);
if (attrs?.styleName) return style;
const def = resolveStyle(exportSheet, DEFAULT_STYLE).para;
if (!def.spaceBefore && !def.spaceAfter) return style;
const spaceBefore = exportSpacingModel === 'max' ? style.spaceBefore : style.spaceBefore ?? 0;
return { ...style, spaceBefore, spaceAfter: style.spaceAfter ?? 0 };
}
function buildCellContent(cell: TiptapNode, c: CellBuilder, force: TextProps = {}): CellBlock[] {
const blocks: CellBlock[] = [];
const state = { emitted: false }; // whether any segment has been emitted yet
@@ -2681,7 +2694,7 @@ function buildCellContent(cell: TiptapNode, c: CellBuilder, force: TextProps = {
for (const block of cell.content ?? []) {
if (block.type === 'paragraph') {
emitSegment(block.content);
blocks.push({ kind: 'paragraph', style: paraStyleFromAttrs(block.attrs) });
blocks.push({ kind: 'paragraph', style: cellParaStyle(block.attrs) });
} else if (block.type === 'heading') {
emitSegment(block.content);
blocks.push({ kind: 'heading', level: (block.attrs?.level as number) ?? 1, style: paraStyleFromAttrs(block.attrs) });
@@ -2693,7 +2706,7 @@ function buildCellContent(cell: TiptapNode, c: CellBuilder, force: TextProps = {
// Empty cell: odf-kit emits an empty <text:p/>; record one empty paragraph so
// applyCellBlocks stays aligned and leaves it untouched.
if (blocks.length === 0) {
blocks.push({ kind: 'paragraph', style: paraStyleFromAttrs(undefined) });
blocks.push({ kind: 'paragraph', style: cellParaStyle(undefined) });
}
return blocks;
}
@@ -3532,6 +3545,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
// (so PGB misses their blocks) and before the inline passes (which then cover them).
exportSheet = styles;
exportSpacingModel = spacingModel;
const images: ImageExport[] = [];
const tocs: TocExport[] = [];
const textBoxes: TextBoxExport[] = [];
+9 -4
View File
@@ -784,14 +784,19 @@
/* Positioning context for the column-resize handle widget (a child div the
resize plugin places at the cell's right edge). */
position: relative;
/* Cells hold paragraphs; suppress their bottom margin so a single-line cell
isn't taller than its text (matches the list-item paragraph treatment). */
}
.paper .tiptap td > p,
.paper .tiptap th > p {
/* No space below a cell paragraph that names no style: LibreOffice's Table Contents and
Word's table styles both zero it, and the space would fill the cell, leaving vertical
alignment no slack. An inline value (direct formatting) still wins. */
.paper .tiptap :is(td, th) > p:not([data-style]) {
margin-bottom: 0;
}
/* Only LibreOffice's style zeroes the space above too; Word's table styles set the
spacing after alone, so a Word document keeps it (as the cell-start rule above does). */
.paper:not([data-spacing-model='max']) .tiptap :is(td, th) > p:not([data-style]) {
--space-before: 0;
}
/* Header-row cells (tableHeaderRow.ts toggle): bold rendered presentationally so all
text — existing, typed, or pasted — is bold. The light fill comes from the cell's
+1 -1
View File
@@ -485,7 +485,7 @@ describe('Leg 1a2c: footnotes and endnotes', () => {
check('ODF: the separator survives',
back.separator.relWidthPercent === 60 && Math.abs(back.separator.weightPt - 1.5) < 0.05 && back.separator.align === 'center', back.separator);
const docx = await buildDocx(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, false, custom);
const docx = await buildDocx(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, custom);
const dback = importDocx(docx).notes;
check('DOCX: format, start and restart survive',
dback.footnote.numFormat === 'A' && dback.footnote.startAt === 4 && dback.footnote.restart === 'page', dback.footnote);
+62
View File
@@ -0,0 +1,62 @@
// A cell paragraph that names no style takes LibreOffice's Table Contents spacing (zero),
// not the default style's — else the space fills the cell and vertical alignment has no
// slack. Word documents ('max') keep the inheritance, as Word itself does.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { builtinStyleSheet, DEFAULT_STYLE } from '../../src/lib/styles/styleSheet';
type N = any;
const doc: N = {
type: 'doc',
content: [
{ type: 'table', content: [{ type: 'tableRow', content: [
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [200] },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Cell' }] }] },
] }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] },
],
};
function sheetWithSpacing(pt: number) {
const sheet = builtinStyleSheet();
sheet.paragraph[DEFAULT_STYLE].para = { spaceBefore: 0, spaceAfter: pt };
return sheet;
}
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const cellPara = (odt: Uint8Array) => {
const content = strFromU8(unzipSync(odt)['content.xml']);
const name = /<table:table-cell\b[^>]*>\s*<text:p text:style-name="([^"]+)"/.exec(content)?.[1] ?? '';
return { name, style: new RegExp(`<style:style style:name="${name}"[^>]*>.*?</style:style>`).exec(content)?.[0] ?? '' };
};
describe('paragraph spacing inside a table cell', () => {
it('ODF: the default style\'s space-after is zeroed on the cell paragraph', async () => {
const { style } = cellPara(await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheetWithSpacing(12)));
expect(style).toContain('fo:margin-bottom="0pt"');
expect(style).toContain('fo:margin-top="0pt"');
});
it('ODF: a document without default spacing keeps the cell paragraph bare', async () => {
const { name, style } = cellPara(await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet()));
expect(name).toBe('Standard');
expect(style).toBe('');
});
it('ODF: a Word document zeroes the space below only', async () => {
const { style } = cellPara(await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheetWithSpacing(12), 1.25, 'max'));
expect(style).toContain('fo:margin-bottom="0pt"');
expect(style).not.toContain('fo:margin-top');
});
it('DOCX: the cell paragraph carries w:spacing 0, the body paragraph does not', async () => {
const bytes = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', sheetWithSpacing(12), 1.25, 'max');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
const cellP = /<w:tc>.*?<w:p>(.*?)<\/w:p>/.exec(xml)?.[1] ?? '';
expect(cellP).toContain('w:after="0"');
expect(xml.slice(xml.indexOf('</w:tbl>'))).not.toContain('w:after="0"');
});
});
+1 -1
View File
@@ -52,7 +52,7 @@ describe('a right-to-left page', () => {
});
it('round-trips through a DOCX export as w:bidi', async () => {
const bytes = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', undefined, undefined, true);
const bytes = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', undefined, undefined, undefined, true);
expect(strFromU8(unzipSync(bytes)['word/document.xml'])).toContain('<w:bidi/>');
expect(importDocx(bytes).rtl).toBe(true);
});