fix: the header/footer band grows to hold the zone it carries

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.
This commit is contained in:
Steffen Becker committed 2026-08-10 21:22:56 +00:00
1 parent dc22bb7523
commit 0fe1a8395d
4 files changed
+37 -11

No files matched your search

+21 -9
View File
@@ -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(','));
@@ -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,
+1 -1
View File
@@ -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
+11 -1
View File
@@ -690,9 +690,17 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
let stops: string | null = null;
const boxMaps: Record<string, string>[] = [];
// 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<string, string> = {};
const attrs: Record<string, string | number> = {};
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] };