feat: the space between two blocks follows the document's own model

LibreOffice adds the upper block's space-below to the lower one's
space-above, and drops the space-above where a page break put the block
at a page top — but for a Word document it takes only the larger of the
two (all probed against soffice). CSS margins can only collapse, so
space-before rides --space-before and editor.css renders it as padding
('add') or margin ('max'); the model is read from settings.xml, assumed
for DOCX, and written back on ODF export.
This commit is contained in:
Steffen Becker committed 2026-08-09 16:53:13 +00:00
1 parent af299d8b9f
commit d6bc656c83
13 files changed
+154 -15

No files matched your search

+11 -2
View File
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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}
+6 -3
View File
@@ -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 @@
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
<div bind:this={paperEl} class="paper" class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} style="transform: scale({appliedZoom / 100});">
<div bind:this={paperEl} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} style="transform: scale({appliedZoom / 100});">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={element} class="tiptap-host"></div>
+1 -1
View File
@@ -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.
+18 -1
View File
@@ -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)
@@ -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<ReturnType> {
@@ -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`,
};
},
},
+36 -2
View File
@@ -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<Uint8Array> {
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<Uint8Array> {
// 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 =
'<config:config-item-set config:name="ooo:configuration-settings">' +
'<config:config-item config:name="AddParaTableSpacing" config:type="boolean">false</config:config-item>' +
'</config:config-item-set>';
const existing = files['settings.xml'];
if (existing) {
files['settings.xml'] = strToU8(strFromU8(existing).replace('</office:settings>', `${item}</office:settings>`));
return rezipOdt(files);
}
files['settings.xml'] = strToU8(
'<?xml version="1.0" encoding="UTF-8"?>' +
'<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"' +
' xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" office:version="1.3">' +
`<office:settings>${item}</office:settings></office:document-settings>`,
);
const manifestBytes = files['META-INF/manifest.xml'];
if (manifestBytes) {
const entry = '<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>';
const manifest = strFromU8(manifestBytes);
if (!manifest.includes('"settings.xml"')) {
files['META-INF/manifest.xml'] = strToU8(manifest.replace('</manifest:manifest>', `${entry}</manifest:manifest>`));
}
}
return rezipOdt(files);
}
function hfAlign(para: TiptapNode): AlignValue | null {
+3
View File
@@ -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,
+12
View File
@@ -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<string, Uint8Array>): SpacingModel {
const xml = files['settings.xml'] ? strFromU8(files['settings.xml']) : '';
return /AddParaTableSpacing"[^>]*>false</.test(xml) ? 'max' : 'add';
}
export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
let files: Record<string, Uint8Array>;
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,
+7
View File
@@ -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`).
+14
View File
@@ -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);
}
+2 -1
View File
@@ -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}`);
+18 -1
View File
@@ -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: '<length>';
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;
}
+22
View File
@@ -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');
});
});