(
xml: string,
themeFonts?: { major: string; minor: string }
)
| 141 | } |
| 142 | |
| 143 | function parseDocxStyles( |
| 144 | xml: string, |
| 145 | themeFonts?: { major: string; minor: string } |
| 146 | ): { |
| 147 | styles: NonNullable<DocumentStyleSummary['styles']> |
| 148 | defaults?: DocumentStyleSummary['defaults'] |
| 149 | } { |
| 150 | // Extract document-default run properties (the baseline for body text) |
| 151 | const defaults: DocumentStyleSummary['defaults'] = {} |
| 152 | const docDefaultsBlock = between(xml, '<w:docDefaults>', '</w:docDefaults>') |
| 153 | if (docDefaultsBlock) { |
| 154 | const rPrBlock = between(docDefaultsBlock, '<w:rPrDefault>', '</w:rPrDefault>') |
| 155 | if (rPrBlock) { |
| 156 | const szMatch = /<w:sz w:val="(\d+)"/.exec(rPrBlock) |
| 157 | if (szMatch) defaults.fontSize = Math.round(Number.parseInt(szMatch[1]) / 2) |
| 158 | const fontAttrMatch = /<w:rFonts([^>]*)>/.exec(rPrBlock) |
| 159 | if (fontAttrMatch) { |
| 160 | const { font } = parseFontAttrs(fontAttrMatch[1], themeFonts) |
| 161 | if (font) defaults.font = font |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // Build a full style map for basedOn inheritance resolution |
| 167 | const styleMap = new Map<string, StyleRaw>() |
| 168 | for (const block of xml.split('<w:style ').slice(1)) { |
| 169 | const id = attr(block, 'w:styleId') |
| 170 | if (!id) continue |
| 171 | const type = attr(block, 'w:type') |
| 172 | const nameMatch = /<w:name w:val="([^"]*)"/.exec(block) |
| 173 | const basedOnMatch = /<w:basedOn w:val="([^"]*)"/.exec(block) |
| 174 | const szMatch = /<w:sz w:val="(\d+)"/.exec(block) |
| 175 | const colorMatch = /<w:color w:val="([A-Fa-f0-9]{6})"/.exec(block) |
| 176 | const fontAttrMatch = /<w:rFonts([^>]*)>/.exec(block) |
| 177 | const { font, themeFont } = fontAttrMatch ? parseFontAttrs(fontAttrMatch[1], themeFonts) : {} |
| 178 | |
| 179 | styleMap.set(id, { |
| 180 | id, |
| 181 | name: nameMatch?.[1] ?? id, |
| 182 | type, |
| 183 | ...(basedOnMatch && { basedOn: basedOnMatch[1] }), |
| 184 | ...(szMatch && { fontSize: Math.round(Number.parseInt(szMatch[1]) / 2) }), |
| 185 | ...(/<w:b\b(?:\s[^/]*)?\/?>/.test(block) && { |
| 186 | bold: !/<w:b\b[^>]*\bw:val=["'](0|false)["']/.test(block), |
| 187 | }), |
| 188 | ...(colorMatch && { color: colorMatch[1].toUpperCase() }), |
| 189 | ...(font && { font }), |
| 190 | ...(themeFont && { themeFont }), |
| 191 | }) |
| 192 | } |
| 193 | |
| 194 | function resolveInheritance(id: string, visited = new Set<string>()): StyleRaw | undefined { |
| 195 | if (visited.has(id)) return undefined |
| 196 | visited.add(id) |
| 197 | const s = styleMap.get(id) |
| 198 | if (!s) return undefined |
| 199 | if (!s.basedOn) return s |
| 200 | const parent = resolveInheritance(s.basedOn, visited) |
no test coverage detected