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:
1 parent
c82a78cc6a
commit
d26fcae94d
7 files changed
+76
-6
No files matched your search
@@ -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' };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user