From 2dcf8b8a20971ec168bd6176d84007a64bae6996 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 9 Aug 2026 14:32:53 +0000 Subject: [PATCH] fix: a title-page master governs one page, not the whole document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/import/CLAUDE.md | 2 +- src/lib/import/odt.ts | 11 ++++++---- src/lib/import/styleResolver.ts | 37 +++++++++++++++++++-------------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 6caca5b..f5be813 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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 diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index e8eb6c8..0f1f0dd 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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); diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 19ceb01..64bd629 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -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'), };