diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 48c2b6e..45af5de 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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'; {/if} - + {#if foldMarks} {/if} diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 00b9fcc..27cfb37 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -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: diff --git a/src/lib/components/PageDecorLayer.svelte b/src/lib/components/PageDecorLayer.svelte index 004915c..907f2a7 100644 --- a/src/lib/components/PageDecorLayer.svelte +++ b/src/lib/components/PageDecorLayer.svelte @@ -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, diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 023cb11..6cebb1e 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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); diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 9d8db65..ce797c2 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; `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 diff --git a/src/lib/storage/pageNumbering.ts b/src/lib/storage/pageNumbering.ts index ad9c25c..af89b06 100644 --- a/src/lib/storage/pageNumbering.ts +++ b/src/lib/storage/pageNumbering.ts @@ -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; } diff --git a/tests/unit/section-page-numbering.test.ts b/tests/unit/section-page-numbering.test.ts index 7ea9097..f13a507 100644 --- a/tests/unit/section-page-numbering.test.ts +++ b/tests/unit/section-page-numbering.test.ts @@ -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]); + }); +});