diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 177619d..62c13cb 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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. diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index e21c5bb..0afe58a 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -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, diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 4ef3991..d38a884 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -2022,6 +2022,9 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { const para: Node = { type: 'paragraph', content: inline }; const attrs: Record = {}; + // 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); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 899e044..b8332a5 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -736,6 +736,9 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { const para: Node = { type: 'paragraph', content }; const attrs: Record = {}; + // 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; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index c54ea5d..4e4e8a8 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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: [