Files
edentext/src/lib/import/docxStyles.ts
T
Steffen Becker aac93e22e8 fix: a note opens with a tab into LibreOffice's hanging pair in DOCX
FootnoteText/EndnoteText ride the factory slots with the probed 0.6cm
start/hanging pair, and the note body puts a tab run after the marker,
matching LibreOffice's own DOCX export; Word no longer glues the number
to the text. The importer strips the tab (also behind a custom mark on
a run of its own) and counts an equal start/hanging pair as the stock
note look, so neither a styleName nor an indent accretes on re-import.
2026-08-30 10:06:41 +00:00

647 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TabAlign, TabStop } from '../editor/extensions/tabStops';
import type { CapsMode } from '../editor/extensions/textEffects';
import { lengthToPt } from './styleResolver';
// Resolves OOXML style indirection for the DOCX importer: Word/LibreOffice spread run
// formatting across w:docDefaults and named styles linked by w:basedOn, and store list
// numbering in numbering.xml. This flattens both so import/docx.ts reads effective values.
export const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
export const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
export const WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing';
export const A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
export const PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relationships';
// Text boxes / shapes: DrawingML shape container, markup-compatibility wrapper
// (Word wraps every shape in mc:AlternateContent), and legacy VML.
export const WPS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
export const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
export const VML = 'urn:schemas-microsoft-com:vml';
// VML's Office extensions (o:hr, o:hralign, …).
export const O = 'urn:schemas-microsoft-com:office:office';
// The bibliography sources Word keeps in a custom-XML part.
export const B = 'http://schemas.openxmlformats.org/officeDocument/2006/bibliography';
// A run's resolved character properties; only set keys are present so layers merge cleanly.
export type RunProps = {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
vertAlign?: 'superscript' | 'subscript';
color?: string; // raw w:val hex (no #) or 'auto'
sizeHalfPt?: number; // w:sz (half-points)
spacingTwip?: number; // w:spacing (character spacing, twentieths of a point)
kernHalfPt?: number; // w:kern: the smallest size pair kerning applies to (0/absent = never)
font?: string; // explicit w:rFonts w:ascii/hAnsi
fontTheme?: 'minor' | 'major'; // w:rFonts w:asciiTheme/hAnsiTheme → theme1.xml font
highlightFill?: string; // text highlight: w:shd w:fill, or w:highlight's palette colour
caps?: CapsMode | false; // w:caps / w:smallCaps; false = a run switching the style's off
underlineVal?: string; // w:u w:val, the line's style ('single' | 'double' | 'wave' | …)
underlineColor?: string; // w:u w:color (raw hex)
doubleStrike?: boolean; // w:dstrike
positionPt?: number; // w:position: pt above the baseline (negative = below)
};
// A numbering level definition (numbering.xml w:lvl). bulletFont is the level's
// w:rPr/w:rFonts (Wingdings/Symbol give the bullet glyph its meaning).
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string; rightAligned?: boolean };
// Paragraph spacing from a w:pPr/w:spacing (only the attributes actually present, so
// omitted ones inherit up the style chain). before/after in twips, line per w:line.
export type ParaSpacing = { before?: number; after?: number; line?: number; lineRule?: string };
export function wVal(el: Element): string | null {
return el.getAttributeNS(W, 'val');
}
// <w:tabs> → stops in cm from the left text margin, w:pos's origin (which the editor's
// attr shares). 'clear' removes an inherited stop and 'bar' is a rule, not a stop.
// w:leader → the fill character; "heavy" is a bold underscore we can only draw plain.
const DOCX_LEADER: Record<string, string> = {
dot: '.', hyphen: '-', underscore: '_', heavy: '_', middleDot: '·',
};
export function readTabStops(tabs: Element): TabStop[] {
const out: TabStop[] = [];
for (const tab of Array.from(tabs.getElementsByTagNameNS(W, 'tab'))) {
const val = wVal(tab);
if (val === 'clear' || val === 'bar') continue;
const pos = parseInt(tab.getAttributeNS(W, 'pos') ?? '', 10);
if (!Number.isFinite(pos)) continue;
const align: TabAlign = val === 'center' ? 'center'
: val === 'right' || val === 'end' ? 'right'
: val === 'decimal' ? 'decimal' : 'left';
out.push({ pos: Math.round((pos / 1440) * 2.54 * 100) / 100, align, leader: DOCX_LEADER[tab.getAttributeNS(W, 'leader') ?? ''] ?? null });
}
return out;
}
// A toggle property (w:b, w:i, …): present with no/true val = on; val false/0/off = off.
export function toggle(el: Element): boolean {
const v = wVal(el);
return v == null || !(v === 'false' || v === '0' || v === 'off');
}
// ST_SignedHpsMeasure: a bare number counts half-points, anything else carries its unit.
function signedHalfPointsPt(v: string | null): number | null {
if (!v) return null;
if (/^-?[\d.]+$/.test(v)) { const n = parseFloat(v); return Number.isFinite(n) ? n / 2 : null; }
return lengthToPt(v) ?? null;
}
// Word writes the two case toggles separately; small caps wins where both are on.
function readCaps(p: RunProps, child: Element): void {
const on = toggle(child);
if (child.localName === 'smallCaps') p.caps = on ? 'smallCaps' : false;
else if (on) { if (p.caps !== 'smallCaps') p.caps = 'uppercase'; }
else if (p.caps === 'uppercase') p.caps = false;
}
// The highlighter pen's fixed palette (w:highlight names a colour where w:shd gives a
// hex). 'none' maps to the same sentinel as an empty w:shd, so it cancels an inherited pen.
const HIGHLIGHT_HEX: Record<string, string> = {
black: '000000', blue: '0000FF', cyan: '00FFFF', darkBlue: '000080', darkCyan: '008080',
darkGray: '808080', darkGreen: '008000', darkMagenta: '800080', darkRed: '800000',
darkYellow: '808000', green: '00FF00', lightGray: 'C0C0C0', magenta: 'FF00FF',
red: 'FF0000', white: 'FFFFFF', yellow: 'FFFF00', none: 'auto',
};
// w:rPr element → RunProps (later layers override earlier; absent keys inherit).
export function parseRunProps(rPr: Element | null | undefined): RunProps {
const p: RunProps = {};
if (!rPr) return p;
for (const child of Array.from(rPr.children)) {
if (child.namespaceURI !== W) continue;
switch (child.localName) {
case 'b': p.bold = toggle(child); break;
case 'i': p.italic = toggle(child); break;
case 'strike': p.strike = toggle(child); break;
case 'u': {
const v = wVal(child);
p.underline = v !== 'none';
if (v) p.underlineVal = v;
const c = child.getAttributeNS(W, 'color');
if (c && c !== 'auto') p.underlineColor = c;
break;
}
case 'dstrike': { const on = toggle(child); p.doubleStrike = on; if (on) p.strike = true; break; }
case 'caps': case 'smallCaps': readCaps(p, child); break;
case 'position': { const pt = signedHalfPointsPt(wVal(child)); if (pt != null) p.positionPt = pt; break; }
case 'vertAlign': {
const v = wVal(child);
if (v === 'superscript' || v === 'subscript') p.vertAlign = v;
break;
}
case 'color': { const v = wVal(child); if (v) p.color = v; break; }
case 'sz': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.sizeHalfPt = n; break; }
case 'spacing': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.spacingTwip = n; break; }
case 'kern': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.kernHalfPt = n; break; }
case 'rFonts': {
const f = child.getAttributeNS(W, 'ascii') ?? child.getAttributeNS(W, 'hAnsi');
if (f) { p.font = f; break; }
// Word's default fonts are theme references (minorHAnsi = body, majorHAnsi = headings).
const theme = child.getAttributeNS(W, 'asciiTheme') ?? child.getAttributeNS(W, 'hAnsiTheme');
if (theme) p.fontTheme = theme.startsWith('major') ? 'major' : 'minor';
break;
}
case 'shd': { const f = child.getAttributeNS(W, 'fill'); if (f && f !== 'auto') p.highlightFill = f; break; }
case 'highlight': { const c = HIGHLIGHT_HEX[wVal(child) ?? '']; if (c) p.highlightFill = c; break; }
}
}
return p;
}
export function mergeRunProps(base: RunProps, over: RunProps): RunProps {
const out = { ...base, ...over };
// A run's font is one logical choice (explicit name or theme ref): the nearer
// level's choice shadows both inherited forms.
if ('font' in over || 'fontTheme' in over) {
if (!('font' in over)) delete out.font;
if (!('fontTheme' in over)) delete out.fontTheme;
}
return out;
}
export class DocxStyles {
private defaultsRun: RunProps = {};
private ownRun = new Map<string, RunProps>(); // styleId → own w:rPr
private basedOn = new Map<string, string | null>();
private memoOwn = new Map<string, RunProps>();
private styleNum = new Map<string, { numId: number; ilvl: number }>();
private ownOutline = new Map<string, number>(); // style's own w:outlineLvl (heading marker)
private ownAlign = new Map<string, string>(); // style's own w:pPr/w:jc
private ownSpacing = new Map<string, ParaSpacing>(); // style's own w:pPr/w:spacing
private ownIndentTwip = new Map<string, number>(); // style's own w:pPr/w:ind left
private ownHangingTwip = new Map<string, number>(); // style's own w:pPr/w:ind hanging
private paraStyleNames = new Map<string, string>(); // paragraph styleId → w:name
private charStyleNames = new Map<string, string>(); // character styleId → w:name
private tableStyleNames = new Map<string, string>(); // table styleId → w:name
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 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 ownKeepLines = new Map<string, boolean>(); // style's own w:pPr/w:keepLines
private ownBidi = new Map<string, boolean>(); // style's own w:pPr/w:bidi
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
private ownCellMar = new Map<string, Element>(); // table style's own w:tblPr/w:tblCellMar
private ownTblBorders = new Map<string, Element>(); // table style's own w:tblPr/w:tblBorders
private ownPBdr = new Map<string, Element>(); // style's own w:pPr/w:pBdr (rule lines)
private ownParaShd = new Map<string, Element>(); // style's own w:pPr/w:shd (colored field)
private ownCond = new Map<string, Map<string, Element>>(); // table styleId → w:type → w:tblStylePr
private ownBandSize = new Map<string, { row: number; col: number }>(); // w:tblStyle*BandSize
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 numStyleNames = new Map<string, string>(); // numbering styleId → display name
private abstractStyleLink = new Map<string, string>(); // abstract → its own numbering style (w:styleLink)
private abstractNumStyleLink = new Map<string, string>(); // abstract → the style it defers to (w:numStyleLink)
private minorFont?: string; // theme1.xml body font (e.g. Calibri)
private majorFont?: string; // theme1.xml heading font (e.g. Calibri Light)
constructor(stylesDoc: Document | null, numberingDoc: Document | null, themeDoc: Document | null = null) {
if (stylesDoc) this.parseStyles(stylesDoc);
if (numberingDoc) this.parseNumbering(numberingDoc);
if (themeDoc) this.parseTheme(themeDoc);
}
private parseTheme(doc: Document): void {
const latin = (scheme: string) => {
const font = doc.getElementsByTagNameNS(A, scheme)[0];
const typeface = font?.getElementsByTagNameNS(A, 'latin')[0]?.getAttribute('typeface');
return typeface || undefined;
};
this.minorFont = latin('minorFont');
this.majorFont = latin('majorFont');
}
themeFont(kind: 'minor' | 'major'): string | undefined {
return kind === 'major' ? this.majorFont : this.minorFont;
}
private parseStyles(doc: Document): void {
const defs = doc.getElementsByTagNameNS(W, 'docDefaults')[0];
if (defs) {
const rPr = defs.getElementsByTagNameNS(W, 'rPrDefault')[0]?.getElementsByTagNameNS(W, 'rPr')[0];
this.defaultsRun = parseRunProps(rPr);
const pPr = defs.getElementsByTagNameNS(W, 'pPrDefault')[0]?.getElementsByTagNameNS(W, 'pPr')[0];
const ddJc = pPr ? firstChild(pPr, 'jc') : null;
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');
if (!id) continue;
this.basedOn.set(id, firstChild(style, 'basedOn') ? wVal(firstChild(style, 'basedOn')!) : null);
this.ownRun.set(id, parseRunProps(firstChild(style, 'rPr')));
if (style.getAttributeNS(W, 'type') === 'paragraph' && (style.getAttributeNS(W, 'default') === '1' || style.getAttributeNS(W, 'default') === 'true')) {
this.defaultParaStyle = id;
}
const ppr = firstChild(style, 'pPr');
const numPr = ppr && firstChild(ppr, 'numPr');
if (numPr) {
const np = readNumPr(numPr);
if (np) this.styleNum.set(id, np);
}
const ol = ppr && firstChild(ppr, 'outlineLvl');
if (ol) { const n = parseInt(wVal(ol) ?? '', 10); if (Number.isFinite(n)) this.ownOutline.set(id, n); }
const jc = ppr && firstChild(ppr, 'jc');
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 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 kl = ppr && firstChild(ppr, 'keepLines');
if (kl) this.ownKeepLines.set(id, toggle(kl));
const bd = ppr && firstChild(ppr, 'bidi');
if (bd) this.ownBidi.set(id, toggle(bd));
const tabs = ppr && firstChild(ppr, 'tabs');
if (tabs) this.ownTabs.set(id, readTabStops(tabs));
const ind = ppr && firstChild(ppr, 'ind');
if (ind) {
const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10);
if (Number.isFinite(left)) this.ownIndentTwip.set(id, left);
const hanging = parseInt(ind.getAttributeNS(W, 'hanging') ?? '', 10);
if (Number.isFinite(hanging)) this.ownHangingTwip.set(id, hanging);
}
const pBdr = ppr && firstChild(ppr, 'pBdr');
if (pBdr) this.ownPBdr.set(id, pBdr);
const paraShd = ppr && firstChild(ppr, 'shd');
if (paraShd) this.ownParaShd.set(id, paraShd);
const tblPr = firstChild(style, 'tblPr');
const cellMar = tblPr && firstChild(tblPr, 'tblCellMar');
if (cellMar) this.ownCellMar.set(id, cellMar);
const tblBorders = tblPr && firstChild(tblPr, 'tblBorders');
if (tblBorders) this.ownTblBorders.set(id, tblBorders);
const conds = Array.from(style.children).filter((c) => c.namespaceURI === W && c.localName === 'tblStylePr');
if (conds.length) {
const m = new Map<string, Element>();
for (const c of conds) { const t = c.getAttributeNS(W, 'type'); if (t) m.set(t, c); }
this.ownCond.set(id, m);
}
const bandSize = (name: string) => {
const el = tblPr && firstChild(tblPr, name);
const n = parseInt((el && wVal(el)) ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 0;
};
const rowBand = bandSize('tblStyleRowBandSize'), colBand = bandSize('tblStyleColBandSize');
if (rowBand || colBand) this.ownBandSize.set(id, { row: rowBand, col: colBand });
const kind = style.getAttributeNS(W, 'type');
if (kind === 'paragraph' || kind === 'character' || kind === 'table' || kind === 'numbering') {
const nameEl = firstChild(style, 'name');
const name = (nameEl && wVal(nameEl)) || id;
if (kind === 'paragraph') this.paraStyleNames.set(id, name);
else if (kind === 'table') this.tableStyleNames.set(id, name);
else if (kind === 'numbering') this.numStyleNames.set(id, name);
else this.charStyleNames.set(id, name);
}
}
}
// Paragraph styles defined in the file: display name and parent, for the style registry.
namedParagraphStyles(): Map<string, { name: string; basedOn: string | null }> {
const out = new Map<string, { name: string; basedOn: string | null }>();
for (const [id, name] of this.paraStyleNames) out.set(id, { name, basedOn: this.basedOn.get(id) ?? null });
return out;
}
// The style's own w:basedOn, i.e. the parent the registry gives it in place of the
// built-in's (collectStyleSheet). Null where the file declares neither.
styleBasedOn(styleId: string | null): string | null {
return (styleId && this.basedOn.get(styleId)) ?? null;
}
// Character styles defined in the file (w:type="character"), for the style registry.
namedCharacterStyles(): Map<string, string> {
return new Map(this.charStyleNames);
}
// A w:tblStyle id → the style's display name (the id itself when it declares none).
tableStyleName(id: string | null | undefined): string | null {
if (!id) return null;
return this.tableStyleNames.get(id) ?? id;
}
// The style's effective left indent (twips) along the basedOn chain.
styleIndentTwip(styleId: string | null | undefined, seen = new Set<string>()): number | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownIndentTwip.get(styleId);
if (own != null) return own;
return this.styleIndentTwip(this.basedOn.get(styleId) ?? null, seen);
}
styleHangingTwip(styleId: string | null | undefined, seen = new Set<string>()): number | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownHangingTwip.get(styleId);
if (own != null) return own;
return this.styleHangingTwip(this.basedOn.get(styleId) ?? null, seen);
}
// The style Word applies when a paragraph names none.
defaultParagraphStyle(): string | null {
return this.defaultParaStyle;
}
private parseNumbering(doc: Document): void {
for (const abs of Array.from(doc.getElementsByTagNameNS(W, 'abstractNum'))) {
const id = abs.getAttributeNS(W, 'abstractNumId');
if (!id) continue;
const levels = new Map<number, LevelDef>();
for (const lvl of Array.from(abs.getElementsByTagNameNS(W, 'lvl'))) {
const ilvl = parseInt(lvl.getAttributeNS(W, 'ilvl') ?? '', 10);
if (!Number.isFinite(ilvl)) continue;
const def: LevelDef = {};
const fmt = firstChild(lvl, 'numFmt'); if (fmt) def.numFmt = wVal(fmt) ?? undefined;
const txt = firstChild(lvl, 'lvlText'); if (txt) def.lvlText = wVal(txt) ?? undefined;
const start = firstChild(lvl, 'start'); if (start) { const n = parseInt(wVal(start) ?? '', 10); if (Number.isFinite(n)) def.start = n; }
const ind = firstChild(lvl, 'pPr') && firstChild(firstChild(lvl, 'pPr')!, 'ind');
if (ind) { const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(l)) def.leftTwip = l; }
const jc = firstChild(lvl, 'lvlJc');
if (jc && (wVal(jc) === 'right' || wVal(jc) === 'end')) def.rightAligned = true;
const rf = firstChild(lvl, 'rPr') && firstChild(firstChild(lvl, 'rPr')!, 'rFonts');
if (rf) { const f = rf.getAttributeNS(W, 'ascii') ?? rf.getAttributeNS(W, 'hAnsi'); if (f) def.bulletFont = f; }
levels.set(ilvl, def);
}
this.abstractLevels.set(id, levels);
const sl = firstChild(abs, 'styleLink');
if (sl) { const v = wVal(sl); if (v) this.abstractStyleLink.set(id, v); }
const nsl = firstChild(abs, 'numStyleLink');
if (nsl) { const v = wVal(nsl); if (v) this.abstractNumStyleLink.set(id, v); }
}
for (const num of Array.from(doc.getElementsByTagNameNS(W, 'num'))) {
const numId = num.getAttributeNS(W, 'numId');
const absId = firstChild(num, 'abstractNumId');
if (numId && absId) this.numToAbstract.set(numId, wVal(absId) ?? '');
}
}
// Chain-resolved own run props (basedOn root → leaf), without the docDefaults baseline.
styleOwn(styleId: string | null | undefined): RunProps {
if (!styleId) return {};
const memo = this.memoOwn.get(styleId);
if (memo) return memo;
this.memoOwn.set(styleId, {}); // break basedOn cycles
const parent = this.basedOn.get(styleId) ?? null;
const base = parent ? this.styleOwn(parent) : {};
const merged = mergeRunProps(base, this.ownRun.get(styleId) ?? {});
this.memoOwn.set(styleId, merged);
return merged;
}
// A paragraph's run baseline: docDefaults ← its paragraph style's basedOn chain. With no
// w:pStyle the w:default="1" paragraph style (Word's "Normal") applies — it usually
// carries the document's real body font, which docDefaults alone gets wrong.
paragraphRun(pStyleId: string | null | undefined): RunProps {
return mergeRunProps(this.defaultsRun, this.styleOwn(pStyleId ?? this.defaultParaStyle));
}
styleNumPr(styleId: string | null | undefined): { numId: number; ilvl: number } | null {
return styleId ? this.styleNum.get(styleId) ?? null : null;
}
// The style's effective outline level (w:outlineLvl) along the w:basedOn chain. 0–8
// marks a heading (Heading1 = 0); the standard, locale-independent heading signal.
styleOutlineLvl(styleId: string | null | undefined, seen = new Set<string>()): number | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownOutline.get(styleId);
if (own != null) return own;
return this.styleOutlineLvl(this.basedOn.get(styleId) ?? null, seen);
}
// The style's own w:jc, nearest along the w:basedOn chain (null if none in the chain).
private styleAlign(styleId: string | null | undefined, seen = new Set<string>()): string | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownAlign.get(styleId);
if (own != null) return own;
return this.styleAlign(this.basedOn.get(styleId) ?? null, seen);
}
// 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;
}
// w:tabs along the w:basedOn chain (direct w:pPr/w:tabs is read and wins in the
// caller). The nearest declaration is taken whole — Word merges a style's stops with
// the paragraph's, which no document here has needed.
paragraphTabs(styleId: string | null | undefined, seen = new Set<string>()): TabStop[] {
if (!styleId || seen.has(styleId)) return [];
seen.add(styleId);
const own = this.ownTabs.get(styleId);
if (own) return own;
return this.paragraphTabs(this.basedOn.get(styleId) ?? null, seen);
}
// w:contextualSpacing along the w:basedOn chain: the style drops its own spacing
// between neighbouring paragraphs that share it (Word's List Paragraph does).
paragraphContextualSpacing(styleId: string | null | undefined, seen = new Set<string>()): boolean {
if (!styleId || seen.has(styleId)) return false;
seen.add(styleId);
const own = this.ownContextual.get(styleId);
if (own != null) return own;
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);
}
// w:keepLines along the same chain (Word's heading styles carry this one too).
paragraphKeepLines(styleId: string | null | undefined, seen = new Set<string>()): boolean {
if (!styleId || seen.has(styleId)) return false;
seen.add(styleId);
const own = this.ownKeepLines.get(styleId);
if (own != null) return own;
return this.paragraphKeepLines(this.basedOn.get(styleId) ?? null, seen);
}
// w:bidi — the style's base direction. null = it declares none, so the section's holds.
paragraphBidi(styleId: string | null | undefined, seen = new Set<string>()): boolean | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
const own = this.ownBidi.get(styleId);
if (own != null) return own;
return this.paragraphBidi(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);
const own = this.ownWidow.get(styleId);
if (own != null) return own;
return this.styleWidow(this.basedOn.get(styleId) ?? null, seen);
}
// 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.
paragraphAlign(pStyleId: string | null | undefined): string | null {
const own = this.styleAlign(pStyleId);
if (own != null) return own;
if (this.defaultParaStyle && this.defaultParaStyle !== pStyleId) {
const def = this.styleAlign(this.defaultParaStyle);
if (def != null) return def;
}
return this.defaultsAlign;
}
// The style's effective spacing along the w:basedOn chain (root → leaf, leaf wins per
// attribute).
private styleSpacing(styleId: string | null | undefined, seen = new Set<string>()): ParaSpacing {
if (!styleId || seen.has(styleId)) return {};
seen.add(styleId);
const base = this.styleSpacing(this.basedOn.get(styleId) ?? null, seen);
return { ...base, ...(this.ownSpacing.get(styleId) ?? {}) };
}
// Effective paragraph spacing from styles only (direct w:pPr/w:spacing wins in the caller):
// docDefaults ← `under` ← the pStyle's basedOn chain, or the default paragraph style when
// there is no w:pStyle. An attribute no layer sets is Word's implied 0 (blockAttrs).
paragraphSpacing(pStyleId: string | null | undefined, under: ParaSpacing = {}): ParaSpacing {
return { ...this.defaultsSpacing, ...under, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) };
}
// A table style's own w:pPr/w:spacing along its w:basedOn chain. Word ranks it under
// the paragraph style, so it goes in as `under` above, never on top.
tableSpacing(styleId: string | null | undefined): ParaSpacing {
return this.styleSpacing(styleId);
}
// The table style's own w:tblCellMar along the w:basedOn chain (leaf wins). The
// element itself, so the caller reads whichever sides it declares.
tableCellMar(styleId: string | null | undefined, seen = new Set<string>()): Element | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
return this.ownCellMar.get(styleId) ?? this.tableCellMar(this.basedOn.get(styleId) ?? null, seen);
}
// Every w:tblBorders along the w:basedOn chain, leaf first. The sides merge one by one,
// so the caller takes each from the nearest style that declares it.
tableBorders(styleId: string | null | undefined, seen = new Set<string>()): Element[] {
if (!styleId || seen.has(styleId)) return [];
seen.add(styleId);
const own = this.ownTblBorders.get(styleId);
const rest = this.tableBorders(this.basedOn.get(styleId) ?? null, seen);
return own ? [own, ...rest] : rest;
}
// Every w:pPr/w:pBdr along the w:basedOn chain, leaf first — a paragraph style's rule
// lines, which the registry renders like any other style property.
paragraphBorders(styleId: string | null | undefined, seen = new Set<string>()): Element[] {
if (!styleId || seen.has(styleId)) return [];
seen.add(styleId);
const own = this.ownPBdr.get(styleId);
const rest = this.paragraphBorders(this.basedOn.get(styleId) ?? null, seen);
return own ? [own, ...rest] : rest;
}
// The nearest w:pPr/w:shd along the chain (the paragraph's own background).
paragraphShading(styleId: string | null | undefined, seen = new Set<string>()): Element | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
return this.ownParaShd.get(styleId) ?? this.paragraphShading(this.basedOn.get(styleId) ?? null, seen);
}
// The table style's conditional areas (w:tblStylePr) along the w:basedOn chain: Word's
// "first row", "banded row 1", … each carrying the w:tcPr and w:rPr it paints. Leaf wins
// per area, so a derived style replaces the whole area rather than merging into it.
tableConditional(styleId: string | null | undefined, seen = new Set<string>()): Map<string, Element> {
if (!styleId || seen.has(styleId)) return new Map();
seen.add(styleId);
const base = this.tableConditional(this.basedOn.get(styleId) ?? null, seen);
for (const [type, el] of this.ownCond.get(styleId) ?? []) base.set(type, el);
return base;
}
// How many rows/columns one band spans (w:tblStyleRowBandSize), 1 where none is declared.
tableBandSize(styleId: string | null | undefined, seen = new Set<string>()): { row: number; col: number } {
if (!styleId || seen.has(styleId)) return { row: 1, col: 1 };
seen.add(styleId);
const base = this.tableBandSize(this.basedOn.get(styleId) ?? null, seen);
const own = this.ownBandSize.get(styleId);
return { row: own?.row || base.row, col: own?.col || base.col };
}
// The abstract a numId's levels really live in: a w:numStyleLink abstract carries no
// levels itself — it defers to the linked numbering style's own numbering.
private resolvedAbstract(numId: number, seen = new Set<string>()): string | null {
const abs = this.numToAbstract.get(String(numId)) ?? null;
if (!abs || seen.has(abs)) return abs;
seen.add(abs);
const linked = this.abstractNumStyleLink.get(abs);
const np = linked ? this.styleNum.get(linked) : null;
if (!np) return abs;
return this.resolvedAbstract(np.numId, seen) ?? abs;
}
level(numId: number, ilvl: number): LevelDef {
const abs = this.resolvedAbstract(numId);
if (!abs) return {};
return this.abstractLevels.get(abs)?.get(ilvl) ?? {};
}
// The named numbering style a numId's abstract belongs to (its own w:styleLink, or
// the one it defers to via w:numStyleLink); null for a private numbering.
numberingStyleOf(numId: number): string | null {
const abs = this.numToAbstract.get(String(numId));
if (!abs) return null;
const styleId = this.abstractNumStyleLink.get(abs) ?? this.abstractStyleLink.get(abs);
return styleId ? this.numStyleNames.get(styleId) ?? styleId : null;
}
}
function firstChild(el: Element, localName: string): Element | null {
for (const c of Array.from(el.children)) if (c.namespaceURI === W && c.localName === localName) return c;
return null;
}
// A w:spacing element's present attributes only (omitted ones inherit up the chain).
export function readSpacing(sp: Element): ParaSpacing {
const num = (name: string) => {
const v = sp.getAttributeNS(W, name);
if (v == null) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
};
const out: ParaSpacing = {};
const before = num('before'); if (before != null) out.before = before;
const after = num('after'); if (after != null) out.after = after;
const line = num('line'); if (line != null) out.line = line;
const rule = sp.getAttributeNS(W, 'lineRule'); if (rule) out.lineRule = rule;
return out;
}
export function readNumPr(numPr: Element): { numId: number; ilvl: number } | null {
const idEl = firstChild(numPr, 'numId');
if (!idEl) return null;
const numId = parseInt(wVal(idEl) ?? '', 10);
if (!Number.isFinite(numId)) return null;
const ilvlEl = firstChild(numPr, 'ilvl');
const ilvl = ilvlEl ? parseInt(wVal(ilvlEl) ?? '0', 10) : 0;
return { numId, ilvl: Number.isFinite(ilvl) ? ilvl : 0 };
}