feat: new page formats A3, A5, letter, legal
This commit is contained in:
1 parent
d11fc065f8
commit
bbd84cbeed
15 files changed
+177
-62
No files matched your search
+16
-3
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user