fix: a block's space below decides whether it still fits the page

A word processor's paragraph frame includes the space below it, so a
block whose text fits but whose spacing doesn't moves to the next page —
where the space above is then dropped, which the page-top rule missed
for a block pushed by its own spacer (its measured top still excludes
that push). Keep-with-next counts the same pair of spacings. Both probed
against LibreOffice; the OPG fixture's pages 3-6 now match it line for
line.
This commit is contained in:
Steffen Becker committed 2026-08-09 20:59:50 +00:00
1 parent aff3eaa812
commit 6a5bd631c8
1 file changed
+24 -13
+24 -13
View File
@@ -208,6 +208,9 @@ type Leaf = {
// pages. Their breaks get a full-width close/open mask so the single continuous
// table box looks closed at each page break (it can't break structurally).
inTableCell?: boolean;
// The block's space below (px). A word processor's paragraph frame includes it, so it
// must fit on the page too — a block whose text fits but whose spacing doesn't moves.
spaceAfter?: number;
// Manual page break before this block (ODF fo:break-before; pageBreak.ts): force the
// leaf to the next page's top even when it would otherwise fit on the current page.
forceBreakBefore?: boolean;
@@ -793,6 +796,7 @@ export const PageBreaks = Extension.create({
kind: isAtomic ? 'atomic' : 'splittable',
naturalTop: topWithin(child) - cumulativeSpacerHeight,
naturalHeight: Math.max(child.offsetHeight, floatBottom) - intraSpacerHeight,
spaceAfter: inTableCell ? 0 : parseFloat(getComputedStyle(child).marginBottom) || 0,
inTableCell,
// A manual page break is honored for top-level blocks only (not in
// a table cell, where the table breaks atomically between rows).
@@ -998,16 +1002,6 @@ export const PageBreaks = Extension.create({
// A forced block that then overflows is split by the normal logic next pass.
const forced = !!leaf.forceBreakBefore && i > 0 && effectiveTop > contentStart + 0.5;
if (
i > 0 && !leaf.inTableCell
&& Math.abs(effectiveTop - contentStart) < 0.5
&& parseFloat(getComputedStyle(leaf.el).paddingTop) > 0.5
) {
const from = docPosBeforeElement(leaf.el);
const node = from !== null ? editorView.state.doc.nodeAt(from) : null;
if (from !== null && node) pageTopBlocks.push({ from, to: from + node.nodeSize });
}
if (forced) {
// The next page's own content start: a section beginning here brings its
// first-page header with it.
@@ -1043,7 +1037,7 @@ export const PageBreaks = Extension.create({
bandOpenY: target,
reason: 'leaf-jump-to-next-page',
});
} else if (effectiveBottom > contentEnd) {
} else if (effectiveBottom + (leaf.spaceAfter ?? 0) > contentEnd) {
if (leaf.kind === 'atomic') {
const cf = leaf.columnsFragment;
if (cf && cf.blockCount > 1 && cf.firstBlockNeededPx + COLUMNS_FIT_MARGIN_PX <= contentEnd - effectiveTop) {
@@ -1122,10 +1116,13 @@ export const PageBreaks = Extension.create({
&& effectiveTop > contentStart + 0.5
) {
const next = leaves[i + 1];
// What the pair needs below this block: its own space below, then the
// successor's space above (its padding; an atomic leaf's height has it
// already) and its first line.
const needed = next.kind === 'atomic'
? Math.min(next.naturalHeight, CONTENT_HEIGHT)
: firstLineHeight(next.el, scale);
if (effectiveBottom + needed > contentEnd) {
: firstLineHeight(next.el, scale) + (parseFloat(getComputedStyle(next.el).paddingTop) || 0);
if (effectiveBottom + (leaf.spaceAfter ?? 0) + needed > contentEnd) {
const target = pageContentStart(page + 1, vm.top, CYCLE_PX);
const { docPos, row } = leafSpacer(leaf);
breaks.push({
@@ -1138,6 +1135,20 @@ export const PageBreaks = Extension.create({
}
}
// A page break — its own spacer, or one that left it at the top — swallows the
// block's space above, as LibreOffice does. A line split doesn't: there the
// page starts mid-block. `effectiveTop` still excludes this leaf's own push.
if (
i > 0 && !leaf.inTableCell
&& (breaks.some((b) => b.reason !== 'line-split')
|| (breaks.length === 0 && Math.abs(effectiveTop - contentStart) < 0.5))
&& parseFloat(getComputedStyle(leaf.el).paddingTop) > 0.5
) {
const from = docPosBeforeElement(leaf.el);
const node = from !== null ? editorView.state.doc.nodeAt(from) : null;
if (from !== null && node) pageTopBlocks.push({ from, to: from + node.nodeSize });
}
leavesDebug.push({
index: i,
tag: leaf.el.tagName,