fix: a title-page master governs one page, not the whole document

An ODF master page whose style:next-style-name names another master applies
to a single page. Read as the section's running header, this file's full-page
title background repeated on every page and its height pushed each block onto
its own — 276 pages instead of 16.

Its zones now become the first-page variants, the successor's the running
ones, and a positioned frame in a zone is dropped as in DOCX.
This commit is contained in:
Steffen Becker committed 2026-08-09 14:32:53 +00:00
1 parent 28a39e017a
commit 2dcf8b8a20
3 files changed
+29 -21

No files matched your search

+1 -1
View File
@@ -23,7 +23,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. The header↔body spacing is only reserved when `style:dynamic-spacing="false"` — a `true` (LibreOffice's usual) means the gap collapses, so reserving it would shove the body down. `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. Per-page variants (`header-first`/`-left`) are ignored 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. The header↔body spacing is only reserved when `style:dynamic-spacing="false"` — a `true` (LibreOffice's usual) means the gap collapses, so reserving it would shove the body down. `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. As in DOCX, a zone's positioned (non-`as-char`) frames are dropped with a warning: the one-paragraph zone can't place them, and a page-sized background reserves the whole page inline. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports.
## Defaults on DOCX import
+7 -4
View File
@@ -1428,11 +1428,14 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
}
if (e.namespaceURI === NS.draw) {
const conv = convertDrawElement(e, ctx);
// The one-paragraph header/footer schema holds inline images (forced
// as-character) but not boxes — those are dropped with a warning.
// The one-paragraph zone takes as-char images only — boxes and positioned
// frames (a page-sized background reserves the whole page inline) are dropped
// with a warning, as the DOCX importer does.
if (hfFields) {
if (conv?.inline?.type === 'image') out.push({ ...conv.inline, attrs: { ...conv.inline.attrs, wrap: 'inline' } });
else if (conv) ctx.warnings.add('Drawings were removed');
const wrap = conv?.inline?.attrs?.wrap;
if (conv?.inline?.type === 'image' && (!wrap || wrap === 'inline')) {
out.push({ ...conv.inline, attrs: { ...conv.inline.attrs, wrap: 'inline' } });
} else if (conv) ctx.warnings.add('Drawings were removed');
continue;
}
if (conv?.inline) out.push(conv.inline);
+21 -16
View File
@@ -518,22 +518,27 @@ export class StyleResolver {
const mp = this.masterPageEl(pageName);
const layout = this.pageLayoutEl();
const zone = (local: string): Element | null => {
if (!mp) return null;
for (const child of Array.from(mp.children)) {
if (child.namespaceURI === NS.style && child.localName === local) return child;
}
return null;
};
// header-first/footer-first (ODF 1.3 style: or older LibreOffice loext:) — match
// by local name in either namespace so both producers round-trip.
const firstZone = (local: string): Element | null => {
if (!mp) return null;
for (const child of Array.from(mp.children)) {
// Match by local name in either namespace: header-first/footer-first are ODF 1.3
// style: or older LibreOffice loext:, so both producers round-trip.
const zoneIn = (owner: Element | null, local: string): Element | null => {
if (!owner) return null;
for (const child of Array.from(owner.children)) {
if (child.localName === local && (child.namespaceURI === NS.style || child.namespaceURI === NS.loext)) return child;
}
return null;
};
// A master page naming a different style:next-style-name governs one page and then
// hands over — the "different first page" idiom. Its own zones become the first-page
// variants, the successor's the ones every later page in the section uses.
const nextName = mp?.getAttributeNS(NS.style, 'next-style-name') ?? null;
const successor = nextName && nextName !== mp?.getAttributeNS(NS.style, 'name')
? this.masterPageEl(nextName)
: null;
const rest = successor ?? mp;
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;
@@ -558,11 +563,11 @@ export class StyleResolver {
return {
header: zone('header'),
footer: zone('footer'),
headerFirst: firstZone('header-first'),
footerFirst: firstZone('footer-first'),
headerFirst: firstZone('header'),
footerFirst: firstZone('footer'),
// Even-page variant (Word odd/even). ODF header-left = the left (even) page.
headerLeft: firstZone('header-left'),
footerLeft: firstZone('footer-left'),
headerLeft: zoneIn(rest, 'header-left'),
footerLeft: zoneIn(rest, 'footer-left'),
headerExtraCm: extraCm('header-style', 'margin-bottom'),
footerExtraCm: extraCm('footer-style', 'margin-top'),
};