/** * Style resolver — converts OOXML color and fill nodes to CSS values. */ import { toCssColor } from '@/lib/colors' import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units' import type { SafeXmlNode } from '../parser/xml-parser' import type { ColorModifier } from '../utils/color' import { applyColorModifiers, hslToRgb, presetColorToHex, rgbToHex } from '../utils/color' import type { RenderContext } from './render-context' // --------------------------------------------------------------------------- // Color Resolution // --------------------------------------------------------------------------- /** * Build a cache key for a color node based on its tag, value, and modifiers. */ function buildColorCacheKey(colorNode: SafeXmlNode): string { const parts: string[] = [colorNode.localName, colorNode.attr('val') ?? ''] for (const child of colorNode.allChildren()) { const tag = child.localName const val = child.attr('val') if (tag) parts.push(`${tag}:${val ?? ''}`) // Include nested color children for wrapper nodes for (const grandchild of child.allChildren()) { const gtag = grandchild.localName const gval = grandchild.attr('val') if (gtag) parts.push(`${gtag}:${gval ?? ''}`) } } return parts.join('|') } /** * Collect OOXML color modifier children from a color node. * Modifiers are child elements like alpha, lumMod, lumOff, tint, shade, satMod, hueMod. */ function collectModifiers(colorNode: SafeXmlNode): ColorModifier[] { const modifiers: ColorModifier[] = [] for (const child of colorNode.allChildren()) { const name = child.localName const val = child.numAttr('val') if (val !== undefined && name) { modifiers.push({ name, val }) } } return modifiers } /** * Resolve a scheme color name through the master colorMap then theme colorScheme. * * OOXML scheme colors use logical names (e.g., "tx1", "bg1", "accent1"). * The master's colorMap remaps some of these (e.g., "tx1" -> "dk1"). * The theme's colorScheme holds the actual hex values keyed by the mapped name. */ function resolveSchemeColor(schemeName: string, ctx: RenderContext): string { // Apply colorMap remapping (layout override takes priority) let mappedName = schemeName if (ctx.layout.colorMapOverride) { const override = ctx.layout.colorMapOverride.get(schemeName) if (override) mappedName = override } if (mappedName === schemeName) { const mapped = ctx.master.colorMap.get(schemeName) if (mapped) mappedName = mapped } // Look up in theme color scheme const hex = ctx.theme.colorScheme.get(mappedName) if (hex) return hex // Fallback: try the original name directly in theme const fallback = ctx.theme.colorScheme.get(schemeName) return fallback || '000000' } /** * Resolve an OOXML color node (srgbClr, schemeClr, sysClr, prstClr, hslClr, scrgbClr) * into a CSS-ready hex color and alpha value. */ export function resolveColor( colorNode: SafeXmlNode, ctx: RenderContext ): { color: string; alpha: number } { // Check cache const cacheKey = buildColorCacheKey(colorNode) const cached = ctx.colorCache.get(cacheKey) if (cached) return cached const result = resolveColorUncached(colorNode, ctx) ctx.colorCache.set(cacheKey, result) return result } function resolveColorUncached( colorNode: SafeXmlNode, ctx: RenderContext, placeholderColorNode?: SafeXmlNode ): { color: string; alpha: number } { // Iterate child elements to find the actual color type node for (const child of colorNode.allChildren()) { const tag = child.localName const modifiers = collectModifiers(child) switch (tag) { case 'srgbClr': { const hex = child.attr('val') || '000000' return applyColorModifiers(hex, modifiers) } case 'schemeClr': { const scheme = child.attr('val') || 'tx1' if (scheme.toLowerCase() === 'phclr' && placeholderColorNode?.exists()) { const base = resolveColor(placeholderColorNode, ctx) const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color const adjusted = applyColorModifiers(baseHex, modifiers) return { color: adjusted.color, alpha: adjusted.alpha * base.alpha } } const hex = resolveSchemeColor(scheme, ctx) return applyColorModifiers(hex, modifiers) } case 'sysClr': { const hex = child.attr('lastClr') || child.attr('val') || '000000' return applyColorModifiers(hex, modifiers) } case 'prstClr': { const name = child.attr('val') || 'black' const hex = presetColorToHex(name) || '#000000' return applyColorModifiers(hex.replace('#', ''), modifiers) } case 'hslClr': { const hue = (child.numAttr('hue') ?? 0) / 60000 // 60000ths of degree -> degrees const sat = (child.numAttr('sat') ?? 0) / 100000 // percentage const lum = (child.numAttr('lum') ?? 0) / 100000 const rgb = hslToRgb(hue, sat, lum) const hex = rgbToHex(rgb.r, rgb.g, rgb.b).replace('#', '') return applyColorModifiers(hex, modifiers) } case 'scrgbClr': { // r, g, b are percentages (0-100000) const r = Math.round(((child.numAttr('r') ?? 0) / 100000) * 255) const g = Math.round(((child.numAttr('g') ?? 0) / 100000) * 255) const b = Math.round(((child.numAttr('b') ?? 0) / 100000) * 255) const hex = rgbToHex(r, g, b).replace('#', '') return applyColorModifiers(hex, modifiers) } default: // Not a recognized color child — continue looking break } } // If the node itself is a color type (no wrapper) const selfTag = colorNode.localName if (selfTag === 'srgbClr') { const hex = colorNode.attr('val') || '000000' return applyColorModifiers(hex, collectModifiers(colorNode)) } if (selfTag === 'schemeClr') { const scheme = colorNode.attr('val') || 'tx1' if (scheme.toLowerCase() === 'phclr' && placeholderColorNode?.exists()) { const base = resolveColor(placeholderColorNode, ctx) const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color const adjusted = applyColorModifiers(baseHex, collectModifiers(colorNode)) return { color: adjusted.color, alpha: adjusted.alpha * base.alpha } } const hex = resolveSchemeColor(scheme, ctx) return applyColorModifiers(hex, collectModifiers(colorNode)) } if (selfTag === 'sysClr') { const hex = colorNode.attr('lastClr') || colorNode.attr('val') || '000000' return applyColorModifiers(hex, collectModifiers(colorNode)) } if (selfTag === 'prstClr') { const name = colorNode.attr('val') || 'black' const hex = presetColorToHex(name) || '#000000' return applyColorModifiers(hex.replace('#', ''), collectModifiers(colorNode)) } return { color: '#000000', alpha: 1 } } /** * Resolve a color node and return a CSS color string. * Convenience wrapper combining resolveColor + toCssColor. */ export function resolveColorToCss(node: SafeXmlNode, ctx: RenderContext): string { const { color, alpha } = resolveColor(node, ctx) return toCssColor(color, alpha) } function resolveColorWithPlaceholder( colorNode: SafeXmlNode, ctx: RenderContext, placeholderColorNode?: SafeXmlNode ): { color: string; alpha: number } { if (!placeholderColorNode?.exists()) return resolveColor(colorNode, ctx) return resolveColorUncached(colorNode, ctx, placeholderColorNode) } // --------------------------------------------------------------------------- // Fill Resolution // --------------------------------------------------------------------------- /** * Resolve a fill from shape properties (spPr) into a CSS background value. * * Returns: * - CSS color/gradient string for solidFill/gradFill * - 'transparent' for noFill * - '' for blipFill (handled by ImageRenderer) or no fill found (inherit) */ export function resolveFill(spPr: SafeXmlNode, ctx: RenderContext): string { // solidFill const solidFill = spPr.child('solidFill') if (solidFill.exists()) { const { color, alpha } = resolveColor(solidFill, ctx) return toCssColor(color, alpha) } // gradFill const gradFill = spPr.child('gradFill') if (gradFill.exists()) { return resolveGradient(gradFill, ctx) } // blipFill — handled externally by ImageRenderer const blipFill = spPr.child('blipFill') if (blipFill.exists()) { return '' } // pattFill — pattern fill rendered as CSS repeating gradient const pattFill = spPr.child('pattFill') if (pattFill.exists()) { return resolvePatternFill(pattFill, ctx) } // grpFill — inherit fill from parent group const grpFill = spPr.child('grpFill') if (grpFill.exists()) { if (ctx.groupFillNode) { return resolveFill(ctx.groupFillNode, ctx) } // No group fill context available — fall through to no fill return '' } // noFill const noFill = spPr.child('noFill') if (noFill.exists()) { return 'transparent' } // No fill found — inherit return '' } // --------------------------------------------------------------------------- // Pattern Fill Resolution // --------------------------------------------------------------------------- /** * Resolve `` into a CSS background value using repeating gradients. * * OOXML defines 40+ pattern presets. We support the most common ones and * fall back to a simple foreground/background 50% mix for unknown patterns. */ function resolvePatternFill(pattFill: SafeXmlNode, ctx: RenderContext): string { const preset = pattFill.attr('prst') ?? 'solid' // Foreground and background colors let fg = '#000000' let bg = '#ffffff' const fgClr = pattFill.child('fgClr') if (fgClr.exists()) { const { color, alpha } = resolveColor(fgClr, ctx) fg = toCssColor(color, alpha) } const bgClr = pattFill.child('bgClr') if (bgClr.exists()) { const { color, alpha } = resolveColor(bgClr, ctx) bg = toCssColor(color, alpha) } // Size of pattern tile in px const s = 8 // Helper: returns CSS `background` shorthand with repeating pattern layer(s) over bg color. // Format: " 0 0/, " // This is a valid multi-layer CSS background shorthand. const pat = (gradient: string): string => `${gradient} 0 0/${s}px ${s}px, ${bg}` const pat2 = (g1: string, g2: string): string => `${g1} 0 0/${s}px ${s}px, ${g2} 0 0/${s}px ${s}px, ${bg}` switch (preset) { // Solid fills case 'solid': case 'solidDmnd': return fg // Percentage fills (dots on background) case 'pct5': case 'pct10': case 'pct20': case 'pct25': return pat(`radial-gradient(${fg} 1px, transparent 1px)`) case 'pct30': case 'pct40': case 'pct50': return pat(`radial-gradient(${fg} 1.5px, transparent 1.5px)`) case 'pct60': case 'pct70': case 'pct75': case 'pct80': case 'pct90': return pat(`radial-gradient(${fg} 2.5px, transparent 2.5px)`) // Horizontal lines case 'horz': case 'ltHorz': case 'narHorz': case 'dkHorz': return pat( `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)` ) // Vertical lines case 'vert': case 'ltVert': case 'narVert': case 'dkVert': return pat( `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)` ) // Diagonal lines (down-right) case 'dnDiag': case 'ltDnDiag': case 'narDnDiag': case 'dkDnDiag': case 'wdDnDiag': return pat( `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)` ) // Diagonal lines (up-right) case 'upDiag': case 'ltUpDiag': case 'narUpDiag': case 'dkUpDiag': case 'wdUpDiag': return pat( `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)` ) // Grid (horizontal + vertical) case 'smGrid': case 'lgGrid': case 'cross': return pat2( `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`, `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)` ) // Diagonal cross case 'smCheck': case 'lgCheck': case 'diagCross': case 'openDmnd': return pat2( `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`, `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)` ) // Dot patterns case 'dotGrid': case 'dotDmnd': return pat(`radial-gradient(${fg} 1px, transparent 1px)`) // Trellis / weave case 'trellis': case 'weave': return pat2( `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 2px, transparent 2px, transparent ${s}px)`, `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 2px, transparent 2px, transparent ${s}px)` ) // Dash variants case 'dashDnDiag': case 'dashUpDiag': case 'dashHorz': case 'dashVert': { const angle = preset.includes('Dn') ? '45deg' : preset.includes('Up') ? '-45deg' : preset.includes('Horz') ? '0deg' : '90deg' return pat( `repeating-linear-gradient(${angle}, ${fg} 0px, ${fg} 3px, transparent 3px, transparent ${s}px)` ) } // Sphere / shingle — radial gradient approximation case 'sphere': case 'shingle': case 'plaid': case 'divot': case 'zigZag': return pat(`radial-gradient(${fg} 2px, transparent 2px)`) default: return bg } } /** * Parse a gradient fill into a CSS gradient string. */ function resolveGradient( gradFill: SafeXmlNode, ctx: RenderContext, placeholderColorNode?: SafeXmlNode ): string { // Parse gradient stops const gsLst = gradFill.child('gsLst') const stops: { position: number; color: string }[] = [] for (const gs of gsLst.children('gs')) { const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode) stops.push({ position: posPercent, color: toCssColor(color, alpha) }) } if (stops.length === 0) { return '' } // Sort stops by position stops.sort((a, b) => a.position - b.position) const stopsStr = stops.map((s) => `${s.color} ${s.position.toFixed(1)}%`).join(', ') // Determine gradient type const lin = gradFill.child('lin') if (lin.exists()) { const angle = angleToDeg(lin.numAttr('ang') ?? 0) // OOXML angle 0 = top-to-bottom in the gradient coordinate system // CSS angle 0 = bottom-to-top, so we need to adjust const cssAngle = (angle + 90) % 360 return `linear-gradient(${cssAngle.toFixed(1)}deg, ${stopsStr})` } const path = gradFill.child('path') if (path.exists()) { const pathType = path.attr('path') if (pathType === 'circle' || pathType === 'shape' || pathType === 'rect') { // OOXML path gradients: stop pos=0 = fillToRect center, pos=100000 = shape edge. // CSS radial-gradient: 0% = center, 100% = edge. // Conventions match — no reversal needed. // Resolve fillToRect center point const ftr = path.child('fillToRect') let cx = 50 let cy = 50 if (ftr.exists()) { const l = (ftr.numAttr('l') ?? 0) / 100000 const t = (ftr.numAttr('t') ?? 0) / 100000 const r = (ftr.numAttr('r') ?? 0) / 100000 const b = (ftr.numAttr('b') ?? 0) / 100000 cx = ((l + (1 - r)) / 2) * 100 cy = ((t + (1 - b)) / 2) * 100 } if (pathType === 'rect') { // Rectangular gradient (L∞ norm / Chebyshev distance): creates cross/X contour // pattern. CSS can't do this natively; approximate by overlaying horizontal and // vertical linear gradients with a radial gradient as fallback. // The SVG path in ShapeRenderer uses the proper blend approach. return `radial-gradient(closest-side at ${cx.toFixed(1)}% ${cy.toFixed(1)}%, ${stopsStr})` } return `radial-gradient(ellipse at ${cx.toFixed(1)}% ${cy.toFixed(1)}%, ${stopsStr})` } } // Default to linear top-to-bottom return `linear-gradient(180deg, ${stopsStr})` } // --------------------------------------------------------------------------- // Line Style Resolution // --------------------------------------------------------------------------- /** * Resolve a line (outline) node into CSS-compatible properties. * * @param ln The `` node from spPr * @param ctx Render context * @param lnRef Optional `` from `` — provides fallback color * when `` has no explicit solidFill (common for connectors) */ export function resolveLineStyle( ln: SafeXmlNode, ctx: RenderContext, lnRef?: SafeXmlNode ): { width: number; color: string; dash: string; dashKind: string } { // Width: a:ln@w is in EMU, convert to px const widthEmu = ln.numAttr('w') ?? 0 let width = emuToPx(widthEmu) // Color from solidFill child let color = 'transparent' const solidFill = ln.child('solidFill') if (solidFill.exists()) { const phClr = solidFill.child('schemeClr') const usesPlaceholder = phClr.exists() && (phClr.attr('val') ?? '').toLowerCase() === 'phclr' if (usesPlaceholder && lnRef && lnRef.exists()) { // Theme line styles often use schemeClr=phClr and expect the concrete color from lnRef. const base = resolveColor(lnRef, ctx) const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color const adjusted = applyColorModifiers(baseHex, collectModifiers(phClr)) color = toCssColor(adjusted.color, adjusted.alpha * base.alpha) } else { const resolved = resolveColor(solidFill, ctx) color = toCssColor(resolved.color, resolved.alpha) } } else if (lnRef?.exists() && (lnRef.numAttr('idx') ?? 0) > 0) { const idx = lnRef.numAttr('idx') ?? 0 // Look up theme line style for width, color, and dash if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) { const themeLn = ctx.theme.lineStyles[idx - 1] // Get width from theme line if not set on the explicit ln node if (width === 0) { const themeW = themeLn.numAttr('w') ?? 0 width = emuToPx(themeW) } // Get color: prefer lnRef's own color child, fall back to theme line's solidFill const resolved = resolveColor(lnRef, ctx) color = toCssColor(resolved.color, resolved.alpha) } else { // Fallback: use lnRef color directly, approximate width from idx const resolved = resolveColor(lnRef, ctx) color = toCssColor(resolved.color, resolved.alpha) if (width === 0 && idx > 0) { width = idx * 0.75 // approximate: idx 1 = ~0.75px, idx 2 = ~1.5px } } } // Width fallback should still use lnRef/theme even when explicit solidFill is present on . if (width === 0 && lnRef && lnRef.exists()) { const idx = lnRef.numAttr('idx') ?? 0 if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) { const themeLn = ctx.theme.lineStyles[idx - 1] const themeW = themeLn.numAttr('w') ?? 0 width = emuToPx(themeW) } else if (idx > 0) { width = idx * 0.75 } } // Dash pattern let dash = 'solid' let dashKind = 'solid' const prstDash = ln.child('prstDash') if (prstDash.exists()) { const val = prstDash.attr('val') || 'solid' dashKind = val dash = ooxmlDashToCss(val) } // If no dash from explicit ln, check theme line style if (dash === 'solid' && lnRef && lnRef.exists()) { const idx = lnRef.numAttr('idx') ?? 0 if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) { const themeLn = ctx.theme.lineStyles[idx - 1] const themeDash = themeLn.child('prstDash') if (themeDash.exists()) { dashKind = themeDash.attr('val') || 'solid' dash = ooxmlDashToCss(dashKind) } } } return { width, color, dash, dashKind } } /** * Map OOXML preset dash values to CSS border-style. */ function ooxmlDashToCss(val: string): string { switch (val) { case 'solid': return 'solid' case 'dot': case 'sysDot': return 'dotted' case 'dash': case 'sysDash': case 'lgDash': return 'dashed' case 'dashDot': case 'lgDashDot': case 'lgDashDotDot': case 'sysDashDot': case 'sysDashDotDot': return 'dashed' default: return 'solid' } } // --------------------------------------------------------------------------- // Gradient Fill Resolution (structured data for SVG use) // --------------------------------------------------------------------------- export interface GradientFillData { type: 'linear' | 'radial' stops: Array<{ position: number; color: string }> /** SVG gradient interpolation space; OOXML gradients visually match linearRGB more closely. */ colorInterpolation?: 'linearRGB' | 'sRGB' /** OOXML angle in degrees (0 = top-to-bottom). Only relevant for linear gradients. */ angle: number /** Radial gradient center X as fraction 0–1. Default 0.5. */ cx?: number /** Radial gradient center Y as fraction 0–1. Default 0.5. */ cy?: number /** OOXML path type for radial gradients: 'rect', 'circle', or 'shape'. */ pathType?: string } function resolveGradientFillNode( gradFill: SafeXmlNode, ctx: RenderContext, placeholderColorNode?: SafeXmlNode ): GradientFillData | null { const gsLst = gradFill.child('gsLst') const stops: Array<{ position: number; color: string }> = [] for (const gs of gsLst.children('gs')) { const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode) stops.push({ position: posPercent, color: toCssColor(color, alpha) }) } if (stops.length === 0) return null stops.sort((a, b) => a.position - b.position) const lin = gradFill.child('lin') if (lin.exists()) { const angle = angleToDeg(lin.numAttr('ang') ?? 0) return { type: 'linear', stops, angle, colorInterpolation: 'linearRGB' } } const path = gradFill.child('path') if (path.exists()) { const pathType = path.attr('path') if (pathType === 'circle' || pathType === 'shape' || pathType === 'rect') { const ftr = path.child('fillToRect') let cx = 0.5 let cy = 0.5 if (ftr.exists()) { const l = (ftr.numAttr('l') ?? 0) / 100000 const t = (ftr.numAttr('t') ?? 0) / 100000 const r = (ftr.numAttr('r') ?? 0) / 100000 const b = (ftr.numAttr('b') ?? 0) / 100000 cx = (l + (1 - r)) / 2 cy = (t + (1 - b)) / 2 } return { type: 'radial', stops, angle: 0, cx, cy, pathType: pathType, colorInterpolation: 'linearRGB', } } } return { type: 'linear', stops, angle: 0, colorInterpolation: 'linearRGB' } } /** * Resolve a gradient fill from `spPr` into structured data suitable for * creating SVG gradient elements. Returns null if no gradient fill is present. */ export function resolveGradientFill( spPr: SafeXmlNode, ctx: RenderContext ): GradientFillData | null { let gradFill = spPr.child('gradFill') // grpFill: inherit gradient from parent group's grpSpPr if (!gradFill.exists() && spPr.child('grpFill').exists() && ctx.groupFillNode) { gradFill = ctx.groupFillNode.child('gradFill') } if (!gradFill.exists()) return null return resolveGradientFillNode(gradFill, ctx) } export function resolveThemeFillReference( fillRef: SafeXmlNode, ctx: RenderContext ): { fillCss: string; gradientFillData: GradientFillData | null } { const idx = fillRef.numAttr('idx') ?? 0 if (idx <= 0 || (ctx.theme.fillStyles?.length ?? 0) < idx) { return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null } } const themeFill = ctx.theme.fillStyles[idx - 1] if (!themeFill?.exists()) { return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null } } if (themeFill.localName === 'solidFill') { const resolved = resolveColorWithPlaceholder(themeFill, ctx, fillRef) return { fillCss: toCssColor(resolved.color, resolved.alpha), gradientFillData: null } } if (themeFill.localName === 'gradFill') { return { fillCss: resolveGradient(themeFill, ctx, fillRef), gradientFillData: resolveGradientFillNode(themeFill, ctx, fillRef), } } if (themeFill.localName === 'pattFill') { return { fillCss: resolvePatternFill(themeFill, ctx), gradientFillData: null } } if (themeFill.localName === 'noFill') { return { fillCss: 'transparent', gradientFillData: null } } return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null } } // --------------------------------------------------------------------------- // Gradient Stroke Resolution // --------------------------------------------------------------------------- export interface GradientStrokeData { stops: Array<{ position: number; color: string }> angle: number width: number colorInterpolation?: 'linearRGB' | 'sRGB' } /** * Resolve a gradient stroke from an `` node that contains ``. * Returns gradient stop data, angle, and line width — or null if no gradient fill is present. */ export function resolveGradientStroke( ln: SafeXmlNode, ctx: RenderContext ): GradientStrokeData | null { const gradFill = ln.child('gradFill') if (!gradFill.exists()) return null const gsLst = gradFill.child('gsLst') const stops: Array<{ position: number; color: string }> = [] for (const gs of gsLst.children('gs')) { const pos = gs.numAttr('pos') ?? 0 const posPercent = pctToDecimal(pos) * 100 const { color, alpha } = resolveColor(gs, ctx) const cssColor = toCssColor(color, alpha) stops.push({ position: posPercent, color: cssColor }) } if (stops.length === 0) return null stops.sort((a, b) => a.position - b.position) const lin = gradFill.child('lin') let angle = 0 if (lin.exists()) { angle = angleToDeg(lin.numAttr('ang') ?? 0) } const widthEmu = ln.numAttr('w') ?? 0 let width = emuToPx(widthEmu) // OOXML default when w is omitted is typically 1 pt; avoid invisible gradient stroke if (width <= 0) width = 1 return { stops, angle, width, colorInterpolation: 'linearRGB' } }