Files
simstudioai--sim/apps/sim/lib/pptx-renderer/model/presentation.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

487 lines
16 KiB
TypeScript

/**
* Top-level presentation builder — assembles all parsed components
* (themes, masters, layouts, slides) into a single PresentationData structure.
*/
import { parseRels, type RelEntry, resolveRelTarget } from '../parser/rel-parser'
import { emuToPx } from '../parser/units'
import { parseXml, type SafeXmlNode } from '../parser/xml-parser'
import type { PptxFiles } from '../parser/zip-parser'
import { type LayoutData, type PlaceholderEntry, parseLayout } from './layout'
import { type MasterData, parseMaster } from './master'
import type { Position, Size } from './nodes/base-node'
import { parseSlide, type SlideData, type SlideNode } from './slide'
import { parseTheme, type ThemeData } from './theme'
export interface PresentationData {
width: number
height: number
slides: SlideData[]
layouts: Map<string, LayoutData>
masters: Map<string, MasterData>
themes: Map<string, ThemeData>
slideToLayout: Map<number, string>
layoutToMaster: Map<string, string>
masterToTheme: Map<string, string>
media: Map<string, Uint8Array>
tableStyles?: SafeXmlNode
charts: Map<string, SafeXmlNode>
isWps: boolean
}
/**
* Derive the base directory from a file path.
* E.g., "ppt/slides/slide1.xml" → "ppt/slides"
*/
function basePath(filePath: string): string {
const idx = filePath.lastIndexOf('/')
return idx >= 0 ? filePath.substring(0, idx) : ''
}
/**
* For a given XML file path, find its corresponding .rels file path.
* E.g., "ppt/slides/slide1.xml" → "ppt/slides/_rels/slide1.xml.rels"
*/
function relsPathFor(filePath: string): string {
const dir = basePath(filePath)
const fileName = filePath.substring(filePath.lastIndexOf('/') + 1)
return `${dir}/_rels/${fileName}.rels`
}
/**
* Detect WPS (Kingsoft Office / WPS Office) by checking for known markers
* in the presentation XML string.
*/
function detectWps(presentationXml: string): boolean {
return (
/\bKingsoft\b/i.test(presentationXml) ||
/\bWPS Office\b/i.test(presentationXml) ||
/xmlns:[\w.-]*kso[\w.-]*=/i.test(presentationXml)
)
}
/**
* Find a rels entry by type substring match.
*/
function findRelByType(rels: Map<string, RelEntry>, typeSubstring: string): RelEntry | undefined {
for (const [, entry] of rels) {
if (entry.type.includes(typeSubstring)) {
return entry
}
}
return undefined
}
/**
* Find ALL rels entries matching a type substring, returning [rId, entry] pairs.
*/
function findRelsByType(rels: Map<string, RelEntry>, typeSubstring: string): [string, RelEntry][] {
const results: [string, RelEntry][] = []
for (const [rId, entry] of rels) {
if (entry.type.includes(typeSubstring)) {
results.push([rId, entry])
}
}
return results
}
/**
* Build the complete PresentationData from extracted PPTX files.
*
* This is the main factory function that wires together all parsed components:
* 1. Parses presentation.xml for slide ordering and size
* 2. Resolves the full relationship chain: slide → layout → master → theme
* 3. Parses each component and assembles the final structure
*/
export function buildPresentation(files: PptxFiles): PresentationData {
// --- Parse presentation root ---
const presRoot = parseXml(files.presentation)
const presRels = parseRels(files.presentationRels)
// --- Slide size ---
const sldSz = presRoot.child('sldSz')
const width = emuToPx(sldSz.numAttr('cx') ?? 9144000) // default 10 inches
const height = emuToPx(sldSz.numAttr('cy') ?? 6858000) // default 7.5 inches
// --- WPS detection ---
const isWps = detectWps(files.presentation)
// --- Parse themes ---
const themes = new Map<string, ThemeData>()
for (const [themePath, themeXml] of files.themes) {
const themeRoot = parseXml(themeXml)
themes.set(themePath, parseTheme(themeRoot))
}
// --- Parse slide masters and build master→theme mapping ---
const masters = new Map<string, MasterData>()
const masterToTheme = new Map<string, string>()
for (const [masterPath, masterXml] of files.slideMasters) {
const masterRoot = parseXml(masterXml)
const masterData = parseMaster(masterRoot)
// Find theme relationship for this master
const masterRelsPath = relsPathFor(masterPath)
const masterRelsXml = files.slideMasterRels.get(masterRelsPath)
if (masterRelsXml) {
const masterRels = parseRels(masterRelsXml)
masterData.rels = masterRels
const themeRel = findRelByType(masterRels, 'theme')
if (themeRel) {
const themePath = resolveRelTarget(basePath(masterPath), themeRel.target)
masterToTheme.set(masterPath, themePath)
}
}
masters.set(masterPath, masterData)
}
// --- Parse slide layouts and build layout→master mapping ---
const layouts = new Map<string, LayoutData>()
const layoutToMaster = new Map<string, string>()
for (const [layoutPath, layoutXml] of files.slideLayouts) {
const layoutRoot = parseXml(layoutXml)
const layoutData = parseLayout(layoutRoot)
// Find master relationship for this layout
const layoutRelsPath = relsPathFor(layoutPath)
const layoutRelsXml = files.slideLayoutRels.get(layoutRelsPath)
if (layoutRelsXml) {
const layoutRels = parseRels(layoutRelsXml)
layoutData.rels = layoutRels
const masterRel = findRelByType(layoutRels, 'slideMaster')
if (masterRel) {
const masterPath = resolveRelTarget(basePath(layoutPath), masterRel.target)
layoutToMaster.set(layoutPath, masterPath)
}
}
layouts.set(layoutPath, layoutData)
}
// --- Parse charts ---
const charts = new Map<string, SafeXmlNode>()
for (const [chartPath, chartXml] of files.charts) {
const chartRoot = parseXml(chartXml)
if (chartRoot.exists()) {
charts.set(chartPath, chartRoot)
}
}
// --- Determine slide ordering ---
// The sldIdLst contains sldId elements with r:id attributes that reference
// presentation.xml.rels. We need to handle the fact that the attr might be
// stored as 'r:id' in the original XML but SafeXmlNode.attr() uses localName.
const sldIdLst = presRoot.child('sldIdLst')
const orderedSlideTargets: string[] = []
for (const sldId of sldIdLst.children('sldId')) {
// Try multiple attribute name patterns
const rId = sldId.attr('r:id') ?? sldId.attr('id')
if (rId) {
const relEntry = presRels.get(rId)
if (relEntry) {
const slidePath = resolveRelTarget('ppt', relEntry.target)
orderedSlideTargets.push(slidePath)
}
}
}
// Fallback: if sldIdLst parsing didn't yield results, use presRels directly
if (orderedSlideTargets.length === 0) {
const slideRels = findRelsByType(presRels, 'slide')
// Sort by rId number to maintain order
slideRels.sort((a, b) => {
const numA = Number.parseInt(a[0].replace(/\D/g, ''), 10) || 0
const numB = Number.parseInt(b[0].replace(/\D/g, ''), 10) || 0
return numA - numB
})
for (const [, entry] of slideRels) {
// Only include direct slide relationships, not slideLayout or slideMaster
if (
entry.type.includes('/slide') &&
!entry.type.includes('slideLayout') &&
!entry.type.includes('slideMaster')
) {
const slidePath = resolveRelTarget('ppt', entry.target)
orderedSlideTargets.push(slidePath)
}
}
}
// --- Parse slides ---
const slides: SlideData[] = []
const slideToLayout = new Map<number, string>()
for (let i = 0; i < orderedSlideTargets.length; i++) {
const slidePath = orderedSlideTargets[i]
const slideXml = files.slides.get(slidePath)
if (!slideXml) continue
// Parse slide rels
const slideRelsPath = relsPathFor(slidePath)
const slideRelsXml = files.slideRels.get(slideRelsPath)
const slideRels = slideRelsXml ? parseRels(slideRelsXml) : new Map<string, RelEntry>()
// Parse slide
const slideRoot = parseXml(slideXml)
const slideData = parseSlide(slideRoot, i, slideRels, slidePath, files.diagramDrawings)
// Resolve layout path from the slide's layout relationship target
if (slideData.layoutIndex) {
const layoutPath = resolveRelTarget(basePath(slidePath), slideData.layoutIndex)
slideData.layoutIndex = layoutPath
slideToLayout.set(i, layoutPath)
}
slides.push(slideData)
}
// --- Table styles ---
let tableStyles: SafeXmlNode | undefined
if (files.tableStyles) {
const tsRoot = parseXml(files.tableStyles)
if (tsRoot.exists()) {
tableStyles = tsRoot
}
}
const result: PresentationData = {
width,
height,
slides,
layouts,
masters,
themes,
slideToLayout,
layoutToMaster,
masterToTheme,
media: files.media,
tableStyles,
charts,
isWps,
}
// --- Resolve placeholder position inheritance ---
resolvePlaceholderInheritance(result)
return result
}
// ---------------------------------------------------------------------------
// Placeholder Position Inheritance
// ---------------------------------------------------------------------------
/**
* Extract placeholder info (type, idx) from a raw placeholder XML node
* stored in layout/master.
*/
function getPhInfo(phNode: SafeXmlNode): { type?: string; idx?: number } {
// Try nvSpPr > nvPr > ph, or nvPicPr > nvPr > ph
for (const wrapper of ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']) {
const nvWrapper = phNode.child(wrapper)
if (nvWrapper.exists()) {
const nvPr = nvWrapper.child('nvPr')
const ph = nvPr.child('ph')
if (ph.exists()) {
const type = ph.attr('type')
const idxStr = ph.attr('idx')
const idx = idxStr !== undefined ? Number(idxStr) : undefined
return { type, idx: idx !== undefined && !Number.isNaN(idx) ? idx : undefined }
}
}
}
return {}
}
/**
* Extract xfrm position/size from a raw placeholder XML node.
*/
function getPhXfrm(phNode: SafeXmlNode): { position: Position; size: Size } | undefined {
// Try spPr > xfrm first (most shapes)
const spPr = phNode.child('spPr')
if (spPr.exists()) {
const xfrm = spPr.child('xfrm')
if (xfrm.exists()) {
const off = xfrm.child('off')
const ext = xfrm.child('ext')
const x = off.numAttr('x')
const cx = ext.numAttr('cx')
if (x !== undefined && cx !== undefined) {
return {
position: { x: emuToPx(off.numAttr('x') ?? 0), y: emuToPx(off.numAttr('y') ?? 0) },
size: { w: emuToPx(ext.numAttr('cx') ?? 0), h: emuToPx(ext.numAttr('cy') ?? 0) },
}
}
}
}
return undefined
}
/**
* Find a matching placeholder node by type and idx.
* Matching rules (based on OOXML spec):
* 1. Exact match on type AND idx
* 2. Match on type only (if idx is undefined or not found)
* 3. For "body" type, also check idx match only
*/
function findMatchingPlaceholder(
placeholders: SafeXmlNode[],
type?: string,
idx?: number
): SafeXmlNode | undefined {
let typeMatch: SafeXmlNode | undefined
for (const ph of placeholders) {
const info = getPhInfo(ph)
// Exact match (type + idx)
if (type !== undefined && info.type === type && idx !== undefined && info.idx === idx) {
return ph
}
// Type-only match
if (type !== undefined && info.type === type) {
if (!typeMatch) typeMatch = ph
}
// idx-only match (for body/content placeholders that may omit type)
if (idx !== undefined && info.idx === idx && type === undefined && info.type === undefined) {
return ph
}
}
// For placeholders without type (defaults to "body"), match by idx
if (type === undefined && idx !== undefined) {
for (const ph of placeholders) {
const info = getPhInfo(ph)
if (info.idx === idx) return ph
}
}
return typeMatch
}
/**
* Find a matching layout placeholder (PlaceholderEntry); use entry.absoluteXfrm when present.
*/
function findMatchingLayoutPlaceholder(
placeholders: PlaceholderEntry[],
type?: string,
idx?: number
): PlaceholderEntry | undefined {
let typeMatch: PlaceholderEntry | undefined
for (const entry of placeholders) {
const info = getPhInfo(entry.node)
if (type !== undefined && info.type === type && idx !== undefined && info.idx === idx) {
return entry
}
if (type !== undefined && info.type === type && !typeMatch) {
typeMatch = entry
}
if (idx !== undefined && info.idx === idx && type === undefined && info.type === undefined) {
return entry
}
}
if (type === undefined && idx !== undefined) {
for (const entry of placeholders) {
if (getPhInfo(entry.node).idx === idx) return entry
}
}
return typeMatch
}
/**
* Walk through all slide nodes (including group children recursively)
* and fill in missing position/size from layout/master placeholders.
*/
function resolvePlaceholderInheritance(pres: PresentationData): void {
for (let i = 0; i < pres.slides.length; i++) {
const slide = pres.slides[i]
const layoutPath = pres.slideToLayout.get(i)
const layout = layoutPath ? pres.layouts.get(layoutPath) : undefined
const masterPath = layoutPath ? pres.layoutToMaster.get(layoutPath) : undefined
const master = masterPath ? pres.masters.get(masterPath) : undefined
resolveNodesPlaceholders(slide.nodes, layout, master)
}
}
/** Extract bodyPr from a placeholder shape node (layout or master). */
function getPhBodyPr(phNode: SafeXmlNode): SafeXmlNode | undefined {
const txBody = phNode.child('txBody')
if (!txBody.exists()) return undefined
const bodyPr = txBody.child('bodyPr')
return bodyPr.exists() ? bodyPr : undefined
}
function resolveNodesPlaceholders(
nodes: SlideNode[],
layout: LayoutData | undefined,
master: MasterData | undefined
): void {
for (const node of nodes) {
// Recursively handle group children
if (node.nodeType === 'group' && 'children' in node) {
// Group children are raw SafeXmlNode, not parsed yet — skip
// (they get parsed during rendering in GroupRenderer)
}
if (!node.placeholder) continue
const { type, idx } = node.placeholder
const sizeIsEmpty = node.size.w === 0 && node.size.h === 0
const positionLooksDefault = node.position.y < 5 // y=0 or near top → use layout position
if (layout) {
const layoutMatch = findMatchingLayoutPlaceholder(layout.placeholders, type, idx)
if (layoutMatch) {
const xfrm = layoutMatch.absoluteXfrm ?? getPhXfrm(layoutMatch.node)
if (xfrm) {
if (sizeIsEmpty) {
node.position = xfrm.position
node.size = xfrm.size
} else if (positionLooksDefault) {
node.position = xfrm.position
}
}
// Inherit bodyPr from layout placeholder for text rendering (anchor, insets, etc.)
if ('textBody' in node && node.textBody) {
const layoutBodyPr = getPhBodyPr(layoutMatch.node)
if (layoutBodyPr) {
node.textBody.layoutBodyProperties = layoutBodyPr
}
}
if (xfrm) continue
}
}
if (master) {
const match = findMatchingPlaceholder(master.placeholders, type, idx)
if (match) {
const xfrm = getPhXfrm(match)
if (xfrm) {
if (sizeIsEmpty) {
node.position = xfrm.position
node.size = xfrm.size
} else if (positionLooksDefault) {
node.position = xfrm.position
}
}
// Inherit bodyPr from master placeholder as fallback
if ('textBody' in node && node.textBody && !node.textBody.layoutBodyProperties) {
const masterBodyPr = getPhBodyPr(match)
if (masterBodyPr) {
node.textBody.layoutBodyProperties = masterBodyPr
}
}
}
}
}
}