diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 8ab5f55..eea186f 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -128,7 +128,7 @@ Both anchors differ: a wheel zoom holds the **point under the cursor** fixed (`p ## Headers & footers (`HeaderFooterLayer.svelte`, `storage/headerFooter.ts`) -One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, persisted to `edentext-header`/`-footer`), repeated on every page. Double-clicking a page margin — or the Layout-panel "Edit header/footer" buttons — opens the zone for editing; clicking back into the body (`onFocus`) or Escape ends it. The edge→zone distance (header from top, footer from bottom) is user-configurable in cm via the Layout panel (`HfDistances`, persisted to `edentext-hf-distances`, default `HF_DISTANCE_CM` 1.27cm, clamped 0–10 and below the body margin). +One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, persisted to `edentext-header`/`-footer`), repeated on every page. Double-clicking a page margin — or the Layout-panel "Edit header/footer" buttons — opens the zone for editing; clicking back into the body (`onFocus`) or Escape ends it. The edge→zone distance (header from top, footer from bottom) is user-configurable in cm via the Layout panel (`HfDistances`, persisted to `edentext-hf-distances`, default `HF_DISTANCE_CM` 1.27cm, clamped 0–10 and below the body margin). A **section** can have its own (`HfSet.distances`, `distancesFirst` where its page style hands over): ODF's page-layout margin *is* that distance, Word's is `w:pgMar w:header/w:footer`, and an index whose header starts 4cm down is not the body's 1.25 — `zoneBox` and `sectionReach` both read the section's. - **Fields:** the bar beside the active zone inserts the `pageField.ts` atoms — page number, page count, chapter (the running head, level 1). Each is patched per page, so the same zone shows a different value on every page. - **Rendering:** the zone's own font is the document's **default paragraph style** (`styleCss` gives `.paper .hf-layer .hf-zone` its text half): both formats base the Header/Footer style on it, so a file whose body is Arial 10pt has an Arial footer, not the editor's serif. `HeaderFooterLayer.svelte` mounts inside the scaled `.paper` (like `.band-layer`), positioning per-page zone boxes in unscaled doc px. A zone's out-of-flow frame (`wrap` ≠ inline) is hidden in the box and painted per page by `.hf-bg-layer` at `z-index: -1` — hence `.paper`, not `.tiptap`, paints the page surface, so the image lands between the sheet and the body text. Inactive zones render as static HTML (`generateHTML` + `hfExtensions`); the active zone hosts the single live TipTap editor. `App.svelte`'s `activeEditor`/`activeTick` route the top toolbars to `hfEditor` while a zone is active, so all body formatting works on the header/footer with no toolbar changes. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 7fde074..6d9cc6b 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -225,13 +225,18 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; 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); + // A section's zones sit at its own edge distance where its page setup gives it one. + const restD = s.distances ?? null; + const firstD = s.distancesFirst ?? restD; + const hDist = (d: HfDistances | null) => (d ? cmToPx(d.header) : headerDistPx); + const fDist = (d: HfDistances | null) => (d ? cmToPx(d.footer) : footerDistPx); const reach = (key: HfZoneKey, dist: number, footer = false) => hfReachPx(s[key] ?? null, dist, footer, zoneHeightPx(i + 1, key)); return [ - 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), + Math.max(topOf(first), reach(s.differentFirstPage ? 'headerFirst' : 'header', hDist(firstD))), + Math.max(topOf(rest), reach('header', hDist(restD)), s.differentOddEven ? reach('headerEven', hDist(restD)) : 0), + Math.max(bottomOf(first), reach(s.differentFirstPage ? 'footerFirst' : 'footer', fDist(firstD), true)), + Math.max(bottomOf(rest), reach('footer', fDist(restD), true), s.differentOddEven ? reach('footerEven', fDist(restD), true) : 0), ]; }), ].map((g) => g.map((n) => Math.round(n)).join('|')).join(',')); diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 0a98045..00b9fcc 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -100,6 +100,15 @@ // bottom margin (Word's w:footer > w:bottom); the zone then grows up into the margin. let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), pageHeightPx)); let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), pageHeightPx)); + // A section whose page setup gives it its own distances puts its zones on those. + function distancesOf(page: number): { header: number; footer: number } { + const i = sectionOf(page); + const s = sets[i]; + const d = (page === sectionFirstPage(i) ? s?.distancesFirst ?? s?.distances : s?.distances) ?? null; + return d + ? { header: Math.min(cmToPx(d.header), pageHeightPx), footer: Math.min(cmToPx(d.footer), pageHeightPx) } + : { header: headerDistPx, footer: footerDistPx }; + } let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1)); @@ -107,17 +116,18 @@ // Mirrored margins: an even page is the left-hand one, so the pair is swapped. const box = boxOf(page); const m = marginsOf(page); + const dist = distancesOf(page); const swap = m.mirrored && page % 2 === 0; // From the page's own left edge — a section on narrower paper is centred. const left = box.left + cmToPx(swap ? m.right : m.left); const width = contentWidthOf(page); if (zone === 'header') { - const top = box.top + headerDistPx; - return { top, left, width, height: Math.max(MIN_ZONE_PX, cmToPx(m.top) - headerDistPx) }; + const top = box.top + dist.header; + return { top, left, width, height: Math.max(MIN_ZONE_PX, cmToPx(m.top) - dist.header) }; } - // Footer anchors its bottom edge at footerDistPx from the page bottom and grows up. - const height = Math.max(MIN_ZONE_PX, cmToPx(m.bottom) - footerDistPx); - const top = box.top + box.height - footerDistPx - height; + // Footer anchors its bottom edge at its distance from the page bottom and grows up. + const height = Math.max(MIN_ZONE_PX, cmToPx(m.bottom) - dist.footer); + const top = box.top + box.height - dist.footer - height; return { top, left, width, height }; } // The active (edited) zone grows to fit its content, keeping the anchored edge fixed diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 5aa22eb..a657efb 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2754,12 +2754,15 @@ export async function buildDocx( // **portrait** box — handed the swapped one it writes a portrait page merely // labelled landscape, which is what LibreOffice then renders. const dims = PAGE_FORMAT_CM[s.format ?? pageFormat]; + // A section's own edge→zone distances, where its page setup gives it others. + const hDist = s.distances ? Math.min(s.distances.header, m.top) : Math.min(headerDist, m.top); + const fDist = s.distances ? Math.min(s.distances.footer, m.bottom) : Math.min(footerDist, m.bottom); return { size: { width: cmToTwip(dims.w), height: cmToTwip(dims.h), orientation: sectionLandscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT }, margin: { top: cmToTwip(m.top), bottom: cmToTwip(m.bottom), left: cmToTwip(m.left), right: cmToTwip(m.right), - header: cmToTwip(Math.min(headerDist, m.top)), footer: cmToTwip(Math.min(footerDist, m.bottom)), + header: cmToTwip(hDist), footer: cmToTwip(fDist), }, // w:pgNumType. Word restarts numbering at every section carrying a start, so a // later section gets one only where it really restarts; the rest continue. diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index e827dd7..f556618 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -5480,7 +5480,12 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: const m = set.margins; const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null; const numFormat = set.pageNumberFormat ?? null; - if ((!m && !paper && !numFormat) || !layoutXml) return layout; + // ODF's page margin *is* the edge→zone distance on a side that has one, so a + // section with its own distances writes them outright rather than as a shift. + const dist = set.distances ?? null; + const distTop = dist && (set.header || set.headerFirst || set.headerEven) ? dist.header : null; + const distBottom = dist && (set.footer || set.footerFirst || set.footerEven) ? dist.footer : null; + if ((!m && !paper && !numFormat && distTop == null && distBottom == null) || !layoutXml) return layout; const name = `${layout}Sec${index + 1}`; layouts.push(layoutXml .replace(`style:name="${layout}"`, `style:name="${name}"`) @@ -5499,6 +5504,8 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: .replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(paper.h)}cm"`) .replace(/style:print-orientation="[^"]*"/, `style:print-orientation="${paper.w > paper.h ? 'landscape' : 'portrait'}"`); } + if (distTop != null) p = p.replace(/fo:margin-top="[^"]*"/, `fo:margin-top="${distTop}cm"`); + if (distBottom != null) p = p.replace(/fo:margin-bottom="[^"]*"/, `fo:margin-bottom="${distBottom}cm"`); // The page-number format rides the layout, which is where LibreOffice keeps it. if (numFormat) { p = /style:num-format="/.test(p) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 0af3888..4e5c086 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -22,7 +22,7 @@ import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; -import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter'; +import { EMPTY_HF_SET, HF_DISTANCE_CM, type HfDistances, type HfDoc, type HfSet } from '../storage/headerFooter'; import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings'; import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering'; @@ -3101,8 +3101,23 @@ function sectionHfSets( doc: { format: PageFormat | null; orientation: Orientation | null; numFormat: NoteNumFormat }, ): HfSet[] { const out: HfSet[] = []; + // Word's edge→zone distances are per section too; the first section's are the + // document's, so only a section that disagrees carries its own. + const distOf = (sect: Element | null): HfDistances | null => { + const pgMar = sect ? fc(sect, 'pgMar') : null; + if (!pgMar) return null; + const cm = (a: string, fallback: number) => { + const tw = intAttr(pgMar, W, a); + return tw == null ? fallback : Math.max(0, round2(twipToCm(tw))); + }; + return { header: cm('header', HF_DISTANCE_CM), footer: cm('footer', HF_DISTANCE_CM) }; + }; + const docDist = distOf(sectPrs[0] ?? null); + const sameDist = (a: HfDistances | null, b: HfDistances | null) => + !!a && !!b && Math.abs(a.header - b.header) < 0.01 && Math.abs(a.footer - b.footer) < 0.01; for (const sect of sectPrs) { const prev = out[out.length - 1] ?? EMPTY_HF_SET; + const dist = distOf(sect); const titlePgEl = sect ? fc(sect, 'titlePg') : null; const titlePg = !!titlePgEl && onOff(titlePgEl); const zone = (type: 'header' | 'footer', variant: string, inherited: HfDoc): HfDoc => { @@ -3121,6 +3136,7 @@ function sectionHfSets( const pgFmt = DOCX_PAGE_NUM_FORMAT[fc(sect, 'pgNumType')?.getAttributeNS(W, 'fmt') ?? ''] ?? '1'; out.push({ margins: sectMargins(sect), + distances: sameDist(dist, docDist) ? null : dist, pageNumberStart: out.length && pgStart != null ? clampPageStart(pgStart) : null, pageNumberFormat: out.length && pgFmt !== doc.numFormat ? pgFmt : null, format: paper.format && paper.format !== doc.format ? paper.format : null, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 31a9765..a320f0b 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -27,7 +27,7 @@ import type { Orientation } from '../storage/pageOrientation'; import type { SpacingModel } from '../storage/spacingModel'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; -import type { HfDoc, HfSet } from '../storage/headerFooter'; +import type { HfDistances, HfDoc, HfSet } from '../storage/headerFooter'; import { NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings'; import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { clampPageStart, type PageNumbering } from '../storage/pageNumbering'; @@ -867,7 +867,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = differentOddEven, hfSections: [ { header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven }, - ...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry, docNumFormat), pageNumberStart: ctx.masterPageStarts[i] ?? null })), + ...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry, docNumFormat, edge), pageNumberStart: ctx.masterPageStarts[i] ?? null })), ], headerDistanceCm: hasHeader ? edge?.top ?? null : null, footerDistanceCm: hasFooter ? edge?.bottom ?? null : null, @@ -885,6 +885,7 @@ function hfSetOfMasterPage( name: string, ctx: Ctx, doc: { orientation: Orientation; format: PageFormat } | null, docNumFormat: NoteNumFormat, + docEdge: { top: number; bottom: number } | null, ): HfSet { const hf = ctx.resolver.masterPageHF(name); const zone = (el: Element | null, footer = false) => @@ -903,7 +904,21 @@ function hfSetOfMasterPage( // The page-number format rides the layout governing the body, like the margins: a // roman front matter is one master before the decimal body's, not a document setting. const numFormat = ctx.resolver.pageNumberFormat(hf.restPage ?? name); + // The layout's own margin is the edge→zone distance, and a section's zones sit on it: + // an index whose header starts 4cm down is not the body's 1.5cm. Matching the + // document's is inheritance, as it is for the margins. + const distOf = (page: string | null): HfDistances | null => { + const e = ctx.resolver.edgeDistancesCm(page); + return e ? { header: e.top, footer: e.bottom } : null; + }; + const sameDist = (a: HfDistances | null, b: HfDistances | null) => + !!a && !!b && Math.abs(a.header - b.header) < 0.01 && Math.abs(a.footer - b.footer) < 0.01; + const docDist = docEdge ? { header: docEdge.top, footer: docEdge.bottom } : null; + const restDist = distOf(hf.restPage ?? name); + const firstDist = hf.restPage ? distOf(name) : null; return { + distances: sameDist(restDist, docDist) ? null : restDist, + distancesFirst: firstDist && !sameDist(firstDist, restDist) ? firstDist : null, margins: rest, marginsFirst: hf.restPage && !hf.mirrorPair ? own : null, pageNumberFormat: numFormat !== docNumFormat ? numFormat : null, diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 89a544a..4565fb7 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -889,8 +889,8 @@ export class StyleResolver { // Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from // the top and footer distance from the bottom when a header/footer is present. - edgeDistancesCm(): { top: number; bottom: number } | null { - const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; + edgeDistancesCm(pageName: string | null = null): { top: number; bottom: number } | null { + const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; if (!props) return null; const inset = this.decorInsetCm(props); const room = (lengthToCm(props.getAttributeNS(NS.fo, 'page-height')) ?? 29.7) - 1; diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 21af68b..3be0ee1 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -38,6 +38,11 @@ export type HfSet = { // How its page-number field counts, where the section disagrees with the document // (roman front matter before decimal body). null = the document's own format. pageNumberFormat?: NoteNumFormat | null; + // Its own edge→zone distances (ODF's page-layout margin, Word's w:pgMar + // w:header/w:footer), where the section's page setup gives it others than the + // document's; `distancesFirst` is its first page's where that hands over. + distances?: HfDistances | null; + distancesFirst?: HfDistances | null; }; export const EMPTY_HF_SET: HfSet = { @@ -45,6 +50,7 @@ export const EMPTY_HF_SET: HfSet = { headerFirst: null, footerFirst: null, differentFirstPage: false, headerEven: null, footerEven: null, differentOddEven: false, margins: null, marginsFirst: null, format: null, orientation: null, + distances: null, distancesFirst: null, pageNumberStart: null, pageNumberFormat: null, }; diff --git a/tests/unit/hf-sections.test.ts b/tests/unit/hf-sections.test.ts index c2b907f..7b432a2 100644 --- a/tests/unit/hf-sections.test.ts +++ b/tests/unit/hf-sections.test.ts @@ -62,4 +62,20 @@ describe('per-section header/footer', () => { expect(zoneText(back.hfSections[1].header)).toBe('Second header'); expect((back.content.content ?? []).filter((b: any) => b.attrs?.sectionBreak).length).toBe(1); }); + + it('carries a section-only edge distance through both formats', async () => { + // An index whose header starts 4cm down, in a body whose own starts at 1.25. + const deep = [sections[0], { ...sections[1], distances: { header: 4, footer: 3 } }]; + const own = { ...hf, sections: deep }; + const odt = await importOdt(await buildOdt(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', own)); + expect(odt.hfSections[0].distances ?? null).toBe(null); + expect(odt.hfSections[1].distances?.header).toBeCloseTo(4, 2); + expect(odt.hfSections[1].distances?.footer).toBeCloseTo(3, 2); + + const docx = importDocx(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait', own)); + expect(docx.hfSections[0].distances ?? null).toBe(null); + // Word clamps the distance below the section's own margin, as both products do. + expect(docx.hfSections[1].distances?.header).toBeCloseTo(2, 2); + expect(docx.hfSections[1].distances?.footer).toBeCloseTo(2, 2); + }); });