fix: an imported page margin is the one the file declares

Both importers capped every side at 10cm, which is the Layout panel's limit and
no file's: a back cover pushing its last lines to the page foot with a 21cm top
margin rendered 11cm high. fitMargins caps only what would leave the page no
strip of text at all, and the zone distances follow the page's own height.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:08 +02:00
1 parent 1f4f5ad4b2
commit 34d5d6014d
5 files changed
+55 -17

No files matched your search

+12 -4
View File
@@ -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
+11 -8
View File
@@ -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 <text:outline-style> 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 };
}
+1 -1
View File
@@ -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
+16 -3
View File
@@ -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`);
}
+15 -1
View File
@@ -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);
});
});