Files
simstudioai--sim/apps/sim/lib/pptx-renderer/renderer/style-resolver.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

805 lines
26 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.
/**
* 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 `<a:pattFill>` 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: "<gradient-layer> 0 0/<size>, <bg-color-layer>"
// 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 `<a:ln>` node from spPr
* @param ctx Render context
* @param lnRef Optional `<a:lnRef>` from `<p:style>` — provides fallback color
* when `<a:ln>` 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 <a:ln>.
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 01. Default 0.5. */
cx?: number
/** Radial gradient center Y as fraction 01. 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 `<a:ln>` node that contains `<a:gradFill>`.
* 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' }
}