From 12f2e7a6235ddc387401b8dbdcd8558eb2c23998 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 11 Aug 2026 07:30:45 +0000 Subject: [PATCH] fix: naming a page style is a page break, same master or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeating the current master was read as "no switch, no break", so a book whose chapter heading style names its own page style ran the chapters together — a fixture lost the break before every chapter. An explicitly empty name now ends the parent walk, so nothing inherits a break it never asked for; the section itself still starts only where the master changes. --- src/lib/import/CLAUDE.md | 2 +- src/lib/import/odt.ts | 15 +++++++++------ src/lib/import/styleResolver.ts | 4 +++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index ae28512..f828afc 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -25,7 +25,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 (the thesis fixture lost every `[7]`-style reference and broke its lines one page short). - **Formulas** (`convertFormulaFrame`): a `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` 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`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. A **master switch always breaks the page** (LibreOffice's "page break with page style"), except for the master the document opens with; `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. +- **Page geometry is the *body's* master page**, not the one named `Standard`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. 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. - **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 behind the text (`anchorPage`, `image.ts`). 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. - **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 039e501..e9969e6 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1250,12 +1250,15 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = // A style:master-page-name switches the page master, which is how ODF gives a section // its own header/footer; the block that does it opens that section. const master = kind === 'body' ? resolver.masterPageOf(styleName) : null; - if (master && ctx.masterPages[ctx.masterPages.length - 1] !== master) { - ctx.masterPages.push(master); - attrs.sectionBreak = true; - // Switching the page master starts a new page in LibreOffice, always — except for - // the master the document opens with, which is where the text already is. - if (ctx.masterPages.length > 1) attrs.breakBefore = 'page'; + if (master) { + // Naming a master *is* a page break, even where the page already uses that one + // (probed) — which is how a book starts every chapter on a fresh page. Only the + // document's first block has nothing above it to break from. + if (ctx.masterBlocks.size) attrs.breakBefore = 'page'; + if (ctx.masterPages[ctx.masterPages.length - 1] !== master) { + ctx.masterPages.push(master); + attrs.sectionBreak = true; + } } // '' = the file's own default master, which the blocks before the first switch use. const governing = kind === 'body' ? ctx.masterPages[ctx.masterPages.length - 1] ?? '' : null; diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index a9cf836..e9ab321 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -519,8 +519,10 @@ export class StyleResolver { while (cur && !seen.has(cur)) { seen.add(cur); const el = this.paraStyleEls.get(cur); + // Present but empty is LibreOffice's explicit "no page style change", so it ends + // the walk instead of letting an ancestor's break through to every child style. const name = el?.getAttributeNS(NS.style, 'master-page-name'); - if (name) return name; + if (name != null) return name || null; cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null; } return null;