diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 51ecccc..f6fa10f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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([]); + 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} /> diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 2a39d93..0f6ad85 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -1,8 +1,9 @@ + +{#if interactive} + +{/if} +
@@ -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; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 6708242..02adf27 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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] }; diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 5cf4a53..3102646 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -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, diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 63690aa..275e3ae 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -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) diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index bb1da44..cc9a9f5 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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}`); }