From 2a7abed91730141345d7b6a3695ce4acbdac1fa3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 18 Jul 2026 16:42:23 +0200 Subject: [PATCH] feat: odd/even variants for footer/header --- CHANGELOG.md | 2 +- src/App.svelte | 37 ++++++++++++- src/lib/components/Editor.svelte | 13 ++++- src/lib/components/HeaderFooterLayer.svelte | 52 ++++++++++++------ src/lib/components/ToolbarExpanded.svelte | 8 ++- src/lib/export/docx.ts | 15 ++++-- src/lib/export/odt.ts | 59 ++++++++++++++------- src/lib/export/pdf.ts | 9 ++++ src/lib/i18n/locales/de.ts | 5 +- src/lib/i18n/locales/en.ts | 5 +- src/lib/import/docx.ts | 49 +++++++++++------ src/lib/import/odt.ts | 20 ++++--- src/lib/import/styleResolver.ts | 8 +-- src/lib/storage/headerFooter.ts | 23 ++++++-- tests/docx-roundtrip.test.ts | 20 +++++++ tests/roundtrip.test.ts | 26 +++++++++ 16 files changed, 272 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf89f4..1e66899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ - Page margins (cm) and page orientation (portrait / landscape) - Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import - Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols` -- Headers & footers with page-number / page-count fields and configurable edge distances +- Headers & footers: page-number / page-count fields, configurable edge distances, and Word-style variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`) - Zoom (20–300 %) - Show formatting marks (spaces, tabs, paragraph marks) diff --git a/src/App.svelte b/src/App.svelte index da93d3d..d31ec81 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -21,7 +21,7 @@ import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins'; import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation'; import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat'; - import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter'; + import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter'; import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; @@ -49,6 +49,10 @@ let headerFirstDoc: HfDoc = $state(loadHfDoc('header', 'first')); let footerFirstDoc: HfDoc = $state(loadHfDoc('footer', 'first')); let differentFirstPage: boolean = $state(loadDifferentFirstPage()); + // Even-page header/footer (Word "Different Odd & Even Pages"), shown on even pages when on. + let headerEvenDoc: HfDoc = $state(loadHfDoc('header', 'even')); + let footerEvenDoc: HfDoc = $state(loadHfDoc('footer', 'even')); + let differentOddEven: boolean = $state(loadDifferentOddEven()); let hfDistances: HfDistances = $state(loadHfDistances()); let hfEditor: Editor | null = $state(null); let hfActive: HfZone | null = $state(null); @@ -167,6 +171,19 @@ saveHfDoc('footer', footerFirstDoc, 'first'); }); + $effect(() => { + saveHfDoc('header', headerEvenDoc, 'even'); + }); + + $effect(() => { + saveHfDoc('footer', footerEvenDoc, 'even'); + }); + + $effect(() => { + saveDifferentOddEven(differentOddEven); + hfActive = null; + }); + // Persist the flag and end any active header/footer edit when it flips (the live // editor is bound to one variant for its lifetime). $effect(() => { @@ -325,6 +342,7 @@ return { header: headerDoc, footer: footerDoc, headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage, + headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven, pageCount: numPages, headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer, }; @@ -333,7 +351,7 @@ function isDocNonEmpty(): boolean { if (!editor) return false; const body = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1; - return body || !hfIsEmpty(headerDoc) || !hfIsEmpty(footerDoc) || !hfIsEmpty(headerFirstDoc) || !hfIsEmpty(footerFirstDoc); + return body || !hfIsEmpty(headerDoc) || !hfIsEmpty(footerDoc) || !hfIsEmpty(headerFirstDoc) || !hfIsEmpty(footerFirstDoc) || !hfIsEmpty(headerEvenDoc) || !hfIsEmpty(footerEvenDoc); } function handleNew() { @@ -347,6 +365,9 @@ headerFirstDoc = null; footerFirstDoc = null; differentFirstPage = false; + headerEvenDoc = null; + footerEvenDoc = null; + differentOddEven = false; pageMargins = { ...DEFAULT_MARGINS }; pageOrientation = 'portrait'; pageFormat = 'A4'; @@ -412,6 +433,9 @@ headerFirstDoc = result.headerFirst; footerFirstDoc = result.footerFirst; differentFirstPage = result.differentFirstPage; + headerEvenDoc = result.headerEven; + footerEvenDoc = result.footerEven; + differentOddEven = result.differentOddEven; hfDistances = { header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header, footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer, @@ -426,6 +450,8 @@ collectFontFamilies(result.footer, fontSet); collectFontFamilies(result.headerFirst, fontSet); collectFontFamilies(result.footerFirst, fontSet); + collectFontFamilies(result.headerEven, fontSet); + collectFontFamilies(result.footerEven, fontSet); const missingFonts = await unavailableFonts(fontSet); const warnings = result.warnings.map(localizeImportMessage); @@ -582,6 +608,9 @@ headerFirstDoc, footerFirstDoc, differentFirstPage, + headerEvenDoc, + footerEvenDoc, + differentOddEven, }); } catch (err) { console.error('[pdf] Print failed:', err); @@ -892,6 +921,7 @@ bind:pageFormat bind:hfDistances bind:differentFirstPage + bind:differentOddEven hfActive={hfActive} onEditZone={(zone) => (hfActive = zone)} onDebugDump={handleDebugDump} @@ -913,6 +943,9 @@ bind:headerFirstDoc bind:footerFirstDoc {differentFirstPage} + bind:headerEvenDoc + bind:footerEvenDoc + {differentOddEven} bind:hfEditor bind:hfActive bind:hfTick diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 768523f..ead2d38 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -35,12 +35,14 @@ pageFormat = 'A4', 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, hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0), }: { editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; + headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number; } = $props(); @@ -70,9 +72,13 @@ // Effective top/bottom margins (px) for other pages vs. page 1's own header/footer // (different first page); pageBreaks reads these to start each page's content below // its header and end it above its footer. - let effTopRest = $derived(Math.max(mTopPx, hfReachPx(headerDoc ?? null, headerDistPx))); + // "rest" = every page ≥ 2; fold the even variant in (max) so even pages never overlap + // a taller even zone — pageBreaks uses one --pb-content-*-rest for all of them. + let evenTopReach = $derived(differentOddEven ? hfReachPx(headerEvenDoc ?? null, headerDistPx) : 0); + let evenBottomReach = $derived(differentOddEven ? hfReachPx(footerEvenDoc ?? null, footerDistPx) : 0); + let effTopRest = $derived(Math.max(mTopPx, hfReachPx(headerDoc ?? null, headerDistPx), evenTopReach)); let effTopFirst = $derived(Math.max(mTopPx, hfReachPx((differentFirstPage ? headerFirstDoc : headerDoc) ?? null, headerDistPx))); - let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx))); + let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx), evenBottomReach)); let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx))); $effect(() => { const s = document.documentElement.style; @@ -733,6 +739,9 @@ bind:headerFirstDoc bind:footerFirstDoc {differentFirstPage} + bind:headerEvenDoc + bind:footerEvenDoc + {differentOddEven} bind:hfEditor bind:hfActive bind:hfTick diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 29e6b51..af9a0f3 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -1,7 +1,7 @@