From 430bef57955b087de8221a0a57eefca77545ba53 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 2 Sep 2026 22:50:54 +0200 Subject: [PATCH] fix: two masters naming each other are a mirrored pair, not a title page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibreOffice's Left Page / Right Page chain into each other through style:next-style-name, which was read as the different-first-page idiom: one master's geometry and zones governed the whole section, so a book-style body lost its running head on every second page and its inner/outer margins with it. A next-style cycle now becomes one mirrored setup — the right page's geometry, the left page's zones as the even variant — and style:page-usage left/right marks the margins as mirrored. --- src/lib/import/odt.ts | 2 +- src/lib/import/styleResolver.ts | 52 +++++++++++++++++++---- tests/unit/mirror-master-pair.test.ts | 61 +++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 tests/unit/mirror-master-pair.test.ts diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b5e6995..e0e5363 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -904,7 +904,7 @@ function hfSetOfMasterPage( const numFormat = ctx.resolver.pageNumberFormat(hf.restPage ?? name); return { margins: rest, - marginsFirst: hf.restPage ? own : null, + marginsFirst: hf.restPage && !hf.mirrorPair ? own : null, pageNumberFormat: numFormat !== docNumFormat ? numFormat : null, format: paper && doc && paper.format !== doc.format ? paper.format : null, orientation: paper && doc && paper.orientation !== doc.orientation ? paper.orientation : null, diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 47346fa..aa71a7f 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -668,6 +668,7 @@ export class StyleResolver { footerBandCm: number; firstPageOnly: boolean; restPage: string | null; + mirrorPair: boolean; } { const mp = this.masterPageEl(pageName); const layout = this.pageLayoutEl(pageName); @@ -688,11 +689,24 @@ export class StyleResolver { const successor = nextName && nextName !== mp?.getAttributeNS(NS.style, 'name') ? this.masterPageEl(nextName) : null; - const rest = successor ?? mp; + let rest = successor ?? mp; + + // Two masters naming each other are the left/right pair of a mirrored book, not a + // hand-over: LibreOffice's own Left Page / Right Page. The right one governs the odd + // pages, the left one becomes the even variant, and neither ends the section. + const twin = this.mirrorTwin(rest); + // The master named here is itself one half of the pair — as opposed to a title page + // handing over *to* the pair, which really does govern one page only. + const pair = !!twin && (rest === mp || twin === mp); + const leftPage = twin + ? (this.pageUsage(rest) === 'left' ? rest : this.pageUsage(twin) === 'left' ? twin : null) + : null; + if (leftPage === rest) rest = twin; + const handover = pair ? null : successor; const zone = (local: string) => zoneIn(rest, local); const firstZone = (local: string): Element | null => - zoneIn(mp, `${local}-first`) ?? (successor ? zoneIn(mp, local) : null); + zoneIn(mp, `${local}-first`) ?? (handover ? zoneIn(mp, local) : null); const bandProps = (local: 'header-style' | 'footer-style'): Element | null => { if (!layout) return null; for (const child of Array.from(layout.children)) { @@ -729,21 +743,38 @@ export class StyleResolver { footer: zone('footer'), headerFirst: firstZone('header'), footerFirst: firstZone('footer'), - // Even-page variant (Word odd/even). ODF header-left = the left (even) page. - headerLeft: zoneIn(rest, 'header-left'), - footerLeft: zoneIn(rest, 'footer-left'), + // Even-page variant (Word odd/even). ODF header-left = the left (even) page; the + // left master of a mirrored pair is that variant in full. + headerLeft: zoneIn(rest, 'header-left') ?? zoneIn(leftPage, 'header'), + footerLeft: zoneIn(rest, 'footer-left') ?? zoneIn(leftPage, 'footer'), headerExtraCm: extraCm('header-style', 'margin-bottom'), footerExtraCm: extraCm('footer-style', 'margin-top'), headerBandCm: bandCm('header-style', 'margin-bottom'), footerBandCm: bandCm('footer-style', 'margin-top'), // Handing over to a successor is itself the "different first page" flag: a title // master with no zones of its own leaves page one deliberately blank. - firstPageOnly: !!successor, + firstPageOnly: !!handover, // The master every page after the first uses — also where their geometry comes from. - restPage: successor?.getAttributeNS(NS.style, 'name') ?? null, + restPage: rest !== mp ? rest?.getAttributeNS(NS.style, 'name') ?? null : null, + mirrorPair: pair, }; } + private pageUsage(mp: Element | null): string { + const name = mp?.getAttributeNS(NS.style, 'name') ?? null; + return name ? this.pageLayoutEl(name)?.getAttributeNS(NS.style, 'page-usage') ?? '' : ''; + } + + // The master that names this one back through style:next-style-name — the other half + // of a left/right pair. A one-way chain is the title-page hand-over instead. + private mirrorTwin(mp: Element | null): Element | null { + const name = mp?.getAttributeNS(NS.style, 'name'); + const next = mp?.getAttributeNS(NS.style, 'next-style-name'); + if (!name || !next || next === name) return null; + const twin = this.masterPageEl(next); + return twin?.getAttributeNS(NS.style, 'next-style-name') === name ? twin : null; + } + // Page margins + orientation + format from the master page's layout. With a // header/footer the body margin is page margin + zone height + spacing (inverse of // the export mapping). Format is matched from fo:page-width/height (fallback A4). @@ -769,8 +800,13 @@ export class StyleResolver { left: cm('margin-left', 2.12), right: cm('margin-right', 2.12), }; - if (this.pageLayoutEl(pageName)?.getAttributeNS(NS.style, 'page-usage') === 'mirrored') { + // A page style that is one side of a book (style:page-usage left/right) mirrors as + // surely as one marked "mirrored"; the editor stores the odd page's pair and swaps + // it on an even page, so a left-hand layout hands over its mirror image. + const usage = this.pageLayoutEl(pageName)?.getAttributeNS(NS.style, 'page-usage'); + if (usage === 'mirrored' || usage === 'left' || usage === 'right') { margins.mirrored = true; + if (usage === 'left') [margins.left, margins.right] = [margins.right, margins.left]; } const w = lengthToCm(props.getAttributeNS(NS.fo, 'page-width')); const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height')); diff --git a/tests/unit/mirror-master-pair.test.ts b/tests/unit/mirror-master-pair.test.ts new file mode 100644 index 0000000..9a8f71d --- /dev/null +++ b/tests/unit/mirror-master-pair.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { importOdt } from '../../src/lib/import/odt'; + +// Two masters naming each other through style:next-style-name are LibreOffice's Left +// Page / Right Page: one mirrored setup whose left half is the even-page variant, not a +// title page handing over to a running one. +const NS = + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' + + 'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' + + 'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' + + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'; + +function odt(chained: boolean): Uint8Array { + const layout = (name: string, usage: string, left: string, right: string) => + ``; + const next = (n: string) => (chained ? ` style:next-style-name="${n}"` : ''); + const styles = ` + + + + ${layout('pmR', 'right', '3.6cm', '2.7cm')}${layout('pmL', 'left', '2.7cm', '3.6cm')} + + + odd head + + even head + `; + const content = ` + first + second + `; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + +const zoneText = (doc: unknown): string => + JSON.stringify(doc).match(/"text":"([^"]*)"/)?.[1] ?? ''; + +describe('a left/right master pair', () => { + const section = importOdt(odt(true)).hfSections?.[1]; + + it('is one mirrored page setup, stored as the right (odd) page', () => { + expect(section?.margins).toEqual({ top: 2, bottom: 2, left: 3.6, right: 2.7, mirrored: true }); + expect(section?.marginsFirst).toBeNull(); + }); + + it('makes the left master the even-page variant', () => { + expect(section?.differentOddEven).toBe(true); + expect(section?.differentFirstPage).toBe(false); + expect(zoneText(section?.header)).toBe('odd head'); + expect(zoneText(section?.headerEven)).toBe('even head'); + }); + + it('leaves an unchained pair alone', () => { + const plain = importOdt(odt(false)).hfSections?.[1]; + expect(plain?.differentOddEven).toBe(false); + expect(zoneText(plain?.header)).toBe('odd head'); + }); +});