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
805 lines
26 KiB
TypeScript
805 lines
26 KiB
TypeScript
/**
|
||
* 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 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 `<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' }
|
||
}
|