diff --git a/src/App.svelte b/src/App.svelte index 82b38f4..ffd7837 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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[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[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[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} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 6d9cc6b..48c2b6e 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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`); diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 8b7d063..023cb11 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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)) ) { diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index a657efb..8be0e29 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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(/]*>/.test(xml) + ? xml.replace(/(]*>)/, '$1') + : xml.replace(/(]*>)/, '$1')); + const out: Record = {}; + 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 { 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, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index f556618..92aaed9 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 { +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 { // 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 = '' + - 'false' + + (model === 'max' ? 'false' : '') + + (atPageStart ? '' : 'false') + ''; const existing = files['settings.xml']; if (existing) { diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 4e5c086..c374316 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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): 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): boolean { const bytes = files['word/settings.xml']; if (!bytes) return false; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 658200c..644a291 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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): SpacingModel { return /AddParaTableSpacing"[^>]*>false): boolean { + const xml = files['settings.xml'] ? strFromU8(files['settings.xml']) : ''; + return !/AddParaTableSpacingAtStart"[^>]*>false; 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, diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 65c5099..891a998 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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. diff --git a/src/lib/storage/spacingModel.ts b/src/lib/storage/spacingModel.ts index 72b3c6a..eb3828f 100644 --- a/src/lib/storage/spacingModel.ts +++ b/src/lib/storage/spacingModel.ts @@ -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'); +} diff --git a/tests/unit/spacing-at-page-start.test.ts b/tests/unit/spacing-at-page-start.test.ts new file mode 100644 index 0000000..500190d --- /dev/null +++ b/tests/unit/spacing-at-page-start.test.ts @@ -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(''); + expect(importDocx(docx).spacingAtPageStart).toBe(false); + }); +});