feat: new page formats A3, A5, letter, legal

This commit is contained in:
Steffen Becker committed 2026-07-08 19:21:18 +00:00
1 parent d11fc065f8
commit bbd84cbeed
15 files changed
+177 -62

No files matched your search

+16 -3
View File
@@ -18,6 +18,7 @@
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
@@ -87,6 +88,7 @@
let zoom = $state(Math.max(20, Math.min(300, parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10))));
let pageMargins: PageMargins = $state(loadPageMargins());
let pageOrientation: Orientation = $state(loadOrientation());
let pageFormat: PageFormat = $state(loadPageFormat());
// The document's spell-check language; round-trips through the .odt. The effect
// below persists it and switches the shared spell controller (loads the dict).
@@ -127,6 +129,10 @@
saveOrientation(pageOrientation);
});
$effect(() => {
savePageFormat(pageFormat);
});
$effect(() => {
saveDocumentLanguage(documentLanguage);
void spellController.setLanguage(documentLanguage);
@@ -310,6 +316,7 @@
footerDoc = null;
pageMargins = { ...DEFAULT_MARGINS };
pageOrientation = 'portrait';
pageFormat = 'A4';
hfDistances = { ...DEFAULT_HF_DISTANCES };
documentName = '';
fileHandle = null;
@@ -336,6 +343,7 @@
// Editor.svelte re-paginates.
if (result.margins) pageMargins = result.margins;
if (result.orientation) pageOrientation = result.orientation;
if (result.format) pageFormat = result.format;
// Adopt the document's spell-check language (the $effect switches the
// controller + loads its dictionary). null = file declared none; keep ours.
if (result.language) documentLanguage = result.language;
@@ -389,7 +397,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage));
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -405,7 +413,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage));
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -423,7 +431,7 @@
try {
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
const { buildDocx } = await import('./lib/export/docx');
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage));
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
await saveAsDocx(bytes, suggestedFilenameDocx(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -447,6 +455,7 @@
json,
fileName: suggestedFilename(json),
orientation: pageOrientation,
pageFormat,
numPages,
});
} catch (err) {
@@ -471,6 +480,7 @@
json,
fileName: suggestedFilename(json),
orientation: pageOrientation,
pageFormat,
numPages,
});
} catch (err) {
@@ -493,6 +503,7 @@
fileName: suggestedFilename(json),
margins: pageMargins,
orientation: pageOrientation,
pageFormat,
headerDoc,
footerDoc,
});
@@ -791,6 +802,7 @@
bind:showFormattingMarks
bind:pageMargins
bind:pageOrientation
bind:pageFormat
bind:hfDistances
hfActive={hfActive}
onEditZone={(zone) => (hfActive = zone)}
@@ -818,6 +830,7 @@
{showFormattingMarks}
{pageMargins}
orientation={pageOrientation}
{pageFormat}
/>
{#if findOpen && editor}
<div class="find-bar-anchor" style="top: {toolbarRegionH + 8}px;">
+10 -6
View File
@@ -18,7 +18,8 @@
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
import { type Orientation } from '../storage/pageOrientation';
import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
@@ -31,11 +32,12 @@
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
pageFormat = 'A4',
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0),
}: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation;
showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat;
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number;
} = $props();
@@ -46,7 +48,7 @@
applyMarginVars(pageMargins);
});
$effect(() => {
applyOrientationVars(orientation);
applyPageSizeVars(pageFormat, orientation);
});
// Nudge the pageBreaks plugin to recompute with the new content area. The dispatch
@@ -54,9 +56,10 @@
// Svelte effect flush (re-entering it would trip effect_update_depth_exceeded).
let marginRecalcRaf = 0;
$effect(() => {
// track each margin + orientation so the effect re-runs on any change
// track each margin + orientation + format so the effect re-runs on any change
void (pageMargins.top + pageMargins.bottom + pageMargins.left + pageMargins.right);
void orientation;
void pageFormat;
const ed = editor;
if (!ed) return;
cancelAnimationFrame(marginRecalcRaf);
@@ -97,8 +100,8 @@
scaledHeight = Math.round(h * z);
}
// Page cycle (page height + 20px gap) in document px. Orientation-dependent, so
// read live from --user-page-height (set by applyOrientationVars). Must match
// Page cycle (page height + 20px gap) in document px. Format/orientation-dependent,
// so read live from --user-page-height (set by applyPageSizeVars). Must match
// pageBreaks.ts. Fallback = A4 portrait (1123 + 20).
function getCycle(): number {
const ph = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--user-page-height'));
@@ -679,6 +682,7 @@
{currentPage}
{pageMargins}
{orientation}
{pageFormat}
{hfDistances}
/>
</div>
+7 -4
View File
@@ -2,8 +2,9 @@
import { Editor, generateHTML, type Content } from '@tiptap/core';
import { hfExtensions } from '../editor/extensions/headerFooter';
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { cmToPx, type PageMargins } from '../storage/pageMargins';
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { type Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { t } from '../i18n/i18n.svelte';
let {
@@ -13,6 +14,7 @@
currentPage,
pageMargins,
orientation,
pageFormat = 'A4',
hfDistances = DEFAULT_HF_DISTANCES,
hfEditor = $bindable(),
hfActive = $bindable(),
@@ -24,6 +26,7 @@
currentPage: number;
pageMargins: PageMargins;
orientation: Orientation;
pageFormat?: PageFormat;
hfDistances?: HfDistances;
hfEditor: Editor | null;
hfActive: HfZone | null;
@@ -36,8 +39,8 @@
// All geometry is in unscaled document px — the layer lives inside the scaled
// .paper, so the zoom transform applies to it identically to the page background.
let pageWidthPx = $derived(orientation === 'landscape' ? PAGE_H_PORTRAIT : PAGE_W_PORTRAIT);
let pageHeightPx = $derived(orientation === 'landscape' ? PAGE_W_PORTRAIT : PAGE_H_PORTRAIT);
let pageWidthPx = $derived(pageDimsCm(pageFormat, orientation).w * PX_PER_CM);
let pageHeightPx = $derived(pageDimsCm(pageFormat, orientation).h * PX_PER_CM);
let cycle = $derived(pageHeightPx + PAGE_GAP);
let mTop = $derived(cmToPx(pageMargins.top));
let mBottom = $derived(cmToPx(pageMargins.bottom));
+28 -6
View File
@@ -14,14 +14,17 @@
} from '../utils/fontDetect';
import { DEFAULT_MARGINS, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
import { listContext } from '../editor/extensions/indent';
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
// Must match the first font in --font-serif in global.css. Bundled as a
// webfont so it is always available and matches the exported .odt's font.
@@ -774,11 +777,11 @@
let imageInput = $state<HTMLInputElement | null>(null);
// The page text box in px, so an inserted image never starts wider/taller than
// one page (mirrors export's content-width math; A4 dims swap with orientation).
// one page (mirrors export's content-width math; dims follow format + orientation).
function contentBoxPx(): { maxW: number; maxH: number } {
const land = pageOrientation === 'landscape';
const wCm = (land ? 29.7 : 21) - pageMargins.left - pageMargins.right;
const hCm = (land ? 21 : 29.7) - pageMargins.top - pageMargins.bottom;
const { w, h } = pageDimsCm(pageFormat, pageOrientation);
const wCm = w - pageMargins.left - pageMargins.right;
const hCm = h - pageMargins.top - pageMargins.bottom;
return { maxW: Math.round(cmToPx(wCm)), maxH: Math.round(cmToPx(hCm)) };
}
@@ -1155,6 +1158,12 @@
</button>
{#if layoutOpen}
<div class="layout-dropdown">
<div class="lh-section-label">{t().toolbarExpanded.pageFormat}</div>
<select class="format-select" bind:value={pageFormat} title={t().toolbarExpanded.pageFormat}>
{#each PAGE_FORMATS as fmt}
<option value={fmt}>{t().toolbarExpanded.pageFormats[fmt]}</option>
{/each}
</select>
<div class="lh-section-label">{t().toolbarExpanded.orientation}</div>
<div class="orientation-row">
<button
@@ -1879,6 +1888,19 @@
padding: 4px 6px 2px;
}
.format-select {
margin: 4px 6px 2px;
width: calc(100% - 12px);
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.78rem;
cursor: pointer;
}
.orientation-btn {
display: flex;
flex-direction: column;
+3 -2
View File
@@ -16,6 +16,7 @@ import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
@@ -810,11 +811,11 @@ export async function buildDocx(
orientation: Orientation = 'portrait',
hf?: HfExport,
language?: { language: string; country: string } | null,
pageFormat: PageFormat = 'A4',
): Promise<Uint8Array> {
const num = new Numbering();
const landscape = orientation === 'landscape';
const pageWidthCm = landscape ? 29.7 : 21;
const pageHeightCm = landscape ? 21 : 29.7;
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
const contentWidthCm = pageWidthCm - margins.left - margins.right;
const textBoxes: TextBoxDocx[] = [];
+8 -5
View File
@@ -2,6 +2,7 @@ import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, ty
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
@@ -1908,7 +1909,7 @@ export type HfExport = {
};
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4'): Promise<Uint8Array> {
// Collect embedded images and swap them for IMG sentinels before serialization;
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
// Text boxes and columns sections are hoisted after replacePageBreaks (so PGB never
@@ -1930,9 +1931,9 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
json = { ...json, content: [{ type: CUST_HF }, ...(json.content ?? [])] };
}
// Text width = A4 page width (portrait 21cm / landscape 29.7cm) minus the L/R
// Text width = the format's page width (orientation-swapped) minus the L/R
// margins. Table column widths are scaled to fill exactly this width.
const pageWidthCm = orientation === 'landscape' ? 29.7 : 21;
const pageWidthCm = pageDimsCm(pageFormat, orientation).w;
const contentWidthCm = pageWidthCm - margins.left - margins.right;
// Filled by exportTable, in document order, one CellBlock[] per table cell.
@@ -1940,8 +1941,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cellBlocks: CellBlock[][] = [];
const odt = await tiptapToOdt(json, {
// Orientation comes from the Layout panel; odf-kit swaps the A4 dimensions
// automatically (29.7×21cm) and writes style:print-orientation accordingly.
// 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,
// 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
+12 -11
View File
@@ -3,8 +3,9 @@
// so we raster the on-screen render and overlay an invisible, positioned text layer.
import { generateHTML } from '@tiptap/core';
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { type Orientation } from '../storage/pageOrientation';
import { DEFAULT_MARGINS, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { extensions } from '../editor/extensions';
import { columnPercents } from '../editor/extensions/tableView';
@@ -25,17 +26,15 @@ export interface PdfOptions {
json: Json; // fallback for the suggested filename
fileName?: string; // document name (any extension); overrides the heading-derived name
orientation?: Orientation;
pageFormat?: PageFormat;
numPages?: number;
}
type Run = { str: string; x: number; y: number; h: number }; // doc px, relative to .paper top-left
function pageDims(orientation: Orientation): { pageW: number; pageH: number } {
const landscape = orientation === 'landscape';
return {
pageW: landscape ? PAGE_H_PORTRAIT : PAGE_W_PORTRAIT,
pageH: landscape ? PAGE_W_PORTRAIT : PAGE_H_PORTRAIT,
};
function pageDims(format: PageFormat, orientation: Orientation): { pageW: number; pageH: number } {
const { w, h } = pageDimsCm(format, orientation);
return { pageW: w * PX_PER_CM, pageH: h * PX_PER_CM };
}
// Off-screen, scale-1, theme-neutral copy of the live .paper. Rendered identically to
@@ -214,7 +213,7 @@ export async function renderPaperToCanvas(opts: PdfOptions, scale = 2): Promise<
const paper = opts.source.closest('.paper') as HTMLElement | null;
if (!paper) throw new Error('paper element not found');
const orientation: Orientation = opts.orientation ?? 'portrait';
const { pageW, pageH } = pageDims(orientation);
const { pageW, pageH } = pageDims(opts.pageFormat ?? 'A4', orientation);
const cycle = pageH + PAGE_GAP;
const { holder, clone, style } = buildClone(paper, pageW);
@@ -310,7 +309,7 @@ export async function printRaster(opts: PdfOptions): Promise<void> {
// 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).
const css = `
@page { size: A4 ${landscape ? 'landscape' : 'portrait'}; margin: 0; }
@page { size: ${opts.pageFormat ?? 'A4'} ${landscape ? 'landscape' : 'portrait'}; 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; }
@@ -353,6 +352,7 @@ export interface PrintPdfOptions {
fileName?: string; // document name (any extension); overrides the heading-derived name
margins: PageMargins;
orientation: Orientation;
pageFormat?: PageFormat;
headerDoc: HfDoc;
footerDoc: HfDoc;
}
@@ -433,7 +433,7 @@ function marginBoxes(headerDoc: HfDoc, footerDoc: HfDoc): string {
function printCss(o: PrintPdfOptions): string {
const m = o.margins;
const size = o.orientation === 'landscape' ? 'A4 landscape' : 'A4 portrait';
const size = `${o.pageFormat ?? 'A4'} ${o.orientation === 'landscape' ? 'landscape' : 'portrait'}`;
return `
@page {
size: ${size};
@@ -462,6 +462,7 @@ export function printPdf(opts: PrintPdfOptions): void {
fileName: opts.fileName,
margins: opts.margins ?? DEFAULT_MARGINS,
orientation: opts.orientation ?? 'portrait',
pageFormat: opts.pageFormat ?? 'A4',
headerDoc: opts.headerDoc ?? null,
footerDoc: opts.footerDoc ?? null,
};
+2
View File
@@ -96,6 +96,8 @@ const de: Messages = {
pt: (v) => `${v} pt`,
pageLayoutMargins: 'Seitenlayout & Ränder',
layout: 'Layout',
pageFormat: 'Seitenformat',
pageFormats: { A4: 'A4', letter: 'Letter', legal: 'Legal', A3: 'A3', A5: 'A5' },
orientation: 'Ausrichtung',
portrait: 'Hochformat',
landscape: 'Querformat',
+2
View File
@@ -99,6 +99,8 @@ const en = {
pt: (v: number) => `${v} pt`,
pageLayoutMargins: 'Page layout & margins',
layout: 'Layout',
pageFormat: 'Page format',
pageFormats: { A4: 'A4', letter: 'Letter', legal: 'Legal', A3: 'A3', A5: 'A5' },
orientation: 'Orientation',
portrait: 'Portrait',
landscape: 'Landscape',
+6 -3
View File
@@ -7,6 +7,7 @@ import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCL
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
import type { OdtImportResult } from './odt';
@@ -136,6 +137,7 @@ export function importDocx(bytes: Uint8Array): OdtImportResult {
content: { type: 'doc', content: blocks },
margins: sect.margins,
orientation: sect.orientation,
format: sect.format,
header: sect.header,
footer: sect.footer,
headerDistanceCm: sect.header ? sect.headerDistCm : null,
@@ -924,16 +926,17 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o
}
function parseSectPr(sect: Element | null, ctx: Ctx): {
margins: PageMargins | null; orientation: Orientation | null;
margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null;
header: HfDoc; footer: HfDoc; headerDistCm: number | null; footerDistCm: number | null;
} {
const empty = { margins: null, orientation: null, header: null, footer: null, headerDistCm: null, footerDistCm: null };
const empty = { margins: null, orientation: null, format: null, header: null, footer: null, 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 pgMar = fc(sect, 'pgMar');
const clampCm = (tw: number | null) => (tw == null ? null : Math.min(10, Math.max(0, round2(twipToCm(tw)))));
@@ -953,7 +956,7 @@ function parseSectPr(sect: Element | null, ctx: Ctx): {
const header = convertHfPart(refId('header'), ctx);
const footer = convertHfPart(refId('footer'), ctx);
return {
margins, orientation, header, footer,
margins, orientation, format, header, footer,
headerDistCm: clampCm(intAttr(pgMar, W, 'header')),
footerDistCm: clampCm(intAttr(pgMar, W, 'footer')),
};
+3
View File
@@ -6,6 +6,7 @@ import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCL
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import type { PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
@@ -26,6 +27,7 @@ export interface OdtImportResult {
content: Node; // { type: 'doc', … }
margins: PageMargins | null;
orientation: Orientation | null;
format: PageFormat | null;
// Single-paragraph docs in the hfExtensions schema; null = no zone.
header: HfDoc;
footer: HfDoc;
@@ -323,6 +325,7 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
content: { type: 'doc', content: blocks },
margins: geometry?.margins ?? null,
orientation: geometry?.orientation ?? null,
format: geometry?.format ?? null,
header: hf.header ? convertHfZone(hf.header, ctx) : null,
footer: hf.footer ? convertHfZone(hf.footer, ctx) : null,
headerDistanceCm: hf.header ? edge?.top ?? null : null,
+7 -5
View File
@@ -1,5 +1,6 @@
import type { PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
// Resolves ODF style indirection for the importer: producers (our export, LibreOffice,
// Word) spread formatting across named/automatic styles and parent-style-name chains.
@@ -370,10 +371,10 @@ export class StyleResolver {
};
}
// Page margins + orientation from the master page's layout. With a header/footer the
// body margin is page margin + zone height + spacing (inverse of the export mapping).
// Page size is intentionally ignored — the editor is A4-only.
pageGeometry(): { margins: PageMargins; orientation: Orientation } | null {
// Page margins + orientation + format from the master page's layout. With a
// header/footer the body margin is page margin + zone height + spacing (inverse of
// the export mapping). Format is matched from fo:page-width/height (fallback A4).
pageGeometry(): { margins: PageMargins; orientation: Orientation; format: PageFormat } | null {
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
if (!props) return null;
@@ -392,7 +393,8 @@ export class StyleResolver {
const w = lengthToCm(props.getAttributeNS(NS.fo, 'page-width'));
const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height'));
const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait';
return { margins, orientation };
const format: PageFormat = w != null && h != null ? (formatFromCm(w, h) ?? 'A4') : 'A4';
return { margins, orientation, format };
}
// Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from
+55
View File
@@ -0,0 +1,55 @@
import { PX_PER_CM } from './pageMargins';
import type { Orientation } from './pageOrientation';
export type PageFormat = 'A4' | 'letter' | 'legal' | 'A3' | 'A5';
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.
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 },
};
const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
export function loadPageFormat(): PageFormat {
const raw = localStorage.getItem(KEY);
return (raw && (FORMATS as string[]).includes(raw)) ? (raw as PageFormat) : 'A4';
}
export function savePageFormat(f: PageFormat): void {
localStorage.setItem(KEY, f);
}
// Page box (cm) for a format + orientation; landscape swaps width/height.
export function pageDimsCm(format: PageFormat, orientation: Orientation): { w: number; h: number } {
const { w, h } = PAGE_FORMAT_CM[format];
return orientation === 'landscape' ? { w: h, h: w } : { w, h };
}
// Sets --user-page-{width,height} (px) on the document root, where they inherit
// down to .paper / .tiptap (see editor.css) and are read per layout pass by
// pageBreaks.ts. Supersedes applyOrientationVars, which only knew A4.
export function applyPageSizeVars(format: PageFormat, orientation: Orientation): void {
const { w, h } = pageDimsCm(format, orientation);
const root = document.documentElement.style;
root.setProperty('--user-page-width', `${w * PX_PER_CM}px`);
root.setProperty('--user-page-height', `${h * PX_PER_CM}px`);
}
// Match a page's dimensions (cm, any orientation) to a known format, within a
// tolerance for producer rounding; null when nothing fits. Used by the importers.
export function formatFromCm(wCm: number, hCm: number, tolCm = 0.2): PageFormat | null {
const w = Math.min(wCm, hCm);
const h = Math.max(wCm, hCm);
for (const f of FORMATS) {
const dims = PAGE_FORMAT_CM[f];
if (Math.abs(dims.w - w) <= tolCm && Math.abs(dims.h - h) <= tolCm) return f;
}
return null;
}
-17
View File
@@ -2,11 +2,6 @@ export type Orientation = 'portrait' | 'landscape';
const KEY = 'odf-editor-page-orientation';
// A4 page box in px @96dpi. Portrait is the editor's historical fixed layout;
// landscape swaps the two dimensions (matches odf-kit's automatic A4 swap).
export const PAGE_W_PORTRAIT = 794; // 210mm
export const PAGE_H_PORTRAIT = 1123; // 297mm
export function loadOrientation(): Orientation {
return localStorage.getItem(KEY) === 'landscape' ? 'landscape' : 'portrait';
}
@@ -14,15 +9,3 @@ export function loadOrientation(): Orientation {
export function saveOrientation(o: Orientation): void {
localStorage.setItem(KEY, o);
}
// Sets --user-page-{width,height} (in px) on the document root, where they
// inherit down to .paper / .tiptap (see editor.css) and are read per layout
// pass by pageBreaks.ts. Landscape swaps the portrait dimensions.
export function applyOrientationVars(o: Orientation): void {
const landscape = o === 'landscape';
const w = landscape ? PAGE_H_PORTRAIT : PAGE_W_PORTRAIT;
const h = landscape ? PAGE_W_PORTRAIT : PAGE_H_PORTRAIT;
const root = document.documentElement.style;
root.setProperty('--user-page-width', `${w}px`);
root.setProperty('--user-page-height', `${h}px`);
}
+18
View File
@@ -301,6 +301,24 @@ describe('Leg 1: editor → buildOdt → importOdt', () => {
});
});
describe('Leg 1a: page format', () => {
it('round-trips a non-A4 format (legal)', async () => {
const doc: N = { type: 'doc', content: [P(null, T('Legal page.'))] };
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'legal');
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
check('styles.xml emits legal page height (35.56cm)', styles.includes('35.56cm'), styles.match(/fo:page-(width|height)="[^"]*"/g));
const res = importOdt(bytes);
check('format round-trips as legal', res.format === 'legal', res.format);
});
it('detects letter format', async () => {
const doc: N = { type: 'doc', content: [P(null, T('US letter.'))] };
const res = importOdt(await buildOdt(doc, margins, 'portrait', undefined, null, 'letter'));
check('format round-trips as letter', res.format === 'letter', res.format);
});
});
describe('Leg 1b: merged table cells (colspan/rowspan)', () => {
// 3×3 grid: A spans 2 cols (row 0); C spans 2 rows (col 0, rows 1–2).
// row0: [A A][B] row1: [C][D][E] row2: [C][F][G]