From 0fe1a8395d1c68b424008bb79e308e14cfe4e6f3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 21:22:56 +0000 Subject: [PATCH] fix: the header/footer band grows to hold the zone it carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fo:min-height is a floor, not the band: LibreOffice grows it for the zone's own lines and, in a footer, for the first paragraph's space above (probed — a header's space below only hangs into the body). The zone paragraph now carries those margins, so the reach counts them at its real font size and a page with no zone of its own keeps the floor. --- src/lib/components/Editor.svelte | 30 ++++++++++++++++------- src/lib/editor/extensions/headerFooter.ts | 4 +++ src/lib/import/CLAUDE.md | 2 +- src/lib/import/odt.ts | 12 ++++++++- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 314f5b4..122da5f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -101,19 +101,31 @@ // its line count) pushes the body's content area in so text doesn't overlap it: the // margin grows to fit the zone. ~18.4px = one 12pt line. const HF_LINE_PX = 16 * 1.15; - function hfReachPx(doc: HfDoc, distPx: number): number { + function hfReachPx(doc: HfDoc, distPx: number, footer = false): number { if (!doc || hfIsEmpty(doc)) return 0; - const inline = ((doc.content?.[0] as { content?: { type?: string; attrs?: { height?: number; wrap?: string } }[] } | undefined)?.content ?? []); + type Run = { type?: string; attrs?: { height?: number; wrap?: string }; marks?: { type?: string; attrs?: { fontSize?: string } }[] }; + const para = doc.content?.[0] as { content?: Run[]; attrs?: { spaceBefore?: number; spaceAfter?: number } } | undefined; + const inline = para?.content ?? []; + // The zone's biggest run sizes its lines: LibreOffice grows the band to hold them + // when fo:min-height is smaller (probed, a 3-line header). + let linePx = HF_LINE_PX; + for (const n of inline) { + const size = n.marks?.find((m) => m.type === 'textStyle')?.attrs?.fontSize; + if (size) linePx = Math.max(linePx, (parseFloat(size) * 96) / 72 * 1.15); + } // Per line, since an as-character image (a letterhead logo) makes its own line // as tall as it is; the others are one text line each. A positioned frame is out // of flow — a page-sized background would otherwise reserve the whole page. let total = 0; let image = 0; for (const n of inline) { - if (n.type === 'hardBreak') { total += Math.max(HF_LINE_PX, image); image = 0; } + if (n.type === 'hardBreak') { total += Math.max(linePx, image); image = 0; } else if (n.type === 'image' && typeof n.attrs?.height === 'number' && (n.attrs.wrap ?? 'inline') === 'inline') image = Math.max(image, n.attrs.height); } - return distPx + total + Math.max(HF_LINE_PX, image); + // A footer is laid out from the page edge up, so its space above rides the band too; + // a header's space below just hangs into the body, which LibreOffice does not move. + const spacing = footer ? ((para?.attrs?.spaceBefore ?? 0) * 96) / 72 : 0; + return distPx + spacing + total + Math.max(linePx, image); } let footerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).footer)); let headerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).header)); @@ -123,11 +135,11 @@ // header/footer: "first" = page 1's own zone, "rest" = every page ≥ 2 with the even // variant folded in (max), since one --pb-content-*-rest covers all of them. let evenTopReach = $derived(differentOddEven ? hfReachPx(headerEvenDoc ?? null, headerDistPx) : 0); - let evenBottomReach = $derived(differentOddEven ? hfReachPx(footerEvenDoc ?? null, footerDistPx) : 0); + let evenBottomReach = $derived(differentOddEven ? hfReachPx(footerEvenDoc ?? null, footerDistPx, true) : 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), evenBottomReach)); - let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx))); + let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx, true), evenBottomReach)); + let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx, true))); // 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([ @@ -135,8 +147,8 @@ ...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), + Math.max(mBottomPx, hfReachPx((s.differentFirstPage ? s.footerFirst : s.footer) ?? null, footerDistPx, true)), + Math.max(mBottomPx, hfReachPx(s.footer ?? null, footerDistPx, true), s.differentOddEven ? hfReachPx(s.footerEven ?? null, footerDistPx, true) : 0), ]), ].map((g) => g.map((n) => Math.round(n)).join('|')).join(',')); diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index d1fecd4..e21c5bb 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -20,6 +20,7 @@ import { PageNumber, PageCount, ChapterField } from './pageField'; import { Image } from './image'; import { Shortcuts } from './shortcuts'; import { TabStops } from './tabStops'; +import { ParagraphSpacing } from './paragraphSpacing'; // Schema for the header/footer mini editors: exactly one paragraph of formatted // runs, line breaks, page fields, and inline (as-character) images. Marks mirror @@ -61,6 +62,9 @@ export function hfExtensions(placeholder = '') { ParagraphBox, // The centre/right stops a header's left\tcentre\tright layout rides on. TabStops.configure({ types: ['paragraph'] }), + // The zone style's own margins: what the band has to be tall enough for + // (Editor.svelte's hfReachPx), not a gap the bottom-anchored footer moves by. + ParagraphSpacing.configure({ types: ['paragraph'] }), CharacterStyle, Highlight.configure({ multicolor: true }), HfHardBreak, diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index f6e456f..ae28512 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -35,7 +35,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( ## Header/footer import -- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. The header↔body spacing is only reserved when `style:dynamic-spacing="false"` — a `true` (LibreOffice's usual) means the gap collapses, so reserving it would shove the body down. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. +- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. The zone's gap to the body is laid out *inside* the band, not added to it, and `style:dynamic-spacing` changes neither side. The band is the declared height only as a **floor**: LibreOffice grows it to hold the zone's own lines, and a footer's first paragraph `fo:margin-top` rides the band with them — a header's `fo:margin-bottom` does not, it just hangs into the body (all probed). That growth is the runtime reach (`Editor.svelte`'s `hfReachPx`, which takes the biggest run's size as the line height and the zone paragraph's `spaceBefore`), so a page whose zone is empty keeps the floor. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. ## Defaults on DOCX import diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 9aec948..953b743 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -690,9 +690,17 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { let stops: string | null = null; const boxMaps: Record[] = []; + // The zone's paragraphs collapse into one, so the outer two margins become its own: + // what the band has to be tall enough to hold (Editor.svelte's hfReachPx). + let spaceBefore: number | null = null; + let spaceAfter = 0; + const addPara = (p: Element) => { if (inline.length) inline.push({ type: 'hardBreak' }); const styleName = p.getAttributeNS(NS.text, 'style-name'); + const outer = ctx.resolver.paraProps(styleName); + spaceBefore ??= snapPt(lengthToPt(outer['fo:margin-top']) ?? 0); + spaceAfter = snapPt(lengthToPt(outer['fo:margin-bottom']) ?? 0); // The zone is one paragraph, so the first line's stops are the zone's. stops ??= formatTabStops(ctx.resolver.tabStops(styleName)); if (textAlign === null) { @@ -721,9 +729,11 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { if (content.length === 0 && Object.keys(box).length === 0) return null; const para: Node = { type: 'paragraph', content }; - const attrs: Record = {}; + const attrs: Record = {}; if (textAlign) attrs.textAlign = textAlign; if (stops) attrs.tabStops = stops; + if (spaceBefore) attrs.spaceBefore = spaceBefore; + if (spaceAfter) attrs.spaceAfter = spaceAfter; Object.assign(attrs, box); if (Object.keys(attrs).length) para.attrs = attrs; return { type: 'doc', content: [para] };