diff --git a/src/lib/editor/extensions/pageBreak.ts b/src/lib/editor/extensions/pageBreak.ts index eed8591..01cf780 100644 --- a/src/lib/editor/extensions/pageBreak.ts +++ b/src/lib/editor/extensions/pageBreak.ts @@ -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 { @@ -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) => { + if (attributes.keepNext !== true) return {}; + return { 'data-keep-next': 'true' }; + }, + }, }, }, ]; diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index b21d0d7..3ceaf91 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 5b21bcb..c6e492f 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 6ef2851..7174d0e 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 '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(' '); } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index d062162..3699460 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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)); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index f75b734..224a513 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -127,6 +127,7 @@ export class DocxStyles { private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing private ownWidow = new Map(); // style's own w:pPr/w:widowControl private ownContextual = new Map(); // style's own w:pPr/w:contextualSpacing + private ownKeepNext = new Map(); // style's own w:pPr/w:keepNext private ownTabs = new Map(); // style's own w:pPr/w:tabs private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); @@ -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()): 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()): boolean | null { if (!styleId || seen.has(styleId)) return null; seen.add(styleId); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b1b9336..a1bbd45 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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);