chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,804 @@
/**
* 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' }
}