diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 0f401d5..a556f63 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 11dc0fb..4d32e9f 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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(/]*>/, (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(/]*>/, (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[] = []; diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 5922cb0..3f3cbe1 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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): 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, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index a2685df..ee8afdc 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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), diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 5c3ba04..8662d5f 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -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 { diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 8dac3c8..e9ca629 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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:name="${name}"[\\s\\S]*?]*>`).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(/]*>/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)); + }); +});