fix: a landscape page is a landscape page in the .docx too

The `docx` package swaps the pair itself for a landscape section, so it
wants the portrait box; handed the swapped one it wrote a portrait page
merely labelled landscape — LibreOffice renders it portrait (measured:
21×29.7cm out of a landscape document).

Per-section paper rides the same fix: an HfSet carries its own format and
orientation, ODF cloning the section's page layout as it already did for
the margins and DOCX writing it into each w:sectPr.
This commit is contained in:
Steffen Becker committed 2026-08-14 10:24:42 +02:00
1 parent c38320a108
commit 12fe4e7239
6 files changed
+138 -31

No files matched your search

+12 -7
View File
@@ -19,7 +19,7 @@ import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { SHAPES, isShapeKind, type ShapeKind } from '../utils/shapes';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import type { SpacingModel } from '../storage/spacingModel';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
@@ -1765,7 +1765,6 @@ export async function buildDocx(
docFormulas = [];
docSources = [];
const num = new Numbering();
const landscape = orientation === 'landscape';
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
const contentWidthCm = pageWidthCm - margins.left - margins.right;
@@ -1815,13 +1814,19 @@ export async function buildDocx(
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
// Page geometry rides every sectPr (Word requires it per section): the size is
// document-wide, the margins the section's own where it has them. Fresh Header/Footer
// instances per section, or a later one inherits ("Link to Previous").
// Page geometry rides every sectPr (Word requires it per section): size and margins
// alike are the section's own where it has them. Fresh Header/Footer instances per
// section, or a later one inherits ("Link to Previous").
const pagePropsFor = (i: number) => {
const m = setAt(i).margins ?? margins;
const s = setAt(i);
const m = s.margins ?? margins;
const sectionLandscape = (s.orientation ?? orientation) === 'landscape';
// The package swaps the pair itself for a landscape section, so it wants the
// **portrait** box — handed the swapped one it writes a portrait page merely
// labelled landscape, which is what LibreOffice then renders.
const dims = PAGE_FORMAT_CM[s.format ?? pageFormat];
return {
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
size: { width: cmToTwip(dims.w), height: cmToTwip(dims.h), orientation: sectionLandscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
margin: {
top: cmToTwip(m.top), bottom: cmToTwip(m.bottom),
left: cmToTwip(m.left), right: cmToTwip(m.right),
+21 -7
View File
@@ -4329,7 +4329,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withWatermark = applyWatermarkOdf(withHf, decor.watermark);
// Sections past the first get their own master page, which is where ODF keeps a
// section's header/footer; the SEC-marked block points at it.
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins);
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation);
return applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props);
}
@@ -4625,7 +4625,7 @@ function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount:
// Point each SEC-marked block at its section's master page (ODF's only per-section
// header/footer), minting the master pages beside the Standard one odf-kit wrote.
function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: number, margins: PageMargins): Uint8Array {
function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: number, margins: PageMargins, format: PageFormat, orientation: Orientation): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
const stylesBytes = files['styles.xml'];
@@ -4666,14 +4666,28 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount:
const layouts: string[] = [];
const layoutFor = (index: number, set: HfSet): string => {
const m = set.margins;
if (!m || !layoutXml) return layout;
const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null;
if ((!m && !paper) || !layoutXml) return layout;
const name = `${layout}Sec${index + 1}`;
layouts.push(layoutXml
.replace(`style:name="${layout}"`, `style:name="${name}"`)
.replace(/<style:page-layout-properties\b[^>]*>/, (props) =>
(['top', 'bottom', 'left', 'right'] as const).reduce((p, side) =>
p.replace(new RegExp(`fo:margin-${side}="([\\d.]+)cm"`), (_x, cm: string) =>
`fo:margin-${side}="${Math.max(0, Math.round((parseFloat(cm) + m[side] - margins[side]) * 1000) / 1000)}cm"`), props)));
.replace(/<style:page-layout-properties\b[^>]*>/, (props) => {
// The margins are a shift, so whatever the header/footer pass folded into them
// survives; the paper is set outright.
let p = m
? (['top', 'bottom', 'left', 'right'] as const).reduce((q, side) =>
q.replace(new RegExp(`fo:margin-${side}="([\\d.]+)cm"`), (_x, cm: string) =>
`fo:margin-${side}="${Math.max(0, Math.round((parseFloat(cm) + m[side] - margins[side]) * 1000) / 1000)}cm"`), props)
: props;
if (paper) {
const round3 = (v: number) => Math.round(v * 1000) / 1000;
p = p
.replace(/fo:page-width="[^"]*"/, `fo:page-width="${round3(paper.w)}cm"`)
.replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(paper.h)}cm"`)
.replace(/style:print-orientation="[^"]*"/, `style:print-orientation="${paper.w > paper.h ? 'landscape' : 'portrait'}"`);
}
return p;
}));
return name;
};
const hfStyles: string[] = [];
+31 -12
View File
@@ -225,11 +225,14 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
// Odd/even pages: a document-level setting (settings.xml), not a section property.
const oddEven = docHasEvenOddHeaders(files);
const mirrored = docHasMirrorMargins(files);
// Page size stays document-wide, from the body-final sectPr; the margins are the
// first section's, since that is the pair .tiptap's padding draws and every later
// section is measured against (Editor.svelte's --pb-section-inset).
// The document's own page setup is the body-final sectPr's; the margins are the first
// section's, since that is the pair .tiptap's padding draws and every later section is
// measured against (Editor.svelte's --pb-section-inset).
const sect = parseSectPr(finalSectPr, ctx, oddEven);
const hfSections = sectionHfSets(groups.map((g) => g.sectPr ?? finalSectPr), ctx, oddEven);
// The paper is the *first* section's, as the margins are: a document whose last
// section is one landscape page is not a landscape document.
const docPaper = sectPaper(groups[0]?.sectPr ?? finalSectPr);
const hfSections = sectionHfSets(groups.map((g) => g.sectPr ?? finalSectPr), ctx, oddEven, docPaper);
const first = hfSections[0];
// A first-page/even zone reserves the header/footer band even when its default is empty;
// the distance is document-wide, so any section having one is enough.
@@ -246,8 +249,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
lineNumbering: docxLineNumbering(finalSectPr),
hyphenate: docAutoHyphenation(files),
pageNumbering: docxPageNumbering(sectPr),
orientation: sect.orientation,
format: sect.format,
orientation: docPaper.orientation,
format: docPaper.format,
tabIntervalCm: docTabInterval(files),
// Word takes the larger of the two spacings, and LibreOffice follows it for a
// Word document — its own ODF default adds them (probed).
@@ -2563,6 +2566,18 @@ function docxDocProperties(files: Record<string, Uint8Array>): DocProperties {
};
}
// A section's paper (w:pgSz). A box wider than it is tall is landscape even where the
// producer left the attribute off, which is how LibreOffice's own DOCX reads.
function sectPaper(sect: Element | null): { orientation: Orientation; format: PageFormat | null } {
const pgSz = sect ? fc(sect, 'pgSz') : null;
const w = intAttr(pgSz, W, 'w');
const h = intAttr(pgSz, W, 'h');
return {
orientation: pgSz?.getAttributeNS(W, 'orient') === 'landscape' || (w && h && w > h) ? 'landscape' : 'portrait',
format: w && h ? (formatFromCm(twipToCm(w), twipToCm(h)) ?? 'A4') : null,
};
}
function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null;
header: HfDoc; footer: HfDoc; headerFirst: HfDoc; footerFirst: HfDoc; differentFirstPage: boolean;
@@ -2572,11 +2587,7 @@ function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
const empty = { margins: null, orientation: null, format: null, header: null, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: false, headerEven: null, footerEven: null, differentOddEven: false, headerDistCm: null, footerDistCm: null };
if (!sect) return empty;
const pgSz = fc(sect, 'pgSz');
const w = intAttr(pgSz, W, 'w');
const h = intAttr(pgSz, W, 'h');
const orientation: Orientation = pgSz?.getAttributeNS(W, 'orient') === 'landscape' || (w && h && w > h) ? 'landscape' : 'portrait';
const format: PageFormat | null = w && h ? (formatFromCm(twipToCm(w), twipToCm(h)) ?? 'A4') : null;
const { orientation, format } = sectPaper(sect);
const pgMar = fc(sect, 'pgMar');
const clampCm = (tw: number | null) => (tw == null ? null : Math.min(10, Math.max(0, round2(twipToCm(tw)))));
@@ -2623,7 +2634,10 @@ function sectMargins(sect: Element | null): PageMargins | null {
// One HfSet per section, in body order, resolving Word's "Link to Previous": a section
// that declares no reference of a type keeps the previous section's. w:titlePg is per
// section, odd/even is document-wide (settings.xml).
function sectionHfSets(sectPrs: (Element | null)[], ctx: Ctx, oddEven: boolean): HfSet[] {
function sectionHfSets(
sectPrs: (Element | null)[], ctx: Ctx, oddEven: boolean,
doc: { format: PageFormat | null; orientation: Orientation | null },
): HfSet[] {
const out: HfSet[] = [];
for (const sect of sectPrs) {
const prev = out[out.length - 1] ?? EMPTY_HF_SET;
@@ -2635,8 +2649,13 @@ function sectionHfSets(sectPrs: (Element | null)[], ctx: Ctx, oddEven: boolean):
: null;
return ref ? convertHfPart(ref.getAttributeNS(R, 'id'), ctx) : inherited;
};
// Only a section disagreeing with the document carries its own paper: matching it is
// inheritance, not formatting, exactly as for the margins.
const paper = sectPaper(sect);
out.push({
margins: sectMargins(sect),
format: paper.format && paper.format !== doc.format ? paper.format : null,
orientation: paper.orientation && paper.orientation !== doc.orientation ? paper.orientation : null,
header: zone('header', 'default', prev.header),
footer: zone('footer', 'default', prev.footer),
headerFirst: titlePg ? zone('header', 'first', prev.headerFirst) : null,
+14 -4
View File
@@ -713,7 +713,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
differentOddEven,
hfSections: [
{ header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven },
...ctx.masterPages.map((name) => hfSetOfMasterPage(name, ctx)),
...ctx.masterPages.map((name) => hfSetOfMasterPage(name, ctx, geometry)),
],
headerDistanceCm: hasHeader ? edge?.top ?? null : null,
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
@@ -727,16 +727,26 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
}
// The zones of a named master page — what a section past the first switches to.
function hfSetOfMasterPage(name: string, ctx: Ctx): HfSet {
function hfSetOfMasterPage(
name: string, ctx: Ctx,
doc: { orientation: Orientation; format: PageFormat } | null,
): HfSet {
const hf = ctx.resolver.masterPageHF(name);
const zone = (el: Element | null) => (el ? convertHfZone(el, ctx) : null);
// Its page layout is the section's own geometry; where the master hands over, that
// layout governs the first page only and the successor's the rest.
const own = ctx.resolver.pageGeometry(name)?.margins ?? null;
const rest = hf.restPage ? ctx.resolver.pageGeometry(hf.restPage)?.margins ?? own : own;
const geo = ctx.resolver.pageGeometry(name);
const own = geo?.margins ?? null;
const restGeo = hf.restPage ? ctx.resolver.pageGeometry(hf.restPage) : null;
const rest = restGeo?.margins ?? own;
// The paper of the layout governing the section's body. Only a section disagreeing
// with the document carries it — matching is inheritance, as it is for the margins.
const paper = restGeo ?? geo;
return {
margins: rest,
marginsFirst: hf.restPage ? own : null,
format: paper && doc && paper.format !== doc.format ? paper.format : null,
orientation: paper && doc && paper.orientation !== doc.orientation ? paper.orientation : null,
header: zone(hf.header),
footer: zone(hf.footer),
headerFirst: zone(hf.headerFirst),
+8 -1
View File
@@ -3,6 +3,8 @@
// page 1, 'even' overrides even pages — also their precedence. null = empty zone.
import type { PageMargins } from './pageMargins';
import type { PageFormat } from './pageFormat';
import type { Orientation } from './pageOrientation';
export type HfZone = 'header' | 'footer';
export type HfVariant = 'default' | 'first' | 'even';
@@ -24,13 +26,18 @@ export type HfSet = {
// The section's first page, where its page style hands over to another after it
// (ODF style:next-style-name — the title-page idiom). null = same as `margins`.
marginsFirst?: PageMargins | null;
// The section's own paper, where the file gives it one (Word's w:pgSz, ODF's page
// layout): a landscape page for a wide table amid portrait ones. null = the
// document's own.
format?: PageFormat | null;
orientation?: Orientation | null;
};
export const EMPTY_HF_SET: HfSet = {
header: null, footer: null,
headerFirst: null, footerFirst: null, differentFirstPage: false,
headerEven: null, footerEven: null, differentOddEven: false,
margins: null, marginsFirst: null,
margins: null, marginsFirst: null, format: null, orientation: null,
};
export function hfSetIsEmpty(s: HfSet): boolean {
+52
View File
@@ -2360,3 +2360,55 @@ describe('Leg 26: bibliography', () => {
check('index round-trips as a bibliography', indexKind(back) === 'bibliography', indexKind(back));
});
});
describe('Leg 27: per-section page size and orientation (ODT + DOCX)', () => {
const secDoc: N = {
type: 'doc',
content: [
P(null, T('portrait section')),
P({ sectionBreak: true, breakBefore: 'page' }, T('the wide table lives here')),
P({ sectionBreak: true, breakBefore: 'page' }, T('portrait again')),
],
};
// The common case: one landscape page amid portrait ones, plus a section on
// another paper entirely.
const sections = [
{ ...EMPTY_HF_SET },
{ ...EMPTY_HF_SET, orientation: 'landscape' as const },
{ ...EMPTY_HF_SET, format: 'A5' as const },
];
const paperOf = (res: N, i: number) =>
`${res.hfSections?.[i]?.format ?? '-'}/${res.hfSections?.[i]?.orientation ?? '-'}`;
it('ODT: each section gets its own page layout', async () => {
const bytes = await buildOdt(secDoc, margins, 'portrait', { sections, pageCount: 3 });
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
const layoutOf = (n: number) => {
const name = new RegExp(`<style:master-page style:name="Section${n}" style:page-layout-name="([^"]*)"`).exec(styles)?.[1];
return name ? new RegExp(`<style:page-layout\\b[^>]*style:name="${name}"[\\s\\S]*?<style:page-layout-properties\\b[^>]*>`).exec(styles)?.[0] ?? '' : '';
};
check('the landscape section swaps the page box',
/fo:page-width="29.7cm"/.test(layoutOf(2)) && /fo:page-height="21cm"/.test(layoutOf(2)), layoutOf(2));
check('and says so on the layout', /style:print-orientation="landscape"/.test(layoutOf(2)), layoutOf(2));
check('the A5 section takes A5', /fo:page-width="14.8cm"/.test(layoutOf(3)) && /fo:page-height="21cm"/.test(layoutOf(3)), layoutOf(3));
const res = importOdt(bytes);
check('the document keeps its own paper', `${res.format}/${res.orientation}` === 'A4/portrait', `${res.format}/${res.orientation}`);
check('the landscape section round-trips', paperOf(res, 1) === '-/landscape', paperOf(res, 1));
check('the A5 section round-trips', paperOf(res, 2) === 'A5/-', paperOf(res, 2));
});
it('DOCX: each sectPr carries its own w:pgSz', async () => {
const bytes = await buildDocx(secDoc, margins, 'portrait', { sections, pageCount: 3 });
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
const sizes = xml.match(/<w:pgSz[^>]*>/g) ?? [];
check('three sections, three page boxes', sizes.length === 3, sizes);
check('the middle one is landscape', /w:orient="landscape"/.test(sizes[1] ?? '') && /w:w="16838"/.test(sizes[1] ?? ''), sizes[1]);
check('the last one is A5', /w:w="839[12]"/.test(sizes[2] ?? ''), sizes[2]);
const res = importDocx(bytes);
check('the document keeps its own paper', `${res.format}/${res.orientation}` === 'A4/portrait', `${res.format}/${res.orientation}`);
check('the landscape section round-trips', paperOf(res, 1) === '-/landscape', paperOf(res, 1));
check('the A5 section round-trips', paperOf(res, 2) === 'A5/-', paperOf(res, 2));
});
});