Files
simstudioai--sim/apps/sim/lib/pptx-renderer/renderer/slide-renderer.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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()
},
}
}