diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 693abc1..c58541c 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -49,7 +49,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( ## Header/footer import -- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` — or `"left"`/`"right"`, which is one side of a book and mirrors just as surely — marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). `style:writing-mode` starting `rl` is a right-to-left page (`rtl`); DOCX reads the section's `w:bidi` for the same thing. The band LibreOffice lays out is **`max(declared height, zone content + the zone's own gap)`**, and the body starts at the page margin plus it — `style:dynamic-spacing` changes neither side. Probed on a 20mm top margin, 10pt zone: one line + `fo:margin-bottom` 0 → body at 24.07mm, the same with 5mm → 29.07, three lines → 37.21, one line under `fo:min-height="20mm"` → 40.00 (the floor wins, the gap is not added on top). A zone paragraph that *wraps* counts as the lines it renders, and its own `fo:padding` counts too. The importer folds only `max(height, gap)` into the page margin — the floor it can know without laying the zone out — and the gap plus the zone's and the paragraphs' `fo:padding` join the collapsed paragraph's spacing on the body-facing side (`convertHfZone`'s `bandCm`), so the runtime's **measured** band (`src/lib/components/CLAUDE.md`) sees them; `max(margin, dist + measured)` is then LibreOffice's `rawTop + max(height, content + gap)` exactly. Verified against a copy of one file's own header: LibreOffice puts the body at 33.71mm, the editor at 33.79. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. +- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` — or `"left"`/`"right"`, which is one side of a book and mirrors just as surely — marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). `style:writing-mode` starting `rl` is a right-to-left page (`rtl`); DOCX reads the section's `w:bidi` for the same thing. The band LibreOffice lays out is **`max(declared height, zone content + the zone's own gap)`**, and the body starts at the page margin plus it — unless the gap is **dynamic** (`style:dynamic-spacing="true"`, which is what LibreOffice writes for a Word header), where it is absorbed rather than added and the band is `max(height, content)`. Probed at min-height 7.51mm, gap 6.51mm, 12pt lines: `false` → 11.37 / 21.11 / 35.71mm for 1 / 3 / 6 lines, `true` → 7.51 / 14.60 / 29.21. Probed on a 20mm top margin, 10pt zone: one line + `fo:margin-bottom` 0 → body at 24.07mm, the same with 5mm → 29.07, three lines → 37.21, one line under `fo:min-height="20mm"` → 40.00 (the floor wins, the gap is not added on top). A zone paragraph that *wraps* counts as the lines it renders, and its own `fo:padding` counts too. The importer folds only `max(height, gap)` into the page margin — the floor it can know without laying the zone out — and the gap plus the zone's and the paragraphs' `fo:padding` join the collapsed paragraph's spacing on the body-facing side (`convertHfZone`'s `bandCm`), so the runtime's **measured** band (`src/lib/components/CLAUDE.md`) sees them; `max(margin, dist + measured)` is then LibreOffice's `rawTop + max(height, content + gap)` exactly. Verified against a copy of one file's own header: LibreOffice puts the body at 33.71mm, the editor at 33.79. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. ## Defaults on DOCX import diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index a2b9fc0..bb896ec 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -717,25 +717,30 @@ export class StyleResolver { 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. + // the zone's own fo:padding counts on both sides of that content — but a *dynamic* + // gap is absorbed instead of added, so there the band is max(height, content). + // Probed at min-height 7.51mm, gap 6.51mm, 12pt lines: false → 11.37 / 21.11 / 35.71 + // for 1 / 3 / 6 lines, true → 7.51 / 14.60 / 29.21. + // `extraCm` is the part known without laying the zone out; `bandCm` what its content + // grows by. + const gapCm = (props: Element, spacingAttr: 'margin-bottom' | 'margin-top'): number => + (props.getAttributeNS(NS.style, 'dynamic-spacing') === 'true' + ? 0 : lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0); 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; - const spacing = lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0; - return Math.max(height, spacing); + return Math.max(height, gapCm(props, spacingAttr)); }; 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 gapCm(props, spacingAttr) + padTop + padBottom; }; return {