chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
/**
* Slide layout parser — extracts color map override, background,
* and placeholder shapes from a p:sldLayout XML.
*/
import { emuToPx } from '../parser/units'
import type { SafeXmlNode } from '../parser/xml-parser'
import { isPlaceholder, parseAllAttributes } from './xml-helpers'
interface PlaceholderXfrm {
position: { x: number; y: number }
size: { w: number; h: number }
}
export interface PlaceholderEntry {
node: SafeXmlNode
/** When placeholder is inside a group, position/size in slide space (px). */
absoluteXfrm?: PlaceholderXfrm
}
export interface LayoutData {
colorMapOverride?: Map<string, string>
background?: SafeXmlNode
placeholders: PlaceholderEntry[]
spTree: SafeXmlNode
rels: Map<string, import('../parser/rel-parser').RelEntry>
/** When false, shapes from the slide master should NOT be rendered on this layout. */
showMasterSp: boolean
}
function getShapeXfrmInEmu(
node: SafeXmlNode
): { offX: number; offY: number; cx: number; cy: number } | null {
const spPr = node.child('spPr')
if (!spPr.exists()) return null
const xfrm = spPr.child('xfrm')
if (!xfrm.exists()) return null
const off = xfrm.child('off')
const ext = xfrm.child('ext')
const offX = off.numAttr('x') ?? 0
const offY = off.numAttr('y') ?? 0
const cx = ext.numAttr('cx') ?? 0
const cy = ext.numAttr('cy') ?? 0
return { offX, offY, cx, cy }
}
function getGroupXfrmInEmu(grpSp: SafeXmlNode): {
offX: number
offY: number
cx: number
cy: number
chOffX: number
chOffY: number
chExtCx: number
chExtCy: number
} | null {
const grpSpPr = grpSp.child('grpSpPr')
if (!grpSpPr.exists()) return null
const xfrm = grpSpPr.child('xfrm')
if (!xfrm.exists()) return null
const off = xfrm.child('off')
const ext = xfrm.child('ext')
const chOff = xfrm.child('chOff')
const chExt = xfrm.child('chExt')
const offX = off.numAttr('x') ?? 0
const offY = off.numAttr('y') ?? 0
const cx = ext.numAttr('cx') ?? 0
const cy = ext.numAttr('cy') ?? 0
// OOXML: when chOff/chExt omitted, child box equals group box (chOff=0,0 and chExt=ext)
const chOffX = chOff.exists() ? (chOff.numAttr('x') ?? 0) : 0
const chOffY = chOff.exists() ? (chOff.numAttr('y') ?? 0) : 0
const chExtCx = chExt.exists() ? (chExt.numAttr('cx') ?? cx) : cx
const chExtCy = chExt.exists() ? (chExt.numAttr('cy') ?? cy) : cy
return {
offX,
offY,
cx,
cy,
chOffX,
chOffY,
chExtCx: chExtCx > 0 ? chExtCx : 1,
chExtCy: chExtCy > 0 ? chExtCy : 1,
}
}
/**
* Recursively collect placeholders; when inside a group, compute position/size in slide space.
*/
function extractPlaceholdersRecursive(
spTree: SafeXmlNode,
groupTransform: { offX: number; offY: number; scaleX: number; scaleY: number } | null
): PlaceholderEntry[] {
const out: PlaceholderEntry[] = []
for (const child of spTree.allChildren()) {
if (child.localName === 'grpSp') {
const gx = getGroupXfrmInEmu(child)
if (gx && gx.chExtCx > 0 && gx.chExtCy > 0) {
const scaleX = gx.cx / gx.chExtCx
const scaleY = gx.cy / gx.chExtCy
const baseOffX = gx.offX - gx.chOffX * scaleX
const baseOffY = gx.offY - gx.chOffY * scaleY
const nextTransform = groupTransform
? {
offX: groupTransform.offX + baseOffX * groupTransform.scaleX,
offY: groupTransform.offY + baseOffY * groupTransform.scaleY,
scaleX: groupTransform.scaleX * scaleX,
scaleY: groupTransform.scaleY * scaleY,
}
: { offX: baseOffX, offY: baseOffY, scaleX, scaleY }
const nested = extractPlaceholdersRecursive(child, nextTransform)
out.push(...nested)
} else {
out.push(...extractPlaceholdersRecursive(child, groupTransform))
}
continue
}
if (!isPlaceholder(child)) continue
const sx = getShapeXfrmInEmu(child)
if (!sx) {
out.push({ node: child })
continue
}
if (groupTransform) {
const absOffX = groupTransform.offX + sx.offX * groupTransform.scaleX
const absOffY = groupTransform.offY + sx.offY * groupTransform.scaleY
const absCx = sx.cx * groupTransform.scaleX
const absCy = sx.cy * groupTransform.scaleY
out.push({
node: child,
absoluteXfrm: {
position: { x: emuToPx(absOffX), y: emuToPx(absOffY) },
size: { w: emuToPx(absCx), h: emuToPx(absCy) },
},
})
} else {
out.push({
node: child,
absoluteXfrm: {
position: { x: emuToPx(sx.offX), y: emuToPx(sx.offY) },
size: { w: emuToPx(sx.cx), h: emuToPx(sx.cy) },
},
})
}
}
return out
}
/**
* Parse a slide layout XML root (`p:sldLayout`) into LayoutData.
*/
export function parseLayout(root: SafeXmlNode): LayoutData {
const cSld = root.child('cSld')
// --- Background ---
const bg = cSld.child('bg')
const background = bg.exists() ? bg : undefined
// --- Shape tree ---
const spTree = cSld.child('spTree')
// --- Color map override ---
let colorMapOverride: Map<string, string> | undefined
const clrMapOvr = root.child('clrMapOvr')
if (clrMapOvr.exists()) {
const overrideMapping = clrMapOvr.child('overrideClrMapping')
if (overrideMapping.exists()) {
colorMapOverride = parseAllAttributes(overrideMapping)
}
}
// --- Placeholders (recursive so we find title/body inside grpSp; resolve position in slide space) ---
const placeholders = extractPlaceholdersRecursive(spTree, null)
// --- showMasterSp: if "0", master shapes should not be rendered for this layout ---
const showMasterSpAttr = root.attr('showMasterSp')
const showMasterSp = showMasterSpAttr !== '0'
return {
colorMapOverride,
background,
placeholders,
spTree,
rels: new Map(), // populated later by buildPresentation
showMasterSp,
}
}
@@ -0,0 +1,80 @@
/**
* Slide master parser — extracts color map, background, text styles,
* and placeholder shapes from a p:sldMaster XML.
*/
import type { SafeXmlNode } from '../parser/xml-parser'
import { isPlaceholder, parseAllAttributes } from './xml-helpers'
export interface MasterData {
colorMap: Map<string, string>
background?: SafeXmlNode
textStyles: {
titleStyle?: SafeXmlNode
bodyStyle?: SafeXmlNode
otherStyle?: SafeXmlNode
}
defaultTextStyle?: SafeXmlNode
placeholders: SafeXmlNode[]
spTree: SafeXmlNode
rels: Map<string, import('../parser/rel-parser').RelEntry>
}
/**
* Extract placeholder shape nodes from an spTree node.
* A shape is considered a placeholder if it has a `p:ph` element in its nvPr.
*/
function extractPlaceholders(spTree: SafeXmlNode): SafeXmlNode[] {
const placeholders: SafeXmlNode[] = []
const allChildren = spTree.allChildren()
for (const child of allChildren) {
if (isPlaceholder(child)) {
placeholders.push(child)
}
}
return placeholders
}
/**
* Parse a slide master XML root (`p:sldMaster`) into MasterData.
*/
export function parseMaster(root: SafeXmlNode): MasterData {
const cSld = root.child('cSld')
// --- Background ---
const bg = cSld.child('bg')
const background = bg.exists() ? bg : undefined
// --- Shape tree ---
const spTree = cSld.child('spTree')
// --- Color map ---
const clrMap = root.child('clrMap')
const colorMap = parseAllAttributes(clrMap)
// --- Text styles ---
const txStyles = root.child('txStyles')
const titleStyle = txStyles.child('titleStyle')
const bodyStyle = txStyles.child('bodyStyle')
const otherStyle = txStyles.child('otherStyle')
// --- Default text style ---
const defaultTextStyle = root.child('defaultTextStyle')
// --- Placeholders ---
const placeholders = extractPlaceholders(spTree)
return {
colorMap,
background,
textStyles: {
titleStyle: titleStyle.exists() ? titleStyle : undefined,
bodyStyle: bodyStyle.exists() ? bodyStyle : undefined,
otherStyle: otherStyle.exists() ? otherStyle : undefined,
},
defaultTextStyle: defaultTextStyle.exists() ? defaultTextStyle : undefined,
placeholders,
spTree,
rels: new Map(), // populated later by buildPresentation
}
}
@@ -0,0 +1,169 @@
/**
* Base node types and property parser shared by all slide node kinds.
*/
import { angleToDeg, emuToPx } from '../../parser/units'
import type { SafeXmlNode } from '../../parser/xml-parser'
export type NodeType = 'shape' | 'picture' | 'table' | 'group' | 'chart' | 'unknown'
export interface Position {
x: number
y: number
}
export interface Size {
w: number
h: number
}
export interface PlaceholderInfo {
type?: string
idx?: number
}
/** Shape-level hyperlink click action (from cNvPr > a:hlinkClick). */
interface HlinkAction {
/** Action URI, e.g. "ppaction://hlinksldjump", "ppaction://hlinkpres", or empty for URL links. */
action?: string
/** Relationship ID for the target (slide, URL, etc.). */
rId?: string
/** Optional tooltip text. */
tooltip?: string
}
export interface BaseNodeData {
id: string
name: string
nodeType: NodeType
position: Position
size: Size
rotation: number
flipH: boolean
flipV: boolean
placeholder?: PlaceholderInfo
/** Shape-level hyperlink/click action (action buttons, clickable shapes). */
hlinkClick?: HlinkAction
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
source: SafeXmlNode
}
/**
* Try to find the non-visual properties container in the given node.
* PPTX uses different wrapper names depending on the shape kind:
* p:nvSpPr (shapes/connectors), p:nvPicPr (pictures),
* p:nvGrpSpPr (groups), p:nvGraphicFramePr (tables/charts).
*/
function findNvProps(node: SafeXmlNode): { cNvPr: SafeXmlNode; nvPr: SafeXmlNode } {
const wrappers = ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']
for (const name of wrappers) {
const wrapper = node.child(name)
if (wrapper.exists()) {
return {
cNvPr: wrapper.child('cNvPr'),
nvPr: wrapper.child('nvPr'),
}
}
}
return {
cNvPr: node.child('cNvPr'),
nvPr: node.child('nvPr'),
}
}
/**
* Find the transform (xfrm) node. Shapes use `p:spPr > a:xfrm`,
* groups use `p:grpSpPr > a:xfrm`, graphic frames use `p:xfrm`.
*/
function findXfrm(node: SafeXmlNode): SafeXmlNode {
// Try spPr first (most shapes)
const spPr = node.child('spPr')
if (spPr.exists()) {
const xfrm = spPr.child('xfrm')
if (xfrm.exists()) return xfrm
}
// Try grpSpPr (groups)
const grpSpPr = node.child('grpSpPr')
if (grpSpPr.exists()) {
const xfrm = grpSpPr.child('xfrm')
if (xfrm.exists()) return xfrm
}
// Try direct xfrm (graphic frames)
const directXfrm = node.child('xfrm')
if (directXfrm.exists()) return directXfrm
// Return empty node — all reads will return defaults
return node.child('__nonexistent__')
}
/**
* Parse placeholder info from nvPr > p:ph.
*/
function parsePlaceholder(nvPr: SafeXmlNode): PlaceholderInfo | undefined {
const ph = nvPr.child('ph')
if (!ph.exists()) return undefined
const type = ph.attr('type')
const idx = ph.numAttr('idx')
return { type, idx }
}
/**
* Parse the base properties common to all node types from a shape-like XML node.
* Returns everything except `nodeType`, which the caller must set.
*/
export function parseBaseProps(spNode: SafeXmlNode): Omit<BaseNodeData, 'nodeType'> {
const { cNvPr, nvPr } = findNvProps(spNode)
const id = cNvPr.attr('id') ?? ''
const name = cNvPr.attr('name') ?? ''
// --- Transform ---
const xfrm = findXfrm(spNode)
const off = xfrm.child('off')
const ext = xfrm.child('ext')
const position: Position = {
x: emuToPx(off.numAttr('x') ?? 0),
y: emuToPx(off.numAttr('y') ?? 0),
}
const size: Size = {
w: emuToPx(ext.numAttr('cx') ?? 0),
h: emuToPx(ext.numAttr('cy') ?? 0),
}
const rotation = angleToDeg(xfrm.numAttr('rot') ?? 0)
const flipH = xfrm.attr('flipH') === '1' || xfrm.attr('flipH') === 'true'
const flipV = xfrm.attr('flipV') === '1' || xfrm.attr('flipV') === 'true'
// --- Placeholder ---
const placeholder = parsePlaceholder(nvPr)
// --- Shape-level hyperlink action (cNvPr > a:hlinkClick) ---
let hlinkClick: HlinkAction | undefined
const hlinkNode = cNvPr.child('hlinkClick')
if (hlinkNode.exists()) {
hlinkClick = {
action: hlinkNode.attr('action') ?? undefined,
rId: hlinkNode.attr('id') ?? hlinkNode.attr('r:id') ?? undefined,
tooltip: hlinkNode.attr('tooltip') ?? undefined,
}
}
return {
id,
name,
position,
size,
rotation,
flipH,
flipV,
placeholder,
hlinkClick,
source: spNode,
}
}
@@ -0,0 +1,55 @@
/**
* Chart node — represents a chart embedded in a graphicFrame element.
*/
import { type RelEntry, resolveRelTarget } from '../../parser/rel-parser'
import type { SafeXmlNode } from '../../parser/xml-parser'
import { type BaseNodeData, parseBaseProps } from './base-node'
export interface ChartNodeData extends BaseNodeData {
nodeType: 'chart'
chartPath: string // e.g. "ppt/charts/chart1.xml"
}
/**
* Parse a graphicFrame containing a chart reference into a ChartNodeData.
*
* @param graphicFrame The graphicFrame XML node
* @param slideRels Relationship entries for the containing slide
* @param slidePath Full path of the slide (e.g. "ppt/slides/slide1.xml")
*/
export function parseChartNode(
graphicFrame: SafeXmlNode,
slideRels: Map<string, RelEntry>,
slidePath: string
): ChartNodeData | undefined {
const base = parseBaseProps(graphicFrame)
// Find chart relationship
const graphic = graphicFrame.child('graphic')
const graphicData = graphic.child('graphicData')
// Find the chart reference - look for c:chart element with r:id
let chartRId: string | undefined
for (const child of graphicData.allChildren()) {
if (child.localName === 'chart') {
chartRId = child.attr('r:id') || child.attr('id')
break
}
}
if (!chartRId) return undefined
const rel = slideRels.get(chartRId)
if (!rel) return undefined
// Resolve chart path relative to slide
const slideDir = slidePath.substring(0, slidePath.lastIndexOf('/'))
const chartPath = resolveRelTarget(slideDir, rel.target)
return {
...base,
nodeType: 'chart' as const,
chartPath,
}
}
@@ -0,0 +1,62 @@
/**
* Group node parser — handles grouped shapes (p:grpSp).
*/
import { emuToPx } from '../../parser/units'
import type { SafeXmlNode } from '../../parser/xml-parser'
import { type BaseNodeData, type Position, parseBaseProps, type Size } from './base-node'
export interface GroupNodeData extends BaseNodeData {
nodeType: 'group'
childOffset: Position
childExtent: Size
/** @internal Raw XML nodes — opaque to consumers. Use serializePresentation() for JSON-safe data. */
children: SafeXmlNode[]
}
/** Tag names of elements that can be children in a group's spTree. */
const GROUP_CHILD_TAGS = new Set(['sp', 'pic', 'grpSp', 'graphicFrame', 'cxnSp'])
/**
* Parse a group shape XML node (`p:grpSp`) into GroupNodeData.
*/
export function parseGroupNode(grpNode: SafeXmlNode): GroupNodeData {
const base = parseBaseProps(grpNode)
// --- Child coordinate space from grpSpPr > a:xfrm ---
// OOXML: when chOff/chExt omitted, child box equals group box (chOff=0,0, chExt=ext).
const grpSpPr = grpNode.child('grpSpPr')
const xfrm = grpSpPr.child('xfrm')
const chOff = xfrm.child('chOff')
const chExt = xfrm.child('chExt')
const childOffset: Position = chOff.exists()
? { x: emuToPx(chOff.numAttr('x') ?? 0), y: emuToPx(chOff.numAttr('y') ?? 0) }
: { x: 0, y: 0 }
const childExtent: Size = (() => {
if (!chExt.exists()) return { w: base.size.w, h: base.size.h }
const cx = chExt.numAttr('cx')
const cy = chExt.numAttr('cy')
return {
w: cx !== undefined && cx > 0 ? emuToPx(cx) : base.size.w,
h: cy !== undefined && cy > 0 ? emuToPx(cy) : base.size.h,
}
})()
// --- Collect direct child shape nodes ---
const children: SafeXmlNode[] = []
for (const child of grpNode.allChildren()) {
if (GROUP_CHILD_TAGS.has(child.localName)) {
children.push(child)
}
}
return {
...base,
nodeType: 'group',
childOffset,
childExtent,
children,
}
}
@@ -0,0 +1,102 @@
/**
* Picture node parser — handles images, video placeholders, and audio placeholders.
*/
import type { SafeXmlNode } from '../../parser/xml-parser'
import { type BaseNodeData, parseBaseProps } from './base-node'
interface CropRect {
top: number
bottom: number
left: number
right: number
}
export interface PicNodeData extends BaseNodeData {
nodeType: 'picture'
blipEmbed?: string
blipLink?: string
crop?: CropRect
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
fill?: SafeXmlNode
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
line?: SafeXmlNode
isVideo?: boolean
isAudio?: boolean
mediaRId?: string
}
/** OOXML encodes srcRect percentages as 1/100000 of full extent. */
const CROP_DIVISOR = 100000
/**
* Parse a picture XML node (`p:pic`) into PicNodeData.
*/
export function parsePicNode(picNode: SafeXmlNode): PicNodeData {
const base = parseBaseProps(picNode)
// --- Blip fill ---
const blipFill = picNode.child('blipFill')
const blip = blipFill.child('blip')
// Try both namespaced and non-namespaced embed attribute
const blipEmbed = blip.attr('embed') ?? blip.attr('r:embed')
const blipLink = blip.attr('link') ?? blip.attr('r:link')
// --- Crop (srcRect) ---
const srcRect = blipFill.child('srcRect')
let crop: CropRect | undefined
if (srcRect.exists()) {
const t = srcRect.numAttr('t')
const b = srcRect.numAttr('b')
const l = srcRect.numAttr('l')
const r = srcRect.numAttr('r')
if (t !== undefined || b !== undefined || l !== undefined || r !== undefined) {
crop = {
top: (t ?? 0) / CROP_DIVISOR,
bottom: (b ?? 0) / CROP_DIVISOR,
left: (l ?? 0) / CROP_DIVISOR,
right: (r ?? 0) / CROP_DIVISOR,
}
}
}
// --- Shape properties (fill + line) ---
const spPr = picNode.child('spPr')
const solidFill = spPr.child('solidFill')
const gradFill = spPr.child('gradFill')
const fill = solidFill.exists() ? solidFill : gradFill.exists() ? gradFill : undefined
const ln = spPr.child('ln')
const line = ln.exists() ? ln : undefined
// --- Video / Audio detection ---
const nvPicPr = picNode.child('nvPicPr')
const nvPr = nvPicPr.child('nvPr')
const videoFile = nvPr.child('videoFile')
const audioFile = nvPr.child('audioFile')
const isVideo = videoFile.exists()
const isAudio = audioFile.exists()
let mediaRId: string | undefined
if (isVideo) {
mediaRId = videoFile.attr('link') ?? videoFile.attr('r:link')
} else if (isAudio) {
mediaRId = audioFile.attr('link') ?? audioFile.attr('r:link')
}
return {
...base,
nodeType: 'picture',
blipEmbed,
blipLink,
crop,
fill,
line,
isVideo: isVideo || undefined,
isAudio: isAudio || undefined,
mediaRId,
}
}
@@ -0,0 +1,267 @@
/**
* Shape node parser — handles auto-shapes, text boxes, and connectors.
*/
import { angleToDeg, emuToPx } from '../../parser/units'
import type { SafeXmlNode } from '../../parser/xml-parser'
import { type BaseNodeData, parseBaseProps } from './base-node'
interface TextRun {
text: string
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
properties?: SafeXmlNode
}
interface TextParagraph {
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
properties?: SafeXmlNode
runs: TextRun[]
level: number
/** @internal End-of-paragraph run properties (a:endParaRPr). Defines font size for trailing paragraph mark. */
endParaRPr?: SafeXmlNode
}
export interface TextBody {
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
bodyProperties?: SafeXmlNode
/** @internal Fallback bodyPr from layout/master placeholder (used when shape's own bodyPr is missing attrs). */
layoutBodyProperties?: SafeXmlNode
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
listStyle?: SafeXmlNode
paragraphs: TextParagraph[]
}
export interface LineEndInfo {
type: string // 'triangle', 'arrow', 'stealth', 'diamond', 'oval', 'none'
w?: string // 'sm', 'med', 'lg'
len?: string // 'sm', 'med', 'lg'
}
/** Text box bounds in shape-local coordinates (used by diagram shapes with txXfrm). */
interface TextBoxBounds {
x: number
y: number
w: number
h: number
rotation?: number
}
export interface ShapeNodeData extends BaseNodeData {
nodeType: 'shape'
presetGeometry?: string
adjustments: Map<string, number>
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
customGeometry?: SafeXmlNode
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
fill?: SafeXmlNode
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
line?: SafeXmlNode
headEnd?: LineEndInfo
tailEnd?: LineEndInfo
textBody?: TextBody
/** When set (e.g. diagram txXfrm), text is laid out in this rect instead of full shape. */
textBoxBounds?: TextBoxBounds
}
/**
* Parse a single text paragraph (`a:p`).
*/
function parseParagraph(pNode: SafeXmlNode): TextParagraph {
const pPr = pNode.child('pPr')
const level = pPr.numAttr('lvl') ?? 0
// Re-scan in document order to get correct interleaving of r, br, fld
const orderedRuns: TextRun[] = []
for (const child of pNode.allChildren()) {
const ln = child.localName
if (ln === 'r') {
const rPr = child.child('rPr')
const tNode = child.child('t')
orderedRuns.push({
text: tNode.text(),
properties: rPr.exists() ? rPr : undefined,
})
} else if (ln === 'br') {
const rPr = child.child('rPr')
orderedRuns.push({
text: '\n',
properties: rPr.exists() ? rPr : undefined,
})
} else if (ln === 'fld') {
const rPr = child.child('rPr')
const tNode = child.child('t')
orderedRuns.push({
text: tNode.text(),
properties: rPr.exists() ? rPr : undefined,
})
}
}
const endParaRPrNode = pNode.child('endParaRPr')
return {
properties: pPr.exists() ? pPr : undefined,
runs: orderedRuns,
level,
endParaRPr: endParaRPrNode.exists() ? endParaRPrNode : undefined,
}
}
/**
* Parse a text body (`p:txBody` or `a:txBody`).
*/
export function parseTextBody(txBody: SafeXmlNode): TextBody | undefined {
if (!txBody.exists()) return undefined
const bodyPr = txBody.child('bodyPr')
const lstStyle = txBody.child('lstStyle')
const paragraphs: TextParagraph[] = []
for (const pNode of txBody.children('p')) {
paragraphs.push(parseParagraph(pNode))
}
return {
bodyProperties: bodyPr.exists() ? bodyPr : undefined,
listStyle: lstStyle.exists() ? lstStyle : undefined,
paragraphs,
}
}
/** Fill type local names in priority order. */
const FILL_TYPES = ['solidFill', 'gradFill', 'blipFill', 'pattFill', 'grpFill', 'noFill'] as const
/**
* Find the first fill element in a shape properties node.
*/
function findFill(spPr: SafeXmlNode): SafeXmlNode | undefined {
for (const fillType of FILL_TYPES) {
const fill = spPr.child(fillType)
if (fill.exists()) return fill
}
return undefined
}
/**
* Parse adjustment values from `a:avLst > a:gd` elements.
* Each guide has a `name` attribute and a `fmla` attribute like "val 50000".
*/
function parseAdjustments(avLst: SafeXmlNode): Map<string, number> {
const adjustments = new Map<string, number>()
for (const gd of avLst.children('gd')) {
const name = gd.attr('name')
const fmla = gd.attr('fmla') ?? ''
if (!name) continue
// fmla is typically "val NNNNN" — extract the numeric part
const match = fmla.match(/val\s+(-?\d+)/)
if (match) {
adjustments.set(name, Number(match[1]))
} else {
// Try direct numeric value
const num = Number(fmla)
if (!Number.isNaN(num)) {
adjustments.set(name, num)
}
}
}
return adjustments
}
/**
* Parse a shape XML node (`p:sp` or `p:cxnSp`) into ShapeNodeData.
*/
export function parseShapeNode(spNode: SafeXmlNode): ShapeNodeData {
const base = parseBaseProps(spNode)
const spPr = spNode.child('spPr')
// --- Preset geometry ---
const prstGeom = spPr.child('prstGeom')
const presetGeometry = prstGeom.attr('prst')
const avLst = prstGeom.child('avLst')
const adjustments = parseAdjustments(avLst)
// --- Custom geometry ---
const custGeom = spPr.child('custGeom')
const customGeometry = custGeom.exists() ? custGeom : undefined
// --- Fill ---
const fill = findFill(spPr)
// --- Line ---
const ln = spPr.child('ln')
const line = ln.exists() ? ln : undefined
// --- Line end markers (arrowheads) ---
let headEnd: LineEndInfo | undefined
let tailEnd: LineEndInfo | undefined
if (ln.exists()) {
const headEndNode = ln.child('headEnd')
if (headEndNode.exists()) {
const t = headEndNode.attr('type')
if (t && t !== 'none') {
headEnd = { type: t, w: headEndNode.attr('w'), len: headEndNode.attr('len') }
}
}
const tailEndNode = ln.child('tailEnd')
if (tailEndNode.exists()) {
const t = tailEndNode.attr('type')
if (t && t !== 'none') {
tailEnd = { type: t, w: tailEndNode.attr('w'), len: tailEndNode.attr('len') }
}
}
}
// --- Text body ---
const txBody = spNode.child('txBody')
const textBody = parseTextBody(txBody)
// --- Text transform (diagram shapes: dsp:txXfrm gives text box position/size in same space as xfrm)
let textBoxBounds: TextBoxBounds | undefined
const txXfrm = spNode.child('txXfrm')
if (txXfrm.exists()) {
const txOff = txXfrm.child('off')
const txExt = txXfrm.child('ext')
const xfrm = spPr.child('xfrm')
const off = xfrm.child('off')
const ext = xfrm.child('ext')
const shapeX = off.numAttr('x') ?? 0
const shapeY = off.numAttr('y') ?? 0
const shapeW = ext.numAttr('cx') ?? 0
const shapeH = ext.numAttr('cy') ?? 0
const txX = txOff.numAttr('x') ?? 0
const txY = txOff.numAttr('y') ?? 0
const txW = txExt.numAttr('cx') ?? 0
const txH = txExt.numAttr('cy') ?? 0
if (shapeW > 0 && shapeH > 0) {
const txRotDeg = angleToDeg(txXfrm.numAttr('rot') ?? 0)
const localX = txX - shapeX
const localY = txY - shapeY
// For 180deg txXfrm, mirror text box placement inside shape-local coordinates.
// (Common in SmartArt where shape xfrm also rotates by 180deg but text should remain upright.)
const isHalfTurn = Math.abs(Math.round(txRotDeg)) % 360 === 180
const boxX = isHalfTurn ? shapeW - (localX + txW) : localX
const boxY = isHalfTurn ? shapeH - (localY + txH) : localY
textBoxBounds = {
x: emuToPx(boxX),
y: emuToPx(boxY),
w: emuToPx(txW),
h: emuToPx(txH),
rotation: txRotDeg,
}
}
}
return {
...base,
nodeType: 'shape',
presetGeometry,
adjustments,
customGeometry,
fill,
line,
headEnd,
tailEnd,
textBody,
textBoxBounds,
}
}
@@ -0,0 +1,135 @@
/**
* Table node parser — handles graphicFrame elements containing a:tbl.
*/
import { emuToPx } from '../../parser/units'
import type { SafeXmlNode } from '../../parser/xml-parser'
import { type BaseNodeData, parseBaseProps } from './base-node'
import { parseTextBody, type TextBody } from './shape-node'
export interface TableCell {
gridSpan: number
rowSpan: number
hMerge: boolean
vMerge: boolean
textBody?: TextBody
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
properties?: SafeXmlNode
}
interface TableRow {
height: number
cells: TableCell[]
}
export interface TableNodeData extends BaseNodeData {
nodeType: 'table'
columns: number[]
rows: TableRow[]
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
properties?: SafeXmlNode
tableStyleId?: string
}
/**
* Parse a single table cell (`a:tc`).
*/
function parseCell(tcNode: SafeXmlNode): TableCell {
const gridSpan = tcNode.numAttr('gridSpan') ?? 1
const rowSpan = tcNode.numAttr('rowSpan') ?? 1
const hMerge = tcNode.attr('hMerge') === '1' || tcNode.attr('hMerge') === 'true'
const vMerge = tcNode.attr('vMerge') === '1' || tcNode.attr('vMerge') === 'true'
// Cell text body
const txBody = tcNode.child('txBody')
const textBody = parseTextBody(txBody)
// Cell properties
const tcPr = tcNode.child('tcPr')
return {
gridSpan,
rowSpan,
hMerge,
vMerge,
textBody,
properties: tcPr.exists() ? tcPr : undefined,
}
}
/**
* Parse a table row (`a:tr`).
*/
function parseRow(trNode: SafeXmlNode): TableRow {
const height = emuToPx(trNode.numAttr('h') ?? 0)
const cells: TableCell[] = []
for (const tcNode of trNode.children('tc')) {
cells.push(parseCell(tcNode))
}
return { height, cells }
}
/**
* Locate the `a:tbl` element inside a graphicFrame.
* Path: `a:graphic > a:graphicData > a:tbl`
*/
function findTable(frameNode: SafeXmlNode): SafeXmlNode {
const graphic = frameNode.child('graphic')
const graphicData = graphic.child('graphicData')
return graphicData.child('tbl')
}
/**
* Extract the table style ID from tblPr.
* It can be in `a:tblStyle@val` or as a direct `tblStyle` attribute.
*/
function extractTableStyleId(tblPr: SafeXmlNode): string | undefined {
// Try <a:tableStyleId>{UUID}</a:tableStyleId> (most common in OOXML)
const tableStyleIdNode = tblPr.child('tableStyleId')
if (tableStyleIdNode.exists()) {
return tableStyleIdNode.text() || tableStyleIdNode.attr('val') || undefined
}
// Try <a:tblStyle val="{UUID}"/>
const tblStyleNode = tblPr.child('tblStyle')
if (tblStyleNode.exists()) {
return tblStyleNode.attr('val') ?? (tblStyleNode.text() || undefined)
}
// Try direct attribute
return tblPr.attr('tblStyle') ?? undefined
}
/**
* Parse a graphicFrame XML node containing a table into TableNodeData.
*/
export function parseTableNode(frameNode: SafeXmlNode): TableNodeData {
const base = parseBaseProps(frameNode)
const tbl = findTable(frameNode)
// --- Column widths ---
const tblGrid = tbl.child('tblGrid')
const columns: number[] = []
for (const gridCol of tblGrid.children('gridCol')) {
columns.push(emuToPx(gridCol.numAttr('w') ?? 0))
}
// --- Rows ---
const rows: TableRow[] = []
for (const trNode of tbl.children('tr')) {
rows.push(parseRow(trNode))
}
// --- Table properties ---
const tblPr = tbl.child('tblPr')
const tableStyleId = extractTableStyleId(tblPr)
return {
...base,
nodeType: 'table',
columns,
rows,
properties: tblPr.exists() ? tblPr : undefined,
tableStyleId,
}
}
@@ -0,0 +1,79 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest'
import { buildPresentation } from '@/lib/pptx-renderer/model/presentation'
import type { PptxFiles } from '@/lib/pptx-renderer/parser/zip-parser'
function createFiles(presentation: string): PptxFiles {
return {
contentTypes: '<Types />',
presentation,
presentationRels: `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml" />
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml" />
</Relationships>`,
slides: new Map([
['ppt/slides/slide1.xml', createSlideXml()],
['ppt/slides/slide2.xml', createSlideXml()],
]),
slideRels: new Map([
['ppt/slides/_rels/slide1.xml.rels', '<Relationships />'],
['ppt/slides/_rels/slide2.xml.rels', '<Relationships />'],
]),
slideLayouts: new Map(),
slideLayoutRels: new Map(),
slideMasters: new Map(),
slideMasterRels: new Map(),
themes: new Map(),
media: new Map(),
charts: new Map(),
chartStyles: new Map(),
chartColors: new Map(),
diagramDrawings: new Map(),
}
}
function createPresentationXml(markers = ''): string {
return `<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ${markers}>
<p:sldSz cx="9144000" cy="5143500" />
<p:sldIdLst>
<p:sldId id="256" r:id="rId2" />
<p:sldId id="257" r:id="rId1" />
</p:sldIdLst>
</p:presentation>`
}
function createSlideXml(): string {
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name="" /><p:cNvGrpSpPr /><p:nvPr /></p:nvGrpSpPr>
<p:grpSpPr />
</p:spTree>
</p:cSld>
</p:sld>`
}
describe('buildPresentation', () => {
it('does not treat the standard wps namespace prefix as WPS Office', () => {
const presentation = buildPresentation(
createFiles(
createPresentationXml(
'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"'
)
)
)
expect(presentation.isWps).toBe(false)
})
it('orders slides by relationship id instead of numeric slide id', () => {
const presentation = buildPresentation(createFiles(createPresentationXml()))
expect(presentation.slides.map((slide) => slide.slidePath)).toEqual([
'ppt/slides/slide2.xml',
'ppt/slides/slide1.xml',
])
})
})
@@ -0,0 +1,486 @@
/**
* 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
}
}
}
}
}
}
+329
View File
@@ -0,0 +1,329 @@
/**
* Slide parser — converts a slide XML into a structured SlideData
* with typed node objects for each shape on the slide.
*/
import { type RelEntry, resolveRelTarget } from '../parser/rel-parser'
import { parseXml, type SafeXmlNode } from '../parser/xml-parser'
import { parseBaseProps } from './nodes/base-node'
import { type ChartNodeData, parseChartNode } from './nodes/chart-node'
import { type GroupNodeData, parseGroupNode } from './nodes/group-node'
import { type PicNodeData, parsePicNode } from './nodes/pic-node'
import { parseShapeNode, type ShapeNodeData } from './nodes/shape-node'
import { parseTableNode, type TableNodeData } from './nodes/table-node'
export type SlideNode = ShapeNodeData | PicNodeData | TableNodeData | GroupNodeData | ChartNodeData
export interface SlideData {
index: number
nodes: SlideNode[]
background?: SafeXmlNode
layoutIndex: string
rels: Map<string, RelEntry>
/** Full path to the slide file (e.g. "ppt/slides/slide3.xml"). */
slidePath: string
/** When false, shapes from the layout and master should NOT be rendered on this slide. */
showMasterSp: boolean
}
/**
* Check whether a graphicFrame contains a table (`a:tbl`).
*/
function isTableFrame(node: SafeXmlNode): boolean {
const graphic = node.child('graphic')
const graphicData = graphic.child('graphicData')
return graphicData.child('tbl').exists()
}
/**
* Check whether a graphicFrame contains a chart.
*/
function isChartFrame(node: SafeXmlNode): boolean {
const graphic = node.child('graphic')
const graphicData = graphic.child('graphicData')
const uri = graphicData.attr('uri') || ''
return uri.includes('chart')
}
/**
* Find p:pic inside OLE graphicData (mc:AlternateContent > mc:Fallback or mc:Choice > p:oleObj > p:pic).
* Returns the pic node if it has blipFill with embed (so we can render the preview image).
*/
function findOleFallbackPic(graphicFrame: SafeXmlNode): SafeXmlNode | null {
const graphic = graphicFrame.child('graphic')
const graphicData = graphic.child('graphicData')
const uri = graphicData.attr('uri') || ''
if (!uri.includes('ole')) return null
const altContent = graphicData.child('AlternateContent')
if (!altContent.exists()) return null
for (const branch of ['Fallback', 'Choice'] as const) {
const oleObj = altContent.child(branch).child('oleObj')
if (!oleObj.exists()) continue
const pic = oleObj.child('pic')
if (!pic.exists()) continue
const blipFill = pic.child('blipFill')
const blip = blipFill.child('blip')
const embed = blip.attr('embed') ?? blip.attr('r:embed')
if (embed) return pic
}
return null
}
/**
* Parse a graphicFrame that contains an OLE object with a fallback picture (preview image).
* Uses the frame's position/size and the inner pic's blip embed.
* Exported for use in GroupRenderer when parsing group children.
*/
export function parseOleFrameAsPicture(graphicFrame: SafeXmlNode): PicNodeData | undefined {
const pic = findOleFallbackPic(graphicFrame)
if (!pic) return undefined
const base = parseBaseProps(graphicFrame)
const blipFill = pic.child('blipFill')
const blip = blipFill.child('blip')
const blipEmbed = blip.attr('embed') ?? blip.attr('r:embed')
const blipLink = blip.attr('link') ?? blip.attr('r:link')
if (!blipEmbed) return undefined
return {
...base,
nodeType: 'picture',
blipEmbed,
blipLink,
source: graphicFrame,
}
}
/**
* Check whether a graphicFrame contains a SmartArt diagram.
*/
function isDiagramFrame(node: SafeXmlNode): boolean {
const graphic = node.child('graphic')
const graphicData = graphic.child('graphicData')
const uri = graphicData.attr('uri') || ''
return uri.includes('diagram')
}
/**
* Parse a SmartArt diagram graphicFrame by resolving the diagram drawing fallback XML.
* The drawing XML contains pre-rendered shapes in a spTree that we can display as a group.
*/
function parseDiagramFrame(
graphicFrame: SafeXmlNode,
rels: Map<string, RelEntry>,
slidePath: string,
diagramDrawings: Map<string, string>
): GroupNodeData | undefined {
const base = parseBaseProps(graphicFrame)
const slideDir = slidePath.substring(0, slidePath.lastIndexOf('/'))
const drawingCandidates = Array.from(rels.values())
.filter(
(entry) => entry.type.includes('diagramDrawing') || entry.target.includes('diagrams/drawing')
)
.map((entry) => {
const target = entry.target
const match = target.match(/drawing(\d+)/)
return {
target,
num: match ? Number.parseInt(match[1], 10) : undefined,
}
})
// Extract the diagram data rId from the relIds element to identify which diagram this is
const graphic = graphicFrame.child('graphic')
const graphicData = graphic.child('graphicData')
const relIds = graphicData.child('relIds')
// Strategy 1: Match data file number to drawing file number
// e.g. data3.xml → drawing3.xml
if (relIds.exists()) {
const dmRId = relIds.attr('r:dm') ?? relIds.attr('dm')
if (dmRId) {
const dmRel = rels.get(dmRId)
if (dmRel) {
// Extract the number from the data target (e.g. "data3" → "3")
const numMatch = dmRel.target.match(/data(\d+)/)
if (numMatch) {
const drawingNum = Number.parseInt(numMatch[1], 10)
// Prefer exact drawingN; if absent, use the nearest numbered drawing relation.
const ordered = drawingCandidates.slice().sort((a, b) => {
const da = a.num === undefined ? Number.POSITIVE_INFINITY : Math.abs(a.num - drawingNum)
const db = b.num === undefined ? Number.POSITIVE_INFINITY : Math.abs(b.num - drawingNum)
return da - db
})
for (const candidate of ordered) {
const drawingPath = resolveRelTarget(slideDir, candidate.target)
const drawingXml = diagramDrawings.get(drawingPath)
if (drawingXml) {
return buildDiagramGroup(base, drawingXml)
}
}
}
}
}
}
// Strategy 2: Fallback - find any diagramDrawing relationship
for (const candidate of drawingCandidates) {
const drawingPath = resolveRelTarget(slideDir, candidate.target)
const drawingXml = diagramDrawings.get(drawingPath)
if (drawingXml) {
return buildDiagramGroup(base, drawingXml)
}
}
return undefined
}
/**
* Build a GroupNodeData from a diagram drawing XML string.
* Diagram drawings use dsp: namespace (drawingml 2008); structure is dsp:drawing > dsp:spTree > dsp:sp.
* Diagram shapes are positioned in the graphicFrame's own coordinate space.
*/
function buildDiagramGroup(
base: ReturnType<typeof parseBaseProps>,
drawingXml: string
): GroupNodeData {
const drawingRoot = parseXml(drawingXml)
const spTree = drawingRoot.child('spTree')
if (!spTree.exists()) {
return {
...base,
nodeType: 'group',
childOffset: { x: 0, y: 0 },
childExtent: { w: base.size.w, h: base.size.h },
children: [],
}
}
const CHILD_TAGS = new Set(['sp', 'pic', 'grpSp', 'graphicFrame', 'cxnSp'])
const children: SafeXmlNode[] = []
for (const child of spTree.allChildren()) {
if (CHILD_TAGS.has(child.localName)) {
children.push(child)
}
}
// Use the graphicFrame's own dimensions as the child coordinate space.
// Diagram shapes are positioned in the frame's coordinate space (EMU converted to px).
// Using frame dimensions gives a 1:1 scale, preserving original positions and sizes.
// This avoids enlarging shapes when the bounding box is smaller than the frame.
const extentW = Math.max(1, base.size.w)
const extentH = Math.max(1, base.size.h)
return {
...base,
nodeType: 'group',
childOffset: { x: 0, y: 0 },
childExtent: { w: extentW, h: extentH },
children,
}
}
/**
* Parse a single child node from spTree, dispatching to the appropriate parser.
*/
function parseChildNode(
child: SafeXmlNode,
rels: Map<string, RelEntry>,
slidePath: string,
diagramDrawings?: Map<string, string>
): SlideNode | undefined {
const tag = child.localName
switch (tag) {
case 'sp':
case 'cxnSp':
return parseShapeNode(child)
case 'pic':
return parsePicNode(child)
case 'grpSp':
return parseGroupNode(child)
case 'graphicFrame':
if (isTableFrame(child)) {
return parseTableNode(child)
}
if (isChartFrame(child)) {
return parseChartNode(child, rels, slidePath)
}
// SmartArt diagram with drawing fallback
if (isDiagramFrame(child) && diagramDrawings) {
return parseDiagramFrame(child, rels, slidePath, diagramDrawings)
}
// OLE object with fallback picture (e.g. embedded PDF preview on slide 34)
{
const olePic = parseOleFrameAsPicture(child)
if (olePic) return olePic
}
// Non-table/chart/ole graphic frames — skip
return undefined
default:
return undefined
}
}
/**
* Find the layout relationship target from a slide's rels map.
* The relationship type URI for slide layouts ends with "slideLayout".
*/
function findLayoutRel(rels: Map<string, RelEntry>): string {
for (const [, entry] of rels) {
if (entry.type.includes('slideLayout')) {
return entry.target
}
}
return ''
}
/**
* Parse a slide XML root (`p:sld`) into SlideData.
*
* @param root Parsed XML root of the slide
* @param index Zero-based slide index
* @param rels Relationship entries for this slide
* @param slidePath Full path to the slide file (e.g. "ppt/slides/slide1.xml")
*/
export function parseSlide(
root: SafeXmlNode,
index: number,
rels: Map<string, RelEntry>,
slidePath = '',
diagramDrawings?: Map<string, string>
): SlideData {
const cSld = root.child('cSld')
// --- Background ---
const bg = cSld.child('bg')
const background = bg.exists() ? bg : undefined
// --- Parse shape tree children ---
const spTree = cSld.child('spTree')
const nodes: SlideNode[] = []
for (const child of spTree.allChildren()) {
const node = parseChildNode(child, rels, slidePath, diagramDrawings)
if (node) {
nodes.push(node)
}
}
// --- Layout relationship ---
const layoutIndex = findLayoutRel(rels)
// --- showMasterSp: if "0", layout/master shapes should not be rendered on this slide ---
const showMasterSpAttr = root.attr('showMasterSp')
const showMasterSp = showMasterSpAttr !== '0'
return {
index,
nodes,
background,
layoutIndex,
rels,
slidePath,
showMasterSp,
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Theme parser — extracts color scheme and font definitions from a:theme XML.
*/
import type { SafeXmlNode } from '../parser/xml-parser'
export interface ThemeData {
colorScheme: Map<string, string>
majorFont: { latin: string; ea: string; cs: string }
minorFont: { latin: string; ea: string; cs: string }
fillStyles: SafeXmlNode[] // from a:fillStyleLst children (indexed 1-based)
lineStyles: SafeXmlNode[] // from a:lnStyleLst children (indexed 1-based)
effectStyles: SafeXmlNode[] // from a:effectStyleLst children (indexed 1-based)
}
/** Known color scheme slot names in a:clrScheme. */
const COLOR_SLOTS = [
'dk1',
'dk2',
'lt1',
'lt2',
'accent1',
'accent2',
'accent3',
'accent4',
'accent5',
'accent6',
'hlink',
'folHlink',
] as const
/**
* Extract a hex color value from a color definition node.
* Handles both `a:srgbClr@val` and `a:sysClr@lastClr`.
*/
function extractColor(node: SafeXmlNode): string | undefined {
const srgb = node.child('srgbClr')
if (srgb.exists()) {
return srgb.attr('val')
}
const sys = node.child('sysClr')
if (sys.exists()) {
return sys.attr('lastClr') ?? sys.attr('val')
}
return undefined
}
/**
* Parse font info from a majorFont or minorFont node.
* Extracts typeface attributes from latin, ea, and cs child elements.
*/
function parseFontInfo(fontNode: SafeXmlNode): { latin: string; ea: string; cs: string } {
return {
latin: fontNode.child('latin').attr('typeface') ?? '',
ea: fontNode.child('ea').attr('typeface') ?? '',
cs: fontNode.child('cs').attr('typeface') ?? '',
}
}
/**
* Parse a theme XML root (`a:theme`) into ThemeData.
*/
export function parseTheme(root: SafeXmlNode): ThemeData {
const themeElements = root.child('themeElements')
// --- Color scheme ---
const clrScheme = themeElements.child('clrScheme')
const colorScheme = new Map<string, string>()
for (const slot of COLOR_SLOTS) {
const slotNode = clrScheme.child(slot)
if (slotNode.exists()) {
const hex = extractColor(slotNode)
if (hex !== undefined) {
colorScheme.set(slot, hex)
}
}
}
// --- Font scheme ---
const fontScheme = themeElements.child('fontScheme')
const majorFont = parseFontInfo(fontScheme.child('majorFont'))
const minorFont = parseFontInfo(fontScheme.child('minorFont'))
// --- Format scheme ---
const fmtScheme = themeElements.child('fmtScheme')
const fillStyleLst = fmtScheme.child('fillStyleLst')
const fillStyles: SafeXmlNode[] = fillStyleLst.allChildren()
const lnStyleLst = fmtScheme.child('lnStyleLst')
const lineStyles: SafeXmlNode[] = lnStyleLst.allChildren()
const effectStyleLst = fmtScheme.child('effectStyleLst')
const effectStyles: SafeXmlNode[] = effectStyleLst.allChildren()
return { colorScheme, majorFont, minorFont, fillStyles, lineStyles, effectStyles }
}
@@ -0,0 +1,33 @@
import type { SafeXmlNode } from '../parser/xml-parser'
/**
* Check whether a shape-like node contains a placeholder definition.
*/
export function isPlaceholder(node: SafeXmlNode): boolean {
const nvSpPr = node.child('nvSpPr')
if (nvSpPr.exists()) {
const nvPr = nvSpPr.child('nvPr')
if (nvPr.child('ph').exists()) return true
}
const nvPicPr = node.child('nvPicPr')
if (nvPicPr.exists()) {
const nvPr = nvPicPr.child('nvPr')
if (nvPr.child('ph').exists()) return true
}
return false
}
/**
* Parse all attributes of a node into a local-name keyed map.
*/
export function parseAllAttributes(node: SafeXmlNode): Map<string, string> {
const result = new Map<string, string>()
const el = node.element
if (!el) return result
const attrs = el.attributes
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i]
result.set(attr.localName, attr.value)
}
return result
}