fix: a header/footer band is as tall as its own font, not the body's

The zone is one paragraph, so its mark sizes every line of the band. Runs
that agree on a font now set that strut (applyUniformRunFont, as the body
already does), the zone schema carries blockFontSize so it renders, and
hfReachPx measures a line by the family's natural height instead of a flat
1.15. A 10pt footer reserved 12pt lines and sat 1.6mm high on every page.
This commit is contained in:
Steffen Becker committed 2026-08-11 17:10:08 +00:00
1 parent 853762a774
commit e2ee55cf35
5 files changed
+25 -9

No files matched your search

+11 -7
View File
@@ -28,7 +28,7 @@
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { wheelZoomFactor } from '../utils/zoom';
import { styleCss } from '../styles/styleSheet';
import { styleCss, singleLineHeight } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
@@ -103,19 +103,23 @@
const HF_LINE_PX = 16 * 1.15;
function hfReachPx(doc: HfDoc, distPx: number, footer = false): number {
if (!doc || hfIsEmpty(doc)) return 0;
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;
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;
const inline = para?.content ?? [];
// The zone's biggest text run sizes its lines: LibreOffice grows the band to hold them
// when fo:min-height is smaller (probed, a 3-line header), and a zone set throughout in
// 10pt reserves 10pt — the body default only stands in for a run that declares none.
let linePx = 0;
// A line is its font's own natural height, which is what the band renders at.
const line = (size?: string, family?: string) =>
size ? (parseFloat(size) * 96) / 72 * singleLineHeight(family) : HF_LINE_PX;
// The paragraph mark is the strut every run that declares no size of its own takes.
const base = line(para?.attrs?.fontSize, para?.attrs?.fontFamily);
let linePx = base;
for (const n of inline) {
if (n.type === 'image') continue;
const size = n.marks?.find((m) => m.type === 'textStyle')?.attrs?.fontSize;
linePx = Math.max(linePx, size ? (parseFloat(size) * 96) / 72 * 1.15 : HF_LINE_PX);
const ts = n.marks?.find((m) => m.type === 'textStyle')?.attrs;
linePx = Math.max(linePx, ts?.fontSize ? line(ts.fontSize, ts.fontFamily) : base);
}
if (!linePx) linePx = HF_LINE_PX;
// 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.
@@ -21,6 +21,7 @@ import { Image } from './image';
import { Shortcuts } from './shortcuts';
import { TabStops } from './tabStops';
import { ParagraphSpacing } from './paragraphSpacing';
import { BlockFontSize } from './blockFontSize';
// Schema for the header/footer mini editors: exactly one paragraph of formatted
// runs, line breaks, page fields, and inline (as-character) images. Marks mirror
@@ -65,6 +66,9 @@ export function hfExtensions(placeholder = '') {
// 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'] }),
// The zone is one paragraph, so its mark's font is the band's line height: a 10pt
// footer reserves 10pt lines, not the body's 12pt.
BlockFontSize.configure({ types: ['paragraph'] }),
CharacterStyle,
Highlight.configure({ multicolor: true }),
HfHardBreak,
+3
View File
@@ -2022,6 +2022,9 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc {
const para: Node = { type: 'paragraph', content: inline };
const attrs: Record<string, string> = {};
// The zone is one paragraph here, so its strut is the whole band's line height —
// runs that agree on a size must set it, or a 10pt footer reserves 12pt lines.
applyUniformRunFont(attrs, inline);
if (textAlign) attrs.textAlign = textAlign;
if (stops) attrs.tabStops = stops;
Object.assign(attrs, box);
+3
View File
@@ -736,6 +736,9 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
const para: Node = { type: 'paragraph', content };
const attrs: Record<string, string | number> = {};
// The zone is one paragraph here, so its strut is the whole band's line height —
// runs that agree on a size must set it, or a 10pt footer reserves 12pt lines.
applyUniformRunFont(attrs, content);
if (textAlign) attrs.textAlign = textAlign;
if (stops) attrs.tabStops = stops;
if (spaceBefore) attrs.spaceBefore = spaceBefore;
+4 -2
View File
@@ -932,10 +932,12 @@ describe('Leg 3a: different first page header/footer → buildOdt → importOdt'
// Arial 10pt on the runs AND the page-field atoms, so the field digits keep the font.
const arial = [{ type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '10pt' } }];
const header: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Default header' }] }] };
const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [
// The strut follows runs that agree on a font (applyUniformRunFont), so the zone
// paragraph carries it either way — stating it keeps the round trip an identity.
const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center', fontFamily: 'Arial', fontSize: '10pt' }, content: [
{ type: 'text', text: 'Seite ', marks: arial }, { type: 'pageNumber', marks: arial }, { type: 'text', text: ' von ', marks: arial }, { type: 'pageCount', marks: arial },
] }] };
const headerFirst: N = { type: 'doc', content: [{ type: 'paragraph', content: [
const headerFirst: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { fontFamily: 'Arial', fontSize: '10pt' }, content: [
{ type: 'text', text: 'Cover', marks: [{ type: 'bold' }, ...arial] },
] }] };
const footerFirst: N = { type: 'doc', content: [{ type: 'paragraph', content: [