fix: two masters naming each other are a mirrored pair, not a title page

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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:06 +02:00
1 parent 48f7825a29
commit 430bef5795
3 files changed
+106 -9

No files matched your search

+1 -1
View File
@@ -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,
+44 -8
View File
@@ -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'));
+61
View File
@@ -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) =>
`<style:page-layout style:name="${name}" style:page-usage="${usage}"><style:page-layout-properties
fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm"
fo:margin-left="${left}" fo:margin-right="${right}"/></style:page-layout>`;
const next = (n: string) => (chained ? ` style:next-style-name="${n}"` : '');
const styles = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles>
<style:style style:name="Standard" style:family="paragraph"/>
<style:style style:name="Body" style:family="paragraph" style:master-page-name="Right"/>
</office:styles><office:automatic-styles>
${layout('pmR', 'right', '3.6cm', '2.7cm')}${layout('pmL', 'left', '2.7cm', '3.6cm')}
</office:automatic-styles><office:master-styles>
<style:master-page style:name="Right" style:page-layout-name="pmR"${next('Left')}>
<style:header><text:p text:style-name="Standard">odd head</text:p></style:header></style:master-page>
<style:master-page style:name="Left" style:page-layout-name="pmL"${next('Right')}>
<style:header><text:p text:style-name="Standard">even head</text:p></style:header></style:master-page>
</office:master-styles></office:document-styles>`;
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text>
<text:p text:style-name="Standard">first</text:p>
<text:p text:style-name="Body">second</text:p>
</office:text></office:body></office:document-content>`;
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');
});
});