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:
1 parent
48f7825a29
commit
430bef5795
3 files changed
+106
-9
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user