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
219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
/**
|
|
* Group renderer — renders grouped shapes with coordinate space remapping.
|
|
*/
|
|
|
|
import type { BaseNodeData } from '../model/nodes/base-node'
|
|
import type { GroupNodeData } from '../model/nodes/group-node'
|
|
import type { ShapeNodeData } from '../model/nodes/shape-node'
|
|
import type { RenderContext } from './render-context'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Group Rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Render a group node into an absolutely-positioned HTML element.
|
|
*
|
|
* Groups define a child coordinate space (childOffset + childExtent) that must
|
|
* be remapped to the group's actual position and size. Each child's position
|
|
* and size are transformed accordingly before rendering.
|
|
*
|
|
* @param node The parsed group node data
|
|
* @param ctx The render context
|
|
* @param renderNode A callback to render individual child nodes (avoids circular deps)
|
|
*/
|
|
export function renderGroup(
|
|
node: GroupNodeData,
|
|
ctx: RenderContext,
|
|
renderNode: (childNode: BaseNodeData, ctx: RenderContext) => HTMLElement
|
|
): 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`
|
|
wrapper.style.height = `${node.size.h}px`
|
|
|
|
// Apply rotation transform
|
|
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(' ')
|
|
wrapper.style.transformOrigin = 'center center'
|
|
}
|
|
|
|
const chOff = node.childOffset
|
|
const chExt = node.childExtent
|
|
const groupW = node.size.w
|
|
const groupH = node.size.h
|
|
|
|
// Resolve group fill from grpSpPr for children that use a:grpFill
|
|
const grpSpPr = node.source.child('grpSpPr')
|
|
const childCtx: RenderContext = { ...ctx }
|
|
if (grpSpPr.exists()) {
|
|
// Check if the group itself has a fill (solidFill, gradFill, etc.)
|
|
// that children can inherit via grpFill
|
|
const FILL_TAGS = ['solidFill', 'gradFill', 'blipFill', 'pattFill']
|
|
for (const tag of FILL_TAGS) {
|
|
if (grpSpPr.child(tag).exists()) {
|
|
childCtx.groupFillNode = grpSpPr
|
|
break
|
|
}
|
|
}
|
|
// If the group itself uses grpFill, propagate the parent's group fill
|
|
if (!childCtx.groupFillNode && grpSpPr.child('grpFill').exists() && ctx.groupFillNode) {
|
|
childCtx.groupFillNode = ctx.groupFillNode
|
|
}
|
|
}
|
|
|
|
// Cycle diagram: 3 pie sectors + 3 circular arrows → one circle (3 equal 120° sectors) centered in the diagram.
|
|
const parsedChildren = new Map<number, BaseNodeData | undefined>()
|
|
const parseByIndex = (index: number): BaseNodeData | undefined => {
|
|
if (!parsedChildren.has(index)) {
|
|
parsedChildren.set(index, parseGroupChild(node.children[index], ctx))
|
|
}
|
|
return parsedChildren.get(index)
|
|
}
|
|
|
|
let pieCommon: { x: number; y: number; w: number; h: number } | null = null
|
|
if (node.children.length === 6 && chExt.w > 0 && chExt.h > 0) {
|
|
const prst = (c: (typeof node.children)[0]) => c.child('spPr').child('prstGeom').attr('prst')
|
|
const firstPie = node.children.slice(0, 3).every((c) => prst(c) === 'pie')
|
|
const nextArrow = node.children.slice(3, 6).every((c) => prst(c) === 'circularArrow')
|
|
if (firstPie && nextArrow) {
|
|
// Use diagram extent center and a single circle size so the circle is centered and fits.
|
|
const pieNodes = [0, 1, 2].map((i) => parseByIndex(i)).filter(Boolean)
|
|
if (pieNodes.length === 3) {
|
|
const pieW = Math.max(...pieNodes.map((n) => n!.size.w))
|
|
const pieH = Math.max(...pieNodes.map((n) => n!.size.h))
|
|
const circleSize = Math.min(pieW, pieH, chExt.w, chExt.h)
|
|
const centerX = chOff.x + chExt.w / 2
|
|
const centerY = chOff.y + chExt.h / 2
|
|
const left = centerX - circleSize / 2
|
|
const top = centerY - circleSize / 2
|
|
pieCommon = {
|
|
x: ((left - chOff.x) / chExt.w) * groupW,
|
|
y: ((top - chOff.y) / chExt.h) * groupH,
|
|
w: (circleSize / chExt.w) * groupW,
|
|
h: (circleSize / chExt.h) * groupH,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cycle diagram: render arrows first (3,4,5) then pies (0,1,2) so blue sectors draw on top.
|
|
const order = pieCommon ? [3, 4, 5, 0, 1, 2] : undefined
|
|
const indices = order ?? node.children.map((_, i) => i)
|
|
|
|
for (const index of indices) {
|
|
try {
|
|
const childNode = parseByIndex(index)
|
|
if (!childNode) continue
|
|
|
|
// Remap child coordinates from child space to group space
|
|
if (chExt.w > 0 && chExt.h > 0) {
|
|
childNode.position = {
|
|
x: ((childNode.position.x - chOff.x) / chExt.w) * groupW,
|
|
y: ((childNode.position.y - chOff.y) / chExt.h) * groupH,
|
|
}
|
|
childNode.size = {
|
|
w: (childNode.size.w / chExt.w) * groupW,
|
|
h: (childNode.size.h / chExt.h) * groupH,
|
|
}
|
|
}
|
|
|
|
// Overlap the 3 pie sectors at the same center so they form one circle
|
|
if (pieCommon && index < 3 && childNode.nodeType === 'shape') {
|
|
const origW = childNode.size.w
|
|
const origH = childNode.size.h
|
|
childNode.position = { x: pieCommon.x, y: pieCommon.y }
|
|
childNode.size = { w: pieCommon.w, h: pieCommon.h }
|
|
// Scale text box so labels stay in the right sector (txXfrm was in original shape space)
|
|
const shapeNode = childNode as ShapeNodeData
|
|
if (origW > 0 && origH > 0 && shapeNode.textBoxBounds) {
|
|
const tb = shapeNode.textBoxBounds
|
|
shapeNode.textBoxBounds = {
|
|
x: (tb.x / origW) * pieCommon.w,
|
|
y: (tb.y / origH) * pieCommon.h,
|
|
w: (tb.w / origW) * pieCommon.w,
|
|
h: (tb.h / origH) * pieCommon.h,
|
|
}
|
|
}
|
|
}
|
|
|
|
const el = renderNode(childNode, childCtx)
|
|
wrapper.appendChild(el)
|
|
} catch {
|
|
// Per-child error handling — create error placeholder
|
|
const errDiv = document.createElement('div')
|
|
errDiv.style.position = 'absolute'
|
|
errDiv.style.border = '1px dashed #ff6b6b'
|
|
errDiv.style.backgroundColor = 'rgba(255,107,107,0.1)'
|
|
errDiv.style.fontSize = '10px'
|
|
errDiv.style.color = '#cc0000'
|
|
errDiv.style.display = 'flex'
|
|
errDiv.style.alignItems = 'center'
|
|
errDiv.style.justifyContent = 'center'
|
|
errDiv.style.padding = '2px'
|
|
errDiv.textContent = 'Group child error'
|
|
wrapper.appendChild(errDiv)
|
|
}
|
|
}
|
|
|
|
return wrapper
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Child Node Parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { parseChartNode } from '../model/nodes/chart-node'
|
|
import { parseGroupNode } from '../model/nodes/group-node'
|
|
import { parsePicNode } from '../model/nodes/pic-node'
|
|
// Import parsers for child dispatch
|
|
import { parseShapeNode } from '../model/nodes/shape-node'
|
|
import { parseTableNode } from '../model/nodes/table-node'
|
|
import { parseOleFrameAsPicture } from '../model/slide'
|
|
import type { SafeXmlNode } from '../parser/xml-parser'
|
|
|
|
/**
|
|
* Parse a raw XML child node from a group's spTree into a typed node object.
|
|
* Returns undefined for unrecognized or unsupported elements.
|
|
*/
|
|
function parseGroupChild(childXml: SafeXmlNode, ctx: RenderContext): BaseNodeData | undefined {
|
|
const tag = childXml.localName
|
|
|
|
switch (tag) {
|
|
case 'sp':
|
|
case 'cxnSp':
|
|
return parseShapeNode(childXml)
|
|
case 'pic':
|
|
return parsePicNode(childXml)
|
|
case 'grpSp':
|
|
return parseGroupNode(childXml)
|
|
case 'graphicFrame': {
|
|
const graphic = childXml.child('graphic')
|
|
const graphicData = graphic.child('graphicData')
|
|
if (graphicData.child('tbl').exists()) {
|
|
return parseTableNode(childXml)
|
|
}
|
|
if ((graphicData.attr('uri') || '').includes('chart')) {
|
|
return parseChartNode(childXml, ctx.slide.rels, ctx.slide.slidePath)
|
|
}
|
|
const olePic = parseOleFrameAsPicture(childXml)
|
|
if (olePic) return olePic
|
|
return undefined
|
|
}
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|