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:
1 parent
c38320a108
commit
12fe4e7239
6 files changed
+138
-31
No files matched your search
+12
-7
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user