From e9e543fe8cfbf9e989a9b87bebaeb35b0d871290 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 20:57:41 +0000 Subject: [PATCH] fix: an image stays on its side of the page break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An as-character image is now a line box in getLineRects, so a paragraph of stacked images breaks between them instead of overflowing the page. Only a floated image keeps its paragraph atomic, and the leaf's height reaches down to the lowest float — a float hangs out of the paragraph's box, so offsetHeight alone let the image walk off the page foot. prevPlacementsKey damps the ping-pong that overhang causes between two layouts. --- docs/architecture/pagination.md | 13 ++++ src/lib/editor/extensions/pageBreaks.ts | 85 +++++++++++++++++++------ 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 8f52044..7d459e3 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -15,6 +15,19 @@ A custom ProseMirror plugin that simulates paginated A4 layout entirely in CSS/D **Widow-orphan control:** `findLineSplit` picks the first line that overflows the page, then applies Word's/LibreOffice's rule (`MIN_KEPT_LINES` = 2, on by default in both via OOXML `w:widowControl`, so not configurable): fewer than two lines carrying over pulls one more down with them, and fewer than two staying behind cancels the split so the caller pushes the whole block. A leaf taller than one page slot passes `minLines: 1` — there the rule can't be satisfied and splitting beats overflowing. +**Images in a paragraph:** an as-character image is a line box like any other (`getLineRects` +measures `.image-node` alongside the text runs, and text sharing its line groups into it), so a +paragraph of stacked images breaks between them instead of overflowing the page. Only a *floated* +image keeps its paragraph atomic — a line-split spacer beside a float is dropped by ProseMirror. +A float hangs out of its paragraph's box, so the leaf's height reaches down to the lowest float +instead of stopping at `offsetHeight` — otherwise the paragraph measures as one text line and the +image walks off the page bottom. Text still wraps beside the float; only the push moves as a unit. +What the model can't see is how far that overhang moves the blocks *below* it: further than the +spacer it accounts for, so two layouts can each imply the other. `prevPlacementsKey` catches that +ping-pong and keeps the current layout; only an edit or a forced recalc forgets it (the TOC +rewrites its page numbers on every `pm-pagecount`, so resetting on any document change would +leave nothing to catch). + **Keeping blocks together** (`pageBreak.ts` attrs, both read from the direct property *and* the paragraph style): `keepNext` (`w:keepNext`, `fo:keep-with-next`) moves a block down when its successor's first line no longer fits below it — headings do that in Word and LibreOffice anyway, so the attr only marks the others. `keepLines` (`w:keepLines`, `fo:keep-together`) makes an otherwise splittable block paginate atomically, unless it is taller than the page slot, where Word splits it too. **Line breaking:** `editor.css` overrides prosemirror-view's `white-space: break-spaces` back to `pre-wrap` on `.paper .tiptap`. Under `break-spaces` a line-end space may not hang into the margin and counts toward the line width, so lines break one word earlier than in Word/LibreOffice whenever a line reaches within a space width of the margin. diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index edffc6e..acd13d9 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -170,7 +170,12 @@ function getPageForY(y: number, cycle: number): number { return Math.floor(y / cycle) + 1; } -const pageBreakKey = new PluginKey('pageBreaks'); +// `recalc` counts forced recomputes, `edit` the document edits. Layout-only writes +// (addToHistory:false) bump neither, so the convergence brake in `update` can tell an +// answer to the last pass from a fresh change. +type PageBreakState = { recalc: number; edit: number }; + +const pageBreakKey = new PluginKey('pageBreaks'); // Transaction meta flag: set it (e.g. when page margins change) to force a // pagination recompute even though the document content is unchanged. @@ -222,21 +227,27 @@ export const PageBreaks = Extension.create({ let isUpdating = false; let rafId: number | null = null; let lastPlacementsKey = ''; + // The layout before the current one. A block below a float's overhang moves further + // than the spacer the model accounts for, so two layouts can each imply the other — + // seeing the older one come back means a ping-pong, and the current one is kept. + let prevPlacementsKey = ''; // Each pass measures against the spacers left by the previous pass, so a changed // result needs one more pass to re-measure and settle (see calculate). Bounded so // a hypothetical two-layout ping-pong can't loop forever; reset per external change. let convergePasses = 0; const MAX_CONVERGE_PASSES = 4; - const plugin = new Plugin({ + const plugin = new Plugin({ key: pageBreakKey, - // A counter bumped whenever a FORCE_PAGE_RECALC transaction arrives. The - // plugin's own decoration dispatches don't carry that meta, so this never - // self-triggers. `update` reschedules a layout pass when the counter moves. + // Counters `update` watches. The plugin's own decoration dispatches carry neither + // FORCE_PAGE_RECALC nor a doc change, so this never self-triggers. state: { - init: () => 0, + init: () => ({ recalc: 0, edit: 0 }), apply(tr, value) { - return tr.getMeta(FORCE_PAGE_RECALC) ? value + 1 : value; + const recalc = value.recalc + (tr.getMeta(FORCE_PAGE_RECALC) ? 1 : 0); + const edit = value.edit + + (tr.docChanged && tr.getMeta('addToHistory') !== false ? 1 : 0); + return recalc === value.recalc && edit === value.edit ? value : { recalc, edit }; }, }, props: { @@ -394,10 +405,21 @@ export const PageBreaks = Extension.create({ return { docPos: preLeafDocPos(leaf.el), row: null }; } - // Walks text nodes inside `el`, skipping any that live inside a spacer - // widget, and returns one rect per visual line (in viewport coords). + // Walks text nodes and inline images inside `el`, skipping any that live inside + // a spacer widget, and returns one rect per visual line (in viewport coords). function getLineRects(el: HTMLElement): { top: number; bottom: number }[] { const allRects: DOMRect[] = []; + // An as-character image is a line box of its own: without it a paragraph of + // images has no lines at all and can only paginate whole. + const atomRects = new Set(); + for (const img of Array.from(el.querySelectorAll('.image-node'))) { + if (img.style.float) continue; + const rect = img.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + allRects.push(rect); + atomRects.add(rect); + } + } const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode(node) { let parent = node.parentElement; @@ -435,13 +457,19 @@ export const PageBreaks = Extension.create({ const tol = advance > 0 ? advance * 0.6 : 3; const lines: { top: number; bottom: number }[] = []; let lineTop = -Infinity; + // How far down the current line still reaches: an image's own box (text beside + // it starts well below the image top), otherwise the tolerance alone. + let lineEnd = -Infinity; for (const r of allRects) { - if (r.top - lineTop > tol) { + const isAtom = atomRects.has(r); + if (r.top - lineTop > tol && r.top >= lineEnd) { lines.push({ top: r.top, bottom: r.bottom }); lineTop = r.top; + lineEnd = isAtom ? r.bottom - 1 : r.top + tol; } else { const last = lines[lines.length - 1]; last.bottom = Math.max(last.bottom, r.bottom); + if (isAtom) lineEnd = Math.max(lineEnd, r.bottom - 1); } } return lines; @@ -714,11 +742,13 @@ export const PageBreaks = Extension.create({ }); continue; } - // A paragraph holding a floated/block image paginates atomically (pushed - // whole, spacer placed before it): a line-split spacer inside it would sit - // next to the float, where ProseMirror drops the widget. + // A paragraph holding a floated image paginates atomically: a line-split + // spacer beside the float is dropped by ProseMirror. As-character images + // are ordinary line boxes, so their paragraph breaks between lines. const splittableTag = SPLITTABLE_TAGS.has(tag); - const hasImage = splittableTag && !!child.querySelector('.image-node'); + const floats = Array.from(child.querySelectorAll('.image-node')) + .filter((img) => !!img.style.float); + const hasImage = splittableTag && floats.length > 0; // Keep lines together (w:keepLines, fo:keep-together): the block moves // whole. One taller than a page still splits — as it does in Word. const keepLines = child.dataset?.keepLines === 'true' && child.offsetHeight <= contentHeight; @@ -731,11 +761,22 @@ export const PageBreaks = Extension.create({ )) { intraSpacerHeight += sp.offsetHeight; } + // A float hangs out of its paragraph's box, so offsetHeight stops above + // it and the image would walk off the page bottom. Reach down to the + // lowest float instead — that space belongs to this leaf. + let floatBottom = 0; + if (floats.length) { + const top = child.getBoundingClientRect().top; + for (const img of floats) { + const below = (img.getBoundingClientRect().bottom - top) / scaleFactor; + floatBottom = Math.max(floatBottom, below); + } + } leaves.push({ el: child, kind: isAtomic ? 'atomic' : 'splittable', naturalTop: topWithin(child) - cumulativeSpacerHeight, - naturalHeight: child.offsetHeight - intraSpacerHeight, + naturalHeight: Math.max(child.offsetHeight, floatBottom) - intraSpacerHeight, inTableCell, // A manual page break is honored for top-level blocks only (not in // a table cell, where the table breaks atomically between rows). @@ -1157,8 +1198,10 @@ export const PageBreaks = Extension.create({ const placementsKey = placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|') + (collapsedTrailing ? `|c${collapsedTrailing.from}` : ''); - const placementsChanged = placementsKey !== lastPlacementsKey; + const placementsChanged = + placementsKey !== lastPlacementsKey && placementsKey !== prevPlacementsKey; if (placementsChanged) { + prevPlacementsKey = lastPlacementsKey; lastPlacementsKey = placementsKey; const doc = editorView.state.doc; const decoArray: Decoration[] = placements.map((p) => { @@ -1264,10 +1307,16 @@ export const PageBreaks = Extension.create({ return { update(_view, prevState) { - const forced = - pageBreakKey.getState(prevState) !== pageBreakKey.getState(editorView.state); + const before = pageBreakKey.getState(prevState); + const after = pageBreakKey.getState(editorView.state); + const forced = before?.recalc !== after?.recalc; + const isEditTr = before?.edit !== after?.edit; if (!isUpdating && (prevState.doc !== editorView.state.doc || forced)) { convergePasses = 0; // fresh convergence budget per external change + // Layout-only writes — the TOC rewriting its page numbers on every + // pm-pagecount — answer the last pass, so forgetting the ping-pong + // memory on them would leave the guard with nothing to catch. + if (forced || isEditTr) prevPlacementsKey = ''; schedule(); } },