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:
1 parent
275a77564e
commit
f48e5508a8
8 files changed
+104
-11
No files matched your search
@@ -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' };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user