simstudioai--sim
d25d482dc2
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
1523 行
66 KiB
TypeScript
1523 行
66 KiB
TypeScript
/**
|
|
* 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
|
|
}
|