From b6556bf82ad896c8e68afded966d489512941ecb Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 7 Jul 2026 22:57:20 +0200 Subject: [PATCH] feat: add further page formats --- CHANGELOG.md | 1 + src/lib/export/odt.ts | 18 ++++++++++++------ src/lib/export/pdf.ts | 13 ++++++++----- src/lib/i18n/locales/de.ts | 8 +++++++- src/lib/i18n/locales/en.ts | 8 +++++++- src/lib/storage/pageFormat.ts | 28 +++++++++++++++++++++------- tests/roundtrip.test.ts | 19 +++++++++++++++++++ 7 files changed, 75 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 121b408..78433af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ **Page & layout** - Page margins (cm) and page orientation (portrait / landscape) +- Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import - Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols` - Headers & footers with page-number / page-count fields and configurable edge distances - Zoom (20–300 %) diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index d68a2ba..ffd60af 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1258,13 +1258,21 @@ function rezipOdt(files: Record): Uint8Array { // Rewrite styles.xml to match the editor's preview: default font Liberation Serif → // Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes & // margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES). -function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null): Uint8Array { +function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation): Uint8Array { const files = unzipSync(odtBytes); const stylesBytes = files['styles.xml']; if (!stylesBytes) return odtBytes; let styles = strFromU8(stylesBytes); + // Exact page box for the chosen format; odf-kit only emits its 5 presets, so + // override the single fo:page-width/height in the page layout (round to 3 dp). + const round3 = (v: number) => Math.round(v * 1000) / 1000; + const dims = pageDimsCm(pageFormat, orientation); + styles = styles + .replace(/fo:page-width="[^"]*"/, `fo:page-width="${round3(dims.w)}cm"`) + .replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(dims.h)}cm"`); + // Document spell-check language: set fo:language/fo:country on the base // Standard paragraph style, which every paragraph inherits from. LibreOffice // and Word read this as the document default language. @@ -1941,10 +1949,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const cellBlocks: CellBlock[][] = []; const odt = await tiptapToOdt(json, { - // Format + orientation come from the Layout panel; odf-kit emits the preset's - // fo:page-width/height and swaps them for landscape. Our explicit margins below - // override the preset's default margins, so only the page size follows the format. - pageFormat, + // Orientation sets style:print-orientation; rewriteStylesXml overrides the exact + // fo:page-width/height for the chosen format (odf-kit only knows 5 presets). orientation, // Margins (cm) from the Layout panel, matching the editor's padding so line // wrapping / page flow is identical. With a header/footer the vertical margin is @@ -2048,7 +2054,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withToc = applyToc(withColumns, tocs, contentWidthCm); const withPageBreaks = applyPageBreaks(withToc); const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks); - const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null); + const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null, pageFormat, orientation); return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist); } diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 09abb46..b964ee7 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -305,11 +305,11 @@ export async function printRaster(opts: PdfOptions): Promise { } const title = (opts.fileName ?? deriveFilename(opts.json)).replace(/\.(odt|pdf)$/i, ''); - const landscape = (opts.orientation ?? 'portrait') === 'landscape'; - // Explicit pt dimensions matching the @page box pin each image to exactly one page - // (a hair of rounding overflow would otherwise spill a blank page after each). + // Explicit cm size (orientation baked into w/h) works for any format, unlike a CSS + // size keyword. Explicit pt image dims pin each image to exactly one page. + const dims = pageDimsCm(opts.pageFormat ?? 'A4', opts.orientation ?? 'portrait'); const css = ` -@page { size: ${opts.pageFormat ?? 'A4'} ${landscape ? 'landscape' : 'portrait'}; margin: 0; } +@page { size: ${dims.w}cm ${dims.h}cm; margin: 0; } html, body { margin: 0; padding: 0; background: #fff; } img.pg { display: block; width: ${pageW * PT}pt; height: ${pageH * PT}pt; break-after: page; page-break-after: always; } img.pg:last-child { break-after: auto; page-break-after: auto; } @@ -433,7 +433,10 @@ function marginBoxes(headerDoc: HfDoc, footerDoc: HfDoc): string { function printCss(o: PrintPdfOptions): string { const m = o.margins; - const size = `${o.pageFormat ?? 'A4'} ${o.orientation === 'landscape' ? 'landscape' : 'portrait'}`; + // Explicit cm size (orientation baked into w/h) works for any format, unlike a CSS + // size keyword such as A4/letter. + const d = pageDimsCm(o.pageFormat ?? 'A4', o.orientation); + const size = `${d.w}cm ${d.h}cm`; return ` @page { size: ${size}; diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index d8186a6..e32877d 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -97,7 +97,13 @@ const de: Messages = { pageLayoutMargins: 'Seitenlayout & Ränder', layout: 'Layout', pageFormat: 'Seitenformat', - pageFormats: { A4: 'A4', letter: 'Letter', legal: 'Legal', A3: 'A3', A5: 'A5' }, + pageFormats: { + A3: 'A3', A4: 'A4', A5: 'A5', A6: 'A6', + isoB4: 'B4 (ISO)', isoB5: 'B5 (ISO)', isoB6: 'B6 (ISO)', + jisB4: 'B4 (JIS)', jisB5: 'B5 (JIS)', + letter: 'Letter', legal: 'Legal', tabloid: 'Tabloid', + executive: 'Executive', folio: 'Folio', statement: 'Statement', + }, orientation: 'Ausrichtung', portrait: 'Hochformat', landscape: 'Querformat', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 6132e0f..3c5b1b2 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -100,7 +100,13 @@ const en = { pageLayoutMargins: 'Page layout & margins', layout: 'Layout', pageFormat: 'Page format', - pageFormats: { A4: 'A4', letter: 'Letter', legal: 'Legal', A3: 'A3', A5: 'A5' }, + pageFormats: { + A3: 'A3', A4: 'A4', A5: 'A5', A6: 'A6', + isoB4: 'B4 (ISO)', isoB5: 'B5 (ISO)', isoB6: 'B6 (ISO)', + jisB4: 'B4 (JIS)', jisB5: 'B5 (JIS)', + letter: 'Letter', legal: 'Legal', tabloid: 'Tabloid', + executive: 'Executive', folio: 'Folio', statement: 'Statement', + }, orientation: 'Orientation', portrait: 'Portrait', landscape: 'Landscape', diff --git a/src/lib/storage/pageFormat.ts b/src/lib/storage/pageFormat.ts index ad3b2b5..fe18161 100644 --- a/src/lib/storage/pageFormat.ts +++ b/src/lib/storage/pageFormat.ts @@ -1,18 +1,32 @@ import { PX_PER_CM } from './pageMargins'; import type { Orientation } from './pageOrientation'; -export type PageFormat = 'A4' | 'letter' | 'legal' | 'A3' | 'A5'; +export type PageFormat = + | 'A3' | 'A4' | 'A5' | 'A6' + | 'isoB4' | 'isoB5' | 'isoB6' + | 'jisB4' | 'jisB5' + | 'letter' | 'legal' | 'tabloid' | 'executive' | 'folio' | 'statement'; const KEY = 'odf-editor-page-format'; // Portrait page dimensions in cm per format (width < height). Landscape swaps them. -// Matches odf-kit's pageFormat presets so ODT/DOCX round-trip is lossless. +// Grouped A / ISO-B / JIS-B / US, matching the Word/LibreOffice paper menus. export const PAGE_FORMAT_CM: Record = { - A4: { w: 21, h: 29.7 }, - letter: { w: 21.59, h: 27.94 }, - legal: { w: 21.59, h: 35.56 }, - A3: { w: 29.7, h: 42 }, - A5: { w: 14.8, h: 21 }, + A3: { w: 29.7, h: 42 }, + A4: { w: 21, h: 29.7 }, + A5: { w: 14.8, h: 21 }, + A6: { w: 10.5, h: 14.8 }, + isoB4: { w: 25, h: 35.3 }, + isoB5: { w: 17.6, h: 25 }, + isoB6: { w: 12.5, h: 17.6 }, + jisB4: { w: 25.7, h: 36.4 }, + jisB5: { w: 18.2, h: 25.7 }, + letter: { w: 21.59, h: 27.94 }, + legal: { w: 21.59, h: 35.56 }, + tabloid: { w: 27.94, h: 43.18 }, + executive: { w: 18.415, h: 26.67 }, + folio: { w: 21.59, h: 33.02 }, + statement: { w: 13.97, h: 21.59 }, }; const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 3f40a32..3e2967b 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -317,6 +317,25 @@ describe('Leg 1a: page format', () => { const res = importOdt(await buildOdt(doc, margins, 'portrait', undefined, null, 'letter')); check('format round-trips as letter', res.format === 'letter', res.format); }); + + it('round-trips a non-preset format via the styles.xml override (tabloid)', async () => { + const doc: N = { type: 'doc', content: [P(null, T('Tabloid page.'))] }; + const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'tabloid'); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + check('styles.xml emits tabloid width (27.94cm)', styles.includes('fo:page-width="27.94cm"'), styles.match(/fo:page-(width|height)="[^"]*"/g)); + check('styles.xml emits tabloid height (43.18cm)', styles.includes('fo:page-height="43.18cm"')); + check('format round-trips as tabloid', importOdt(bytes).format === 'tabloid', importOdt(bytes).format); + }); + + it('round-trips executive (fractional cm) and landscape swap', async () => { + const doc: N = { type: 'doc', content: [P(null, T('Executive landscape.'))] }; + const bytes = await buildOdt(doc, margins, 'landscape', undefined, null, 'executive'); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + check('landscape swaps executive width (26.67cm)', styles.includes('fo:page-width="26.67cm"'), styles.match(/fo:page-(width|height)="[^"]*"/g)); + const res = importOdt(bytes); + check('format round-trips as executive', res.format === 'executive', res.format); + check('orientation round-trips as landscape', res.orientation === 'landscape', res.orientation); + }); }); describe('Leg 1b: merged table cells (colspan/rowspan)', () => {