diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 87ddbc9..39bceb6 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -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`; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index d6d849f..f3d86aa 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 = {}; 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 { docLangTag = localeTag(language ? language.language : 'en'); exportSheet = styles; + exportSpacingModel = spacingModel; docFormulas = []; const num = new Numbering(); const landscape = orientation === 'landscape'; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 35869ac..81f1d90 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 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 ; 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[] = []; diff --git a/src/styles/editor.css b/src/styles/editor.css index ff2293b..eb003e7 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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 diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 15d1eac..5c15076 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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); diff --git a/tests/unit/cell-spacing.test.ts b/tests/unit/cell-spacing.test.ts new file mode 100644 index 0000000..2dbaee4 --- /dev/null +++ b/tests/unit/cell-spacing.test.ts @@ -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 = /]*>\s*]*>.*?`).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:p>/.exec(xml)?.[1] ?? ''; + expect(cellP).toContain('w:after="0"'); + expect(xml.slice(xml.indexOf(''))).not.toContain('w:after="0"'); + }); +}); diff --git a/tests/unit/writing-mode.test.ts b/tests/unit/writing-mode.test.ts index 6d63d9b..bb4b3a0 100644 --- a/tests/unit/writing-mode.test.ts +++ b/tests/unit/writing-mode.test.ts @@ -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(''); expect(importDocx(bytes).rtl).toBe(true); });