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:
1 parent
af299d8b9f
commit
d6bc656c83
13 files changed
+154
-15
No files matched your search
+11
-2
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user