diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index c7b4c9b..0af3888 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -17,7 +17,7 @@ import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes'; import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime'; import { shapeFromPrst, isLineKind, lineKindFor, parseSvgPath, parseVmlPath, fitPath } from '../utils/shapes'; import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats'; -import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins'; +import { PX_PER_CM, cmToPx, fitMargins, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval'; @@ -3044,7 +3044,8 @@ function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): { const { orientation, format } = sectPaper(sect); const pgMar = fc(sect, 'pgMar'); - const clampCm = (tw: number | null) => (tw == null ? null : Math.min(10, Math.max(0, round2(twipToCm(tw))))); + const pageHighCm = (intAttr(fc(sect, 'pgSz'), W, 'h') ?? 16838) / 1440 * 2.54 - 1; + const clampCm = (tw: number | null) => (tw == null ? null : Math.min(pageHighCm, Math.max(0, round2(twipToCm(tw))))); const margins = sectMargins(sect); // Different first page: w:titlePg turns on the "first"-type refs for page 1. @@ -3080,9 +3081,16 @@ function sectMargins(sect: Element | null): PageMargins | null { if (!pgMar) return null; const cm = (a: string, fallback: number) => { const tw = intAttr(pgMar, W, a); - return tw == null ? fallback : Math.min(10, Math.max(0, round2(twipToCm(tw)))); + return tw == null ? fallback : Math.max(0, round2(twipToCm(tw))); }; - return { top: cm('top', 2.54), bottom: cm('bottom', 2.54), left: cm('left', 2.12), right: cm('right', 2.12) }; + const pgSz = sect ? fc(sect, 'pgSz') : null; + const dim = (a: string, fallback: number) => { + const tw = intAttr(pgSz, W, a); + return tw == null ? fallback : twipToCm(tw); + }; + return fitMargins( + { top: cm('top', 2.54), bottom: cm('bottom', 2.54), left: cm('left', 2.12), right: cm('right', 2.12) }, + dim('w', 21), dim('h', 29.7)); } // One HfSet per section, in body order, resolving Word's "Link to Previous": a section diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index bb896ec..89a544a 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -1,4 +1,4 @@ -import type { PageMargins } from '../storage/pageMargins'; +import { fitMargins, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { ODF_IMPLIED_TAB_CM } from '../storage/tabInterval'; @@ -795,7 +795,7 @@ export class StyleResolver { const cm = (attr: string, fallback: number, extra = 0) => { const v = lengthToCm(props.getAttributeNS(NS.fo, attr)); if (v == null) return fallback; - return Math.min(10, Math.max(0, Math.round((v + extra + inset) * 100) / 100)); + return Math.max(0, Math.round((v + extra + inset) * 100) / 100); }; const margins: PageMargins = { top: cm('margin-top', 2.54, hf.header ? hf.headerExtraCm : 0), @@ -805,22 +805,24 @@ export class StyleResolver { left: cm('margin-left', 2.12), right: cm('margin-right', 2.12), }; - // A page style that is one side of a book (style:page-usage left/right) mirrors as - // surely as one marked "mirrored"; the editor stores the odd page's pair and swaps - // it on an even page, so a left-hand layout hands over its mirror image. + // A page style that is one side of a book mirrors as surely as one marked + // "mirrored" — but only where the two masters really alternate: style:page-usage + // ="right" on a single layout is a page that starts on the right, nothing more, + // and swapping its pair would draw it at the wrong margin (probed). const usage = this.pageLayoutEl(pageName)?.getAttributeNS(NS.style, 'page-usage'); - if (usage === 'mirrored' || usage === 'left' || usage === 'right') { + if (usage === 'mirrored' || ((usage === 'left' || usage === 'right') && hf.mirrorPair)) { margins.mirrored = true; if (usage === 'left') [margins.left, margins.right] = [margins.right, margins.left]; } const w = lengthToCm(props.getAttributeNS(NS.fo, 'page-width')); const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height')); + const fitted = fitMargins(margins, w ?? 21, h ?? 29.7); const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait'; const format: PageFormat = w != null && h != null ? (formatFromCm(w, h) ?? 'A4') : 'A4'; // style:writing-mode: rl-tb is a right-to-left page — the columns fill from the // right and the text's base direction is RTL. The vertical modes are not read. const rtl = (props.getAttributeNS(NS.style, 'writing-mode') ?? '').startsWith('rl'); - return { margins, orientation, format, rtl }; + return { margins: fitted, orientation, format, rtl }; } // The document's chapter numbering — one in styles.xml. @@ -891,9 +893,10 @@ export class StyleResolver { const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; if (!props) return null; const inset = this.decorInsetCm(props); + const room = (lengthToCm(props.getAttributeNS(NS.fo, 'page-height')) ?? 29.7) - 1; const cm = (attr: string) => { const v = lengthToCm(props.getAttributeNS(NS.fo, attr)); - return v == null ? null : Math.min(10, Math.max(0, Math.round((v + inset) * 100) / 100)); + return v == null ? null : Math.min(room, Math.max(0, Math.round((v + inset) * 100) / 100)); }; return { top: cm('margin-top') ?? 1.25, bottom: cm('margin-bottom') ?? 1.25 }; } diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 02441b6..65c5099 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -5,7 +5,7 @@ password-protected: the protection is on the saved file, not on the browser copy ## Page layout settings -- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; clamped 0–10). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. Optional `mirrored` (ODF `style:page-usage="mirrored"`, Word `w:mirrorMargins`) makes left/right the **inner/outer** pair, which an even page swaps: `--user-margin-mirror` carries the difference, `pageBreaks.ts` insets an even page's blocks by it and `HeaderFooterLayer.svelte` moves the band. The key is **absent**, never `false`, when off — margins are compared whole in the round-trip tests. +- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; `fitMargins` only caps an imported pair at what leaves the page a 1cm strip of text). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. Optional `mirrored` (ODF `style:page-usage="mirrored"`, Word `w:mirrorMargins`) makes left/right the **inner/outer** pair, which an even page swaps: `Editor.svelte` publishes the difference per section as `--pb-section-mirror` (a cover between mirrored pages mirrors nothing), `pageBreaks.ts` insets an even page's blocks by it and `HeaderFooterLayer.svelte` moves the band. The key is **absent**, never `false`, when off — margins are compared whole in the round-trip tests. - **`tabInterval.ts`** — the document's default tab interval in **cm** (`DEFAULT_TAB_INTERVAL_CM = 1.25`, LibreOffice's for a new document; clamped 0.05–10). `applyTabIntervalVar` sets `--tab-interval`, which is `.tiptap`'s `tab-size`. A file that declares none falls back to its format's own value, neither of them ours: `ODF_IMPLIED_TAB_CM` 2cm (measured against LibreOffice), `DOCX_IMPLIED_TAB_CM` 1.27cm — so both exports always write the value out. - **`spacingModel.ts`** — how the gap between two blocks is measured: `'add'` (LibreOffice's own — space-below **plus** space-above) or `'max'` (the larger of the two, what it uses for a diff --git a/src/lib/storage/pageMargins.ts b/src/lib/storage/pageMargins.ts index 35991c9..9dc8dc2 100644 --- a/src/lib/storage/pageMargins.ts +++ b/src/lib/storage/pageMargins.ts @@ -10,6 +10,22 @@ const KEY = 'edentext-page-margins'; // document gets these — an imported one always adopts its own page geometry. export const DEFAULT_MARGINS: PageMargins = { top: 2, bottom: 2, left: 2, right: 2 }; +// An imported margin is laid out as the file declares it — a back cover really does push +// its five lines to the page foot with a 21cm top. The only cap is that the page keeps a +// strip of text, or the flow would measure a column of no height at all. +export const MIN_CONTENT_CM = 1; + +export function fitMargins(m: PageMargins, pageWidthCm: number, pageHeightCm: number): PageMargins { + const pair = (a: number, b: number, page: number): [number, number] => { + const room = Math.max(0, page - MIN_CONTENT_CM); + const first = Math.min(Math.max(0, a), room); + return [first, Math.min(Math.max(0, b), room - first)]; + }; + const [top, bottom] = pair(m.top, m.bottom, pageHeightCm); + const [left, right] = pair(m.left, m.right, pageWidthCm); + return { ...m, top, bottom, left, right }; +} + export const PX_PER_CM = 96 / 2.54; // 37.795 — A4 @96dpi export const cmToPx = (cm: number) => cm * PX_PER_CM; @@ -51,7 +67,4 @@ export function applyMarginVars(m: PageMargins): void { root.setProperty('--user-margin-bottom', `${cmToPx(m.bottom)}px`); root.setProperty('--user-margin-left', `${cmToPx(m.left)}px`); root.setProperty('--user-margin-right', `${cmToPx(m.right)}px`); - // How far an even page's text block moves right; .tiptap's padding draws the odd - // page's pair, so pageBreaks.ts insets the even one by the difference. - root.setProperty('--user-margin-mirror', `${m.mirrored ? cmToPx(m.right - m.left) : 0}px`); } diff --git a/tests/unit/page-margins.test.ts b/tests/unit/page-margins.test.ts index e2a3d4d..6b6bfe8 100644 --- a/tests/unit/page-margins.test.ts +++ b/tests/unit/page-margins.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { PX_PER_CM, cmToPx, DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins'; +import { PX_PER_CM, cmToPx, DEFAULT_MARGINS, fitMargins } from '../../src/lib/storage/pageMargins'; describe('pageMargins units', () => { it('PX_PER_CM is A4 @96dpi (96 / 2.54)', () => { @@ -21,4 +21,18 @@ describe('pageMargins units', () => { it("default margins are LibreOffice's 2cm all round", () => { expect(DEFAULT_MARGINS).toEqual({ top: 2, bottom: 2, left: 2, right: 2 }); }); + + it('keeps a margin the file declares, however large', () => { + // A back cover pushes its last lines to the page foot with a 21cm top margin. + expect(fitMargins({ top: 21, bottom: 2, left: 3.6, right: 2.7 }, 21, 29.7)) + .toEqual({ top: 21, bottom: 2, left: 3.6, right: 2.7 }); + }); + + it('leaves the page a strip of text where the pair would take it all', () => { + const m = fitMargins({ top: 28, bottom: 5, left: 30, right: 5 }, 21, 29.7); + expect(m.top).toBe(28); + expect(m.bottom).toBeCloseTo(0.7, 6); + expect(m.left).toBe(20); + expect(m.right).toBe(0); + }); });