feat: keep a block with the next one across a page break

A heading whose successor no longer fits below it was left stranded at the
page foot; Word and LibreOffice move it down with them, which is why their
heading styles all carry w:keepNext / fo:keep-with-next. Headings do it by
default here, so the attr only marks the other blocks that ask for it.

One level deep: a run of kept-together blocks would need the pagination pass
to reconsider leaves it has already placed.
This commit is contained in:
Steffen Becker committed 2026-08-08 08:30:05 +00:00
1 parent c82a78cc6a
commit d26fcae94d
7 files changed
+76 -6

No files matched your search

+13 -2
View File
@@ -1,9 +1,11 @@
import { Extension } from '@tiptap/core';
import { DEFAULT_SHORTCUTS } from '../shortcuts';
// The two text-flow attrs of a paragraph/heading. breakBefore: 'page' round-trips to ODF
// The text-flow attrs of a paragraph/heading. breakBefore: 'page' round-trips to ODF
// fo:break-before and DOCX w:pageBreakBefore; widowControl: false turns off the
// widow-orphan minimum (DOCX w:widowControl, ODF fo:widows/fo:orphans). null = default.
// widow-orphan minimum (w:widowControl, fo:widows/fo:orphans); keepNext keeps the block
// on the page its successor starts on (w:keepNext, fo:keep-with-next) — headings do
// that anyway in both, so the attr only marks the other blocks. null = default.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -48,6 +50,15 @@ export const PageBreak = Extension.create({
return { 'data-widow-control': 'false' };
},
},
keepNext: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-keep-next') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.keepNext !== true) return {};
return { 'data-keep-next': 'true' };
},
},
},
},
];
+38
View File
@@ -192,6 +192,9 @@ type Leaf = {
// 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;
// Keep with next (DOCX w:keepNext, ODF fo:keep-with-next; pageBreak.ts): the leaf
// moves to the next page when its successor's first line no longer fits below it.
keepNext?: boolean;
// Flow brackets for a too-tall row's cells (each cell is an independent flow from
// the row top, so placement resets cumulative shift per cell): cellStart = a cell's
// first leaf, rowStart = the row's first cell, rowEnd = last leaf of the last cell.
@@ -443,6 +446,13 @@ export const PageBreaks = Extension.create({
// `minLines` is the widow-orphan minimum, or 1 for a leaf taller than one page
// slot — there the rule is unsatisfiable and splitting beats overflowing.
// What a keep-with-next block has to fit below itself: the successor's first
// line, in unscaled doc px. An empty block falls back to its whole height.
function firstLineHeight(el: HTMLElement, scale: number): number {
const first = getLineRects(el)[0];
return first ? (first.bottom - first.top) / scale : el.offsetHeight;
}
function findLineSplit(
el: HTMLElement,
overflowDistance: number,
@@ -724,6 +734,10 @@ export const PageBreaks = Extension.create({
// A manual page break is honored for top-level blocks only (not in
// a table cell, where the table breaks atomically between rows).
forceBreakBefore: !inTableCell && child.dataset?.pageBreakBefore === 'page',
// A heading keeps with the next block in both Word and LibreOffice,
// so their styles carry it and the attr only marks the other blocks.
keepNext: !inTableCell
&& (child.dataset?.keepNext === 'true' || /^H[1-5]$/.test(child.tagName)),
});
cumulativeSpacerHeight += intraSpacerHeight;
continue;
@@ -998,6 +1012,30 @@ export const PageBreaks = Extension.create({
}
}
// Keep with next: a heading that fits but whose successor's first line does
// not is left stranded at the page foot, so it goes down with it. One level
// only — a run of them would need the pass to reconsider earlier leaves.
if (
breaks.length === 0 && leaf.keepNext && i + 1 < leaves.length
&& effectiveTop > contentStart + 0.5
) {
const next = leaves[i + 1];
const needed = next.kind === 'atomic'
? Math.min(next.naturalHeight, CONTENT_HEIGHT)
: firstLineHeight(next.el, scale);
if (effectiveBottom + needed > contentEnd) {
const target = pageContentStart(page + 1, vm.top, CYCLE_PX);
const { docPos, row } = leafSpacer(leaf);
breaks.push({
height: target - effectiveTop,
docPos,
row,
bandOpenY: target,
reason: 'keep-with-next',
});
}
}
leavesDebug.push({
index: i,
tag: leaf.el.tagName,
+1
View File
@@ -655,6 +655,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
indent: Object.keys(indent).length ? indent : undefined,
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
widowControl: attrs.widowControl === false ? false : undefined,
keepNext: attrs.keepNext === true || undefined,
tabStops: stops.length
? stops.map((s) => ({ type: DOCX_TAB_TYPE[s.align], position: cmToTwip(s.pos) }))
: undefined,
+7 -4
View File
@@ -230,6 +230,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (typeof attrs.tabStops === 'string' && attrs.tabStops) return true;
if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true;
if (attrs.widowControl === false) return true;
if (attrs.keepNext === true) return true;
for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'])
if (typeof attrs[s] === 'string' && attrs[s] && attrs[s] !== 'none') return true;
const ta = attrs.textAlign;
@@ -1052,19 +1053,20 @@ function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array {
// A top-level paragraph's box spec for the PBX sentinel: bg|borderTop|Right|Bottom|Left,
// each the raw value (canonical border '<W>pt solid #RRGGBB' is a valid fo:border) or '',
// then a widow flag and the right indent. '' overall when the paragraph needs none.
// then a widow flag, the right indent and a keep-with-next flag. '' when it needs none.
function paraBoxSpec(attrs: TiptapNode['attrs']): string {
const s = paraStyleFromAttrs(attrs);
const noWidow = attrs?.widowControl === false;
const keepNext = attrs?.keepNext === true;
// odf-kit has a paragraph option for the left indent but none for the right one.
const right = typeof attrs?.indentRight === 'number' && attrs.indentRight > 0 ? attrs.indentRight : 0;
if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right) return '';
if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right && !keepNext) return '';
return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft]
.map((v) => v ?? '').concat(noWidow ? 'w0' : '', right ? `${right}cm` : '').join('|');
.map((v) => v ?? '').concat(noWidow ? 'w0' : '', right ? `${right}cm` : '', keepNext ? 'k1' : '').join('|');
}
function boxSpecToProps(spec: string): string {
const [bg, bt, br, bb, bl, widow, marginRight] = spec.split('|');
const [bg, bt, br, bb, bl, widow, marginRight, keepNext] = spec.split('|');
const props: string[] = [];
if (bg) props.push(`fo:background-color="${bg}"`);
if (bt) props.push(`fo:border-top="${bt}"`);
@@ -1074,6 +1076,7 @@ function boxSpecToProps(spec: string): string {
// LibreOffice writes 0/0 for "off"; absent means the XSL-FO default of 2.
if (widow === 'w0') props.push('fo:orphans="0"', 'fo:widows="0"');
if (marginRight) props.push(`fo:margin-right="${marginRight}"`);
if (keepNext === 'k1') props.push('fo:keep-with-next="always"');
return props.join(' ');
}
+2
View File
@@ -701,6 +701,8 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
if (!(directWc ? onOff(directWc) : ctx.styles.paragraphWidowControl(styleId))) {
attrs.widowControl = false;
}
const directKn = fc(ppr, 'keepNext');
if (!level && (directKn ? onOff(directKn) : ctx.styles.paragraphKeepNext(styleId))) attrs.keepNext = true;
// Tab stops: a direct w:tabs replaces the style's, which the resolver walks for.
const directTabs = fc(ppr, 'tabs');
const stops = formatTabStops(directTabs ? readTabStops(directTabs) : ctx.styles.paragraphTabs(styleId));
+12
View File
@@ -127,6 +127,7 @@ export class DocxStyles {
private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing
private ownWidow = new Map<string, boolean>(); // style's own w:pPr/w:widowControl
private ownContextual = new Map<string, boolean>(); // style's own w:pPr/w:contextualSpacing
private ownKeepNext = new Map<string, boolean>(); // style's own w:pPr/w:keepNext
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
private numToAbstract = new Map<string, string>();
@@ -191,6 +192,8 @@ export class DocxStyles {
if (wc) this.ownWidow.set(id, toggle(wc));
const cs = ppr && firstChild(ppr, 'contextualSpacing');
if (cs) this.ownContextual.set(id, toggle(cs));
const kn = ppr && firstChild(ppr, 'keepNext');
if (kn) this.ownKeepNext.set(id, toggle(kn));
const tabs = ppr && firstChild(ppr, 'tabs');
if (tabs) this.ownTabs.set(id, readTabStops(tabs));
const ind = ppr && firstChild(ppr, 'ind');
@@ -344,6 +347,15 @@ export class DocxStyles {
return this.paragraphContextualSpacing(this.basedOn.get(styleId) ?? null, seen);
}
// w:keepNext along the w:basedOn chain — Word's heading styles all carry it.
paragraphKeepNext(styleId: string | null | undefined, seen = new Set<string>()): boolean {
if (!styleId || seen.has(styleId)) return false;
seen.add(styleId);
const own = this.ownKeepNext.get(styleId);
if (own != null) return own;
return this.paragraphKeepNext(this.basedOn.get(styleId) ?? null, seen);
}
private styleWidow(styleId: string | null | undefined, seen = new Set<string>()): boolean | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
+3
View File
@@ -898,6 +898,9 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
// paragraph-mark size), or text smaller than the style keeps the taller strut.
const markSize = lengthToPt(baseTextProps['fo:font-size']);
if (markSize != null && Math.abs(markSize - defaults.fontSizePt) > 0.05) attrs.fontSize = formatPt(markSize);
// Keep with next: a heading does that anyway (pageBreaks.ts), so only a plain
// paragraph carries it — otherwise every heading would accrete the producer's flag.
if (!isHeading && paraProps['fo:keep-with-next'] === 'always') attrs.keepNext = true;
const markFont = resolver.fontFamilyOf(baseTextProps);
if (markFont && !defaults.fonts.has(markFont.toLowerCase())) attrs.fontFamily = markFont;
applyUniformRunFont(attrs, content);