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