diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index e76544a..201ed47 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -162,14 +162,18 @@ export function readVerticalMargins(dom: HTMLElement): VMargins { // offsetParent chain, spacers included) divided by the page cycle. The TOC and every // cross-reference resolve their page numbers with it. export function pageOfElement(view: EditorView, el: HTMLElement, cycle: number): number { + return Math.max(1, Math.floor(topInEditor(view, el) / cycle) + 1); +} + +// An element's top in document px from the editor's own top — spacers included, since +// they sit in the flow above it. +export function topInEditor(view: EditorView, el: HTMLElement): number { const tiptap = view.dom as HTMLElement; let top = 0; - let n: HTMLElement | null = el; - while (n && n !== tiptap) { + for (let n: HTMLElement | null = el; n && n !== tiptap; n = n.offsetParent as HTMLElement | null) { top += n.offsetTop; - n = n.offsetParent as HTMLElement | null; } - return Math.max(1, Math.floor(top / cycle) + 1); + return top; } function pageContentStart(page: number, marginTop: number, cycle: number): number { diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 01d0a24..1d7b047 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -2,7 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core'; import type { Editor } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; import { MAX_HEADING_LEVEL } from '../../export/odt'; -import { readVerticalMargins, pageOfElement } from './pageBreaks'; +import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC } from './pageBreaks'; // A generated table of contents: a block atom listing every heading (levels 1–5) with its // live page number. The node view regenerates entries from the headings + pagination and @@ -93,6 +93,7 @@ class TocView { private getPos: () => number; private scheduled = false; private lastKey = ''; + private wasPaginated = false; private paper: HTMLElement | null = null; private onPageCount = () => this.schedule(); @@ -152,10 +153,43 @@ class TocView { const cycle = this.cycle(); const entries: TocEntry[] = heads.map(h => ({ text: h.text, level: h.level, page: this.pageOf(h.pos, cycle) })); const key = JSON.stringify(entries); - if (key === this.lastKey) return; - this.lastKey = key; - this.paint(entries, heads); - this.syncAttr(entries); + if (key !== this.lastKey) { + this.lastKey = key; + this.paint(entries, heads); + this.syncAttr(entries); + } + this.paginate(); + } + + // The index is a block atom: it has no inner document positions for pagination to put + // a spacer at, so one longer than a page breaks itself — the row that would cross the + // boundary takes the gap to the next page's content top as its margin. + private paginate(): void { + const rows = Array.from(this.dom.querySelectorAll('.toc-entry')); + if (!rows.length) return; + const view = this.editor.view; + const vm = readVerticalMargins(view.dom as HTMLElement); + for (const row of rows) row.style.marginTop = ''; + // Read every natural top first: applying a gap moves each row below it, and one + // reflow for the whole index beats one per row. + const tops = rows.map(row => topInEditor(view, row)); + let shift = 0; + let moved = false; + rows.forEach((row, i) => { + const top = tops[i] + shift; + const page = Math.max(1, Math.floor(top / vm.cycle) + 1); + if (top + row.offsetHeight <= (page - 1) * vm.cycle + vm.top + vm.contentHeight) return; + const gap = page * vm.cycle + vm.top - top; + if (gap <= 0) return; + row.style.marginTop = `${gap}px`; + shift += gap; + moved = true; + }); + // The index just changed height, and pagination measured the old one. + if (moved !== this.wasPaginated) { + this.wasPaginated = moved; + view.dispatch(view.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true)); + } } private paint(entries: TocEntry[], heads: HeadingRef[]): void { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 9810f24..c5cecdc 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -101,6 +101,9 @@ type Ctx = { contentWidthCm: number; // Master pages the body switches to, in order — one section each past the first. masterPages: string[]; + // How many body blocks each of them governs: the largest count is the document's own + // page geometry, which is not per section. + masterBlocks: Map; // Bookmark ranges open at this point of the walk, outermost first; a range may end in // a later paragraph than it started in, so the set outlives one convertInline call. openBookmarks: Set; @@ -543,7 +546,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const contentWidthCm = geo ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right : pageDimsCm('A4', 'portrait').w - 2 * 2.12; - const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], openBookmarks: new Set() }; + const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set() }; let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm))); @@ -557,9 +560,18 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = blocks = wrapped; } - const hf = resolver.masterPageHF(); + // Page geometry — margins, format, and the band a header/footer reserves — is + // document-wide, so it comes from the master governing most of the body rather than + // from one the file merely declares (`geo` above only sized the tables). + let dominant = ''; + let governed = 0; + for (const [name, count] of ctx.masterBlocks) { + if (count > governed) { dominant = name; governed = count; } + } + resolver.setDefaultMaster(dominant || null); - const geometry = geo; + const hf = resolver.masterPageHF(); + const geometry = resolver.pageGeometry() ?? geo; const edge = resolver.edgeDistancesCm(); const fonts: EmbeddedFont[] = []; @@ -1145,6 +1157,9 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = // the master the document opens with, which is where the text already is. if (ctx.masterPages.length > 1) attrs.breakBefore = 'page'; } + // '' = the file's own default master, which the blocks before the first switch use. + const governing = kind === 'body' ? ctx.masterPages[ctx.masterPages.length - 1] ?? '' : null; + if (governing != null) ctx.masterBlocks.set(governing, (ctx.masterBlocks.get(governing) ?? 0) + 1); // Tab stops live in a child element of the paragraph properties, so they come from // the resolver's own walk rather than the flattened paraProps. const stops = formatTabStops(resolver.tabStops(styleName)); diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 143238d..66fa57e 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -159,6 +159,7 @@ export class StyleResolver { private paraStyleEls = new Map(); private mergedCache = new Map(); private stylesDoc: Document | null; + private defaultMaster: string | null = null; private namedParagraphNames = new Set(); private namedTextNames = new Set(); private namedTableNames = new Set(); @@ -489,10 +490,21 @@ export class StyleResolver { const doc = this.stylesDoc; if (!doc) return null; const pages = Array.from(doc.getElementsByTagNameNS(NS.style, 'master-page')); - if (name) return pages.find(p => p.getAttributeNS(NS.style, 'name') === name) ?? null; + const want = name ?? this.defaultMaster; + if (want) { + const found = pages.find(p => p.getAttributeNS(NS.style, 'name') === want); + if (found || name) return found ?? null; + } return pages.find(p => p.getAttributeNS(NS.style, 'name') === 'Standard') ?? pages[0] ?? null; } + // The master page the body actually uses. Page geometry is document-wide, so it has + // to come from that one: a file whose "Standard" master no paragraph references would + // otherwise take a geometry — and a header/footer band — no page of it has. + setDefaultMaster(name: string | null): void { + this.defaultMaster = name; + } + // The master page a paragraph style switches to, walking style:parent-style-name. masterPageOf(styleName: string | null): string | null { const seen = new Set();