Files
simstudioai--sim/apps/sim/lib/pptx-renderer/renderer/shape-renderer.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

1523 lines
66 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.
/**
* Shape renderer — converts ShapeNodeData into positioned HTML/SVG elements.
*/
import type { LineEndInfo, ShapeNodeData, TextBody } from '../model/nodes/shape-node'
import type { RenderContext } from './render-context'
/** True if the text body has at least one non-empty run (avoids covering shapes with empty placeholder text). */
function hasVisibleText(textBody: TextBody): boolean {
for (const p of textBody.paragraphs) {
for (const r of p.runs) {
if (r.text != null && r.text.trim().length > 0) return true
}
}
return false
}
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
import { emuToPx } from '../parser/units'
import type { SafeXmlNode } from '../parser/xml-parser'
import { renderCustomGeometry } from '../shapes/custom-geometry'
import {
getActionButtonIconPath,
getMultiPathPreset,
getPresetShapePath,
type PresetSubPath,
} from '../shapes/presets'
import { applyTint, hexToRgb, rgbToHex } from '../utils/color'
import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media'
import {
resolveColor,
resolveColorToCss,
resolveFill,
resolveGradientFill,
resolveGradientStroke,
resolveLineStyle,
resolveThemeFillReference,
} from './style-resolver'
import { renderTextBody } from './text-renderer'
// ---------------------------------------------------------------------------
// Shape blipFill (image fill) — resolve to blob URL for reuse (e.g. SVG/PNG in process diagrams)
// ---------------------------------------------------------------------------
/** Resolve shape blipFill to a blob URL so we can render it (e.g. slide 23 process graphic). */
function resolveShapeBlipUrl(blipFill: SafeXmlNode, ctx: RenderContext): string | null {
const blip = blipFill.child('blip')
const embedId = blip.attr('embed') ?? blip.attr('r:embed')
if (!embedId) return null
const rel = ctx.slide.rels.get(embedId)
if (!rel) return null
const mediaPath = resolveMediaPath(rel.target)
const data = ctx.presentation.media.get(mediaPath)
if (!data) return null
return getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache)
}
// ---------------------------------------------------------------------------
// Line End Marker (Arrowhead) Helpers
// ---------------------------------------------------------------------------
let markerIdCounter = 0
let gradientIdCounter = 0
function svgDashArrayForKind(dashKind: string, strokeWidth: number): string | null {
const w = Math.max(strokeWidth, 1)
switch (dashKind) {
case 'dot':
case 'sysDot':
return `${w},${w * 2}`
case 'dash':
case 'sysDash':
return `${w * 4},${w * 2}`
case 'lgDash':
return `${w * 8},${w * 3}`
case 'dashDot':
case 'sysDashDot':
return `${w * 4},${w * 2},${w},${w * 2}`
case 'lgDashDot':
return `${w * 8},${w * 3},${w},${w * 3}`
case 'lgDashDotDot':
case 'sysDashDotDot':
return `${w * 8},${w * 3},${w},${w * 2},${w},${w * 2}`
default:
return null
}
}
function parseCssColorToRgb(color: string): { r: number; g: number; b: number } | null {
if (!color) return null
const hex = color.trim()
if (hex.startsWith('#')) {
return hexToRgb(hex)
}
const m = hex.match(/rgba?\(([^)]+)\)/i)
if (!m) return null
const parts = m[1].split(',').map((s) => Number.parseFloat(s.trim()))
if (parts.length < 3 || parts.some((v) => Number.isNaN(v))) return null
return {
r: Math.max(0, Math.min(255, parts[0])),
g: Math.max(0, Math.min(255, parts[1])),
b: Math.max(0, Math.min(255, parts[2])),
}
}
function mixRgb(
base: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
t: number
): string {
const k = Math.max(0, Math.min(1, t))
return rgbToHex(
base.r + (target.r - base.r) * k,
base.g + (target.g - base.g) * k,
base.b + (target.b - base.b) * k
)
}
/**
* Convert an OOXML gradient angle (in degrees, where 0 = right-to-left in OOXML coords)
* to SVG linearGradient x1/y1/x2/y2 coordinates (as percentages).
*/
function angleToSvgGradientCoords(angleDeg: number): {
x1: string
y1: string
x2: string
y2: string
} {
// OOXML: 0° = left-to-right, 90° = top-to-bottom (clockwise)
// Convert to radians for trig
const rad = (angleDeg * Math.PI) / 180
// Calculate direction vector
const x2 = Math.round(50 + 50 * Math.cos(rad))
const y2 = Math.round(50 + 50 * Math.sin(rad))
const x1 = Math.round(50 - 50 * Math.cos(rad))
const y1 = Math.round(50 - 50 * Math.sin(rad))
return {
x1: `${x1}%`,
y1: `${y1}%`,
x2: `${x2}%`,
y2: `${y2}%`,
}
}
/**
* Get the marker size multiplier based on OOXML size string.
*/
function getMarkerSize(size: string | undefined): number {
switch (size) {
case 'sm':
return 0.5
case 'lg':
return 1.5
default:
return 1.0 // 'med' or undefined
}
}
/**
* Create an SVG marker element for a line end (arrowhead).
*/
function createArrowMarker(
svgNs: string,
info: LineEndInfo,
strokeColor: string,
strokeWidth: number,
isHead: boolean
): SVGMarkerElement | null {
const marker = document.createElementNS(svgNs, 'marker') as SVGMarkerElement
const id = `arrow-marker-${++markerIdCounter}`
marker.setAttribute('id', id)
// Use userSpaceOnUse so markerWidth/Height are in SVG pixels directly.
// This avoids the quadratic blow-up from markerUnits="strokeWidth" combined
// with a base size that already factors in stroke width.
marker.setAttribute('markerUnits', 'userSpaceOnUse')
marker.setAttribute('orient', 'auto')
const wMul = getMarkerSize(info.w)
const lenMul = getMarkerSize(info.len)
// Arrow size proportional to stroke width with balanced floor:
// avoid tiny markers, but do not overgrow relative to line length.
const baseLen = Math.max(strokeWidth * 4, 6.5)
const baseW = Math.max(strokeWidth * 3.2, 5)
const markerW = baseLen * lenMul
const markerH = baseW * wMul
switch (info.type) {
case 'triangle':
case 'arrow': {
marker.setAttribute('viewBox', '0 0 10 10')
// Anchor the arrow tip on the path endpoint so it does not intrude into target shapes.
marker.setAttribute('refX', '10')
marker.setAttribute('refY', '5')
marker.setAttribute('markerWidth', String(markerW))
marker.setAttribute('markerHeight', String(markerH))
const polygon = document.createElementNS(svgNs, 'polygon')
if (isHead) {
// headEnd at marker-start: arrow points backward (-x / left)
polygon.setAttribute('points', '0,5 10,0 10,10')
} else {
// tailEnd at marker-end: arrow points forward (+x / right)
polygon.setAttribute('points', '10,5 0,0 0,10')
}
polygon.setAttribute('fill', strokeColor)
marker.appendChild(polygon)
break
}
case 'stealth': {
marker.setAttribute('viewBox', '0 0 10 10')
marker.setAttribute('refX', '10')
marker.setAttribute('refY', '5')
marker.setAttribute('markerWidth', String(markerW))
marker.setAttribute('markerHeight', String(markerH))
const path = document.createElementNS(svgNs, 'path')
if (isHead) {
// headEnd at marker-start: arrow points backward (-x / left)
path.setAttribute('d', 'M0,5 L10,0 L7,5 L10,10 Z')
} else {
// tailEnd at marker-end: arrow points forward (+x / right)
path.setAttribute('d', 'M10,5 L0,0 L3,5 L0,10 Z')
}
path.setAttribute('fill', strokeColor)
marker.appendChild(path)
break
}
case 'diamond': {
marker.setAttribute('viewBox', '0 0 10 10')
marker.setAttribute('refX', '5')
marker.setAttribute('refY', '5')
marker.setAttribute('markerWidth', String(markerW))
marker.setAttribute('markerHeight', String(markerH))
const diamond = document.createElementNS(svgNs, 'polygon')
diamond.setAttribute('points', '5,0 10,5 5,10 0,5')
diamond.setAttribute('fill', strokeColor)
marker.appendChild(diamond)
break
}
case 'oval': {
marker.setAttribute('viewBox', '0 0 10 10')
marker.setAttribute('refX', '5')
marker.setAttribute('refY', '5')
marker.setAttribute('markerWidth', String(markerW))
marker.setAttribute('markerHeight', String(markerH))
const circle = document.createElementNS(svgNs, 'circle')
circle.setAttribute('cx', '5')
circle.setAttribute('cy', '5')
circle.setAttribute('r', '4')
circle.setAttribute('fill', strokeColor)
marker.appendChild(circle)
break
}
default:
return null
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(marker as any)._markerId = id
return marker
}
/** Read headEnd/tailEnd from an OOXML a:ln node (e.g. theme line style). */
function getLineEndsFromLn(ln: SafeXmlNode): { headEnd?: LineEndInfo; tailEnd?: LineEndInfo } {
const out: { headEnd?: LineEndInfo; tailEnd?: LineEndInfo } = {}
const he = ln.child('headEnd')
if (he.exists()) {
const t = he.attr('type')
if (t && t !== 'none') out.headEnd = { type: t, w: he.attr('w'), len: he.attr('len') }
}
const te = ln.child('tailEnd')
if (te.exists()) {
const t = te.attr('type')
if (t && t !== 'none') out.tailEnd = { type: t, w: te.attr('w'), len: te.attr('len') }
}
return out
}
// ---------------------------------------------------------------------------
// Shape Rendering
// ---------------------------------------------------------------------------
/**
* Render a shape node into an absolutely-positioned HTML element with SVG geometry.
*/
export function renderShape(node: ShapeNodeData, ctx: RenderContext): HTMLElement {
const wrapper = document.createElement('div')
wrapper.style.position = 'absolute'
wrapper.style.left = `${node.position.x}px`
wrapper.style.top = `${node.position.y}px`
wrapper.style.width = `${node.size.w}px`
// Line-like: preset line/connector, or cxnSp (connection shape), or flat extent (one dimension 0)
const presetKey = node.presetGeometry?.toLowerCase() ?? ''
const outlineOnlyPresets = new Set([
'arc',
'leftbracket',
'rightbracket',
'leftbrace',
'rightbrace',
'bracketpair',
'bracepair',
])
const presetIsLine =
!!presetKey &&
(presetKey === 'line' ||
presetKey === 'lineinv' ||
presetKey.includes('connector') ||
outlineOnlyPresets.has(presetKey))
const isConnectorShape = node.source.localName === 'cxnSp'
const flatExtent =
(node.size.w > 0 && node.size.h === 0) || (node.size.w === 0 && node.size.h > 0)
const isLineLike = presetIsLine || isConnectorShape || flatExtent
const minH = isLineLike && node.size.h === 0 ? 1 : node.size.h
const minW = isLineLike && node.size.w === 0 ? 1 : node.size.w
wrapper.style.height = `${minH}px`
if (node.size.w === 0) wrapper.style.width = `${minW}px`
wrapper.style.overflow = 'visible'
// Apply transforms (rotation + flip)
const transforms: string[] = []
if (node.rotation !== 0) {
transforms.push(`rotate(${node.rotation}deg)`)
}
if (node.flipH) {
transforms.push('scaleX(-1)')
}
if (node.flipV) {
transforms.push('scaleY(-1)')
}
if (transforms.length > 0) {
wrapper.style.transform = transforms.join(' ')
}
const w = node.size.w
const h = node.size.h
// For path generation, pass original w/h so preset functions can detect zero-extent
// directions (e.g. line preset draws vertical when w=0, horizontal when h=0).
// For SVG viewport, use minW/minH to guarantee a visible container.
const pathW = w
const pathH = h
// Style references (needed for path fallback and line resolution)
const styleNode = node.source.child('style')
const lnRef = styleNode.exists() ? styleNode.child('lnRef') : undefined
const fillRef = styleNode.exists() ? styleNode.child('fillRef') : undefined
// ---- Generate SVG path ----
let pathD = ''
let multiPaths: PresetSubPath[] | null = null
if (node.presetGeometry) {
// For connector shapes (cxnSp), the 'line' preset should draw from start to end
// point (0,0)→(w,h), not a horizontal midline. Use 'straightConnector1' instead,
// which correctly handles diagonal/near-vertical connectors (e.g. cx≈0 but non-zero).
let effectivePreset = node.presetGeometry
if (isConnectorShape && effectivePreset === 'line') {
effectivePreset = 'straightConnector1'
}
// Try multi-path preset first (complex shapes like scrolls with darkenLess paths)
multiPaths = getMultiPathPreset(effectivePreset, pathW, pathH, node.adjustments)
if (multiPaths) {
// Use the first (main fill) path as pathD for backwards-compatible code paths
pathD = multiPaths[0]?.d ?? ''
} else {
pathD = getPresetShapePath(effectivePreset, pathW, pathH, node.adjustments)
}
} else if (node.customGeometry) {
const extNode = node.source.child('spPr').child('xfrm').child('ext')
const sourceExtentEmu = {
w: extNode.numAttr('cx') ?? 0,
h: extNode.numAttr('cy') ?? 0,
}
pathD = renderCustomGeometry(node.customGeometry, pathW, pathH, sourceExtentEmu)
}
// Connectors (cxnSp) or flat-extent shapes with line style but no geometry: draw as line
if (
!pathD &&
isLineLike &&
(node.line?.exists() ||
(lnRef?.exists() &&
(lnRef.numAttr('idx') ?? 0) > 0 &&
(ctx.theme.lineStyles?.length ?? 0) >= (lnRef.numAttr('idx') ?? 0)))
) {
pathD = getPresetShapePath(
isConnectorShape ? 'straightConnector1' : 'line',
pathW,
pathH,
undefined
)
}
// ---- Resolve fill and line styles ----
const spPr = node.source.child('spPr')
let fillCss = ''
// Resolve structured gradient fill data (for SVG gradient elements)
let gradientFillData = node.fill ? resolveGradientFill(spPr, ctx) : null
if (node.fill?.exists()) {
if (node.fill.localName === 'solidFill') {
const colorChild = node.fill.child('srgbClr').exists()
? node.fill.child('srgbClr')
: node.fill.child('schemeClr').exists()
? node.fill.child('schemeClr')
: node.fill.child('scrgbClr').exists()
? node.fill.child('scrgbClr')
: node.fill.child('sysClr').exists()
? node.fill.child('sysClr')
: undefined
if (colorChild?.exists()) fillCss = resolveColorToCss(colorChild, ctx)
}
if (!fillCss) fillCss = resolveFill(spPr, ctx)
}
// Diagram/SmartArt: read fill directly from source when still missing (spPr > solidFill > color)
if (!fillCss) {
const solidFill = spPr.child('solidFill')
if (solidFill.exists()) {
const colorChild = solidFill.child('srgbClr').exists()
? solidFill.child('srgbClr')
: solidFill.child('schemeClr').exists()
? solidFill.child('schemeClr')
: solidFill.child('scrgbClr').exists()
? solidFill.child('scrgbClr')
: solidFill.child('sysClr').exists()
? solidFill.child('sysClr')
: undefined
if (colorChild?.exists()) fillCss = resolveColorToCss(colorChild, ctx)
}
}
// fillRef fallback: when no explicit fill but fillRef idx > 0, use fillRef color
if (!fillCss && fillRef && fillRef.exists()) {
const resolvedThemeFill = resolveThemeFillReference(fillRef, ctx)
fillCss = resolvedThemeFill.fillCss
if (!gradientFillData) gradientFillData = resolvedThemeFill.gradientFillData
}
// Connectors and other line-like presets are stroke-only in OOXML. They may still
// carry style fillRefs, but those must not become filled ribbons in SVG.
if (isLineLike) {
fillCss = ''
gradientFillData = null
}
let strokeColor = 'none'
let strokeWidth = 0
let strokeDash = ''
let strokeDashKind = 'solid'
let strokeLinecap = ''
let strokeLinejoin = ''
let gradientStroke: ReturnType<typeof resolveGradientStroke> = null
// Resolve effective line: explicit <a:ln> on shape, or use theme line from lnRef.
// When line is explicitly <a:noFill/>, do not use lnRef — diagram arrows (e.g. circularArrow) must have no stroke.
const lineIsNoFill = node.line?.child('noFill').exists()
const hasExplicitLine = node.line && !lineIsNoFill
const themeLineFromLnRef =
!hasExplicitLine &&
!lineIsNoFill &&
lnRef?.exists() &&
(lnRef.numAttr('idx') ?? 0) > 0 &&
(ctx.theme.lineStyles?.length ?? 0) >= (lnRef.numAttr('idx') ?? 0)
? ctx.theme.lineStyles![(lnRef.numAttr('idx') ?? 1) - 1]
: undefined
let effectiveLine = hasExplicitLine ? node.line! : themeLineFromLnRef
if (lineIsNoFill) effectiveLine = undefined
if (effectiveLine?.exists()) {
gradientStroke = resolveGradientStroke(effectiveLine, ctx)
if (!gradientStroke) {
const lineStyle = resolveLineStyle(effectiveLine, ctx, lnRef)
strokeColor = lineStyle.color
strokeWidth = lineStyle.width
strokeDash = lineStyle.dash
strokeDashKind = lineStyle.dashKind
}
// Line cap: a:ln@cap → SVG stroke-linecap
const capAttr = effectiveLine.attr('cap')
if (capAttr === 'rnd') strokeLinecap = 'round'
else if (capAttr === 'sq') strokeLinecap = 'square'
else if (capAttr === 'flat') strokeLinecap = 'butt'
// Line join: from child elements
if (effectiveLine.child('round').exists()) strokeLinejoin = 'round'
else if (effectiveLine.child('bevel').exists()) strokeLinejoin = 'bevel'
else if (effectiveLine.child('miter').exists()) strokeLinejoin = 'miter'
}
if (lineIsNoFill) {
strokeColor = 'none'
strokeWidth = 0
gradientStroke = null
}
// SmartArt circularArrow must be fill-only (no stroke); preset-based override so diagram XML is not relied on.
const isCircularArrow = node.presetGeometry?.toLowerCase() === 'circulararrow'
if (isCircularArrow) {
strokeColor = 'none'
strokeWidth = 0
gradientStroke = null
if (!fillCss) {
const solid = spPr.child('solidFill')
if (solid.exists()) {
const color = solid.child('srgbClr').exists()
? solid.child('srgbClr')
: solid.child('schemeClr').exists()
? solid.child('schemeClr')
: solid.child('scrgbClr').exists()
? solid.child('scrgbClr')
: solid.child('sysClr').exists()
? solid.child('sysClr')
: undefined
if (color?.exists()) fillCss = resolveColorToCss(color, ctx)
}
}
}
// ---- Create SVG element ----
if (pathD) {
const svgNs = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(svgNs, 'svg')
const svgW = isLineLike ? minW : w
const svgH = isLineLike ? minH : h
svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`)
svg.setAttribute('width', String(svgW))
svg.setAttribute('height', String(svgH))
svg.style.position = 'absolute'
svg.style.left = '0'
svg.style.top = '0'
svg.style.overflow = 'visible'
const blipFill = spPr.child('blipFill')
const blipUrl = blipFill.exists() ? resolveShapeBlipUrl(blipFill, ctx) : null
// When shape has image fill (blipFill), render image clipped to path so complex graphics (e.g. slide 23 process) show
if (blipUrl) {
const defs = document.createElementNS(svgNs, 'defs')
const clipId = `shape-clip-${++gradientIdCounter}`
const clipPath = document.createElementNS(svgNs, 'clipPath')
clipPath.setAttribute('id', clipId)
const clipPathPath = document.createElementNS(svgNs, 'path')
clipPathPath.setAttribute('d', pathD)
clipPath.appendChild(clipPathPath)
defs.appendChild(clipPath)
const image = document.createElementNS(svgNs, 'image')
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', blipUrl)
image.setAttribute('x', '0')
image.setAttribute('y', '0')
image.setAttribute('width', String(svgW))
image.setAttribute('height', String(svgH))
image.setAttribute('clip-path', `url(#${clipId})`)
image.setAttribute('preserveAspectRatio', 'xMidYMid slice')
svg.appendChild(defs)
svg.appendChild(image)
wrapper.appendChild(svg)
// Skip path fill/stroke/markers — image replaces fill
} else {
// Create <defs> for gradients and markers
const defs = document.createElementNS(svgNs, 'defs')
const path = document.createElementNS(svgNs, 'path')
path.setAttribute('d', pathD)
const presetLower = node.presetGeometry?.toLowerCase()
if (presetLower === 'curveduparrow' || presetLower === 'curveddownarrow') {
// Curved arrows can contain overlapping sub-contours near arrowhead roots.
// evenodd avoids tiny anti-alias seams that appear with nonzero winding.
path.setAttribute('fill-rule', 'evenodd')
path.setAttribute('stroke-linejoin', 'round')
} else if (presetLower === 'funnel') {
// Funnel has an inset ellipse sub-path that creates a "hole" (even-odd fill).
path.setAttribute('fill-rule', 'evenodd')
}
// Fill
if (fillCss) {
if (gradientFillData && gradientFillData.stops.length > 0) {
// Create SVG gradient definition for proper shape-clipped gradient fills
const fillGradId = `grad-fill-${++gradientIdCounter}`
if (gradientFillData.type === 'radial' && gradientFillData.pathType === 'rect') {
// OOXML path="rect" gradient: Chebyshev distance (L∞ norm) creates
// rectangular contour lines (the characteristic cross/X pattern).
// SVG/CSS radial-gradient only supports elliptical contours.
// Approximation: two linear gradients (H + V) blended with "lighten"
// (per-channel max). max(dx, dy) = L∞ norm = rectangular contours.
const gcx = gradientFillData.cx ?? 0.5
const gcy = gradientFillData.cy ?? 0.5
const stops = gradientFillData.stops
// Mirror stops for center-out: original stop at N% → two stops at
// (center - N%*distToEdge) and (center + N%*distToEdge) in gradient coords.
const mirrorStops = (centerFrac: number) => {
const mirrored: Array<{ offset: number; color: string }> = []
for (const s of stops) {
const t = s.position / 100 // 0..1 from center to edge
const below = centerFrac - t * centerFrac
const above = centerFrac + t * (1 - centerFrac)
mirrored.push({ offset: below, color: s.color })
mirrored.push({ offset: above, color: s.color })
}
mirrored.sort((a, b) => a.offset - b.offset)
return mirrored
}
// Horizontal linear gradient (left → right, center at gcx)
const hGradId = `${fillGradId}-h`
const hGrad = document.createElementNS(svgNs, 'linearGradient')
hGrad.setAttribute('id', hGradId)
hGrad.setAttribute(
'color-interpolation',
gradientFillData.colorInterpolation ?? 'linearRGB'
)
hGrad.setAttribute('x1', '0%')
hGrad.setAttribute('y1', '0%')
hGrad.setAttribute('x2', '100%')
hGrad.setAttribute('y2', '0%')
for (const ms of mirrorStops(gcx)) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${(ms.offset * 100).toFixed(2)}%`)
svgStop.setAttribute('stop-color', ms.color)
hGrad.appendChild(svgStop)
}
defs.appendChild(hGrad)
// Vertical linear gradient (top → bottom, center at gcy)
const vGradId = `${fillGradId}-v`
const vGrad = document.createElementNS(svgNs, 'linearGradient')
vGrad.setAttribute('id', vGradId)
vGrad.setAttribute(
'color-interpolation',
gradientFillData.colorInterpolation ?? 'linearRGB'
)
vGrad.setAttribute('x1', '0%')
vGrad.setAttribute('y1', '0%')
vGrad.setAttribute('x2', '0%')
vGrad.setAttribute('y2', '100%')
for (const ms of mirrorStops(gcy)) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${(ms.offset * 100).toFixed(2)}%`)
svgStop.setAttribute('stop-color', ms.color)
vGrad.appendChild(svgStop)
}
defs.appendChild(vGrad)
// Use clipPath to constrain the blend group to the shape
const clipId = `${fillGradId}-clip`
const clipPath = document.createElementNS(svgNs, 'clipPath')
clipPath.setAttribute('id', clipId)
const clipUsePath = document.createElementNS(svgNs, 'path')
clipUsePath.setAttribute('d', pathD)
clipPath.appendChild(clipUsePath)
defs.appendChild(clipPath)
// Isolated group: black backdrop + two gradient layers with lighten blend.
// lighten = per-channel max. Against black (0,0,0), first layer is identity.
// Second layer's lighten against first = max(H, V) per channel.
const blendGroup = document.createElementNS(svgNs, 'g')
blendGroup.setAttribute('clip-path', `url(#${clipId})`)
blendGroup.setAttribute('style', 'isolation: isolate')
const bgRect = document.createElementNS(svgNs, 'rect')
bgRect.setAttribute('width', '100%')
bgRect.setAttribute('height', '100%')
bgRect.setAttribute('fill', 'black')
blendGroup.appendChild(bgRect)
const hPath = document.createElementNS(svgNs, 'path')
hPath.setAttribute('d', pathD)
hPath.setAttribute('fill', `url(#${hGradId})`)
hPath.setAttribute('style', 'mix-blend-mode: lighten')
blendGroup.appendChild(hPath)
const vPath = document.createElementNS(svgNs, 'path')
vPath.setAttribute('d', pathD)
vPath.setAttribute('fill', `url(#${vGradId})`)
vPath.setAttribute('style', 'mix-blend-mode: lighten')
blendGroup.appendChild(vPath)
// Mark path as no-fill; the blend group handles it.
// Tag the blend group so we can insert it before the main path later.
path.setAttribute('fill', 'none')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(path as any).__rectBlendGroup = blendGroup
} else if (gradientFillData.type === 'radial') {
const radialGrad = document.createElementNS(svgNs, 'radialGradient')
radialGrad.setAttribute('id', fillGradId)
radialGrad.setAttribute(
'color-interpolation',
gradientFillData.colorInterpolation ?? 'linearRGB'
)
radialGrad.setAttribute('gradientUnits', 'userSpaceOnUse')
const gcx = gradientFillData.cx ?? 0.5
const gcy = gradientFillData.cy ?? 0.5
radialGrad.setAttribute('cx', String(gcx * svgW))
radialGrad.setAttribute('cy', String(gcy * svgH))
// path="circle"/"shape": gradient reaches farthest corner
const maxDx = Math.max(gcx, 1 - gcx)
const maxDy = Math.max(gcy, 1 - gcy)
const r = Math.sqrt(maxDx * maxDx + maxDy * maxDy)
radialGrad.setAttribute('r', String(r * Math.max(svgW, svgH)))
for (const stop of gradientFillData.stops) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${stop.position}%`)
svgStop.setAttribute('stop-color', stop.color)
radialGrad.appendChild(svgStop)
}
defs.appendChild(radialGrad)
} else {
// Linear gradient
const linearGrad = document.createElementNS(svgNs, 'linearGradient')
linearGrad.setAttribute('id', fillGradId)
linearGrad.setAttribute(
'color-interpolation',
gradientFillData.colorInterpolation ?? 'linearRGB'
)
linearGrad.setAttribute('gradientUnits', 'userSpaceOnUse')
const coords = angleToSvgGradientCoords(gradientFillData.angle)
linearGrad.setAttribute('x1', String((Number.parseFloat(coords.x1) / 100) * svgW))
linearGrad.setAttribute('y1', String((Number.parseFloat(coords.y1) / 100) * svgH))
linearGrad.setAttribute('x2', String((Number.parseFloat(coords.x2) / 100) * svgW))
linearGrad.setAttribute('y2', String((Number.parseFloat(coords.y2) / 100) * svgH))
for (const stop of gradientFillData.stops) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${stop.position}%`)
svgStop.setAttribute('stop-color', stop.color)
linearGrad.appendChild(svgStop)
}
defs.appendChild(linearGrad)
}
// For rect blend group, fill was already handled (path set to 'none', blend group added).
if (!(gradientFillData.type === 'radial' && gradientFillData.pathType === 'rect')) {
path.setAttribute('fill', `url(#${fillGradId})`)
}
} else if (fillCss === 'transparent') {
path.setAttribute('fill', 'none')
} else if (fillCss.includes('gradient')) {
// Fallback for gradients without structured data (shouldn't normally happen)
// Apply to wrapper as before
wrapper.style.background = fillCss
path.setAttribute('fill', 'transparent')
} else {
path.setAttribute('fill', fillCss)
}
} else {
path.setAttribute('fill', 'none')
}
// SmartArt circularArrow: force no stroke; fill already resolved via fillRef/solidFill above
if (isCircularArrow) {
// fillCss was already resolved (including fillRef fallback). Only override if still empty.
if (!fillCss || fillCss === 'none' || fillCss === 'transparent') {
// Try spPr > solidFill > color child as last resort
const colorTags = ['srgbClr', 'schemeClr', 'scrgbClr', 'sysClr', 'hslClr', 'prstClr']
let fallbackFill = ''
const solid = spPr.child('solidFill')
if (solid.exists()) {
for (const child of solid.allChildren()) {
if (colorTags.includes(child.localName)) {
fallbackFill = resolveColorToCss(child, ctx)
break
}
}
}
if (!fallbackFill && node.fill?.exists()) {
for (const child of node.fill.allChildren()) {
if (colorTags.includes(child.localName)) {
fallbackFill = resolveColorToCss(child, ctx)
break
}
}
}
if (fallbackFill) path.setAttribute('fill', fallbackFill)
}
path.setAttribute('stroke', 'none')
}
// Resolve arrow ends and effective stroke width before applying stroke (so we can enforce min width for connectors)
let effectiveHeadEnd = node.headEnd
let effectiveTailEnd = node.tailEnd
if ((!effectiveHeadEnd || !effectiveTailEnd) && effectiveLine?.exists()) {
const fromLn = getLineEndsFromLn(effectiveLine)
if (!effectiveHeadEnd && fromLn.headEnd) effectiveHeadEnd = fromLn.headEnd
if (!effectiveTailEnd && fromLn.tailEnd) effectiveTailEnd = fromLn.tailEnd
}
// For gradient strokes, use first stop for marker-start and last stop for marker-end
// so arrowhead colours match the visible gradient end rather than always using the lightest stop.
const gradStartColor = gradientStroke
? gradientStroke.stops[0]?.color || 'black'
: strokeColor
const gradEndColor = gradientStroke
? gradientStroke.stops[gradientStroke.stops.length - 1]?.color || gradStartColor
: strokeColor
let effectiveStrokeWidth = gradientStroke ? gradientStroke.width : strokeWidth
if (isLineLike && (effectiveHeadEnd || effectiveTailEnd) && effectiveStrokeWidth <= 0) {
effectiveStrokeWidth = 1 // so connector line and arrows both show (e.g. slide 24)
}
// Stroke — gradient stroke or solid stroke (skip for circularArrow; already set stroke=none above)
// For multi-path presets where the first sub-path specifies stroke:false (e.g. callout1/2/3,
// accentCallout1/2/3), suppress stroke on the main path element — the leader line and accent
// bar are rendered as separate sub-path elements with their own stroke settings.
const mainPathStrokeSuppressed = multiPaths && multiPaths[0]?.stroke === false
if (
!isCircularArrow &&
!mainPathStrokeSuppressed &&
gradientStroke &&
gradientStroke.stops.length > 0
) {
// Create SVG linearGradient for the gradient stroke.
// Use userSpaceOnUse so the gradient is defined in SVG coordinate space rather
// than objectBoundingBox. This is critical for straight line paths (zero-width or
// zero-height bounding box) where objectBoundingBox produces degenerate coordinates
// and the gradient becomes invisible.
const gradId = `grad-stroke-${++gradientIdCounter}`
const linearGrad = document.createElementNS(svgNs, 'linearGradient')
linearGrad.setAttribute('id', gradId)
linearGrad.setAttribute(
'color-interpolation',
gradientStroke.colorInterpolation ?? 'linearRGB'
)
linearGrad.setAttribute('gradientUnits', 'userSpaceOnUse')
// Convert gradient angle to absolute coordinates in SVG user space
const rad = (gradientStroke.angle * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
// Centre of the SVG viewBox
const cx = svgW / 2
const cy = svgH / 2
// Half-extent along each axis (use max of both dimensions so the gradient covers the path)
const halfLen = Math.max(svgW, svgH) / 2
linearGrad.setAttribute('x1', String(cx - halfLen * cos))
linearGrad.setAttribute('y1', String(cy - halfLen * sin))
linearGrad.setAttribute('x2', String(cx + halfLen * cos))
linearGrad.setAttribute('y2', String(cy + halfLen * sin))
for (const stop of gradientStroke.stops) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${stop.position}%`)
svgStop.setAttribute('stop-color', stop.color)
linearGrad.appendChild(svgStop)
}
defs.appendChild(linearGrad)
const strokeW = Math.max(gradientStroke.width, 1)
path.setAttribute('stroke', `url(#${gradId})`)
path.setAttribute('stroke-width', String(strokeW))
if (strokeLinecap) path.setAttribute('stroke-linecap', strokeLinecap)
if (strokeLinejoin) path.setAttribute('stroke-linejoin', strokeLinejoin)
} else if (
!isCircularArrow &&
!mainPathStrokeSuppressed &&
effectiveStrokeWidth > 0 &&
strokeColor !== 'transparent'
) {
path.setAttribute('stroke', strokeColor)
path.setAttribute('stroke-width', String(effectiveStrokeWidth))
if (strokeLinecap) path.setAttribute('stroke-linecap', strokeLinecap)
if (strokeLinejoin) path.setAttribute('stroke-linejoin', strokeLinejoin)
const svgDashArray = svgDashArrayForKind(strokeDashKind, effectiveStrokeWidth)
if (svgDashArray) {
path.setAttribute('stroke-dasharray', svgDashArray)
} else if (strokeDash === 'dashed') {
path.setAttribute(
'stroke-dasharray',
`${effectiveStrokeWidth * 4},${effectiveStrokeWidth * 2}`
)
} else if (strokeDash === 'dotted') {
path.setAttribute(
'stroke-dasharray',
`${effectiveStrokeWidth},${effectiveStrokeWidth * 2}`
)
}
} else {
path.setAttribute('stroke', 'none')
}
// Line end markers (arrowheads)
// Use gradient start colour for head (marker-start) and end colour for tail (marker-end)
if (effectiveStrokeWidth > 0 && (effectiveHeadEnd || effectiveTailEnd)) {
if (effectiveHeadEnd) {
const marker = createArrowMarker(
svgNs,
effectiveHeadEnd,
gradStartColor,
effectiveStrokeWidth,
true
)
if (marker) {
defs.appendChild(marker)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
path.setAttribute('marker-start', `url(#${(marker as any)._markerId})`)
}
}
if (effectiveTailEnd) {
const marker = createArrowMarker(
svgNs,
effectiveTailEnd,
gradEndColor,
effectiveStrokeWidth,
false
)
if (marker) {
defs.appendChild(marker)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
path.setAttribute('marker-end', `url(#${(marker as any)._markerId})`)
}
}
}
// Insert rect blend group (two linear gradients + lighten) before the main path
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((path as any).__rectBlendGroup) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
svg.appendChild((path as any).__rectBlendGroup)(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
path as any
).__rectBlendGroup = undefined
}
svg.appendChild(path)
// --- Multi-path preset rendering ---
// For complex shapes (scrolls, etc.) that have multiple sub-paths with different
// fill modifiers (darkenLess for shadow areas, none for stroke-only detail lines).
if (multiPaths && multiPaths.length > 1) {
const mainPathFill = path.getAttribute('fill') ?? ''
const presetLower = node.presetGeometry?.toLowerCase() ?? ''
const shadingBaseFill =
mainPathFill && !mainPathFill.startsWith('url(')
? mainPathFill
: fillRef?.exists()
? resolveColorToCss(fillRef, ctx)
: (gradientFillData?.stops[0]?.color ?? fillCss)
const baseRgb = parseCssColorToRgb(shadingBaseFill)
const appendTintedGradientFill = (
amount: number,
target: { r: number; g: number; b: number }
): string | undefined => {
if (gradientFillData?.type !== 'linear' || gradientFillData.stops.length === 0)
return undefined
const gradId = `grad-fill-detail-${++gradientIdCounter}`
const linearGrad = document.createElementNS(svgNs, 'linearGradient')
linearGrad.setAttribute('id', gradId)
linearGrad.setAttribute('gradientUnits', 'userSpaceOnUse')
linearGrad.setAttribute(
'color-interpolation',
gradientFillData.colorInterpolation ?? 'sRGB'
)
const coords = angleToSvgGradientCoords(gradientFillData.angle)
linearGrad.setAttribute('x1', String((Number.parseFloat(coords.x1) / 100) * svgW))
linearGrad.setAttribute('y1', String((Number.parseFloat(coords.y1) / 100) * svgH))
linearGrad.setAttribute('x2', String((Number.parseFloat(coords.x2) / 100) * svgW))
linearGrad.setAttribute('y2', String((Number.parseFloat(coords.y2) / 100) * svgH))
for (const stop of gradientFillData.stops) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${stop.position}%`)
const stopRgb = parseCssColorToRgb(stop.color)
svgStop.setAttribute(
'stop-color',
stopRgb ? mixRgb(stopRgb, target, amount) : stop.color
)
linearGrad.appendChild(svgStop)
}
defs.appendChild(linearGrad)
return `url(#${gradId})`
}
// The first path was already rendered above as the main path.
// Render additional sub-paths (darkenLess shadow, stroke-only detail lines).
for (let pi = 1; pi < multiPaths.length; pi++) {
const sp = multiPaths[pi]
const extraPath = document.createElementNS(svgNs, 'path')
extraPath.setAttribute('d', sp.d)
if (sp.fill === 'none') {
extraPath.setAttribute('fill', 'none')
} else if (sp.fill === 'darkenLess') {
extraPath.setAttribute(
'fill',
appendTintedGradientFill(0.15, { r: 0, g: 0, b: 0 }) ||
(baseRgb ? mixRgb(baseRgb, { r: 0, g: 0, b: 0 }, 0.15) : 'rgba(0,0,0,0.15)')
)
} else if (sp.fill === 'darken') {
extraPath.setAttribute(
'fill',
appendTintedGradientFill(0.3, { r: 0, g: 0, b: 0 }) ||
(baseRgb ? mixRgb(baseRgb, { r: 0, g: 0, b: 0 }, 0.3) : 'rgba(0,0,0,0.3)')
)
} else if (sp.fill === 'lightenLess') {
extraPath.setAttribute(
'fill',
appendTintedGradientFill(0.18, { r: 255, g: 255, b: 255 }) ||
(baseRgb
? mixRgb(baseRgb, { r: 255, g: 255, b: 255 }, 0.18)
: 'rgba(255,255,255,0.15)')
)
} else if (sp.fill === 'lighten') {
let canHighlight: string | undefined
if (
presetLower === 'can' &&
gradientFillData?.type === 'linear' &&
gradientFillData.stops.length > 0
) {
const faceGradId = `grad-fill-face-${++gradientIdCounter}`
const faceGrad = document.createElementNS(svgNs, 'linearGradient')
faceGrad.setAttribute('id', faceGradId)
faceGrad.setAttribute('gradientUnits', 'userSpaceOnUse')
faceGrad.setAttribute('color-interpolation', 'sRGB')
const coords = angleToSvgGradientCoords(gradientFillData.angle)
faceGrad.setAttribute('x1', String((Number.parseFloat(coords.x1) / 100) * svgW))
faceGrad.setAttribute('y1', String((Number.parseFloat(coords.y1) / 100) * svgH))
faceGrad.setAttribute('x2', String((Number.parseFloat(coords.x2) / 100) * svgW))
faceGrad.setAttribute('y2', String((Number.parseFloat(coords.y2) / 100) * svgH))
for (const stop of gradientFillData.stops) {
const svgStop = document.createElementNS(svgNs, 'stop')
svgStop.setAttribute('offset', `${stop.position}%`)
svgStop.setAttribute('stop-color', applyTint(stop.color, 65000))
faceGrad.appendChild(svgStop)
}
defs.appendChild(faceGrad)
canHighlight = `url(#${faceGradId})`
} else if (presetLower === 'can' && mainPathFill.startsWith('url(')) {
canHighlight = mainPathFill
}
extraPath.setAttribute(
'fill',
canHighlight ||
(baseRgb
? mixRgb(baseRgb, { r: 255, g: 255, b: 255 }, 0.3)
: 'rgba(255,255,255,0.3)')
)
} else {
// 'norm' — same fill as main path
extraPath.setAttribute('fill', mainPathFill || 'none')
}
if (sp.stroke && effectiveStrokeWidth > 0 && strokeColor !== 'transparent') {
extraPath.setAttribute('stroke', strokeColor)
const isBorderCalloutLeader =
node.presetGeometry?.toLowerCase() === 'bordercallout1' && sp.fill === 'none'
const scaledStrokeWidth =
sp.strokeWidthScale && Number.isFinite(sp.strokeWidthScale) && sp.strokeWidthScale > 0
? effectiveStrokeWidth * sp.strokeWidthScale
: effectiveStrokeWidth
const extraStrokeWidth = isBorderCalloutLeader
? Math.max(scaledStrokeWidth, 2.4)
: scaledStrokeWidth
extraPath.setAttribute('stroke-width', String(extraStrokeWidth))
if (isBorderCalloutLeader) extraPath.setAttribute('stroke-linecap', 'round')
if (
sp.maskToMainOutlineBandScale &&
sp.maskToMainOutlineBandScale > 0 &&
sp.maskToMainOutlineBandScale < 1
) {
const maskId = `shape-detail-band-mask-${++gradientIdCounter}`
const mask = document.createElementNS(svgNs, 'mask')
mask.setAttribute('id', maskId)
mask.setAttribute('maskUnits', 'userSpaceOnUse')
mask.setAttribute('maskContentUnits', 'userSpaceOnUse')
const maskBg = document.createElementNS(svgNs, 'rect')
maskBg.setAttribute('x', '0')
maskBg.setAttribute('y', '0')
maskBg.setAttribute('width', String(svgW))
maskBg.setAttribute('height', String(svgH))
maskBg.setAttribute('fill', 'black')
mask.appendChild(maskBg)
const outerPath = document.createElementNS(svgNs, 'path')
outerPath.setAttribute('d', pathD)
outerPath.setAttribute('fill', 'white')
outerPath.setAttribute('stroke', 'none')
mask.appendChild(outerPath)
const insetScale = sp.maskToMainOutlineBandScale
const insetPath = document.createElementNS(svgNs, 'path')
insetPath.setAttribute('d', pathD)
insetPath.setAttribute('fill', 'black')
insetPath.setAttribute('stroke', 'none')
const tx = (svgW * (1 - insetScale)) / 2
const ty = (svgH * (1 - insetScale)) / 2
insetPath.setAttribute('transform', `translate(${tx} ${ty}) scale(${insetScale})`)
mask.appendChild(insetPath)
defs.appendChild(mask)
extraPath.setAttribute('mask', `url(#${maskId})`)
} else if (sp.maskToMainOutline) {
const maskId = `shape-detail-mask-${++gradientIdCounter}`
const mask = document.createElementNS(svgNs, 'mask')
mask.setAttribute('id', maskId)
mask.setAttribute('maskUnits', 'userSpaceOnUse')
mask.setAttribute('maskContentUnits', 'userSpaceOnUse')
const maskBg = document.createElementNS(svgNs, 'rect')
maskBg.setAttribute('x', '0')
maskBg.setAttribute('y', '0')
maskBg.setAttribute('width', String(svgW))
maskBg.setAttribute('height', String(svgH))
maskBg.setAttribute('fill', 'black')
mask.appendChild(maskBg)
const maskPath = document.createElementNS(svgNs, 'path')
maskPath.setAttribute('d', pathD)
maskPath.setAttribute('fill', 'none')
maskPath.setAttribute('stroke', 'white')
const maskStrokeWidth = Math.max(
extraStrokeWidth *
(sp.maskStrokeScale && sp.maskStrokeScale > 0 ? sp.maskStrokeScale : 3),
extraStrokeWidth
)
maskPath.setAttribute('stroke-width', String(maskStrokeWidth))
maskPath.setAttribute('stroke-linecap', 'round')
maskPath.setAttribute('stroke-linejoin', 'round')
mask.appendChild(maskPath)
defs.appendChild(mask)
extraPath.setAttribute('mask', `url(#${maskId})`)
}
} else if (sp.stroke) {
// Detail lines without explicit line style: avoid using identical fill color,
// otherwise guide lines (e.g. chartX diagonals) become visually invisible.
const detailStroke = baseRgb ? mixRgb(baseRgb, { r: 0, g: 0, b: 0 }, 0.55) : '#666666'
extraPath.setAttribute('stroke', detailStroke)
extraPath.setAttribute('stroke-width', '1')
} else {
extraPath.setAttribute('stroke', 'none')
}
svg.appendChild(extraPath)
}
}
// Some multi-path detail rendering adds masks/gradients after the initial defs population.
if (defs.children.length > 0 && !defs.parentNode) {
svg.insertBefore(defs, svg.firstChild)
}
// circularArrow: ensure no stroke and remove markers
if (isCircularArrow) {
path.setAttribute('stroke', 'none')
path.removeAttribute('stroke-width')
path.removeAttribute('marker-start')
path.removeAttribute('marker-end')
}
// --- Action button icon overlay (legacy fallback) ---
// Only used for action buttons that don't have multiPathPresets entries.
// Shapes with multiPathPresets already include the icon in their darken sub-paths.
if (node.presetGeometry && !multiPaths) {
const iconD = getActionButtonIconPath(node.presetGeometry, pathW, pathH)
if (iconD) {
const iconPath = document.createElementNS(svgNs, 'path')
iconPath.setAttribute('d', iconD)
// PowerPoint uses a darkened shade (~50%) of the fill colour for action button icons.
let iconFill = '#333333'
if (fillCss && fillCss !== 'transparent' && fillCss !== 'none') {
const m = fillCss.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i)
if (m) {
const r = Number.parseInt(m[1], 16)
const g = Number.parseInt(m[2], 16)
const b = Number.parseInt(m[3], 16)
// Shade at 50%: darken each channel by half
iconFill = rgbToHex(Math.round(r * 0.5), Math.round(g * 0.5), Math.round(b * 0.5))
}
}
iconPath.setAttribute('fill', iconFill)
iconPath.setAttribute('stroke', 'none')
svg.appendChild(iconPath)
}
}
// (Can top ellipse overlay removed — now handled by multiPathPresets 'can' lighten sub-path)
wrapper.appendChild(svg)
}
} else if (fillCss && fillCss !== 'transparent') {
// No geometry but has fill — apply as background color
if (fillCss.includes('gradient')) {
wrapper.style.background = fillCss
} else {
wrapper.style.backgroundColor = fillCss
}
}
// ---- Render text overlay (only when there is visible text; skip for decorative shapes with empty txBody) ----
if (node.textBody && node.textBody.paragraphs.length > 0 && hasVisibleText(node.textBody)) {
const textContainer = document.createElement('div')
textContainer.style.position = 'absolute'
if (node.textBoxBounds) {
textContainer.style.left = `${node.textBoxBounds.x}px`
textContainer.style.top = `${node.textBoxBounds.y}px`
textContainer.style.width = `${node.textBoxBounds.w}px`
textContainer.style.height = `${node.textBoxBounds.h}px`
} else {
textContainer.style.left = '0'
textContainer.style.top = '0'
textContainer.style.width = '100%'
textContainer.style.height = '100%'
}
textContainer.style.display = 'flex'
textContainer.style.flexDirection = 'column'
textContainer.style.boxSizing = 'border-box'
// Overflow handling based on bodyPr auto-fit mode:
// - spAutoFit: shape resizes to fit text → overflow visible
// - normAutofit: text shrinks to fit shape → apply fontScale, overflow hidden
// - noAutofit: text clips → overflow hidden
// - (default, no child): PowerPoint implicitly auto-shrinks → overflow visible
const bodyPrForFit = node.textBody?.bodyProperties
const hasSpAutoFit = bodyPrForFit?.child('spAutoFit').exists()
const normAutofit = bodyPrForFit?.child('normAutofit')
const hasNormAutofit = normAutofit?.exists()
textContainer.style.overflowX = 'visible'
// noAutofit means "don't auto-fit" — NOT "clip text". PowerPoint allows text to
// overflow the shape boundary visibly.
textContainer.style.overflowY = 'visible'
// normAutofit: PowerPoint stores the computed fontScale (1000ths of percent).
// Apply it as a CSS transform to shrink text so it fits the shape.
let needsDynamicAutofit = false
if (hasNormAutofit && normAutofit) {
textContainer.style.overflowY = 'hidden'
const fontScale = normAutofit.numAttr('fontScale')
const lnSpcReduction = normAutofit.numAttr('lnSpcReduction') ?? 0
if (fontScale != null && fontScale < 100000) {
const scale = fontScale / 100000
textContainer.style.transformOrigin = 'top left'
textContainer.style.transform = `scale(${scale})`
// Expand container dimensions so the scaled content fills the original space
textContainer.style.width = `${100 / scale}%`
textContainer.style.height = `${100 / scale}%`
} else if (fontScale == null) {
// fontScale not stored in XML — PowerPoint computes it at runtime.
// We'll measure after DOM insertion and apply dynamic scaling.
needsDynamicAutofit = true
}
if (lnSpcReduction > 0) {
const lnFactor = 1 - lnSpcReduction / 100000
textContainer.style.lineHeight = `${lnFactor}`
}
}
// spAutoFit requests in-shape text fitting. In browser rendering we cannot
// resize the absolutely positioned shape like PowerPoint editor behavior,
// so use bounded dynamic scaling to prevent bleed across neighboring nodes.
if (hasSpAutoFit && !hasNormAutofit) {
textContainer.style.overflowY = 'hidden'
needsDynamicAutofit = true
}
// Apply bodyPr (text body properties)
// Use layout/master bodyPr as fallback for missing attributes
{
const bodyPr = node.textBody.bodyProperties
const fallbackBp = node.textBody.layoutBodyProperties
if (bodyPr) {
// Text wrap: only wrap="none" should force single-line.
// Title placeholders without explicit wrap should still be allowed to wrap.
const wrap = bodyPr.attr('wrap') || (fallbackBp ? fallbackBp.attr('wrap') : null)
if (wrap === 'none') {
textContainer.style.whiteSpace = 'nowrap'
}
}
// Vertical alignment (anchor): prefer shape's own, then layout placeholder
const anchor =
(bodyPr ? bodyPr.attr('anchor') : null) || (fallbackBp ? fallbackBp.attr('anchor') : null)
if (anchor === 't') {
textContainer.style.justifyContent = 'flex-start'
} else if (anchor === 'ctr') {
textContainer.style.justifyContent = 'center'
} else if (anchor === 'b') {
textContainer.style.justifyContent = 'flex-end'
}
// Internal margins (insets): prefer shape's own, then layout, then OOXML defaults
const lIns =
(bodyPr ? bodyPr.numAttr('lIns') : undefined) ??
(fallbackBp ? fallbackBp.numAttr('lIns') : undefined)
const tIns =
(bodyPr ? bodyPr.numAttr('tIns') : undefined) ??
(fallbackBp ? fallbackBp.numAttr('tIns') : undefined)
const rIns =
(bodyPr ? bodyPr.numAttr('rIns') : undefined) ??
(fallbackBp ? fallbackBp.numAttr('rIns') : undefined)
const bIns =
(bodyPr ? bodyPr.numAttr('bIns') : undefined) ??
(fallbackBp ? fallbackBp.numAttr('bIns') : undefined)
// Default insets are 91440 EMU (0.1 inch) for L/R, 45720 EMU (0.05 inch) for T/B
const leftPad = lIns !== undefined ? emuToPx(lIns) : emuToPx(91440)
const topPad = tIns !== undefined ? emuToPx(tIns) : emuToPx(45720)
const rightPad = rIns !== undefined ? emuToPx(rIns) : emuToPx(91440)
const bottomPad = bIns !== undefined ? emuToPx(bIns) : emuToPx(45720)
textContainer.style.paddingLeft = `${leftPad}px`
textContainer.style.paddingTop = `${topPad}px`
textContainer.style.paddingRight = `${rightPad}px`
textContainer.style.paddingBottom = `${bottomPad}px`
// Vertical text support (bodyPr@vert)
const vert =
(bodyPr ? bodyPr.attr('vert') : null) || (fallbackBp ? fallbackBp.attr('vert') : null)
if (vert === 'eaVert') {
textContainer.style.writingMode = 'vertical-rl'
} else if (vert === 'vert' || vert === 'wordArtVert') {
textContainer.style.writingMode = 'vertical-rl'
} else if (vert === 'vert270') {
textContainer.style.writingMode = 'vertical-rl'
textContainer.style.transform = `${textContainer.style.transform || ''} rotate(180deg)`
}
}
// Diagram text can carry its own txXfrm rotation; apply it inside the shape wrapper.
if (node.textBoxBounds?.rotation && node.textBoxBounds.rotation !== 0) {
const existing = textContainer.style.transform || ''
textContainer.style.transform = `${existing} rotate(${node.textBoxBounds.rotation}deg)`.trim()
textContainer.style.transformOrigin = 'center center'
}
// If text was flipped, un-flip the text so it reads correctly
// Append to existing transforms (don't overwrite vert270 rotation)
if (node.flipH || node.flipV) {
const existing = textContainer.style.transform || ''
const flipParts: string[] = []
if (node.flipH) flipParts.push('scaleX(-1)')
if (node.flipV) flipParts.push('scaleY(-1)')
textContainer.style.transform = `${existing} ${flipParts.join(' ')}`.trim()
}
// Resolve fontRef color from shape style element (used by SmartArt diagram shapes
// where text color is specified via dsp:style > a:fontRef > a:schemeClr).
let fontRefColor: string | undefined
const shapeStyle = node.source.child('style')
if (shapeStyle.exists()) {
const fontRef = shapeStyle.child('fontRef')
if (fontRef.exists() && fontRef.allChildren().length > 0) {
fontRefColor = resolveColorToCss(fontRef, ctx)
}
}
renderTextBody(
node.textBody,
node.placeholder,
ctx,
textContainer,
fontRefColor ? { fontRefColor } : undefined
)
wrapper.appendChild(textContainer)
// Dynamic normAutofit: when fontScale is not stored in the XML, measure the
// rendered text and compute the needed scale so all text fits the container.
if (needsDynamicAutofit) {
// The wrapper isn't in the DOM yet, so temporarily attach it offscreen to measure.
wrapper.style.visibility = 'hidden'
document.body.appendChild(wrapper)
// Temporarily neutralise vertical alignment so content overflows downward
// (flex-end would push content upward, making scrollHeight == clientHeight).
const savedJC = textContainer.style.justifyContent
textContainer.style.justifyContent = 'flex-start'
const containerH = textContainer.clientHeight
const contentH = textContainer.scrollHeight
textContainer.style.justifyContent = savedJC
document.body.removeChild(wrapper)
wrapper.style.visibility = ''
if (contentH > containerH && containerH > 0) {
const scale = containerH / contentH
textContainer.style.transformOrigin = 'top left'
textContainer.style.transform = `scale(${scale})`
textContainer.style.width = `${100 / scale}%`
textContainer.style.height = `${100 / scale}%`
}
}
}
// ---- Effects (explicit effectLst or theme effectRef fallback) ----
let effectiveEffectLst = spPr.child('effectLst')
if (!effectiveEffectLst.exists()) {
const effectRef = node.source.child('style').child('effectRef')
const idx = effectRef.numAttr('idx') ?? 0
if (idx > 0 && (ctx.theme.effectStyles?.length ?? 0) >= idx) {
const themeEffect = ctx.theme.effectStyles[idx - 1]
if (themeEffect.exists()) {
const lst = themeEffect.child('effectLst')
if (lst.exists()) effectiveEffectLst = lst
}
}
}
if (effectiveEffectLst.exists()) {
const outerShdw = effectiveEffectLst.child('outerShdw')
if (outerShdw.exists()) {
const dir = outerShdw.numAttr('dir') ?? 0 // direction in 60000ths of degree
const dist = outerShdw.numAttr('dist') ?? 0 // distance in EMU
const blurRad = outerShdw.numAttr('blurRad') ?? 0 // blur radius in EMU
const sx = outerShdw.numAttr('sx') // horizontal scale (100000 = 100%)
const sy = outerShdw.numAttr('sy') // vertical scale (100000 = 100%)
const algn = outerShdw.attr('algn') // alignment anchor (t, b, tl, tr, etc.)
const dirDeg = dir / 60000
const distPx = emuToPx(dist)
const blurPx = emuToPx(blurRad)
const offsetX = distPx * Math.cos((dirDeg * Math.PI) / 180)
const offsetY = distPx * Math.sin((dirDeg * Math.PI) / 180)
// Resolve shadow color
let shadowColor = 'rgba(0,0,0,0.4)'
const { color: shdColor, alpha: shdAlpha } = resolveColor(outerShdw, ctx)
if (shdColor) {
const hex = shdColor.startsWith('#') ? shdColor : `#${shdColor}`
const { r: sr, g: sg, b: sb } = hexToRgb(hex)
shadowColor = `rgba(${sr},${sg},${sb},${shdAlpha.toFixed(3)})`
}
// PowerPoint outerShdw with sx/sy creates a scaled shadow copy, then draws the
// shape on top. When dist=0 and scale ≈ 100%, only the thin edge overhang is
// visible far subtler than a CSS drop-shadow with the full blur radius.
// Approximate with box-shadow using spread derived from scale and reduced blur.
if (sx != null && sy != null && sx > 0 && sy > 0) {
const scaleX = sx / 100000
const scaleY = sy / 100000
const shapeW = node.size?.w ?? 100
const shapeH = node.size?.h ?? 100
// For line-like shapes, sx/sy should scale line thickness, not full line length.
// Using shape width here can explode spread on long connectors (slide 68 regression).
let spreadBasisW = shapeW
let spreadBasisH = shapeH
if (isLineLike || shapeW <= 1 || shapeH <= 1) {
const lineWEmu = node.line?.numAttr('w') ?? 12700
const lineThickness = Math.max(1, emuToPx(lineWEmu))
spreadBasisW = lineThickness
spreadBasisH = lineThickness
}
// Spread = how far the shadow extends beyond the shape on each side
const spreadX = (spreadBasisW * (scaleX - 1)) / 2
const spreadY = (spreadBasisH * (scaleY - 1)) / 2
const spread = Math.max(0, (spreadX + spreadY) / 2)
// Alignment shifts the shadow anchor point; compute extra offset
let alignOffX = 0
let alignOffY = 0
if (algn) {
// OOXML algn is an enum (t, b, l, r, tl, tr, bl, br, ctr), not a substring bag.
// Exact matching avoids misinterpreting "ctr" as containing both "t" and "r".
const a = algn.toLowerCase()
if (a === 't' || a === 'tl' || a === 'tr') alignOffY = (spreadBasisH * (scaleY - 1)) / 2
if (a === 'b' || a === 'bl' || a === 'br') alignOffY = (-spreadBasisH * (scaleY - 1)) / 2
if (a === 'l' || a === 'tl' || a === 'bl') alignOffX = (spreadBasisW * (scaleX - 1)) / 2
if (a === 'r' || a === 'tr' || a === 'br') alignOffX = (-spreadBasisW * (scaleX - 1)) / 2
}
// When spread is tiny relative to blurPx, PowerPoint's Gaussian blur
// distributes energy across the full blur area. The visible edge (only
// `spread` wide) receives only a fraction of the original alpha.
// Attenuate alpha accordingly so thin-edge shadows are nearly invisible.
const effectiveBlur = Math.min(blurPx, spread * 3)
let effectiveAlpha = shdAlpha
if (blurPx > 0 && spread < blurPx) {
effectiveAlpha = shdAlpha * (spread / blurPx)
}
// Skip shadow entirely if effective alpha is negligible
if (effectiveAlpha >= 0.01) {
const bsX = (offsetX + alignOffX).toFixed(1)
const bsY = (offsetY + alignOffY).toFixed(1)
// Recompute shadow color with attenuated alpha
let attenuatedColor = shadowColor
if (shdColor) {
const hex2 = shdColor.startsWith('#') ? shdColor : `#${shdColor}`
const { r: sr2, g: sg2, b: sb2 } = hexToRgb(hex2)
attenuatedColor = `rgba(${sr2},${sg2},${sb2},${effectiveAlpha.toFixed(4)})`
}
wrapper.style.boxShadow = `${bsX}px ${bsY}px ${effectiveBlur.toFixed(1)}px ${spread.toFixed(1)}px ${attenuatedColor}`
}
} else {
wrapper.style.filter = `drop-shadow(${offsetX.toFixed(1)}px ${offsetY.toFixed(1)}px ${blurPx.toFixed(1)}px ${shadowColor})`
}
}
// Reflection is not directly representable in standard CSS across browsers.
// Approximate via -webkit-box-reflect when available (Chromium/WebKit).
const reflection = effectiveEffectLst.child('reflection')
if (reflection.exists()) {
const dist = emuToPx(reflection.numAttr('dist') ?? 0)
const stA = (reflection.numAttr('stA') ?? 50000) / 100000
const endA = (reflection.numAttr('endA') ?? 0) / 100000
const stPos = Math.max(0, Math.min(100, (reflection.numAttr('stPos') ?? 0) / 1000))
const endPos = Math.max(0, Math.min(100, (reflection.numAttr('endPos') ?? 100000) / 1000))
const mask = `linear-gradient(to bottom, rgba(255,255,255,${stA.toFixed(3)}) ${stPos.toFixed(1)}%, rgba(255,255,255,${endA.toFixed(3)}) ${endPos.toFixed(1)}%)`
const reflectValue = `below ${dist.toFixed(1)}px ${mask}`
wrapper.style.setProperty('-webkit-box-reflect', reflectValue)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(wrapper.style as any).webkitBoxReflect = reflectValue
}
}
// ---- Shape-level hyperlink / action button navigation ----
if (node.hlinkClick && ctx.onNavigate) {
const { action, rId } = node.hlinkClick
if (action === 'ppaction://hlinksldjump' && rId) {
// Resolve slide target from relationship
const rel = ctx.slide.rels.get(rId)
if (rel) {
// Target is like "slide28.xml" → slide index 27 (0-based)
const match = rel.target.match(/slide(\d+)\.xml/)
if (match) {
const slideIndex = Number.parseInt(match[1], 10) - 1
wrapper.style.cursor = 'pointer'
wrapper.title = node.hlinkClick.tooltip || `Go to slide ${slideIndex + 1}`
wrapper.addEventListener('click', (e) => {
e.stopPropagation()
ctx.onNavigate!({ slideIndex })
})
}
}
} else if (rId) {
// External URL link
const rel = ctx.slide.rels.get(rId)
if (rel && rel.targetMode === 'External' && isAllowedExternalUrl(rel.target)) {
wrapper.style.cursor = 'pointer'
wrapper.title = node.hlinkClick.tooltip || rel.target
wrapper.addEventListener('click', (e) => {
e.stopPropagation()
ctx.onNavigate!({ url: rel.target })
})
}
}
}
return wrapper
}