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:
1 parent
0cf43de1b1
commit
ce9dc9bb4d
6 files changed
+143
-40
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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] };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user