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:
1 parent
4ad743dc28
commit
e3a5594f10
4 files changed
+78
-13
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user