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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user