diff --git a/src/App.svelte b/src/App.svelte index 07ff1ca..4bde3fd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -24,6 +24,7 @@ import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins'; import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation'; import { loadTabInterval, saveTabInterval, applyTabIntervalVar, DEFAULT_TAB_INTERVAL_CM } from './lib/storage/tabInterval'; + import { loadSpacingModel, saveSpacingModel, type SpacingModel } from './lib/storage/spacingModel'; import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat'; import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte'; import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet'; @@ -123,6 +124,7 @@ let pageOrientation: Orientation = $state(loadOrientation()); let pageFormat: PageFormat = $state(loadPageFormat()); let tabIntervalCm = $state(loadTabInterval()); + let spacingModel: SpacingModel = $state(loadSpacingModel()); // The document's spell-check language; round-trips through the .odt. The effect // below persists it and switches the shared spell controller (loads the dict). @@ -180,6 +182,10 @@ applyTabIntervalVar(tabIntervalCm); }); + $effect(() => { + saveSpacingModel(spacingModel); + }); + $effect(() => { saveDocumentLanguage(documentLanguage); void spellController.setLanguage(documentLanguage); @@ -449,6 +455,7 @@ pageOrientation = 'portrait'; pageFormat = 'A4'; tabIntervalCm = DEFAULT_TAB_INTERVAL_CM; + spacingModel = 'add'; hfDistances = { ...DEFAULT_HF_DISTANCES }; extraHfSections = []; documentName = ''; @@ -506,6 +513,7 @@ if (result.orientation) pageOrientation = result.orientation; if (result.format) pageFormat = result.format; if (result.tabIntervalCm) tabIntervalCm = result.tabIntervalCm; + spacingModel = result.spacingModel; // Adopt the document's spell-check language (the $effect switches the // controller + loads its dictionary). null = file declared none; keep ours. if (result.language) documentLanguage = result.language; @@ -584,7 +592,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -600,7 +608,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -1046,6 +1054,7 @@ bind:hfTick {hfDistances} {tabIntervalCm} + {spacingModel} bind:extraHfSections {zoom} onZoom={setZoom} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index b005825..6f64a69 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -21,6 +21,7 @@ import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval'; import { type Orientation } from '../storage/pageOrientation'; + import { type SpacingModel } from '../storage/spacingModel'; import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat'; import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter'; import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks'; @@ -38,7 +39,7 @@ let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', - pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, + pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES, headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false, headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false, @@ -47,7 +48,7 @@ }: { editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; onZoom?: (zoom: number) => void; - showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; tabIntervalCm?: number; + showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; tabIntervalCm?: number; spacingModel?: SpacingModel; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -131,6 +132,8 @@ void pageFormat; // …the tab grid, which changes where a tab lands and so where a line breaks… void tabIntervalCm; + // …and how the space between two blocks is measured. + void spacingModel; // …and the header/footer-driven effective margins, so growing a zone re-paginates. void (effTopRest + effTopFirst + effBottomRest + effBottomFirst); void sectionReach; @@ -827,7 +830,7 @@
-
+
diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 5735758..87b3615 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -21,7 +21,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`fontColor.ts`** (`FontColor`) — `color` attr on the TextStyle mark; also emits `data-color` so theme CSS (allBlack) can target color-bearing spans. - **`fontWeight.ts`** (`FontWeight`) — `fontWeight` attr on TextStyle (set `normal` to un-bold a heading without changing the node type). - **`lineHeight.ts`** (`LineHeight`) — `lineHeight` attr on paragraph/heading. `LINE_HEIGHT_RATIO = 1.15`: ODF line spacing multiplies the font's *natural* line height (Liberation Serif ≈1.15× em), CSS multiplies the font size — so the on-screen value is scaled to match what LibreOffice renders. A spacing above single puts **all** of its extra leading below each line (probed: at 150% LibreOffice starts line 1 flush under the block above and adds 6.9pt after every line, the last included); CSS half-leads it, so `editor.css` shifts the block up by that half with `position:relative; top:` — flow, and so pagination, unchanged. -- **`paragraphSpacing.ts`** (`ParagraphSpacing`) — `spaceBefore`/`spaceAfter` in **pt**, round-tripping 1:1 to `fo:margin-top`/`fo:margin-bottom`. +- **`paragraphSpacing.ts`** (`ParagraphSpacing`) — `spaceBefore`/`spaceAfter` in **pt**, round-tripping 1:1 to `fo:margin-top`/`fo:margin-bottom`. Space after is a margin, space before rides `--space-before` (`storage/spacingModel.ts` decides padding vs margin); pageBreaks.ts drops it where a page break put the block at a page top, as LibreOffice does. - **`blockFontSize.ts`** — the font of the paragraph mark (Word `w:pPr/w:rPr`, ODF the paragraph's own text properties). - **`pageBreak.ts`** — the text-flow attrs of a paragraph/heading: `breakBefore: 'page'`, plus `keepNext`/`keepLines` (see `docs/architecture/pagination.md`). Owns `Mod-Enter`. - **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set. diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index ab4f3f6..8124413 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -891,6 +891,9 @@ export const PageBreaks = Extension.create({ // Node-decoration range collapsing the trailing empty paragraph after a // document-final columns chain when it sits past the page content area. let collapsedTrailing: { from: number; to: number } | null = null; + // Node-decoration ranges dropping the space before a block a soft break put + // at a page top (probed in LibreOffice); a hard break and the first block keep it. + const pageTopBlocks: { from: number; to: number }[] = []; // Close/open bands for in-cell + between-rows table breaks. Keyed by the // rounded openY (the grouping id) → the unrounded openY (so the band lands // exactly on the page cycle) plus whether it's a between-rows break. @@ -995,6 +998,16 @@ export const PageBreaks = Extension.create({ // A forced block that then overflows is split by the normal logic next pass. const forced = !!leaf.forceBreakBefore && i > 0 && effectiveTop > contentStart + 0.5; + if ( + i > 0 && !leaf.inTableCell + && Math.abs(effectiveTop - contentStart) < 0.5 + && parseFloat(getComputedStyle(leaf.el).paddingTop) > 0.5 + ) { + const from = docPosBeforeElement(leaf.el); + const node = from !== null ? editorView.state.doc.nodeAt(from) : null; + if (from !== null && node) pageTopBlocks.push({ from, to: from + node.nodeSize }); + } + if (forced) { // The next page's own content start: a section beginning here brings its // first-page header with it. @@ -1213,7 +1226,8 @@ export const PageBreaks = Extension.create({ // recreates the spacer DOM nodes (no `key` on the widgets) for no gain. const placementsKey = placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|') + - (collapsedTrailing ? `|c${collapsedTrailing.from}` : ''); + (collapsedTrailing ? `|c${collapsedTrailing.from}` : '') + + pageTopBlocks.map((b) => `|t${b.from}`).join(''); const placementsChanged = placementsKey !== lastPlacementsKey && placementsKey !== prevPlacementsKey; if (placementsChanged) { @@ -1248,6 +1262,9 @@ export const PageBreaks = Extension.create({ style: 'height:0;min-height:0;margin:0;overflow:hidden', })); } + for (const b of pageTopBlocks) { + decoArray.push(Decoration.node(b.from, b.to, { style: 'padding-top:0;margin-top:0' })); + } decorations = decoArray.length > 0 ? DecorationSet.create(doc, decoArray) diff --git a/src/lib/editor/extensions/paragraphSpacing.ts b/src/lib/editor/extensions/paragraphSpacing.ts index a50af13..69b1c56 100644 --- a/src/lib/editor/extensions/paragraphSpacing.ts +++ b/src/lib/editor/extensions/paragraphSpacing.ts @@ -1,8 +1,8 @@ import { Extension } from '@tiptap/core'; -// Space above/below a paragraph, stored in pt so it round-trips 1:1 to ODF's -// fo:margin-top/bottom. null inherits the style/CSS default. Renders as an -// inline margin overriding editor.css; exported via export/odt.ts. +// Space above/below a paragraph in pt, round-tripping 1:1 to fo:margin-top/bottom; +// null inherits the style default. Space before rides `--space-before`, which editor.css +// turns into padding or margin per the document's spacing model (storage/spacingModel.ts). declare module '@tiptap/core' { interface Commands { @@ -42,7 +42,7 @@ export const ParagraphSpacing = Extension.create({ const pt = Number(attributes.spaceBefore); return { 'data-space-before': String(pt), - style: `margin-top: ${pt}pt`, + style: `--space-before: ${pt}pt`, }; }, }, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 646b98e..9ecb3a2 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2,6 +2,7 @@ import { tiptapToOdt, type TiptapNode, type TiptapMark, type TextFormatting, typ import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; +import type { SpacingModel } from '../storage/spacingModel'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval'; import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter'; @@ -3253,7 +3254,7 @@ export type HfExport = { }; // The full document → .odt pipeline, DOM-free; returns the .odt bytes. -export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM): Promise { +export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add'): Promise { // Images become IMG sentinels before serialization; applyImages resolves them and writes // 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). @@ -3480,7 +3481,40 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); // Sections past the first get their own master page, which is where ODF keeps a // section's header/footer; the SEC-marked block points at it. - return applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1); + const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1); + return applySpacingModel(withSections, spacingModel); +} + +// A document that takes the larger of two adjoining spacings needs LibreOffice's +// AddParaTableSpacing=false — its own default adds them, so without this the space +// between every pair of blocks would grow when the file is reopened. +function applySpacingModel(odtBytes: Uint8Array, model: SpacingModel): Uint8Array { + if (model !== 'max') return odtBytes; + const files = unzipSync(odtBytes); + const item = + '' + + 'false' + + ''; + const existing = files['settings.xml']; + if (existing) { + files['settings.xml'] = strToU8(strFromU8(existing).replace('', `${item}`)); + return rezipOdt(files); + } + files['settings.xml'] = strToU8( + '' + + '' + + `${item}`, + ); + const manifestBytes = files['META-INF/manifest.xml']; + if (manifestBytes) { + const entry = ''; + const manifest = strFromU8(manifestBytes); + if (!manifest.includes('"settings.xml"')) { + files['META-INF/manifest.xml'] = strToU8(manifest.replace('', `${entry}`)); + } + } + return rezipOdt(files); } function hfAlign(para: TiptapNode): AlignValue | null { diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index bbb65b5..930fb8e 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -187,6 +187,9 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = orientation: sect.orientation, format: sect.format, tabIntervalCm: docTabInterval(files), + // Word takes the larger of the two spacings, and LibreOffice follows it for a + // Word document — its own ODF default adds them (probed). + spacingModel: 'max' as const, header: first.header, footer: first.footer, headerFirst: first.differentFirstPage ? first.headerFirst : null, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 903b3b9..b670ff3 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -16,6 +16,7 @@ import { astToLatex } from '../math/latex'; import { parseMathml } from '../math/mathml'; import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; +import type { SpacingModel } from '../storage/spacingModel'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc, HfSet } from '../storage/headerFooter'; @@ -43,6 +44,9 @@ export interface OdtImportResult { // The grid every tab past the last custom stop falls on; the format's own fallback // when the file declares none. tabIntervalCm: number; + // How the space between two blocks is measured; 'max' only where the file says so + // (settings.xml AddParaTableSpacing=false, what LibreOffice writes for a Word import). + spacingModel: SpacingModel; // Single-paragraph docs in the hfExtensions schema; null = no zone. header: HfDoc; footer: HfDoc; @@ -495,6 +499,13 @@ const LIST_INDENT_EPS_CM = 0.05; // ---- entry -------------------------------------------------------------------- +// LibreOffice writes AddParaTableSpacing=false for a document it imported from Word, +// and then takes the larger of two adjoining spacings instead of adding them. +function odfSpacingModel(files: Record): SpacingModel { + const xml = files['settings.xml'] ? strFromU8(files['settings.xml']) : ''; + return /AddParaTableSpacing"[^>]*>false; try { @@ -582,6 +593,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = orientation: geometry?.orientation ?? null, format: geometry?.format ?? null, tabIntervalCm: resolver.defaultTabInterval(), + spacingModel: odfSpacingModel(files), header, footer, headerFirst, diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 40e274e..fdbe965 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -4,6 +4,12 @@ - **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; clamped 0–10). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. - **`tabInterval.ts`** — the document's default tab interval in **cm** (`DEFAULT_TAB_INTERVAL_CM = 1.25`, LibreOffice's for a new document; clamped 0.05–10). `applyTabIntervalVar` sets `--tab-interval`, which is `.tiptap`'s `tab-size`. A file that declares none falls back to its format's own value, neither of them ours: `ODF_IMPLIED_TAB_CM` 2cm (measured against LibreOffice), `DOCX_IMPLIED_TAB_CM` 1.27cm — so both exports always write the value out. +- **`spacingModel.ts`** — how the gap between two blocks is measured: `'add'` (LibreOffice's + own — space-below **plus** space-above) or `'max'` (the larger of the two, what it uses for a + Word document). Probed against soffice; per document, read from ODF `settings.xml` + (`AddParaTableSpacing`), always `'max'` on DOCX import, and written back on ODF export. + `editor.css` turns `--space-before` into padding or margin accordingly (CSS margins can + only collapse, so `'add'` cannot be a margin). - **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap). Both margins and orientation are passed into the ODT export so the exported document's geometry/line-wrapping matches the on-screen preview. @@ -19,6 +25,7 @@ Both margins and orientation are passed into the ODT export so the exported docu - **Page margins:** `odf-editor-page-margins` — JSON cm values. - **Page orientation:** `odf-editor-page-orientation` — `'portrait' | 'landscape'`. - **Tab interval:** `odf-editor-tab-interval` — cm as a decimal string. +- **Spacing model:** `odf-editor-spacing-model` — `'add' | 'max'`. - **Recent fonts:** `odf-editor-recent-fonts` — JSON string array (ToolbarExpanded). - **Header/footer:** `odf-editor-header` / `-footer` (HfDoc), `odf-editor-hf-distances`. - **Styles:** `odf-editor-styles` — the style registry (`styles/sheet.svelte.ts`). diff --git a/src/lib/storage/spacingModel.ts b/src/lib/storage/spacingModel.ts new file mode 100644 index 0000000..184c279 --- /dev/null +++ b/src/lib/storage/spacingModel.ts @@ -0,0 +1,14 @@ +// How the space between two blocks is measured. LibreOffice adds the upper block's +// space-below to the lower one's space-above ('add', its native ODF behaviour) but takes +// only the larger of the two for a Word document ('max') — probed, and per document. +export type SpacingModel = 'add' | 'max'; + +const KEY = 'odf-editor-spacing-model'; + +export function loadSpacingModel(): SpacingModel { + return localStorage.getItem(KEY) === 'max' ? 'max' : 'add'; +} + +export function saveSpacingModel(m: SpacingModel): void { + localStorage.setItem(KEY, m); +} diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 9e479e7..26c6949 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -245,7 +245,8 @@ function declarations(r: ResolvedStyle): string[] { const out = textDeclarations(r.text, true); if (p.textAlign) out.push(`text-align: ${p.textAlign}`); if (p.lineHeight) out.push(`--line-factor: ${p.lineHeight}`); - if (p.spaceBefore != null) out.push(`margin-top: ${p.spaceBefore}pt`); + // Padding or margin per the document's spacing model — editor.css resolves it. + if (p.spaceBefore != null) out.push(`--space-before: ${p.spaceBefore}pt`); if (p.spaceAfter != null) out.push(`margin-bottom: ${p.spaceAfter}pt`); if (p.indent != null) out.push(`margin-left: ${p.indent}cm`); if (p.backgroundColor) out.push(`background-color: ${p.backgroundColor}`); diff --git a/src/styles/editor.css b/src/styles/editor.css index 5cee549..d9e245c 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -392,6 +392,7 @@ } .paper .tiptap .textbox-content > :first-child { margin-top: 0; + padding-top: 0; } .paper .tiptap .textbox-content > :last-child { margin-bottom: 0; @@ -405,6 +406,7 @@ } .paper .tiptap .columns-node > :first-child { margin-top: 0; + padding-top: 0; } .paper .tiptap .columns-node > :last-child { margin-bottom: 0; @@ -462,6 +464,22 @@ margin: 0; } +/* Space above a block. LibreOffice adds it to the space below the block above, which CSS + margins can't do (they collapse to the larger) — so it renders as padding. A Word + document takes the larger, LibreOffice included, and gets a margin instead. */ +@property --space-before { + syntax: ''; + inherits: false; + initial-value: 0; +} +.paper .tiptap :is(p, h1, h2, h3, h4, h5, .toc, .toc-title) { + padding-top: var(--space-before); +} +.paper[data-spacing-model='max'] .tiptap :is(p, h1, h2, h3, h4, h5, .toc, .toc-title) { + padding-top: 0; + margin-top: var(--space-before); +} + /* The line box: the paragraph's spacing factor times its font's natural line height. Either half may come from the style rule or, nearer, from the block's own attrs (lineHeight.ts / blockFontSize.ts) — multiplying them here is what keeps both. */ @@ -848,7 +866,6 @@ } .paper .tiptap > *[data-page-break] { - margin-top: 0 !important; page-break-before: always; } diff --git a/tests/unit/spacing-model.test.ts b/tests/unit/spacing-model.test.ts new file mode 100644 index 0000000..fba3762 --- /dev/null +++ b/tests/unit/spacing-model.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { importOdt } from '../../src/lib/import/odt'; +import { buildOdt } from '../../src/lib/export/odt'; + +// LibreOffice adds the space below a block to the space above the next one, but takes +// only the larger of the two for a Word document — probed against soffice, and carried +// per document in settings.xml (AddParaTableSpacing). +const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }; + +describe('paragraph spacing model', () => { + it('round-trips the model through the ODF export', async () => { + const max = await buildOdt(doc as never, undefined, 'portrait', undefined, null, 'A4', undefined, undefined, 'max'); + expect(strFromU8(unzipSync(max)['settings.xml'])).toContain('"AddParaTableSpacing" config:type="boolean">false<'); + expect(importOdt(max).spacingModel).toBe('max'); + + // A file that says nothing gets LibreOffice's own default, which is to add them. + const add = await buildOdt(doc as never, undefined, 'portrait'); + expect(strFromU8(unzipSync(add)['settings.xml'])).not.toContain('AddParaTableSpacing'); + expect(importOdt(add).spacingModel).toBe('add'); + }); +});