fix: the space below a zone's last paragraph is no part of its band

Probed: a header of one 10pt line with a 12mm bottom margin puts the body at the
band's min-height, not 12mm lower, and a footer's trailing margin is dropped the
same way — while a margin between two of the zone's paragraphs counts in full
(two lines 12mm apart give a 20.16mm band). The collapsed zone paragraph carries
only the margins between its source paragraphs now. One 16-page fixture went
183 reports → 5, its body having sat 10.3mm low on every page.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:09 +02:00
1 parent 1d221c1c56
commit 86cacc40b1
3 files changed
+18 -10

No files matched your search

+2 -2
View File
@@ -32,7 +32,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short). The exception is one tagged `edentext-placeholder`: it survives whole as a `placeholderField` node, its export-minted gray stripped so the presentation color never becomes a mark. ODF's `text:placeholder` becomes the same node, the shown text's `<>`/`‹›` brackets stripped.
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
- **Page geometry is the *body's* master page**, not the one named `Standard`: a pre-pass over the body's paragraphs (`dominantMasterPage`) counts the blocks each `style:master-page-name` governs and geometry comes from the winner — **before** the walk, since the text width it yields is what a table's margins, an inline image's fit and the tab-stop suppression measure against (17.0cm against a real 14.7 on one file when it was elected after). A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` (a left layout's pair is stored swapped, since the editor keeps the odd page's). Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it.
- **Page geometry is the *body's* master page**, not the one named `Standard`: a pre-pass over the body's paragraphs (`dominantMasterPage`) counts the blocks each `style:master-page-name` governs and geometry comes from the winner — **before** the walk, since the text width it yields is what a table's margins, an inline image's fit and the tab-stop suppression measure against (17.0cm against a real 14.7 on one file when it was elected after). A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` **only where that twin really exists** — on a single-sided layout it means a page that starts on the right, and swapping its pair drew an index 9mm off. Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it.
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
@@ -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 — 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.
- **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 **last** paragraph's `fo:margin-bottom` is no part of the band, in either zone — probed: a header of one 10pt line with a 12mm bottom margin puts the body at the 6mm min-height, and a footer's is dropped the same way, while a margin *between* two of the zone's paragraphs counts in full (two lines 12mm apart → a 20.16mm band). The collapsed paragraph therefore carries only the margins between the source paragraphs, so a zone's own trailing margin does not survive a re-save; nothing renders differently for it. Missing that put a page's body 10.3mm low on every page of one file. 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
+11 -6
View File
@@ -945,10 +945,14 @@ function convertHfZone(zoneEl: Element, ctx: Ctx, bandCm = 0, footer = false): H
let stops: string | null = null;
const boxMaps: Record<string, string>[] = [];
// The zone's paragraphs collapse into one, so the outer two margins become its own:
// what the band has to be tall enough to hold (Editor.svelte's hfReachPx).
// The zone's paragraphs collapse into one, so their spacing becomes its own: what the
// band has to be tall enough to hold (Editor.svelte's hfReachPx). The **last**
// paragraph's own margin-bottom is not part of it — probed: a header of one 10pt line
// with a 12mm bottom margin puts the body at min-height, and a footer's is dropped the
// same way, while every margin *between* two of its paragraphs counts in full.
let spaceBefore: number | null = null;
let spaceAfter = 0;
let between = 0;
let prevBottomPt = 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;
@@ -963,8 +967,9 @@ function convertHfZone(zoneEl: Element, ctx: Ctx, bandCm = 0, footer = false): H
const styleName = p.getAttributeNS(NS.text, 'style-name');
styleFontPt ??= lengthToPt(ctx.resolver.paraTextProps(styleName)['fo:font-size']) ?? null;
const outer = ctx.resolver.paraProps(styleName);
spaceBefore ??= snapPt(lengthToPt(outer['fo:margin-top']) ?? 0);
spaceAfter = snapPt(lengthToPt(outer['fo:margin-bottom']) ?? 0);
const topPt = snapPt(lengthToPt(outer['fo:margin-top']) ?? 0);
if (spaceBefore == null) spaceBefore = topPt; else between += prevBottomPt + topPt;
prevBottomPt = 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;
@@ -1009,7 +1014,7 @@ function convertHfZone(zoneEl: Element, ctx: Ctx, bandCm = 0, footer = false): H
if (stops) attrs.tabStops = stops;
const bandPt = (bandCm / 2.54) * 72;
const before = snapPt((spaceBefore ?? 0) + padTopPt + (footer ? bandPt : 0));
const after = snapPt(spaceAfter + padBottomPt + (footer ? 0 : bandPt));
const after = snapPt(between + padBottomPt + (footer ? 0 : bandPt));
if (before) attrs.spaceBefore = before;
if (after) attrs.spaceAfter = after;
Object.assign(attrs, box);
+5 -2
View File
@@ -1,5 +1,6 @@
// A header/footer zone's own vertical margins: LibreOffice keeps them on the Header /
// Footer paragraph style, and a footer's space above is what grows the band.
// Footer paragraph style. A footer's space above grows the band; the space *below* the
// zone's last paragraph does not — LibreOffice drops it, so neither zone carries it back.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
@@ -28,7 +29,9 @@ describe('header/footer zone margins', () => {
expect(styles).toContain('fo:margin-top="24pt"');
const back = await importOdt(bytes);
expect(back.header?.content?.[0]?.attrs?.spaceAfter).toBeCloseTo(34, 1);
expect(back.footer?.content?.[0]?.attrs?.spaceBefore).toBeCloseTo(24, 1);
// Probed: a header of one 10pt line with a 12mm bottom margin puts the body at the
// band's min-height, not 12mm lower — the trailing margin is no part of the band.
expect(back.header?.content?.[0]?.attrs?.spaceAfter ?? 0).toBe(0);
});
});