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'); } // → 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 = { 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 = { 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(); // styleId → own w:rPr private basedOn = new Map(); private memoOwn = new Map(); private styleNum = new Map(); private ownOutline = new Map(); // style's own w:outlineLvl (heading marker) private ownAlign = new Map(); // style's own w:pPr/w:jc private ownSpacing = new Map(); // style's own w:pPr/w:spacing private ownIndentTwip = new Map(); // style's own w:pPr/w:ind left private ownHangingTwip = new Map(); // style's own w:pPr/w:ind hanging private paraStyleNames = new Map(); // paragraph styleId → w:name private charStyleNames = new Map(); // character styleId → w:name private tableStyleNames = new Map(); // 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(); // 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 ownKeepLines = new Map(); // style's own w:pPr/w:keepLines private ownBidi = new Map(); // style's own w:pPr/w:bidi private ownTabs = new Map(); // style's own w:pPr/w:tabs private ownCellMar = new Map(); // table style's own w:tblPr/w:tblCellMar private ownTblBorders = new Map(); // table style's own w:tblPr/w:tblBorders private ownPBdr = new Map(); // style's own w:pPr/w:pBdr (rule lines) private ownParaShd = new Map(); // style's own w:pPr/w:shd (colored field) private ownCond = new Map>(); // table styleId → w:type → w:tblStylePr private ownBandSize = new Map(); // w:tblStyle*BandSize private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); private numStyleNames = new Map(); // numbering styleId → display name private abstractStyleLink = new Map(); // abstract → its own numbering style (w:styleLink) private abstractNumStyleLink = new Map(); // 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(); 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 { const out = new Map(); 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 { 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()): 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()): 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(); 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()): 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 | 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()): 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()): 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()): 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()): 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()): 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()): 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()): 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()): 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()): 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()): 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()): 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()): Map { 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()): { 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 | 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 }; }