From bbd84cbeedc730d6ce4f1161d1ef5d643480a694 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 7 Jul 2026 21:06:45 +0200 Subject: [PATCH] feat: new page formats A3, A5, letter, legal --- src/App.svelte | 19 +++++-- src/lib/components/Editor.svelte | 16 +++--- src/lib/components/HeaderFooterLayer.svelte | 11 +++-- src/lib/components/ToolbarExpanded.svelte | 34 ++++++++++--- src/lib/export/docx.ts | 5 +- src/lib/export/odt.ts | 13 +++-- src/lib/export/pdf.ts | 23 ++++----- src/lib/i18n/locales/de.ts | 2 + src/lib/i18n/locales/en.ts | 2 + src/lib/import/docx.ts | 9 ++-- src/lib/import/odt.ts | 3 ++ src/lib/import/styleResolver.ts | 12 +++-- src/lib/storage/pageFormat.ts | 55 +++++++++++++++++++++ src/lib/storage/pageOrientation.ts | 17 ------- tests/roundtrip.test.ts | 18 +++++++ 15 files changed, 177 insertions(+), 62 deletions(-) create mode 100644 src/lib/storage/pageFormat.ts diff --git a/src/App.svelte b/src/App.svelte index f0da589..12d8895 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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[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[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[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}
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 1b0dd49..eeb5d56 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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} />
diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 2507394..9d6891e 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -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)); diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 8d70c44..5c3a841 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -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('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('portrait'), pageFormat = $bindable('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(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 @@ {#if layoutOpen}
+ +