chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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,
}
}