fix: the side of a page follows its printed number, not its sheet

Probed: restart the numbering at 2 on the third sheet of a mirrored document and
LibreOffice puts sheets 2 and 3 both at the outer margin with the left-page zone —
two left pages in a row. Restart at 5 and the parity stays with the sheets. Mirrored
margins, the page border and the odd/even zone now all read `printedPageNumber`.
On a fixture built for it the sheets went 15.0/30.1 → 30.1/15.0mm, LibreOffice's
30.0/15.0. One fixture rises 26 → 62 reports, all downstream of a page-count defect
there: its heads now agree with the numbers we print instead of another layout's sheets.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:10 +02:00
1 parent 321e410cd9
commit a5d185fa6d
7 files changed
+90 -19

No files matched your search

+17 -2
View File
@@ -40,7 +40,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import { type Orientation } from '../storage/pageOrientation';
import { type SpacingModel } from '../storage/spacingModel';
import { NO_LANGUAGE } from '../storage/documentLanguage';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { DEFAULT_PAGE_NUMBERING, isLeftPage, printedPageNumber, type PageNumbering } from '../storage/pageNumbering';
import { applyNoteVars } from '../storage/noteSettings';
import { noteSettings } from '../storage/notes.svelte';
import { RESYNC_NOTES } from '../editor/extensions/notes';
@@ -303,8 +303,23 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
...extraHfSections.map((s) => [s.marginsFirst ?? s.margins ?? pageMargins, s.margins ?? pageMargins]),
].map((g) => g.map((m) => (m.mirrored ? Math.round(cmToPx(m.right) - cmToPx(m.left)) : 0)).join('|')).join(','));
// Where numbering restarts, per section — section 0 is the document's own start.
// A section that counts on carries `x`; the break plugin needs it to tell which
// side a page is, and that follows the number, not the sheet.
let sectionNumStarts = $derived([pageNumbering.start,
...extraHfSections.map((s) => s.pageNumberStart ?? 'x')].join(','));
// The number a page shows, and from it its side: a left page is an even-numbered one.
let printedNumberAt = $derived.by(() => {
const starts = [pageNumbering.start, ...extraHfSections.map((s) => s.pageNumberStart ?? null)];
const firstPage = (i: number) => (i === 0 ? 1 : sectionStartPages[i - 1] ?? 1);
return (page: number) => printedPageNumber(page, pageBoxes[page - 1]?.section ?? 0, starts, firstPage);
});
let leftPageAt = $derived((page: number) => isLeftPage(printedNumberAt(page)));
$effect(() => {
const s = document.documentElement.style;
s.setProperty('--pb-section-numstart', sectionNumStarts);
s.setProperty('--pb-section-inset', sectionInset);
s.setProperty('--pb-space-at-page-start', spacingAtPageStart ? '1' : '0');
s.setProperty('--pb-section-mirror', sectionMirror);
@@ -1537,7 +1552,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
</div>
{/if}
<PageSheetLayer {pageBoxes} />
<PageDecorLayer decor={pageDecor} {pageBoxes} {pageMargins} hfInsets={decorHfInsets} />
<PageDecorLayer decor={pageDecor} {pageBoxes} {pageMargins} hfInsets={decorHfInsets} isLeft={leftPageAt} />
{#if foldMarks}
<FoldMarkLayer {pageBoxes} />
{/if}
+10 -13
View File
@@ -7,7 +7,7 @@
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { type Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { DEFAULT_PAGE_NUMBERING, isLeftPage, printedPageNumber, type PageNumbering } from '../storage/pageNumbering';
import { formatOrdinal } from '../utils/orderedListTypes';
import { chapterOn, type ChapterStart } from '../utils/chapterField';
import { t } from '../i18n/i18n.svelte';
@@ -117,7 +117,7 @@
const box = boxOf(page);
const m = marginsOf(page);
const dist = distancesOf(page);
const swap = m.mirrored && page % 2 === 0;
const swap = m.mirrored && isLeftPage(pageNumberAt(page));
// From the page's own left edge — a section on narrower paper is centred.
const left = box.left + cmToPx(swap ? m.right : m.left);
const width = contentWidthOf(page);
@@ -240,7 +240,7 @@
function variantFor(page: number, index = sectionOf(page)): HfVariant {
const s = sets[index] ?? sets[0];
if (s.differentFirstPage && page === sectionFirstPage(index)) return 'first';
if (s.differentOddEven && page % 2 === 0) return 'even';
if (s.differentOddEven && isLeftPage(pageNumberAt(page))) return 'even';
return 'default';
}
function zoneHtml(zone: HfZone, page: number): string {
@@ -270,17 +270,14 @@
return { update: apply };
}
// The number a page shows, in its section's format (a roman front matter) or the
// document's: counted from the nearest section at or above it that restarts
// numbering, else from the document's start.
// The number a page shows, and the label of it in its section's format (a roman
// front matter) or the document's.
function pageNumberAt(page: number): number {
const starts = [pageNumbering.start, ...sets.slice(1).map((s) => s.pageNumberStart ?? null)];
return printedPageNumber(page, sectionOf(page), starts, sectionFirstPage);
}
function pageLabel(page: number): string {
let base = pageNumbering.start;
let from = 1;
for (let i = sectionOf(page); i > 0; i--) {
const start = sets[i]?.pageNumberStart;
if (start != null) { base = start; from = sectionFirstPage(i); break; }
}
return formatOrdinal(page - from + base, sets[sectionOf(page)]?.pageNumberFormat ?? pageNumbering.format);
return formatOrdinal(pageNumberAt(page), sets[sectionOf(page)]?.pageNumberFormat ?? pageNumbering.format);
}
// Replace the placeholder text in every page-field span with the real value:
+4 -2
View File
@@ -5,13 +5,15 @@
// The page's border and watermark, one box per page — the background itself is a
// custom property on .paper, which already paints the sheet. Geometry is unscaled
// document px: the layer sits inside .paper, so the zoom transform covers it.
let { decor, pageBoxes, pageMargins, hfInsets = { headerCm: null, footerCm: null } }: {
let { decor, pageBoxes, pageMargins, hfInsets = { headerCm: null, footerCm: null }, isLeft = () => false }: {
decor: PageDecor;
/** One box per page (Editor.svelte): a section on its own paper differs in size. */
pageBoxes: { top: number; left: number; height: number; width: number }[];
pageMargins: PageMargins;
/** Zone edge distances where a header/footer exists — the border wraps the band. */
hfInsets?: { headerCm: number | null; footerCm: number | null };
/** Whether a page is a left-hand one — its number is even, not its sheet. */
isLeft?: (page: number) => boolean;
} = $props();
// The watermark's aspect ratio, measured off LibreOffice's own shape.
@@ -22,7 +24,7 @@
// processors wrap it), so its edge distance replaces the margin there.
let inset = $derived.by(() => {
const pad = cmToPx(decor.border?.paddingCm ?? 0);
const swap = (page: number) => pageMargins.mirrored === true && page % 2 === 0;
const swap = (page: number) => pageMargins.mirrored === true && isLeft(page);
return (page: number) => ({
top: cmToPx(hfInsets.headerCm ?? pageMargins.top) - pad,
bottom: cmToPx(hfInsets.footerCm ?? pageMargins.bottom) - pad,
+11 -1
View File
@@ -2,6 +2,7 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import { COLUMNS_FIT_MARGIN_PX } from './columns';
import { isLeftPage, printedPageNumber } from '../../storage/pageNumbering';
import { RESYNC_NOTES, setNoteAnchorPages } from './notes';
export type PageBreakDebugSnapshot = {
@@ -1167,6 +1168,10 @@ export const PageBreaks = Extension.create({
// ones has margins of its own and mirrors nothing.
// The document's "spacing at the start of a page" switch (spacingModel.ts).
const spacingAtStart = csRoot.getPropertyValue('--pb-space-at-page-start').trim() !== '0';
// Where each section restarts numbering ('x' = it counts on), section 0 the
// document's own start: the side a page is on follows its number, not its sheet.
const numStarts = csRoot.getPropertyValue('--pb-section-numstart').split(',')
.map((v) => (Number.isFinite(Number(v)) && v.trim() !== '' ? Number(v) : null));
const mirrors = csRoot.getPropertyValue('--pb-section-mirror').split(',')
.map((g) => g.split('|').map(Number))
.filter((g) => g.length === 2 && g.every(Number.isFinite));
@@ -1195,6 +1200,8 @@ export const PageBreaks = Extension.create({
const sectionStartPages: number[] = [];
let sectionIndex = 0;
let sectionFirstPage = 1;
// First page per section, which is what a numbering restart counts from.
const sectionFirstPages: number[] = [1];
// The grid this placement lays out against, grown as each section's first
// page becomes known. Built here, not read back, so a pass never measures
// its own last answer.
@@ -1244,6 +1251,7 @@ export const PageBreaks = Extension.create({
const pushed = !!leaf.forceBreakBefore && i > 0 && effectiveTop > prevStart + 0.5;
sectionIndex++;
sectionFirstPage = pushed ? page + 1 : page;
sectionFirstPages[sectionIndex] = sectionFirstPage;
// Its paper governs from its first page on. Setting it here can only move
// pages *below* that one, so the page just resolved stays valid.
grid.setFrom(sectionFirstPage, paperAt(sectionIndex));
@@ -1589,7 +1597,9 @@ export const PageBreaks = Extension.create({
const landedPage = grid.pageAt(leaf.naturalTop + cumulativeShift);
if (leaf.sectionStart) sectionStartPages.push(landedPage);
const ins = insetAt(sectionIndex);
const mir = landedPage % 2 === 0
const printed = printedPageNumber(landedPage, sectionIndex, numStarts,
(i) => sectionFirstPages[i] ?? 1);
const mir = isLeftPage(printed)
? mirrorAt(sectionIndex)[landedPage === sectionFirstPage ? 0 : 1] : 0;
const insLeft = Math.round((landedPage === sectionFirstPage ? ins[0] : ins[2]) + mir);
const insRight = Math.round((landedPage === sectionFirstPage ? ins[1] : ins[3]) - mir);
+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; `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, which is what `withMirrored` is for; `marginAxisLabel` gives both margin UIs the Inner/Outer field names that go with it.
- **`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, which is what `withMirrored` is for; `marginAxisLabel` gives both margin UIs the Inner/Outer field names that go with it. **Which side a page is on comes from its printed number** (`pageNumbering.ts`'s `printedPageNumber` + `isLeftPage`), not its sheet: probed, LibreOffice calls a page left when its number is even, so a section restarting on the other parity really does show two left pages in a row. The mirror inset, the page border and the odd/even zone all read it.
- **`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
+21
View File
@@ -14,6 +14,27 @@ export const DEFAULT_PAGE_NUMBERING: PageNumbering = { format: '1', start: 1 };
// The five both word processors offer, in their own order.
export const PAGE_NUM_FORMATS: NoteNumFormat[] = ['1', 'i', 'I', 'a', 'A'];
// The number a page shows: counted from the nearest section at or above it that
// restarts numbering, else from the document's start. `starts[0]` is the document's;
// a section that counts on carries null.
export function printedPageNumber(
page: number,
section: number,
starts: (number | null)[],
firstPage: (index: number) => number,
): number {
for (let i = Math.min(section, starts.length - 1); i > 0; i--) {
const start = starts[i];
if (start != null) return page - firstPage(i) + start;
}
return page - 1 + (starts[0] ?? 1);
}
// Mirrored margins and the left-page header follow the page *number*, not the sheet:
// probed, LibreOffice calls a page left when its number is even, so a document that
// restarts numbering on the wrong parity really does show two left pages in a row.
export const isLeftPage = (printed: number): boolean => printed % 2 === 0;
export function clampPageStart(n: number): number {
return Number.isFinite(n) ? Math.min(9999, Math.max(1, Math.round(n))) : 1;
}
+26
View File
@@ -8,6 +8,7 @@ import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
import { EMPTY_HF_SET } from '../../src/lib/storage/headerFooter';
import { isLeftPage, printedPageNumber } from '../../src/lib/storage/pageNumbering';
type N = any;
@@ -48,3 +49,28 @@ describe('per-section page numbering', () => {
expect(starts(importDocx(bytes))).toEqual([null, null, 1]);
});
});
// Probed: LibreOffice's mirrored margins and its left-page zone follow the page
// *number*. A restart onto the wrong parity really does show two left pages in a row —
// the file's own fault, which the word processors show rather than hide.
describe('the side a page is on', () => {
const firstPage = (i: number) => [1, 3][i] ?? 1;
it('counts on from the document start where nothing restarts', () => {
expect(printedPageNumber(3, 0, [1], firstPage)).toBe(3);
expect(printedPageNumber(1, 0, [3], firstPage)).toBe(3);
});
it('counts from the nearest section that restarts', () => {
// Section 1 opens on sheet 3 and restarts at 2 — measured against LibreOffice.
expect([3, 4].map((p) => printedPageNumber(p, 1, [1, 2], firstPage))).toEqual([2, 3]);
expect([3, 4].map((p) => isLeftPage(printedPageNumber(p, 1, [1, 2], firstPage)))).toEqual([true, false]);
// Two left pages in a row: sheet 2 is number 2 as well.
expect(isLeftPage(printedPageNumber(2, 0, [1, 2], firstPage))).toBe(true);
});
it('is the sheet again where the restart keeps the parity', () => {
expect([3, 4].map((p) => printedPageNumber(p, 1, [1, 5], firstPage))).toEqual([5, 6]);
expect([3, 4].map((p) => isLeftPage(printedPageNumber(p, 1, [1, 5], firstPage)))).toEqual([false, true]);
});
});