Files
simstudioai--sim/apps/sim/lib/pptx-renderer/shapes/custom-geometry.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

179 lines
5.5 KiB
TypeScript

/**
* Parse OOXML custom geometry (a:custGeom) into SVG path strings.
*/
import type { SafeXmlNode } from '../parser/xml-parser'
function inferPathExtent(pathNode: SafeXmlNode): { w: number; h: number } {
let maxX = 0
let maxY = 0
for (const cmd of pathNode.allChildren()) {
if (cmd.localName === 'moveTo' || cmd.localName === 'lnTo') {
const pt = cmd.child('pt')
maxX = Math.max(maxX, pt.numAttr('x') ?? 0)
maxY = Math.max(maxY, pt.numAttr('y') ?? 0)
continue
}
if (cmd.localName === 'cubicBezTo' || cmd.localName === 'quadBezTo') {
for (const pt of cmd.children('pt')) {
maxX = Math.max(maxX, pt.numAttr('x') ?? 0)
maxY = Math.max(maxY, pt.numAttr('y') ?? 0)
}
continue
}
if (cmd.localName === 'arcTo') {
maxX = Math.max(maxX, cmd.numAttr('wR') ?? 0)
maxY = Math.max(maxY, cmd.numAttr('hR') ?? 0)
}
}
return {
w: Math.max(1, maxX),
h: Math.max(1, maxY),
}
}
/**
* Render a custom geometry element to an SVG path d-attribute string.
*
* @param custGeom - SafeXmlNode wrapping the `a:custGeom` element
* @param width - Target width in pixels
* @param height - Target height in pixels
* @returns SVG path d-attribute string
*/
export function renderCustomGeometry(
custGeom: SafeXmlNode,
width: number,
height: number,
sourceExtent?: { w: number; h: number }
): string {
const pathLst = custGeom.child('pathLst')
if (!pathLst.exists()) return ''
const paths = pathLst.children('path')
const segments: string[] = []
for (const pathNode of paths) {
const fallbackExtent = inferPathExtent(pathNode)
const pathW = pathNode.numAttr('w') ?? sourceExtent?.w ?? fallbackExtent.w
const pathH = pathNode.numAttr('h') ?? sourceExtent?.h ?? fallbackExtent.h
const scaleX = pathW > 0 ? width / pathW : 1
const scaleY = pathH > 0 ? height / pathH : 1
// Track current position for arcTo calculations
let curX = 0
let curY = 0
const commands = pathNode.allChildren()
for (const cmd of commands) {
switch (cmd.localName) {
case 'moveTo': {
const pt = cmd.child('pt')
const x = (pt.numAttr('x') ?? 0) * scaleX
const y = (pt.numAttr('y') ?? 0) * scaleY
segments.push(`M${x},${y}`)
curX = x
curY = y
break
}
case 'lnTo': {
const pt = cmd.child('pt')
const x = (pt.numAttr('x') ?? 0) * scaleX
const y = (pt.numAttr('y') ?? 0) * scaleY
segments.push(`L${x},${y}`)
curX = x
curY = y
break
}
case 'cubicBezTo': {
const pts = cmd.children('pt')
if (pts.length >= 3) {
const x1 = (pts[0].numAttr('x') ?? 0) * scaleX
const y1 = (pts[0].numAttr('y') ?? 0) * scaleY
const x2 = (pts[1].numAttr('x') ?? 0) * scaleX
const y2 = (pts[1].numAttr('y') ?? 0) * scaleY
const x3 = (pts[2].numAttr('x') ?? 0) * scaleX
const y3 = (pts[2].numAttr('y') ?? 0) * scaleY
segments.push(`C${x1},${y1} ${x2},${y2} ${x3},${y3}`)
curX = x3
curY = y3
}
break
}
case 'quadBezTo': {
const pts = cmd.children('pt')
if (pts.length >= 2) {
const x1 = (pts[0].numAttr('x') ?? 0) * scaleX
const y1 = (pts[0].numAttr('y') ?? 0) * scaleY
const x2 = (pts[1].numAttr('x') ?? 0) * scaleX
const y2 = (pts[1].numAttr('y') ?? 0) * scaleY
segments.push(`Q${x1},${y1} ${x2},${y2}`)
curX = x2
curY = y2
}
break
}
case 'arcTo': {
const wRRaw = cmd.numAttr('wR') ?? 0
const hRRaw = cmd.numAttr('hR') ?? 0
const wR = wRRaw * scaleX
const hR = hRRaw * scaleY
const stAngRaw = cmd.numAttr('stAng') ?? 0
const swAngRaw = cmd.numAttr('swAng') ?? 0
// OOXML angles are in 60000ths of a degree
const stAng = stAngRaw / 60000
const swAng = swAngRaw / 60000
if (wR === 0 || hR === 0 || swAng === 0) {
// Degenerate arc, skip
break
}
// OOXML arcTo angles are visual (geometric ray) angles in path coordinate space.
// Convert to parametric using UNSCALED radii before computing positions.
const stVisRad = (stAng * Math.PI) / 180
const stAngRad = Math.atan2(wRRaw * Math.sin(stVisRad), hRRaw * Math.cos(stVisRad))
const endVisRad = ((stAng + swAng) * Math.PI) / 180
const endAngRad = Math.atan2(wRRaw * Math.sin(endVisRad), hRRaw * Math.cos(endVisRad))
// Compute center and endpoint in unscaled path space, then scale
const curXU = curX / scaleX
const curYU = curY / scaleY
const cx = curXU - wRRaw * Math.cos(stAngRad)
const cy = curYU - hRRaw * Math.sin(stAngRad)
const endX = (cx + wRRaw * Math.cos(endAngRad)) * scaleX
const endY = (cy + hRRaw * Math.sin(endAngRad)) * scaleY
// SVG arc flags
const largeArc = Math.abs(swAng) > 180 ? 1 : 0
const sweep = swAng > 0 ? 1 : 0
segments.push(`A${wR},${hR} 0 ${largeArc},${sweep} ${endX},${endY}`)
curX = endX
curY = endY
break
}
case 'close': {
segments.push('Z')
break
}
default:
// Unknown command, skip
break
}
}
}
return segments.join(' ')
}