From 0c1eb2626c70a185e1b78fba95d99ebdd2e08d76 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 01:20:25 +0200 Subject: [PATCH] fix: a section's zones sit at the distance its own page setup gives them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge→zone distance was document-wide, so an index whose header starts 4cm down had its body open 14.5mm too high and the page under it followed. HfSet carries the section's own pair (and its first page's, where the page style hands over): ODF's page-layout margin is that distance, Word's is w:pgMar w:header/w:footer, and both exports write it back. --- src/lib/components/CLAUDE.md | 2 +- src/lib/components/Editor.svelte | 13 +++++++++---- src/lib/components/HeaderFooterLayer.svelte | 20 +++++++++++++++----- src/lib/export/docx.ts | 5 ++++- src/lib/export/odt.ts | 9 ++++++++- src/lib/import/docx.ts | 18 +++++++++++++++++- src/lib/import/odt.ts | 19 +++++++++++++++++-- src/lib/import/styleResolver.ts | 4 ++-- src/lib/storage/headerFooter.ts | 6 ++++++ tests/unit/hf-sections.test.ts | 16 ++++++++++++++++ 10 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 8ab5f55..eea186f 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -128,7 +128,7 @@ Both anchors differ: a wheel zoom holds the **point under the cursor** fixed (`p ## Headers & footers (`HeaderFooterLayer.svelte`, `storage/headerFooter.ts`) -One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, persisted to `edentext-header`/`-footer`), repeated on every page. Double-clicking a page margin — or the Layout-panel "Edit header/footer" buttons — opens the zone for editing; clicking back into the body (`onFocus`) or Escape ends it. The edge→zone distance (header from top, footer from bottom) is user-configurable in cm via the Layout panel (`HfDistances`, persisted to `edentext-hf-distances`, default `HF_DISTANCE_CM` 1.27cm, clamped 0–10 and below the body margin). +One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, persisted to `edentext-header`/`-footer`), repeated on every page. Double-clicking a page margin — or the Layout-panel "Edit header/footer" buttons — opens the zone for editing; clicking back into the body (`onFocus`) or Escape ends it. The edge→zone distance (header from top, footer from bottom) is user-configurable in cm via the Layout panel (`HfDistances`, persisted to `edentext-hf-distances`, default `HF_DISTANCE_CM` 1.27cm, clamped 0–10 and below the body margin). A **section** can have its own (`HfSet.distances`, `distancesFirst` where its page style hands over): ODF's page-layout margin *is* that distance, Word's is `w:pgMar w:header/w:footer`, and an index whose header starts 4cm down is not the body's 1.25 — `zoneBox` and `sectionReach` both read the section's. - **Fields:** the bar beside the active zone inserts the `pageField.ts` atoms — page number, page count, chapter (the running head, level 1). Each is patched per page, so the same zone shows a different value on every page. - **Rendering:** the zone's own font is the document's **default paragraph style** (`styleCss` gives `.paper .hf-layer .hf-zone` its text half): both formats base the Header/Footer style on it, so a file whose body is Arial 10pt has an Arial footer, not the editor's serif. `HeaderFooterLayer.svelte` mounts inside the scaled `.paper` (like `.band-layer`), positioning per-page zone boxes in unscaled doc px. A zone's out-of-flow frame (`wrap` ≠ inline) is hidden in the box and painted per page by `.hf-bg-layer` at `z-index: -1` — hence `.paper`, not `.tiptap`, paints the page surface, so the image lands between the sheet and the body text. Inactive zones render as static HTML (`generateHTML` + `hfExtensions`); the active zone hosts the single live TipTap editor. `App.svelte`'s `activeEditor`/`activeTick` route the top toolbars to `hfEditor` while a zone is active, so all body formatting works on the header/footer with no toolbar changes. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 7fde074..6d9cc6b 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -225,13 +225,18 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; const first = s.marginsFirst ?? rest; const topOf = (m: PageMargins | null) => (m ? cmToPx(m.top) : mTopPx); const bottomOf = (m: PageMargins | null) => (m ? cmToPx(m.bottom) : mBottomPx); + // A section's zones sit at its own edge distance where its page setup gives it one. + const restD = s.distances ?? null; + const firstD = s.distancesFirst ?? restD; + const hDist = (d: HfDistances | null) => (d ? cmToPx(d.header) : headerDistPx); + const fDist = (d: HfDistances | null) => (d ? cmToPx(d.footer) : footerDistPx); const reach = (key: HfZoneKey, dist: number, footer = false) => hfReachPx(s[key] ?? null, dist, footer, zoneHeightPx(i + 1, key)); return [ - Math.max(topOf(first), reach(s.differentFirstPage ? 'headerFirst' : 'header', headerDistPx)), - Math.max(topOf(rest), reach('header', headerDistPx), s.differentOddEven ? reach('headerEven', headerDistPx) : 0), - Math.max(bottomOf(first), reach(s.differentFirstPage ? 'footerFirst' : 'footer', footerDistPx, true)), - Math.max(bottomOf(rest), reach('footer', footerDistPx, true), s.differentOddEven ? reach('footerEven', footerDistPx, true) : 0), + Math.max(topOf(first), reach(s.differentFirstPage ? 'headerFirst' : 'header', hDist(firstD))), + Math.max(topOf(rest), reach('header', hDist(restD)), s.differentOddEven ? reach('headerEven', hDist(restD)) : 0), + Math.max(bottomOf(first), reach(s.differentFirstPage ? 'footerFirst' : 'footer', fDist(firstD), true)), + Math.max(bottomOf(rest), reach('footer', fDist(restD), true), s.differentOddEven ? reach('footerEven', fDist(restD), true) : 0), ]; }), ].map((g) => g.map((n) => Math.round(n)).join('|')).join(',')); diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 0a98045..00b9fcc 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -100,6 +100,15 @@ // bottom margin (Word's w:footer > w:bottom); the zone then grows up into the margin. let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), pageHeightPx)); let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), pageHeightPx)); + // A section whose page setup gives it its own distances puts its zones on those. + function distancesOf(page: number): { header: number; footer: number } { + const i = sectionOf(page); + const s = sets[i]; + const d = (page === sectionFirstPage(i) ? s?.distancesFirst ?? s?.distances : s?.distances) ?? null; + return d + ? { header: Math.min(cmToPx(d.header), pageHeightPx), footer: Math.min(cmToPx(d.footer), pageHeightPx) } + : { header: headerDistPx, footer: footerDistPx }; + } let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1)); @@ -107,17 +116,18 @@ // Mirrored margins: an even page is the left-hand one, so the pair is swapped. const box = boxOf(page); const m = marginsOf(page); + const dist = distancesOf(page); const swap = m.mirrored && page % 2 === 0; // From the page's own left edge — a section on narrower paper is centred. const left = box.left + cmToPx(swap ? m.right : m.left); const width = contentWidthOf(page); if (zone === 'header') { - const top = box.top + headerDistPx; - return { top, left, width, height: Math.max(MIN_ZONE_PX, cmToPx(m.top) - headerDistPx) }; + const top = box.top + dist.header; + return { top, left, width, height: Math.max(MIN_ZONE_PX, cmToPx(m.top) - dist.header) }; } - // Footer anchors its bottom edge at footerDistPx from the page bottom and grows up. - const height = Math.max(MIN_ZONE_PX, cmToPx(m.bottom) - footerDistPx); - const top = box.top + box.height - footerDistPx - height; + // Footer anchors its bottom edge at its distance from the page bottom and grows up. + const height = Math.max(MIN_ZONE_PX, cmToPx(m.bottom) - dist.footer); + const top = box.top + box.height - dist.footer - height; return { top, left, width, height }; } // The active (edited) zone grows to fit its content, keeping the anchored edge fixed diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 5aa22eb..a657efb 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2754,12 +2754,15 @@ export async function buildDocx( // **portrait** box — handed the swapped one it writes a portrait page merely // labelled landscape, which is what LibreOffice then renders. const dims = PAGE_FORMAT_CM[s.format ?? pageFormat]; + // A section's own edge→zone distances, where its page setup gives it others. + const hDist = s.distances ? Math.min(s.distances.header, m.top) : Math.min(headerDist, m.top); + const fDist = s.distances ? Math.min(s.distances.footer, m.bottom) : Math.min(footerDist, m.bottom); return { size: { width: cmToTwip(dims.w), height: cmToTwip(dims.h), orientation: sectionLandscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT }, margin: { top: cmToTwip(m.top), bottom: cmToTwip(m.bottom), left: cmToTwip(m.left), right: cmToTwip(m.right), - header: cmToTwip(Math.min(headerDist, m.top)), footer: cmToTwip(Math.min(footerDist, m.bottom)), + header: cmToTwip(hDist), footer: cmToTwip(fDist), }, // w:pgNumType. Word restarts numbering at every section carrying a start, so a // later section gets one only where it really restarts; the rest continue. diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index e827dd7..f556618 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -5480,7 +5480,12 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: const m = set.margins; const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null; const numFormat = set.pageNumberFormat ?? null; - if ((!m && !paper && !numFormat) || !layoutXml) return layout; + // ODF's page margin *is* the edge→zone distance on a side that has one, so a + // section with its own distances writes them outright rather than as a shift. + const dist = set.distances ?? null; + const distTop = dist && (set.header || set.headerFirst || set.headerEven) ? dist.header : null; + const distBottom = dist && (set.footer || set.footerFirst || set.footerEven) ? dist.footer : null; + if ((!m && !paper && !numFormat && distTop == null && distBottom == null) || !layoutXml) return layout; const name = `${layout}Sec${index + 1}`; layouts.push(layoutXml .replace(`style:name="${layout}"`, `style:name="${name}"`) @@ -5499,6 +5504,8 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: .replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(paper.h)}cm"`) .replace(/style:print-orientation="[^"]*"/, `style:print-orientation="${paper.w > paper.h ? 'landscape' : 'portrait'}"`); } + if (distTop != null) p = p.replace(/fo:margin-top="[^"]*"/, `fo:margin-top="${distTop}cm"`); + if (distBottom != null) p = p.replace(/fo:margin-bottom="[^"]*"/, `fo:margin-bottom="${distBottom}cm"`); // The page-number format rides the layout, which is where LibreOffice keeps it. if (numFormat) { p = /style:num-format="/.test(p) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 0af3888..4e5c086 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -22,7 +22,7 @@ import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; -import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter'; +import { EMPTY_HF_SET, HF_DISTANCE_CM, type HfDistances, type HfDoc, type HfSet } from '../storage/headerFooter'; import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings'; import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering'; @@ -3101,8 +3101,23 @@ function sectionHfSets( doc: { format: PageFormat | null; orientation: Orientation | null; numFormat: NoteNumFormat }, ): HfSet[] { const out: HfSet[] = []; + // Word's edge→zone distances are per section too; the first section's are the + // document's, so only a section that disagrees carries its own. + const distOf = (sect: Element | null): HfDistances | null => { + const pgMar = sect ? fc(sect, 'pgMar') : null; + if (!pgMar) return null; + const cm = (a: string, fallback: number) => { + const tw = intAttr(pgMar, W, a); + return tw == null ? fallback : Math.max(0, round2(twipToCm(tw))); + }; + return { header: cm('header', HF_DISTANCE_CM), footer: cm('footer', HF_DISTANCE_CM) }; + }; + const docDist = distOf(sectPrs[0] ?? null); + const sameDist = (a: HfDistances | null, b: HfDistances | null) => + !!a && !!b && Math.abs(a.header - b.header) < 0.01 && Math.abs(a.footer - b.footer) < 0.01; for (const sect of sectPrs) { const prev = out[out.length - 1] ?? EMPTY_HF_SET; + const dist = distOf(sect); const titlePgEl = sect ? fc(sect, 'titlePg') : null; const titlePg = !!titlePgEl && onOff(titlePgEl); const zone = (type: 'header' | 'footer', variant: string, inherited: HfDoc): HfDoc => { @@ -3121,6 +3136,7 @@ function sectionHfSets( const pgFmt = DOCX_PAGE_NUM_FORMAT[fc(sect, 'pgNumType')?.getAttributeNS(W, 'fmt') ?? ''] ?? '1'; out.push({ margins: sectMargins(sect), + distances: sameDist(dist, docDist) ? null : dist, pageNumberStart: out.length && pgStart != null ? clampPageStart(pgStart) : null, pageNumberFormat: out.length && pgFmt !== doc.numFormat ? pgFmt : null, format: paper.format && paper.format !== doc.format ? paper.format : null, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 31a9765..a320f0b 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -27,7 +27,7 @@ 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'; +import type { HfDistances, HfDoc, HfSet } from '../storage/headerFooter'; import { NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings'; import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { clampPageStart, type PageNumbering } from '../storage/pageNumbering'; @@ -867,7 +867,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = differentOddEven, hfSections: [ { header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven }, - ...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry, docNumFormat), pageNumberStart: ctx.masterPageStarts[i] ?? null })), + ...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry, docNumFormat, edge), pageNumberStart: ctx.masterPageStarts[i] ?? null })), ], headerDistanceCm: hasHeader ? edge?.top ?? null : null, footerDistanceCm: hasFooter ? edge?.bottom ?? null : null, @@ -885,6 +885,7 @@ function hfSetOfMasterPage( name: string, ctx: Ctx, doc: { orientation: Orientation; format: PageFormat } | null, docNumFormat: NoteNumFormat, + docEdge: { top: number; bottom: number } | null, ): HfSet { const hf = ctx.resolver.masterPageHF(name); const zone = (el: Element | null, footer = false) => @@ -903,7 +904,21 @@ function hfSetOfMasterPage( // The page-number format rides the layout governing the body, like the margins: a // roman front matter is one master before the decimal body's, not a document setting. const numFormat = ctx.resolver.pageNumberFormat(hf.restPage ?? name); + // The layout's own margin is the edge→zone distance, and a section's zones sit on it: + // an index whose header starts 4cm down is not the body's 1.5cm. Matching the + // document's is inheritance, as it is for the margins. + const distOf = (page: string | null): HfDistances | null => { + const e = ctx.resolver.edgeDistancesCm(page); + return e ? { header: e.top, footer: e.bottom } : null; + }; + const sameDist = (a: HfDistances | null, b: HfDistances | null) => + !!a && !!b && Math.abs(a.header - b.header) < 0.01 && Math.abs(a.footer - b.footer) < 0.01; + const docDist = docEdge ? { header: docEdge.top, footer: docEdge.bottom } : null; + const restDist = distOf(hf.restPage ?? name); + const firstDist = hf.restPage ? distOf(name) : null; return { + distances: sameDist(restDist, docDist) ? null : restDist, + distancesFirst: firstDist && !sameDist(firstDist, restDist) ? firstDist : null, margins: rest, marginsFirst: hf.restPage && !hf.mirrorPair ? own : null, pageNumberFormat: numFormat !== docNumFormat ? numFormat : null, diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 89a544a..4565fb7 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -889,8 +889,8 @@ export class StyleResolver { // Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from // the top and footer distance from the bottom when a header/footer is present. - edgeDistancesCm(): { top: number; bottom: number } | null { - const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; + edgeDistancesCm(pageName: string | null = null): { top: number; bottom: number } | null { + const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; if (!props) return null; const inset = this.decorInsetCm(props); const room = (lengthToCm(props.getAttributeNS(NS.fo, 'page-height')) ?? 29.7) - 1; diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 21af68b..3be0ee1 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -38,6 +38,11 @@ export type HfSet = { // How its page-number field counts, where the section disagrees with the document // (roman front matter before decimal body). null = the document's own format. pageNumberFormat?: NoteNumFormat | null; + // Its own edge→zone distances (ODF's page-layout margin, Word's w:pgMar + // w:header/w:footer), where the section's page setup gives it others than the + // document's; `distancesFirst` is its first page's where that hands over. + distances?: HfDistances | null; + distancesFirst?: HfDistances | null; }; export const EMPTY_HF_SET: HfSet = { @@ -45,6 +50,7 @@ export const EMPTY_HF_SET: HfSet = { headerFirst: null, footerFirst: null, differentFirstPage: false, headerEven: null, footerEven: null, differentOddEven: false, margins: null, marginsFirst: null, format: null, orientation: null, + distances: null, distancesFirst: null, pageNumberStart: null, pageNumberFormat: null, }; diff --git a/tests/unit/hf-sections.test.ts b/tests/unit/hf-sections.test.ts index c2b907f..7b432a2 100644 --- a/tests/unit/hf-sections.test.ts +++ b/tests/unit/hf-sections.test.ts @@ -62,4 +62,20 @@ describe('per-section header/footer', () => { expect(zoneText(back.hfSections[1].header)).toBe('Second header'); expect((back.content.content ?? []).filter((b: any) => b.attrs?.sectionBreak).length).toBe(1); }); + + it('carries a section-only edge distance through both formats', async () => { + // An index whose header starts 4cm down, in a body whose own starts at 1.25. + const deep = [sections[0], { ...sections[1], distances: { header: 4, footer: 3 } }]; + const own = { ...hf, sections: deep }; + const odt = await importOdt(await buildOdt(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', own)); + expect(odt.hfSections[0].distances ?? null).toBe(null); + expect(odt.hfSections[1].distances?.header).toBeCloseTo(4, 2); + expect(odt.hfSections[1].distances?.footer).toBeCloseTo(3, 2); + + const docx = importDocx(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', own)); + expect(docx.hfSections[0].distances ?? null).toBe(null); + // Word clamps the distance below the section's own margin, as both products do. + expect(docx.hfSections[1].distances?.header).toBeCloseTo(2, 2); + expect(docx.hfSections[1].distances?.footer).toBeCloseTo(2, 2); + }); });