feat: a document can turn off the spacing at the start of a page

LibreOffice's compatibility option (ODF AddParaTableSpacingAtStart, Word's
w:suppressSpBfAfterPgBrk read the other way round). Off, the block that opens a
page loses its space above however the page broke — probed by flipping the flag
in one 63-page document, whose chapter heading then moved from 25.4mm to 20.0mm,
which is where the editor had been drawing every one of them. On is the default
both products write, and both formats carry the flag back out. Fixture 244 → 160.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:09 +02:00
1 parent 86cacc40b1
commit 80f68f1883
10 files changed
+129 -20

No files matched your search

+12 -6
View File
@@ -30,7 +30,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 { loadSpacingModel, saveSpacingModel, loadSpacingAtPageStart, saveSpacingAtPageStart, type SpacingModel } from './lib/storage/spacingModel';
import { loadPageRtl, savePageRtl } from './lib/storage/writingMode';
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
@@ -168,6 +168,7 @@
let pageFormat: PageFormat = $state(loadPageFormat());
let tabIntervalCm = $state(loadTabInterval());
let spacingModel: SpacingModel = $state(loadSpacingModel());
let spacingAtPageStart = $state(loadSpacingAtPageStart());
// The page's text direction, from the file's own page setup (writingMode.ts).
let pageRtl = $state(loadPageRtl());
// Bumped for each document opened (import, new); Editor hides the page until that
@@ -259,6 +260,7 @@
$effect(() => {
saveSpacingModel(spacingModel);
saveSpacingAtPageStart(spacingAtPageStart);
});
@@ -576,6 +578,7 @@
pageFormat = 'A4';
tabIntervalCm = DEFAULT_TAB_INTERVAL_CM;
spacingModel = 'add';
spacingAtPageStart = true;
hfDistances = { ...DEFAULT_HF_DISTANCES };
extraHfSections = [];
documentName = '';
@@ -722,6 +725,7 @@
if (result.format) pageFormat = result.format;
if (result.tabIntervalCm) tabIntervalCm = result.tabIntervalCm;
spacingModel = result.spacingModel;
spacingAtPageStart = result.spacingAtPageStart !== false;
pageRtl = result.rtl;
hyphenate = result.hyphenate;
pageNumbering = result.pageNumbering;
@@ -863,12 +867,12 @@
// reference word processors — not silently rewritten to .odt under its old name.
if (documentFormat === 'docx') {
const { buildDocx } = await import('./lib/export/docx');
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks);
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
fileHandle = await saveDocx(bytes, suggestedFilenameDocx(json), fileHandle, docPassword);
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilenameDocx(json), fileHandle);
return;
}
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle, docPassword);
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle);
} catch (err) {
@@ -886,7 +890,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, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
fileHandle = await saveAsOdt(bytes, suggestedFilename(json), docPassword);
documentFormat = 'odt'; // Save As is odt-only, so a docx-opened document switches format here.
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle);
@@ -906,7 +910,7 @@
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
await saveAsTemplate(async (kind) => {
const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks] as const;
const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart] as const;
const { odtToOtt, docxToDotx } = await import('./lib/export/template');
if (kind === 'dotx') {
const { buildDocx } = await import('./lib/export/docx');
@@ -959,7 +963,7 @@
try {
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
const { buildDocx } = await import('./lib/export/docx');
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks);
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
await saveAsDocx(bytes, suggestedFilenameDocx(json), docPassword);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -1135,6 +1139,7 @@
// dump carries the spacing that decides that: the sheet and the model it uses.
tableCells: getTableCellDebug(editor.view),
spacingModel,
spacingAtPageStart,
styles: styleSheet(),
colors: getColorDebug(editor),
};
@@ -1539,6 +1544,7 @@
{hfDistances}
{tabIntervalCm}
{spacingModel}
{spacingAtPageStart}
{hyphenate}
{documentLanguage}
{pageNumbering}
+3 -2
View File
@@ -60,7 +60,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, splitView = false, pageColumns = 1, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING, foldMarks = false, commentAuthor = '',
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', spacingAtPageStart = true, documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING, foldMarks = false, commentAuthor = '',
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,
@@ -69,7 +69,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
}: {
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; spacingModel?: SpacingModel;
showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; tabIntervalCm?: number; spacingModel?: SpacingModel; spacingAtPageStart?: boolean;
/** Two panes onto this document, scrolled on their own (Word's View ▸ Split). */
splitView?: boolean;
/** How many pages sit side by side (LibreOffice's View layout ▸ Columns); 1 = off. */
@@ -306,6 +306,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
$effect(() => {
const s = document.documentElement.style;
s.setProperty('--pb-section-inset', sectionInset);
s.setProperty('--pb-space-at-page-start', spacingAtPageStart ? '1' : '0');
s.setProperty('--pb-section-mirror', sectionMirror);
s.setProperty('--pb-section-page', sectionPaper.map((p) => p.h).join(','));
s.setProperty('--pb-paper-width', `${paperWidth}px`);
+7 -4
View File
@@ -1165,6 +1165,8 @@ export const PageBreaks = Extension.create({
// Mirrored margins: the padding draws the odd page's pair, so an even page's
// blocks move right by the difference. Per section — a cover between mirrored
// ones has margins of its own and mirrors nothing.
// The document's "spacing at the start of a page" switch (spacingModel.ts).
const spacingAtStart = csRoot.getPropertyValue('--pb-space-at-page-start').trim() !== '0';
const mirrors = csRoot.getPropertyValue('--pb-section-mirror').split(',')
.map((g) => g.split('|').map(Number))
.filter((g) => g.length === 2 && g.every(Number.isFinite));
@@ -1486,11 +1488,12 @@ export const PageBreaks = Extension.create({
// A page break the flow had to make — its own spacer, or one that left it at
// the top — swallows the block's space above; a break the document asks for
// keeps it (probed: a page top is 20.01mm, a manual break with 6/20mm above
// puts the block at 26.00/40.01, an automatic one at 20.01 for either). A line
// split doesn't: there the page starts mid-block. `effectiveTop` still
// excludes this leaf's own push.
// puts the block at 26.00/40.01, an automatic one at 20.01 for either) —
// unless the document turns spacing at a page start off altogether, where
// even a manual break loses it. A line split doesn't: there the page starts
// mid-block. `effectiveTop` still excludes this leaf's own push.
if (
i > 0 && !leaf.inTableCell && !leaf.forceBreakBefore && (leaf.spaceAbove ?? 0) > 0.5
i > 0 && !leaf.inTableCell && !(leaf.forceBreakBefore && spacingAtStart) && (leaf.spaceAbove ?? 0) > 0.5
&& (breaks.some((b) => b.reason !== 'line-split')
|| (breaks.length === 0 && Math.abs(effectiveTop - contentStart) < 0.5))
) {
+19 -1
View File
@@ -1767,6 +1767,22 @@ function orderDocxSettings(bytes: Uint8Array): Uint8Array {
return zipSync(out);
}
// Post-pack pass: no space above the block that opens a page — Word keeps it in the
// compatibility set, where LibreOffice reads it into AddParaTableSpacingAtStart.
function applySpacingAtStartDocx(bytes: Uint8Array): Uint8Array {
const files = unzipSync(bytes);
const setBytes = files['word/settings.xml'];
if (!setBytes) return bytes;
const xml = strFromU8(setBytes);
if (xml.includes('w:suppressSpBfAfterPgBrk')) return bytes;
files['word/settings.xml'] = strToU8(/<w:compat\b[^>]*>/.test(xml)
? xml.replace(/(<w:compat\b[^>]*>)/, '$1<w:suppressSpBfAfterPgBrk/>')
: xml.replace(/(<w:settings\b[^>]*>)/, '$1<w:compat><w:suppressSpBfAfterPgBrk/></w:compat>'));
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
// Post-pack pass: Word's mirror margins are a document setting (w:mirrorMargins in
// word/settings.xml); the docx lib writes only the per-section w:pgMar, where left and
// right are already the inner/outer pair the editor holds.
@@ -2676,6 +2692,7 @@ export async function buildDocx(
lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING,
recordChanges = false,
foldMarks = false,
spacingAtPageStart = true,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -2882,7 +2899,8 @@ export async function buildDocx(
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(threaded) : threaded;
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
const dims = pageDimsCm(pageFormat, orientation);
const marked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10);
const foldMarked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10);
const marked = spacingAtPageStart ? foldMarked : applySpacingAtStartDocx(foldMarked);
if (isEmptyPageDecor(decor)) return orderDocxSettings(marked);
const pt = (cm: number) => (cm / 2.54) * 72;
return orderDocxSettings(applyPageDecorDocx(marked, decor,
+8 -6
View File
@@ -4856,7 +4856,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, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false, foldMarks = false): 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', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false, foldMarks = false, spacingAtPageStart = true): 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).
@@ -5121,7 +5121,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// 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.
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation);
return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props)));
return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel, spacingAtPageStart), pageNumbering.start), props)));
}
// The package declares ODF 1.3 in every part — the version LibreOffice writes, and
@@ -5187,13 +5187,15 @@ function applyDocProperties(odtBytes: Uint8Array, props: DocProperties): Uint8Ar
// 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;
// between every pair of blocks would grow when the file is reopened. The same file
// carries whether a block opening a page keeps its space above.
function applySpacingModel(odtBytes: Uint8Array, model: SpacingModel, atPageStart = true): Uint8Array {
if (model !== 'max' && atPageStart) 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>' +
(model === 'max' ? '<config:config-item config:name="AddParaTableSpacing" config:type="boolean">false</config:config-item>' : '') +
(atPageStart ? '' : '<config:config-item config:name="AddParaTableSpacingAtStart" config:type="boolean">false</config:config-item>') +
'</config:config-item-set>';
const existing = files['settings.xml'];
if (existing) {
+15
View File
@@ -278,6 +278,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
// 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,
// Word applies space above at a page top unless its own compatibility flag says not to.
spacingAtPageStart: !docSuppressSpaceAfterBreak(files),
header: first.header,
footer: first.footer,
headerFirst: first.differentFirstPage ? first.headerFirst : null,
@@ -2926,6 +2928,19 @@ function docxPageNumbering(sectPr: Element | null): PageNumbering {
}
// Word's Layout ▸ Hyphenation, from settings.xml (absent = off, as in Word).
// w:suppressSpBfAfterPgBrk — Word's own "no space above the block that opens a page",
// the flag LibreOffice reads into AddParaTableSpacingAtStart.
function docSuppressSpaceAfterBreak(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
try {
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'suppressSpBfAfterPgBrk')[0];
return !!el && onOff(el);
} catch {
return false;
}
}
function docAutoHyphenation(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
+9
View File
@@ -86,6 +86,7 @@ export interface OdtImportResult {
// 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;
spacingAtPageStart: boolean;
// Single-paragraph docs in the hfExtensions schema; null = no zone.
header: HfDoc;
footer: HfDoc;
@@ -749,6 +750,13 @@ function odfSpacingModel(files: Record<string, Uint8Array>): SpacingModel {
return /AddParaTableSpacing"[^>]*>false</.test(xml) ? 'max' : 'add';
}
// The same settings file says whether the block that opens a page keeps its space above
// (AddParaTableSpacingAtStart). Absent = on, which is what LibreOffice writes by default.
function odfSpacingAtPageStart(files: Record<string, Uint8Array>): boolean {
const xml = files['settings.xml'] ? strFromU8(files['settings.xml']) : '';
return !/AddParaTableSpacingAtStart"[^>]*>false</.test(xml);
}
export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): OdtImportResult {
let files: Record<string, Uint8Array>;
try {
@@ -857,6 +865,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
pageNumbering: { format: docNumFormat, start: odfPageNumberStart(resolver, body) },
tabIntervalCm: resolver.defaultTabInterval(),
spacingModel: odfSpacingModel(files),
spacingAtPageStart: odfSpacingAtPageStart(files),
header,
footer,
headerFirst,
+6 -1
View File
@@ -12,7 +12,12 @@ password-protected: the protection is on the saved file, not on the browser copy
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).
only collapse, so `'add'` cannot be a margin). The same module holds **spacing at a page
start** (`AddParaTableSpacingAtStart`, Word's `w:suppressSpBfAfterPgBrk` read the other
way): off, the block that opens a page loses its space above however the page broke —
probed by flipping the flag in one document, whose heading then moved 25.4mm → 20.0mm.
On is the default both products write, and `pageBreaks.ts` reads it as
`--pb-space-at-page-start`.
- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `edentext-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read.
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it).
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true.
+16
View File
@@ -12,3 +12,19 @@ export function loadSpacingModel(): SpacingModel {
export function saveSpacingModel(m: SpacingModel): void {
localStorage.setItem(KEY, m);
}
// LibreOffice's "add spacing between paragraphs and tables at the beginning of pages"
// (ODF settings.xml AddParaTableSpacingAtStart, Word's w:suppressSpBfAfterPgBrk read the
// other way round). Off, the block that opens a page loses its space above however the
// page broke — probed: the same document renders its heading at 25.4mm with the option
// on and at 20.0mm with it off. On is the default both products write.
const AT_START_KEY = 'edentext-spacing-at-page-start';
export function loadSpacingAtPageStart(): boolean {
return localStorage.getItem(AT_START_KEY) !== 'false';
}
export function saveSpacingAtPageStart(on: boolean): void {
if (on) localStorage.removeItem(AT_START_KEY);
else localStorage.setItem(AT_START_KEY, 'false');
}
+34
View File
@@ -0,0 +1,34 @@
// LibreOffice's "add spacing at the beginning of pages" (ODF AddParaTableSpacingAtStart,
// Word's w:suppressSpBfAfterPgBrk): off, the block opening a page loses its space above.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { importOdt } from '../../src/lib/import/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importDocx } from '../../src/lib/import/docx';
type N = any;
const doc: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }] };
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const args = [margins, 'portrait' as const, undefined, null, 'A4' as const] as const;
describe('spacing at the start of a page', () => {
it('is on unless the file turns it off', async () => {
const on = await buildOdt(doc, ...args);
expect(strFromU8(unzipSync(on)['settings.xml'] ?? new Uint8Array())).not.toContain('AddParaTableSpacingAtStart');
expect(importOdt(on).spacingAtPageStart).toBe(true);
expect(importDocx(await buildDocx(doc, ...args)).spacingAtPageStart).toBe(true);
});
it('rides both formats when it is off', async () => {
const rest = [undefined, undefined, 'add' as const, false, undefined, undefined, false,
undefined, undefined, undefined, false, false, false] as const;
const odt = await buildOdt(doc, ...args, ...rest);
expect(strFromU8(unzipSync(odt)['settings.xml'])).toContain('AddParaTableSpacingAtStart');
expect(importOdt(odt).spacingAtPageStart).toBe(false);
const docx = await buildDocx(doc, ...args, ...rest);
expect(strFromU8(unzipSync(docx)['word/settings.xml'])).toContain('<w:suppressSpBfAfterPgBrk/>');
expect(importDocx(docx).spacingAtPageStart).toBe(false);
});
});