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:
1 parent
321e410cd9
commit
a5d185fa6d
7 files changed
+90
-19
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user