From 6accc7268f9278d7e99284c6250d5ca1004a29ef Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 14:02:36 +0000 Subject: [PATCH] feat: every section keeps its own header and footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document had one set for all of it, taken from whichever section declared one last — so a fixture lost its letterhead entirely and page 1 got the empty header its final section asked for. A body block now carries `sectionBreak`, ordinal rather than indexed so editing cannot desync it. Import resolves Word's "Link to Previous" per reference type; export writes a set per sectPr, and on the ODF side each section past the first gets its own master page, which the block switches to. The layer picks a page's set from where pageBreaks reports the sections starting, and each section's zone reaches ride `--pb-section-reach` so a later section's letterhead makes room for itself: page 10 of a fixture was 24.9mm out, now 1.1mm. Editing still reaches section 1 only; the rest render read-only. --- src/App.svelte | 20 +++- src/lib/components/CLAUDE.md | 13 ++ src/lib/components/Editor.svelte | 27 ++++- src/lib/components/HeaderFooterLayer.svelte | 74 +++++++++--- src/lib/editor/extensions/pageBreak.ts | 16 ++- src/lib/editor/extensions/pageBreaks.ts | 47 ++++++-- src/lib/export/docx.ts | 84 ++++++++----- src/lib/export/odt.ts | 124 +++++++++++++++++++- src/lib/import/docx.ts | 86 ++++++++------ src/lib/import/odt.ts | 43 ++++++- src/lib/import/styleResolver.ts | 24 +++- src/lib/storage/headerFooter.ts | 46 ++++++++ tests/render-parity/README.md | 7 +- tests/unit/hf-sections.test.ts | 65 ++++++++++ 14 files changed, 567 insertions(+), 109 deletions(-) create mode 100644 tests/unit/hf-sections.test.ts diff --git a/src/App.svelte b/src/App.svelte index 2a973b5..5b9dd86 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -26,7 +26,7 @@ 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'; - import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, 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, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } 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'; @@ -69,6 +69,8 @@ let footerEvenDoc: HfDoc = $state(loadHfDoc('footer', 'even')); let differentOddEven: boolean = $state(loadDifferentOddEven()); let hfDistances: HfDistances = $state(loadHfDistances()); + // Sections past the first: imported and exported, not editable (see HeaderFooterLayer). + let extraHfSections: HfSet[] = $state(loadExtraHfSections()); let hfEditor: Editor | null = $state(null); let hfActive: HfZone | null = $state(null); let hfTick: number = $state(0); @@ -214,6 +216,9 @@ $effect(() => { saveHfDistances(hfDistances); }); + $effect(() => { + saveExtraHfSections(extraHfSections); + }); $effect(() => { saveDocName(documentName); @@ -377,6 +382,15 @@ let docxBusy = $state(false); let exportMenuOpen = $state(false); + // The editable zones as one section — section 1 of the export. + function hfSetOfState(): HfSet { + return { + header: headerDoc, footer: footerDoc, + headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage, + headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven, + }; + } + // odf-kit export options for the current header/footer + page geometry. function hfOpts() { return { @@ -384,6 +398,7 @@ headerFirst: headerFirstDoc, footerFirst: footerFirstDoc, differentFirstPage, headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven, pageCount: numPages, + sections: [hfSetOfState(), ...extraHfSections], headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer, }; } @@ -426,6 +441,7 @@ pageOrientation = 'portrait'; pageFormat = 'A4'; hfDistances = { ...DEFAULT_HF_DISTANCES }; + extraHfSections = []; documentName = ''; fileHandle = null; // Styles live in the document, so a new one starts from the built-ins @@ -497,6 +513,7 @@ headerEvenDoc = result.headerEven; footerEvenDoc = result.footerEven; differentOddEven = result.differentOddEven; + extraHfSections = (result.hfSections ?? []).slice(1); hfDistances = { header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header, footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer, @@ -1018,6 +1035,7 @@ bind:hfActive bind:hfTick {hfDistances} + {extraHfSections} {zoom} onZoom={setZoom} {showFormattingMarks} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 5ff9e01..a86c41c 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -52,3 +52,16 @@ One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, per ## Debug tooling (dev only) In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry) and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans) — used to diagnose pagination and color round-trip issues. + +## Per-section header/footer + +A body block carrying `sectionBreak` (pageBreak.ts) opens a section; `HeaderFooterLayer` +picks that section's `HfSet` per page from `sectionStartPages`, which `pageBreaks.ts` +reports on `pm-pagecount`. Section 1 is the app's own editable state, the rest ride along +from the file (`extraHfSections`, persisted whole) and are read-only. + +Word's "different first page" is per section, so a later section's first page shows its +own variant. `Editor.svelte` publishes each section's zone reaches as `--pb-section-reach` +("topFirst|topRest|bottomFirst|bottomRest" per section, comma-separated) and pageBreaks +resolves the content area per page from it — without it a tall letterhead on a later +section's first page would sit on top of the body (measured: 24.9mm). diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index fc89ec5..fef0eeb 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -21,7 +21,7 @@ import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { type Orientation } from '../storage/pageOrientation'; import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat'; - import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter'; + 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'; import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte'; import { wheelZoomFactor } from '../utils/zoom'; @@ -41,6 +41,7 @@ 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), + extraHfSections = [], }: { editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; onZoom?: (zoom: number) => void; @@ -49,8 +50,13 @@ headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number; + extraHfSections?: HfSet[]; } = $props(); + // Page each section after the first starts on (pageBreaks reports it); the layer + // turns it into a per-page set. + let sectionStartPages = $state([]); + // Apply the page margins + orientation to the :root CSS vars (visual padding, // page dimensions, and pagination all read these). DOM-only, safe in effects. $effect(() => { @@ -90,12 +96,25 @@ let effTopFirst = $derived(Math.max(mTopPx, hfReachPx((differentFirstPage ? headerFirstDoc : headerDoc) ?? null, headerDistPx))); let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx), evenBottomReach)); let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx))); + // Per-section reaches for pageBreaks: "topFirst|topRest|bottomFirst|bottomRest" in px, + // one group per section, comma-separated. Section 1 repeats the four vars below. + let sectionReach = $derived([ + [effTopFirst, effTopRest, effBottomFirst, effBottomRest], + ...extraHfSections.map((s) => [ + Math.max(mTopPx, hfReachPx((s.differentFirstPage ? s.headerFirst : s.header) ?? null, headerDistPx)), + Math.max(mTopPx, hfReachPx(s.header ?? null, headerDistPx), s.differentOddEven ? hfReachPx(s.headerEven ?? null, headerDistPx) : 0), + Math.max(mBottomPx, hfReachPx((s.differentFirstPage ? s.footerFirst : s.footer) ?? null, footerDistPx)), + Math.max(mBottomPx, hfReachPx(s.footer ?? null, footerDistPx), s.differentOddEven ? hfReachPx(s.footerEven ?? null, footerDistPx) : 0), + ]), + ].map((g) => g.map((n) => Math.round(n)).join('|')).join(',')); + $effect(() => { const s = document.documentElement.style; s.setProperty('--pb-content-top-rest', `${effTopRest}px`); s.setProperty('--pb-content-top-first', `${effTopFirst}px`); s.setProperty('--pb-content-bottom-rest', `${effBottomRest}px`); s.setProperty('--pb-content-bottom-first', `${effBottomFirst}px`); + s.setProperty('--pb-section-reach', sectionReach); }); // Nudge the pageBreaks plugin to recompute with the new content area. The dispatch @@ -109,6 +128,7 @@ void pageFormat; // …and the header/footer-driven effective margins, so growing a zone re-paginates. void (effTopRest + effTopFirst + effBottomRest + effBottomFirst); + void sectionReach; const ed = editor; if (!ed) return; cancelAnimationFrame(marginRecalcRaf); @@ -591,10 +611,11 @@ } function onPageCount(e: Event) { - const detail = (e as CustomEvent<{ numPages: number; docHeight?: number; tableBreakBands?: TableBreakBand[] }>).detail; + const detail = (e as CustomEvent<{ numPages: number; docHeight?: number; tableBreakBands?: TableBreakBand[]; sectionStartPages?: number[] }>).detail; numPages = detail.numPages; if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight; tableBandsDoc = detail.tableBreakBands ?? []; + sectionStartPages = detail.sectionStartPages ?? []; // The document height changed → resize the scaled scroll footprint. recomputeScaledSize(); updateCurrentPage(); @@ -829,6 +850,8 @@ {orientation} {pageFormat} {hfDistances} + {extraHfSections} + {sectionStartPages} /> diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index a8fd158..fbc1848 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -1,7 +1,7 @@