feat: add further page formats

This commit is contained in:
Steffen Becker committed 2026-07-08 19:21:27 +00:00
1 parent bbd84cbeed
commit b6556bf82a
7 files changed
+75 -20

No files matched your search

+1
View File
@@ -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 %)
+12 -6
View File
@@ -1258,13 +1258,21 @@ function rezipOdt(files: Record<string, Uint8Array>): 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);
}
+8 -5
View File
@@ -305,11 +305,11 @@ export async function printRaster(opts: PdfOptions): Promise<void> {
}
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};
+7 -1
View File
@@ -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',
+7 -1
View File
@@ -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',
+21 -7
View File
@@ -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<PageFormat, { w: number; h: number }> = {
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[];
+19
View File
@@ -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)', () => {