From f48e5508a8b8b7f93c94ac486a0f7037a90b501d Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 6 Aug 2026 19:45:08 +0000 Subject: [PATCH] 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. --- src/lib/editor/extensions/pageBreak.ts | 15 +++++++-- src/lib/editor/extensions/pageBreaks.ts | 4 ++- src/lib/export/docx.ts | 1 + src/lib/export/odt.ts | 15 ++++++--- src/lib/import/docx.ts | 7 ++++- src/lib/import/docxStyles.ts | 28 ++++++++++++++++- src/lib/import/odt.ts | 4 +++ tests/unit/widow-control.test.ts | 41 +++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 tests/unit/widow-control.test.ts diff --git a/src/lib/editor/extensions/pageBreak.ts b/src/lib/editor/extensions/pageBreak.ts index fb1a7b6..eed8591 100644 --- a/src/lib/editor/extensions/pageBreak.ts +++ b/src/lib/editor/extensions/pageBreak.ts @@ -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 { @@ -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) => { + if (attributes.widowControl !== false) return {}; + return { 'data-widow-control': 'false' }; + }, + }, }, }, ]; diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index a22e108..b21d0d7 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index b063c7a..fb1d9dd 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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), diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 3f29ea5..35e08a1 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 'pt solid #RRGGBB' is a valid fo:border) or ''. -// '' overall when the paragraph has no background/border. +// each the raw value (canonical border '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(' '); } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 3af80f7..a1530e8 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index b8ff9a1..715f605 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -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(); // style's own w:pPr/w:widowControl + private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); 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()): 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; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index adbc08f..2378158 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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)); diff --git a/tests/unit/widow-control.test.ts b/tests/unit/widow-control.test.ts new file mode 100644 index 0000000..df36626 --- /dev/null +++ b/tests/unit/widow-control.test.ts @@ -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); + }); +});