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
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
/**
|
|
* Slide renderer — orchestrates rendering of a complete slide with all its nodes.
|
|
*/
|
|
|
|
import type { ECharts } from 'echarts'
|
|
import type { BaseNodeData } from '../model/nodes/base-node'
|
|
import type { ChartNodeData } from '../model/nodes/chart-node'
|
|
import { type GroupNodeData, parseGroupNode } from '../model/nodes/group-node'
|
|
import { type PicNodeData, parsePicNode } from '../model/nodes/pic-node'
|
|
import { parseShapeNode, type ShapeNodeData } from '../model/nodes/shape-node'
|
|
import { parseTableNode, type TableNodeData } from '../model/nodes/table-node'
|
|
import type { PresentationData } from '../model/presentation'
|
|
import type { SlideData } from '../model/slide'
|
|
import type { SafeXmlNode } from '../parser/xml-parser'
|
|
import { renderBackground } from './background-renderer'
|
|
import { renderChart } from './chart-renderer'
|
|
import { renderGroup } from './group-renderer'
|
|
import { renderImage } from './image-renderer'
|
|
import { createRenderContext, type RenderContext } from './render-context'
|
|
import { renderShape } from './shape-renderer'
|
|
import { renderTable } from './table-renderer'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SlideRendererOptions {
|
|
/** Called when a single node fails to render. */
|
|
onNodeError?: (nodeId: string, error: unknown) => void
|
|
/**
|
|
* Navigation callback for shape-level hyperlink actions (action buttons, etc.).
|
|
* Called with target slide index (0-based) for slide jumps,
|
|
* or with a URL string for external links.
|
|
*/
|
|
onNavigate?: (target: { slideIndex?: number; url?: string }) => void
|
|
/** Shared media URL cache for blob URL reuse across slides. */
|
|
mediaUrlCache?: Map<string, string>
|
|
/** Shared set of live ECharts instances for explicit disposal. */
|
|
chartInstances?: Set<ECharts>
|
|
}
|
|
|
|
/**
|
|
* Per-slide resource handle returned by `renderSlide()`.
|
|
* Allows the caller to dispose of slide-specific resources (chart instances,
|
|
* blob URLs in standalone mode) without tearing down the whole viewer.
|
|
*/
|
|
export interface SlideHandle {
|
|
/** The rendered slide DOM element. */
|
|
readonly element: HTMLElement
|
|
/** Dispose slide-specific resources (charts inside this slide, blob URLs if standalone). */
|
|
dispose(): void
|
|
/** Support `using` declarations (TC39 Explicit Resource Management). */
|
|
[Symbol.dispose](): void
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node Dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Dispatch a typed node to its appropriate renderer.
|
|
* This function is also passed into GroupRenderer for recursive child rendering.
|
|
*/
|
|
function renderNode(node: BaseNodeData, ctx: RenderContext): HTMLElement {
|
|
switch (node.nodeType) {
|
|
case 'shape':
|
|
return renderShape(node as ShapeNodeData, ctx)
|
|
case 'picture':
|
|
return renderImage(node as PicNodeData, ctx)
|
|
case 'table':
|
|
return renderTable(node as TableNodeData, ctx)
|
|
case 'group':
|
|
return renderGroup(node as GroupNodeData, ctx, renderNode)
|
|
case 'chart':
|
|
return renderChart(node as ChartNodeData, ctx)
|
|
default: {
|
|
// Unknown node type — render as empty positioned div
|
|
const el = document.createElement('div')
|
|
el.style.position = 'absolute'
|
|
el.style.left = `${node.position.x}px`
|
|
el.style.top = `${node.position.y}px`
|
|
el.style.width = `${node.size.w}px`
|
|
el.style.height = `${node.size.h}px`
|
|
return el
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error Placeholder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a visual error placeholder at the node's position.
|
|
*/
|
|
function createErrorPlaceholder(node: BaseNodeData): HTMLElement {
|
|
const el = document.createElement('div')
|
|
el.style.position = 'absolute'
|
|
el.style.left = `${node.position.x}px`
|
|
el.style.top = `${node.position.y}px`
|
|
el.style.width = `${node.size.w}px`
|
|
el.style.height = `${node.size.h}px`
|
|
el.style.border = '2px dashed #ff4444'
|
|
el.style.backgroundColor = 'rgba(255,68,68,0.08)'
|
|
el.style.display = 'flex'
|
|
el.style.alignItems = 'center'
|
|
el.style.justifyContent = 'center'
|
|
el.style.color = '#cc0000'
|
|
el.style.fontSize = '11px'
|
|
el.style.fontFamily = 'monospace'
|
|
el.style.overflow = 'hidden'
|
|
el.style.boxSizing = 'border-box'
|
|
el.style.padding = '4px'
|
|
el.textContent = `Render Error`
|
|
el.title = `Failed to render node: ${node.id} (${node.name})`
|
|
return el
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Master/Layout Shape Parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check whether a shape node is a placeholder (has p:ph in nvPr).
|
|
*/
|
|
function isPlaceholderNode(node: SafeXmlNode): boolean {
|
|
for (const wrapper of ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']) {
|
|
const nv = node.child(wrapper)
|
|
if (nv.exists()) {
|
|
const nvPr = nv.child('nvPr')
|
|
if (nvPr.child('ph').exists()) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Parse and collect renderable shapes from a master or layout spTree.
|
|
* Only includes NON-placeholder shapes (decorative elements, logos, footers).
|
|
* Placeholder shapes are never rendered from master/layout — they only serve
|
|
* as position/size inheritance templates.
|
|
*/
|
|
function parseTemplateShapes(spTree: SafeXmlNode, _slideNodes: BaseNodeData[]): BaseNodeData[] {
|
|
const nodes: BaseNodeData[] = []
|
|
if (!spTree || !spTree.exists || !spTree.exists()) return nodes
|
|
|
|
for (const child of spTree.allChildren()) {
|
|
const tag = child.localName
|
|
|
|
// Skip ALL placeholder shapes — they're templates, not renderable content
|
|
if (isPlaceholderNode(child)) continue
|
|
|
|
try {
|
|
let node: BaseNodeData | undefined
|
|
switch (tag) {
|
|
case 'sp':
|
|
case 'cxnSp':
|
|
node = parseShapeNode(child)
|
|
break
|
|
case 'pic':
|
|
node = parsePicNode(child)
|
|
break
|
|
case 'grpSp':
|
|
node = parseGroupNode(child)
|
|
break
|
|
case 'graphicFrame': {
|
|
const graphic = child.child('graphic')
|
|
const graphicData = graphic.child('graphicData')
|
|
if (graphicData.child('tbl').exists()) {
|
|
node = parseTableNode(child)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
// Skip empty/invisible nodes (0x0 size and no text)
|
|
if (node && (node.size.w > 0 || node.size.h > 0)) {
|
|
nodes.push(node)
|
|
}
|
|
} catch {
|
|
// Skip unparseable template shapes silently
|
|
}
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Slide Render Function
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Render a complete slide into an HTML element.
|
|
*
|
|
* Rendering order:
|
|
* 1. Background (slide → layout → master inheritance)
|
|
* 2. Master non-placeholder shapes (behind everything)
|
|
* 3. Layout non-placeholder shapes
|
|
* 4. Slide shapes (on top)
|
|
*/
|
|
export function renderSlide(
|
|
presentation: PresentationData,
|
|
slide: SlideData,
|
|
options?: SlideRendererOptions
|
|
): SlideHandle {
|
|
const isSharedCache = !!options?.mediaUrlCache
|
|
|
|
// Create render context (resolves slide -> layout -> master -> theme chain)
|
|
const ctx = createRenderContext(
|
|
presentation,
|
|
slide,
|
|
options?.mediaUrlCache,
|
|
options?.chartInstances
|
|
)
|
|
if (options?.onNavigate) {
|
|
ctx.onNavigate = options.onNavigate
|
|
}
|
|
|
|
// Create slide container
|
|
const container = document.createElement('div')
|
|
container.style.position = 'relative'
|
|
container.style.width = `${presentation.width}px`
|
|
container.style.height = `${presentation.height}px`
|
|
container.style.overflow = 'hidden'
|
|
container.style.backgroundColor = '#FFFFFF'
|
|
|
|
// Render background
|
|
try {
|
|
renderBackground(ctx, container)
|
|
} catch (e) {
|
|
options?.onNodeError?.('__background__', e)
|
|
}
|
|
|
|
// --- Render master template shapes (behind layout and slide) ---
|
|
// Respect showMasterSp flags:
|
|
// - layout.showMasterSp === false → skip master shapes
|
|
// - slide.showMasterSp === false → skip both master AND layout shapes
|
|
if (slide.showMasterSp && ctx.layout.showMasterSp) {
|
|
const masterCtx: RenderContext = {
|
|
...ctx,
|
|
slide: { ...ctx.slide, rels: ctx.master.rels },
|
|
}
|
|
const masterShapes = parseTemplateShapes(ctx.master.spTree, slide.nodes)
|
|
for (const node of masterShapes) {
|
|
try {
|
|
const el = renderNode(node, masterCtx)
|
|
container.appendChild(el)
|
|
} catch {
|
|
// Master shape errors are non-fatal
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Render layout template shapes ---
|
|
if (slide.showMasterSp) {
|
|
const layoutCtx: RenderContext = {
|
|
...ctx,
|
|
slide: { ...ctx.slide, rels: ctx.layout.rels },
|
|
}
|
|
const layoutShapes = parseTemplateShapes(ctx.layout.spTree, slide.nodes)
|
|
for (const node of layoutShapes) {
|
|
try {
|
|
const el = renderNode(node, layoutCtx)
|
|
container.appendChild(el)
|
|
} catch {
|
|
// Layout shape errors are non-fatal
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Render slide shapes (on top) ---
|
|
for (const node of slide.nodes) {
|
|
try {
|
|
const el = renderNode(node, ctx)
|
|
container.appendChild(el)
|
|
} catch (e) {
|
|
options?.onNodeError?.(node.id, e)
|
|
container.appendChild(createErrorPlaceholder(node))
|
|
}
|
|
}
|
|
|
|
// Build SlideHandle
|
|
let disposed = false
|
|
const chartInstances = options?.chartInstances
|
|
const mediaUrlCache = ctx.mediaUrlCache
|
|
|
|
const dispose = (): void => {
|
|
if (disposed) return
|
|
disposed = true
|
|
|
|
// Dispose chart instances whose DOM is inside this slide container
|
|
if (chartInstances) {
|
|
for (const chart of chartInstances) {
|
|
if (!chart.isDisposed() && container.contains(chart.getDom())) {
|
|
chart.dispose()
|
|
chartInstances.delete(chart)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Revoke blob URLs only in standalone mode (caller doesn't own a shared cache)
|
|
if (!isSharedCache) {
|
|
for (const url of mediaUrlCache.values()) {
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
mediaUrlCache.clear()
|
|
}
|
|
}
|
|
|
|
return {
|
|
element: container,
|
|
dispose,
|
|
[Symbol.dispose](): void {
|
|
dispose()
|
|
},
|
|
}
|
|
}
|