fix: page geometry from the body's own master, and a TOC that breaks

The default master was the one literally named Standard, which a document
need never reference: a fixture's body runs on OOoPageStyle, so its
footer band went unreserved and every page held ~15mm too much text.

An index longer than a page has no inner document positions for a
pagination spacer, so it now breaks itself between entries.
This commit is contained in:
Steffen Becker committed 2026-08-10 08:44:34 +00:00
1 parent 4ad743dc28
commit e3a5594f10
4 files changed
+78 -13

No files matched your search

+8 -4
View File
@@ -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 {
+39 -5
View File
@@ -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<HTMLElement>('.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 {
+18 -3
View File
@@ -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<string, number>;
// 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<string>;
@@ -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));
+13 -1
View File
@@ -159,6 +159,7 @@ export class StyleResolver {
private paraStyleEls = new Map<string, Element>();
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
private defaultMaster: string | null = null;
private namedParagraphNames = new Set<string>();
private namedTextNames = new Set<string>();
private namedTableNames = new Set<string>();
@@ -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<string>();