fix: the header/footer band is measured, not counted in lines

The body's margin has to clear the band the zone renders at, and the
estimate counted paragraphs — blind to a line that wraps, to a run smaller
than the 12pt floor, and to the zone's padding. HeaderFooterLayer now lays
each section's zones out off-screen at their text width and reports the
height; the ODF zone's own gap to the body and its padding ride the
collapsed paragraph's spacing, which is where the export already puts them.
Measured on one file: a three-line band read as two, 14.9mm a page.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:05 +02:00
1 parent 0cf43de1b1
commit ce9dc9bb4d
6 files changed
+143 -40

No files matched your search

+29 -14
View File
@@ -46,7 +46,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import { RESYNC_NOTES } from '../editor/extensions/notes';
import { applyPageSizeVars, pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { MAX_PAGE_COLUMNS } from '../storage/theme';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { DEFAULT_HF_DISTANCES, HF_ZONE_KEYS, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet, type HfZoneKey } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, PAGE_GAP, pageOfElement, readVerticalMargins, topInEditor, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
@@ -158,7 +158,11 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// 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, footer = false): number {
// Rendered zone heights from HeaderFooterLayer, in HF_ZONE_KEYS order per section.
let hfZoneHeights = $state<number[][]>([]);
const zoneHeightPx = (section: number, key: HfZoneKey): number =>
hfZoneHeights[section]?.[HF_ZONE_KEYS.indexOf(key)] ?? 0;
function hfReachPx(doc: HfDoc, distPx: number, footer = false, measuredPx = 0): number {
if (!doc || hfIsEmpty(doc)) return 0;
type Run = { type?: string; attrs?: { height?: number; wrap?: string }; marks?: { type?: string; attrs?: { fontSize?: string; fontFamily?: string } }[] };
const para = doc.content?.[0] as { content?: Run[]; attrs?: { spaceBefore?: number; spaceAfter?: number; fontSize?: string; fontFamily?: string } } | undefined;
@@ -186,8 +190,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
else if (n.type === 'image' && typeof n.attrs?.height === 'number' && (n.attrs.wrap ?? 'inline') === 'inline') image = Math.max(image, n.attrs.height);
}
// 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.
// a header's space below is part of the band the body starts under.
const spacing = footer ? ((para?.attrs?.spaceBefore ?? 0) * 96) / 72 : 0;
// The measured band wins where there is one: the estimate below cannot see a line
// that wraps, and it only ever grows a line past the 12pt default. It carries the
// paragraph's space above already (HeaderFooterLayer measures with it).
if (measuredPx > 0) return distPx + measuredPx;
return distPx + spacing + total + Math.max(linePx, image);
}
let footerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).footer));
@@ -197,27 +205,33 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// Effective top/bottom margins (px) pageBreaks reads to keep content clear of the
// 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, 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, true), evenBottomReach));
let effBottomFirst = $derived(Math.max(mBottomPx, hfReachPx((differentFirstPage ? footerFirstDoc : footerDoc) ?? null, footerDistPx, true)));
let evenTopReach = $derived(differentOddEven ? hfReachPx(headerEvenDoc ?? null, headerDistPx, false, zoneHeightPx(0, 'headerEven')) : 0);
let evenBottomReach = $derived(differentOddEven ? hfReachPx(footerEvenDoc ?? null, footerDistPx, true, zoneHeightPx(0, 'footerEven')) : 0);
let effTopRest = $derived(Math.max(mTopPx, hfReachPx(headerDoc ?? null, headerDistPx, false, zoneHeightPx(0, 'header')), evenTopReach));
let effTopFirst = $derived(Math.max(mTopPx, differentFirstPage
? hfReachPx(headerFirstDoc ?? null, headerDistPx, false, zoneHeightPx(0, 'headerFirst'))
: hfReachPx(headerDoc ?? null, headerDistPx, false, zoneHeightPx(0, 'header'))));
let effBottomRest = $derived(Math.max(mBottomPx, hfReachPx(footerDoc ?? null, footerDistPx, true, zoneHeightPx(0, 'footer')), evenBottomReach));
let effBottomFirst = $derived(Math.max(mBottomPx, differentFirstPage
? hfReachPx(footerFirstDoc ?? null, footerDistPx, true, zoneHeightPx(0, 'footerFirst'))
: hfReachPx(footerDoc ?? null, footerDistPx, true, zoneHeightPx(0, 'footer'))));
// Per-section reaches for pageBreaks: "topFirst|topRest|bottomFirst|bottomRest" in px,
// one group per section, comma-separated. Section 1 repeats the four vars below; a
// section with page margins of its own measures against those (`marginsFirst` = page 1).
let sectionReach = $derived([
[effTopFirst, effTopRest, effBottomFirst, effBottomRest],
...extraHfSections.map((s) => {
...extraHfSections.map((s, i) => {
const rest = s.margins ?? null;
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);
const reach = (key: HfZoneKey, dist: number, footer = false) =>
hfReachPx(s[key] ?? null, dist, footer, zoneHeightPx(i + 1, key));
return [
Math.max(topOf(first), hfReachPx((s.differentFirstPage ? s.headerFirst : s.header) ?? null, headerDistPx)),
Math.max(topOf(rest), hfReachPx(s.header ?? null, headerDistPx), s.differentOddEven ? hfReachPx(s.headerEven ?? null, headerDistPx) : 0),
Math.max(bottomOf(first), hfReachPx((s.differentFirstPage ? s.footerFirst : s.footer) ?? null, footerDistPx, true)),
Math.max(bottomOf(rest), hfReachPx(s.footer ?? null, footerDistPx, true), s.differentOddEven ? hfReachPx(s.footerEven ?? null, footerDistPx, true) : 0),
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),
];
}),
].map((g) => g.map((n) => Math.round(n)).join('|')).join(','));
@@ -1539,6 +1553,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
{sectionStartPages}
{chapterStarts}
{pageNumbering}
bind:zoneHeights={hfZoneHeights}
interactive={i === 0}
/>
</div>
+58 -4
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Editor, generateHTML, type Content } from '@tiptap/core';
import { layOutZoneTabs } from '../editor/extensions/tabStops';
import { hfExtensions } from '../editor/extensions/headerFooter';
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfVariant, type HfDistances, type HfSet } from '../storage/headerFooter';
import { hfIsEmpty, DEFAULT_HF_DISTANCES, HF_ZONE_KEYS, type HfDoc, type HfZone, type HfVariant, type HfDistances, type HfSet, type HfZoneKey } from '../storage/headerFooter';
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { type Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
@@ -34,6 +35,7 @@
sectionStartPages = [],
chapterStarts = [],
pageNumbering = DEFAULT_PAGE_NUMBERING,
zoneHeights = $bindable([]),
interactive = true,
}: {
headerDoc: HfDoc;
@@ -60,6 +62,9 @@
extraHfSections?: HfSet[];
sectionStartPages?: number[];
chapterStarts?: ChapterStart[];
/** Rendered height (px) of each set's six zones, in HF_ZONE_KEYS order — read back
* by Editor.svelte, whose margins have to clear the band the zone really needs. */
zoneHeights?: number[][];
/** False in a split view's second pane: it draws the zones, it does not edit them. */
interactive?: boolean;
} = $props();
@@ -175,6 +180,27 @@
headerEven: backgrounds(s.headerEven), footerEven: backgrounds(s.footerEven),
})));
// Off-screen copy of every zone at its section's text width. What the body has to
// clear is the height the zone renders at: counting paragraphs sees neither a line
// that wraps, nor a taller run, nor the paragraph's own padding.
let measureRoot = $state<HTMLDivElement | null>(null);
$effect(() => {
const root = measureRoot;
if (!root) return;
const boxes = Array.from(root.querySelectorAll<HTMLElement>('.hf-measure-box'));
const n = HF_ZONE_KEYS.length;
const read = () => {
const next = setHtml.map((_, i) => HF_ZONE_KEYS.map((_, k) => boxes[i * n + k]?.offsetHeight ?? 0));
if (String(next) !== String(untrack(() => zoneHeights))) zoneHeights = next;
};
// A web font arriving late (an embedded one lands after the import) reflows the zone,
// so the height is observed rather than read once.
const ro = new ResizeObserver(read);
for (const el of boxes) ro.observe(el);
read();
return () => ro.disconnect();
});
// Which section a page belongs to: the count of section starts at or before it,
// clamped to what the document actually carries.
function sectionOf(page: number): number {
@@ -263,9 +289,8 @@
let liveZone: HfZone | null = null;
// Which HfSet field a zone + variant is.
type ZoneKey = 'header' | 'footer' | 'headerFirst' | 'footerFirst' | 'headerEven' | 'footerEven';
const zoneKey = (zone: HfZone, variant: HfVariant): ZoneKey =>
(zone + (variant === 'first' ? 'First' : variant === 'even' ? 'Even' : '')) as ZoneKey;
const zoneKey = (zone: HfZone, variant: HfVariant): HfZoneKey =>
(zone + (variant === 'first' ? 'First' : variant === 'even' ? 'Even' : '')) as HfZoneKey;
function zoneDoc(index: number, zone: HfZone, variant: HfVariant): HfDoc {
return (sets[index] ?? sets[0])[zoneKey(zone, variant)];
@@ -376,6 +401,18 @@
}
</script>
<!-- Only the measuring pane runs it: every pane renders the same zones, and a second
writer would just re-report the same heights. -->
{#if interactive}
<div class="hf-measure" aria-hidden="true" bind:this={measureRoot}>
{#each setHtml as html, i}
{#each HF_ZONE_KEYS as key}
<div class="hf-zone hf-measure-box" style="width: {contentWidthOf(sectionFirstPage(i))}px">{@html html[key]}</div>
{/each}
{/each}
</div>
{/if}
<!-- Own layer below the body (z-index -1 against .paper's zoom stacking context), so a
full-page background sits under the text the way LibreOffice paints it. -->
<div class="hf-bg-layer">
@@ -464,6 +501,23 @@
display: none;
}
.hf-measure {
position: absolute;
top: 0;
left: -10000px;
visibility: hidden;
pointer-events: none;
}
.hf-measure .hf-zone {
position: static;
height: auto;
}
/* The paragraph's space above is band height as well. The visible zone anchors its
text at the band edge instead, so only the measurement takes it. */
.hf-measure .hf-zone :global(p) {
margin-top: var(--space-before, 0);
}
.hf-zone {
position: absolute;
display: flex;
+26 -13
View File
@@ -825,20 +825,20 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
}
}
const headerFirst = hf.headerFirst ? convertHfZone(hf.headerFirst, ctx) : null;
const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx) : null;
const headerFirst = hf.headerFirst ? convertHfZone(hf.headerFirst, ctx, hf.headerBandCm) : null;
const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx, hf.footerBandCm, true) : null;
// The presence of a first-page element is the flag, even when it's empty (an empty
// first-page zone deliberately blanks page 1 while the default fills later pages).
const differentFirstPage = !!(hf.headerFirst || hf.footerFirst || hf.firstPageOnly);
const headerEven = hf.headerLeft ? convertHfZone(hf.headerLeft, ctx) : null;
const footerEven = hf.footerLeft ? convertHfZone(hf.footerLeft, ctx) : null;
const headerEven = hf.headerLeft ? convertHfZone(hf.headerLeft, ctx, hf.headerBandCm) : null;
const footerEven = hf.footerLeft ? convertHfZone(hf.footerLeft, ctx, hf.footerBandCm, true) : null;
const differentOddEven = !!(hf.headerLeft || hf.footerLeft);
// A first-page/even zone reserves the band even if its default counterpart is empty.
const hasHeader = hf.header || headerFirst || headerEven;
const hasFooter = hf.footer || footerFirst || footerEven;
const header = hf.header ? convertHfZone(hf.header, ctx) : null;
const footer = hf.footer ? convertHfZone(hf.footer, ctx) : null;
const header = hf.header ? convertHfZone(hf.header, ctx, hf.headerBandCm) : null;
const footer = hf.footer ? convertHfZone(hf.footer, ctx, hf.footerBandCm, true) : null;
return {
content: { type: 'doc', content: blocks },
@@ -883,7 +883,8 @@ function hfSetOfMasterPage(
doc: { orientation: Orientation; format: PageFormat } | null,
): HfSet {
const hf = ctx.resolver.masterPageHF(name);
const zone = (el: Element | null) => (el ? convertHfZone(el, ctx) : null);
const zone = (el: Element | null, footer = false) =>
(el ? convertHfZone(el, ctx, footer ? hf.footerBandCm : hf.headerBandCm, footer) : null);
// Its page layout is the section's own geometry; where the master hands over, that
// layout governs the first page only and the successor's the rest.
const geo = ctx.resolver.pageGeometry(name);
@@ -899,19 +900,21 @@ function hfSetOfMasterPage(
format: paper && doc && paper.format !== doc.format ? paper.format : null,
orientation: paper && doc && paper.orientation !== doc.orientation ? paper.orientation : null,
header: zone(hf.header),
footer: zone(hf.footer),
footer: zone(hf.footer, true),
headerFirst: zone(hf.headerFirst),
footerFirst: zone(hf.footerFirst),
footerFirst: zone(hf.footerFirst, true),
differentFirstPage: !!(hf.headerFirst || hf.footerFirst || hf.firstPageOnly),
headerEven: zone(hf.headerLeft),
footerEven: zone(hf.footerLeft),
footerEven: zone(hf.footerLeft, true),
differentOddEven: !!(hf.headerLeft || hf.footerLeft),
};
}
// A header/footer zone → one single-paragraph doc (hfExtensions schema). Multiple
// paragraphs collapse to hard line breaks; block structures flatten to their text.
function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
// `bandCm` is the zone's own gap to the body plus its padding: space inside the band,
// so it rides the collapsed paragraph's spacing on the side facing the body.
function convertHfZone(zoneEl: Element, ctx: Ctx, bandCm = 0, footer = false): HfDoc {
const inline: Node[] = [];
let textAlign: string | null = null;
let stops: string | null = null;
@@ -921,6 +924,10 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
// what the band has to be tall enough to hold (Editor.svelte's hfReachPx).
let spaceBefore: number | null = null;
let spaceAfter = 0;
// A zone paragraph's own padding is band height too, and the collapsed paragraph has
// no padding of its own to carry it — so it joins the spacing on its own side.
let padTopPt = 0;
let padBottomPt = 0;
const addPara = (p: Element) => {
if (inline.length) inline.push({ type: 'hardBreak' });
@@ -928,6 +935,9 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
const outer = ctx.resolver.paraProps(styleName);
spaceBefore ??= snapPt(lengthToPt(outer['fo:margin-top']) ?? 0);
spaceAfter = snapPt(lengthToPt(outer['fo:margin-bottom']) ?? 0);
const pad = lengthToPt(outer['fo:padding']);
padTopPt += lengthToPt(outer['fo:padding-top']) ?? pad ?? 0;
padBottomPt += lengthToPt(outer['fo:padding-bottom']) ?? pad ?? 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) {
@@ -962,8 +972,11 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
applyUniformRunFont(attrs, content);
if (textAlign) attrs.textAlign = textAlign;
if (stops) attrs.tabStops = stops;
if (spaceBefore) attrs.spaceBefore = spaceBefore;
if (spaceAfter) attrs.spaceAfter = spaceAfter;
const bandPt = (bandCm / 2.54) * 72;
const before = snapPt((spaceBefore ?? 0) + padTopPt + (footer ? bandPt : 0));
const after = snapPt(spaceAfter + padBottomPt + (footer ? 0 : bandPt));
if (before) attrs.spaceBefore = before;
if (after) attrs.spaceAfter = after;
Object.assign(attrs, box);
if (Object.keys(attrs).length) para.attrs = attrs;
return { type: 'doc', content: [para] };
+23 -8
View File
@@ -664,6 +664,8 @@ export class StyleResolver {
footerLeft: Element | null;
headerExtraCm: number;
footerExtraCm: number;
headerBandCm: number;
footerBandCm: number;
firstPageOnly: boolean;
restPage: string | null;
} {
@@ -691,25 +693,36 @@ export class StyleResolver {
const zone = (local: string) => zoneIn(rest, local);
const firstZone = (local: string): Element | null =>
zoneIn(mp, `${local}-first`) ?? (successor ? zoneIn(mp, local) : null);
const extraCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
if (!layout) return 0;
let props: Element | null = null;
const bandProps = (local: 'header-style' | 'footer-style'): Element | null => {
if (!layout) return null;
for (const child of Array.from(layout.children)) {
if (child.namespaceURI === NS.style && child.localName === local) {
props = child.getElementsByTagNameNS(NS.style, 'header-footer-properties')[0] ?? null;
break;
return child.getElementsByTagNameNS(NS.style, 'header-footer-properties')[0] ?? null;
}
}
return null;
};
// The band LibreOffice lays out is max(declared height, zone content + its gap), and
// the zone's own fo:padding counts on both sides of that content (probed). `extraCm`
// is the part known without laying the zone out; `bandCm` what its content grows by.
const extraCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
const props = bandProps(local);
if (!props) return 0;
const height = lengthToCm(props.getAttributeNS(NS.svg, 'height'))
?? lengthToCm(props.getAttributeNS(NS.fo, 'min-height'))
?? 0;
// The zone's gap to the body is laid out *inside* that height, not added to it
// (probed: a footer's fo:margin-top moves its text down the band and leaves the
// last body line where it was; style:dynamic-spacing changes neither side).
const spacing = lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0;
return Math.max(height, spacing);
};
const bandCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
const props = bandProps(local);
if (!props) return 0;
const spacing = lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0;
const pad = lengthToCm(props.getAttributeNS(NS.fo, 'padding'));
const padTop = lengthToCm(props.getAttributeNS(NS.fo, 'padding-top')) ?? pad ?? 0;
const padBottom = lengthToCm(props.getAttributeNS(NS.fo, 'padding-bottom')) ?? pad ?? 0;
return spacing + padTop + padBottom;
};
return {
header: zone('header'),
@@ -721,6 +734,8 @@ export class StyleResolver {
footerLeft: zoneIn(rest, 'footer-left'),
headerExtraCm: extraCm('header-style', 'margin-bottom'),
footerExtraCm: extraCm('footer-style', 'margin-top'),
headerBandCm: bandCm('header-style', 'margin-bottom'),
footerBandCm: bandCm('footer-style', 'margin-top'),
// Handing over to a successor is itself the "different first page" flag: a title
// master with no zones of its own leaves page one deliberately blank.
firstPageOnly: !!successor,
+5
View File
@@ -43,6 +43,11 @@ export const EMPTY_HF_SET: HfSet = {
margins: null, marginsFirst: null, format: null, orientation: null, pageNumberStart: null,
};
// The six zone docs of a set, in the order their measured heights travel from
// HeaderFooterLayer to Editor.svelte.
export const HF_ZONE_KEYS = ['header', 'footer', 'headerFirst', 'footerFirst', 'headerEven', 'footerEven'] as const;
export type HfZoneKey = (typeof HF_ZONE_KEYS)[number];
export function hfSetIsEmpty(s: HfSet): boolean {
return hfIsEmpty(s.header) && hfIsEmpty(s.footer)
&& hfIsEmpty(s.headerFirst) && hfIsEmpty(s.footerFirst)
+2 -1
View File
@@ -329,7 +329,8 @@ export function styleCss(sheet: StyleSheet): string {
items.push('.paper .tiptap li:has(> p:not([data-style]))');
// A header/footer zone is a paragraph of the file too, and both formats base its
// style on the default one — without this it renders in the editor's own font.
items.push('.paper .hf-layer .hf-zone');
// The off-screen copy the band is measured from has to match it exactly.
items.push('.paper .hf-layer .hf-zone', '.paper .hf-measure .hf-zone');
}
rules.push(`${items.join(',\n')} {\n ${text.join(';\n ')};\n}`);
}