feat: honour the document's widow-orphan setting

Widow-orphan control was hardcoded on. It is a per-paragraph property in
both formats, so a file that turns it off paginated differently from
Word and LibreOffice.

Stored as the widowControl attr next to breakBefore. DOCX resolves
w:widowControl direct-then-style-chain-then-docDefaults; ODF reads
fo:widows/fo:orphans, where only an explicit 0 means off (absent is the
XSL-FO default of 2). Export rides the existing PBX sentinel rather than
adding a pass.
This commit is contained in:
Steffen Becker committed 2026-08-06 19:45:08 +00:00
1 parent 275a77564e
commit f48e5508a8
8 files changed
+104 -11

No files matched your search

+12 -3
View File
@@ -1,9 +1,9 @@
import { Extension } from '@tiptap/core';
import { DEFAULT_SHORTCUTS } from '../shortcuts';
// Manual page break before a paragraph/heading, stored as breakBefore: 'page' | null and
// rendered as data-page-break-before. Round-trips to ODF fo:break-before="page" (import
// blockAttrs, export replace/applyPageBreaks); pageBreaks.ts forces it to the next page.
// The two 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.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -39,6 +39,15 @@ export const PageBreak = Extension.create({
return { 'data-page-break-before': 'page' };
},
},
widowControl: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-widow-control') === 'false' ? false : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.widowControl !== false) return {};
return { 'data-widow-control': 'false' };
},
},
},
},
];
+3 -1
View File
@@ -961,7 +961,9 @@ export const PageBreaks = Extension.create({
let boundaryNatural = contentEnd - effectiveTop;
let targetPage = page + 1;
let extraShift = 0;
const minLines = leaf.naturalHeight <= CONTENT_HEIGHT ? MIN_KEPT_LINES : 1;
const guarded = leaf.naturalHeight <= CONTENT_HEIGHT
&& leaf.el.getAttribute('data-widow-control') !== 'false';
const minLines = guarded ? MIN_KEPT_LINES : 1;
while (boundaryNatural < leaf.naturalHeight) {
const split = findLineSplit(leaf.el, boundaryNatural, scale, minLines);
if (split === null) break;
+1
View File
@@ -618,6 +618,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
spacing: spacingOf(attrs),
indent: indent.left != null ? indent : undefined,
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
widowControl: attrs.widowControl === false ? false : undefined,
numbering: opts.numbering,
shading: paraShadingOf(attrs),
border: paraBordersOf(attrs),
+10 -5
View File
@@ -224,6 +224,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true;
if (typeof attrs.indent === 'number' && attrs.indent > 0) return true;
if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true;
if (attrs.widowControl === false) 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;
@@ -1034,22 +1035,26 @@ 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 ''.
// '' overall when the paragraph has no background/border.
// each the raw value (canonical border '<W>pt solid #RRGGBB' is a valid fo:border) or '',
// plus a widow-control flag. '' overall when the paragraph needs none of them.
function paraBoxSpec(attrs: TiptapNode['attrs']): string {
const s = paraStyleFromAttrs(attrs);
if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft) return '';
return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft].map((v) => v ?? '').join('|');
const noWidow = attrs?.widowControl === false;
if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow) return '';
return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft]
.map((v) => v ?? '').concat(noWidow ? 'w0' : '').join('|');
}
function boxSpecToProps(spec: string): string {
const [bg, bt, br, bb, bl] = spec.split('|');
const [bg, bt, br, bb, bl, widow] = spec.split('|');
const props: string[] = [];
if (bg) props.push(`fo:background-color="${bg}"`);
if (bt) props.push(`fo:border-top="${bt}"`);
if (br) props.push(`fo:border-right="${br}"`);
if (bb) props.push(`fo:border-bottom="${bb}"`);
if (bl) props.push(`fo:border-left="${bl}"`);
// 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"');
return props.join(' ');
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, toggle as onOff, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
@@ -678,6 +678,11 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
// w:pPr counts as formatting on the block.
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, {});
const styleId = styleIdOf(ppr, ctx);
// Widow-orphan control has no registry home, so the resolved value rides the block.
const directWc = fc(ppr, 'widowControl');
if (!(directWc ? onOff(directWc) : ctx.styles.paragraphWidowControl(styleId))) {
attrs.widowControl = false;
}
const baseRun = ctx.styles.paragraphRun(styleId);
const defaults = blockDefaults(baseRun, level, boldByDefault);
const content = convertInline(el, ctx, baseRun, defaults, false);
+27 -1
View File
@@ -40,7 +40,7 @@ export function wVal(el: Element): string | null {
}
// A toggle property (w:b, w:i, …): present with no/true val = on; val false/0/off = off.
function toggle(el: Element): boolean {
export function toggle(el: Element): boolean {
const v = wVal(el);
return v == null || !(v === 'false' || v === '0' || v === 'off');
}
@@ -104,6 +104,8 @@ export class DocxStyles {
private defaultParaStyle: string | null = null; // the w:default="1" paragraph style
private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc
private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing
private ownWidow = new Map<string, boolean>(); // style's own w:pPr/w:widowControl
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
private numToAbstract = new Map<string, string>();
private abstractLevels = new Map<string, Map<number, LevelDef>>();
private minorFont?: string; // theme1.xml body font (e.g. Calibri)
@@ -139,6 +141,8 @@ export class DocxStyles {
if (ddJc) this.defaultsAlign = wVal(ddJc);
const ddSp = pPr ? firstChild(pPr, 'spacing') : null;
if (ddSp) this.defaultsSpacing = readSpacing(ddSp);
const ddWc = pPr ? firstChild(pPr, 'widowControl') : null;
if (ddWc) this.defaultsWidow = toggle(ddWc);
}
for (const style of Array.from(doc.getElementsByTagNameNS(W, 'style'))) {
const id = style.getAttributeNS(W, 'styleId');
@@ -160,6 +164,8 @@ export class DocxStyles {
if (jc) { const v = wVal(jc); if (v) this.ownAlign.set(id, v); }
const sp = ppr && firstChild(ppr, 'spacing');
if (sp) this.ownSpacing.set(id, readSpacing(sp));
const wc = ppr && firstChild(ppr, 'widowControl');
if (wc) this.ownWidow.set(id, toggle(wc));
const ind = ppr && firstChild(ppr, 'ind');
if (ind) {
const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10);
@@ -281,6 +287,26 @@ export class DocxStyles {
// Effective paragraph alignment from styles only (direct w:pPr/w:jc is read and wins in
// the caller): the pStyle's basedOn chain, else the default paragraph style, else
// docDefaults. Lets a paragraph inheriting justify from its style keep it. null = unset.
// w:widowControl along the w:basedOn chain, then the default style, then docDefaults.
// Absent everywhere is OOXML's implied "on".
paragraphWidowControl(pStyleId: string | null | undefined): boolean {
const own = this.styleWidow(pStyleId);
if (own != null) return own;
if (this.defaultParaStyle && this.defaultParaStyle !== pStyleId) {
const def = this.styleWidow(this.defaultParaStyle);
if (def != null) return def;
}
return this.defaultsWidow ?? true;
}
private styleWidow(styleId: string | null | undefined, seen = new Set<string>()): boolean | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownWidow.get(styleId);
if (own != null) return own;
return this.styleWidow(this.basedOn.get(styleId) ?? null, seen);
}
paragraphAlign(pStyleId: string | null | undefined): string | null {
const own = this.styleAlign(pStyleId);
if (own != null) return own;
+4
View File
@@ -936,6 +936,10 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefau
// breaks, so only "page". Cell/list blocks can't carry it.
if (kind === 'body' && paraProps['fo:break-before'] === 'page') attrs.breakBefore = 'page';
// Widow-orphan control: LibreOffice writes 0 for "off", absent means the XSL-FO
// default of 2 (on) — so only an explicit 0 disables it.
if (paraProps['fo:widows'] === '0' || paraProps['fo:orphans'] === '0') attrs.widowControl = false;
// Paragraph background ("colored field") + per-side borders ("rule line").
Object.assign(attrs, paraBoxAttrs(paraProps));
+41
View File
@@ -0,0 +1,41 @@
// Widow-orphan control is on by default in both formats, so only "off" travels:
// DOCX w:widowControl (direct or inherited), ODF fo:widows/fo:orphans="0".
// jsdom (vitest `environment`) supplies the global DOMParser.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
type N = any;
const P = (text: string, attrs?: N): N =>
({ type: 'paragraph', ...(attrs ? { attrs } : {}), content: [{ type: 'text', text }] });
const doc: N = {
type: 'doc',
content: [P('guarded'), P('unguarded', { widowControl: false })],
};
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const widowOf = (d: N, i: number) => d.content[i].attrs?.widowControl ?? null;
describe('widow-orphan control', () => {
it('round-trips through ODF', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
expect(xml).toMatch(/fo:orphans="0" fo:widows="0"/);
const back = importOdt(bytes).content as N;
expect(widowOf(back, 0)).toBe(null);
expect(widowOf(back, 1)).toBe(false);
});
it('round-trips through DOCX', async () => {
const bytes = await buildDocx(doc, margins, 'portrait');
const back = importDocx(bytes).content as N;
expect(widowOf(back, 0)).toBe(null);
expect(widowOf(back, 1)).toBe(false);
});
});