chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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

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,81 @@
/**
* Parser for .rels (Relationship) XML files in OOXML packages.
* These files map relationship IDs (rId1, rId2, ...) to targets.
*/
import { parseXml } from './xml-parser'
export interface RelEntry {
type: string
target: string
targetMode?: string
}
/**
* Parse a .rels XML string into a Map of relationship ID -> RelEntry.
*
* Example input:
* ```xml
* <Relationships xmlns="...">
* <Relationship Id="rId1" Type="http://...slide" Target="slides/slide1.xml"/>
* </Relationships>
* ```
*/
export function parseRels(xmlString: string): Map<string, RelEntry> {
const result = new Map<string, RelEntry>()
if (!xmlString) return result
const root = parseXml(xmlString)
if (!root.exists()) return result
const relationships = root.children('Relationship')
for (const rel of relationships) {
const id = rel.attr('Id')
const type = rel.attr('Type')
const target = rel.attr('Target')
const targetMode = rel.attr('TargetMode')
if (id && type !== undefined && target !== undefined) {
result.set(id, { type, target, targetMode })
}
}
return result
}
/**
* Resolve a relative target path against a base path.
*
* Examples:
* resolveRelTarget('ppt/slides', '../slideLayouts/slideLayout1.xml')
* → 'ppt/slideLayouts/slideLayout1.xml'
*
* resolveRelTarget('ppt/slides', 'media/image1.png')
* → 'ppt/slides/media/image1.png'
*
* resolveRelTarget('ppt', 'slides/slide1.xml')
* → 'ppt/slides/slide1.xml'
*/
export function resolveRelTarget(basePath: string, target: string): string {
// Absolute targets (start with /) are returned as-is (strip leading /)
if (target.startsWith('/')) {
return target.slice(1)
}
// Split the base path into segments
const baseParts = basePath.replace(/\\/g, '/').split('/').filter(Boolean)
const targetParts = target.replace(/\\/g, '/').split('/').filter(Boolean)
// Walk through target parts, resolving '..' by popping base parts
const resolved = [...baseParts]
for (const part of targetParts) {
if (part === '..') {
resolved.pop()
} else if (part !== '.') {
resolved.push(part)
}
}
return resolved.join('/')
}
@@ -0,0 +1,59 @@
/**
* Unit conversion utilities for OOXML / PPTX.
*
* PPTX uses several unit systems:
* - EMU (English Metric Units): 1 inch = 914400 EMU
* - Points: 1 inch = 72 pt
* - Hundredths of a point: used for font sizes
* - 60000ths of a degree: used for angles
* - 100000ths (percentage): used for scale factors
*/
/** EMU to pixels (at 96 DPI). */
export function emuToPx(emu: number): number {
return (emu / 914400) * 96
}
/** EMU to points. */
export function emuToPt(emu: number): number {
return emu / 12700
}
/** OOXML angle (60000ths of a degree) to degrees. */
export function angleToDeg(angle: number): number {
return angle / 60000
}
/** OOXML percentage (100000ths) to a decimal fraction (0..1 range for 0%..100%). */
export function pctToDecimal(pct: number): number {
return pct / 100000
}
/** Hundredths of a point to points (used for font sizes in OOXML). */
export function hundredthPtToPt(val: number): number {
return val / 100
}
/** Points to pixels (at 96 DPI). */
export function ptToPx(pt: number): number {
return (pt * 96) / 72
}
/**
* Heuristic: detect whether a value is in EMU or points.
* Values with abs > 20000 are almost certainly EMU (a single point = 12700 EMU).
*/
export function detectUnit(value: number): 'emu' | 'point' {
return Math.abs(value) > 20000 ? 'emu' : 'point'
}
/**
* Smart conversion to pixels: auto-detects whether the value is EMU or points
* and converts accordingly.
*/
export function smartToPx(value: number): number {
if (detectUnit(value) === 'emu') {
return emuToPx(value)
}
return ptToPx(value)
}
@@ -0,0 +1,105 @@
import { createLogger } from '@sim/logger'
const logger = createLogger('PptxXmlParser')
/**
* Safe XML parser using browser DOMParser.
* All operations are null-safe — accessing missing elements never crashes.
*/
export class SafeXmlNode {
private readonly el: Element | null
constructor(el: Element | null) {
this.el = el
}
/** Get a string attribute value, or undefined if missing. */
attr(name: string): string | undefined {
if (!this.el) return undefined
return this.el.hasAttribute(name) ? this.el.getAttribute(name)! : undefined
}
/** Get a numeric attribute value, or undefined if missing or not a number. */
numAttr(name: string): number | undefined {
const raw = this.attr(name)
if (raw === undefined) return undefined
const n = Number(raw)
return Number.isNaN(n) ? undefined : n
}
/**
* Find the first child element matching the given localName (namespace-agnostic).
* Returns an empty SafeXmlNode if not found, so chaining never crashes.
*/
child(localName: string): SafeXmlNode {
if (!this.el) return new SafeXmlNode(null)
const children = this.el.children
for (let i = 0; i < children.length; i++) {
if (children[i].localName === localName) {
return new SafeXmlNode(children[i])
}
}
return new SafeXmlNode(null)
}
/**
* Get child elements, optionally filtered by localName (namespace-agnostic).
* If no localName is given, returns all direct child elements.
*/
children(localName?: string): SafeXmlNode[] {
if (!this.el) return []
const result: SafeXmlNode[] = []
const children = this.el.children
for (let i = 0; i < children.length; i++) {
if (localName === undefined || children[i].localName === localName) {
result.push(new SafeXmlNode(children[i]))
}
}
return result
}
/** Get the text content, or empty string if the element is missing. */
text(): string {
if (!this.el) return ''
return this.el.textContent ?? ''
}
/** Whether the underlying element actually exists. */
exists(): boolean {
return this.el !== null
}
/** All direct child elements as SafeXmlNode[]. */
allChildren(): SafeXmlNode[] {
return this.children()
}
/** The localName of the underlying element, or empty string. */
get localName(): string {
return this.el?.localName ?? ''
}
/** Raw access to the underlying Element (may be null). */
get element(): Element | null {
return this.el
}
}
/**
* Parse an XML string into a SafeXmlNode wrapping the document element.
* Uses the browser's built-in DOMParser.
*/
export function parseXml(xmlString: string): SafeXmlNode {
const parser = new DOMParser()
const doc = parser.parseFromString(xmlString, 'application/xml')
// Check for parser errors — DOMParser returns a parsererror document on failure
const errorNode = doc.querySelector('parsererror')
if (errorNode) {
logger.warn('XML parse error', { error: errorNode.textContent ?? '' })
return new SafeXmlNode(null)
}
return new SafeXmlNode(doc.documentElement)
}
@@ -0,0 +1,51 @@
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { parseZip } from '@/lib/pptx-renderer/parser/zip-parser'
async function createZip(entries: Record<string, string | Uint8Array>): Promise<ArrayBuffer> {
const zip = new JSZip()
for (const [path, content] of Object.entries(entries)) {
zip.file(path, content)
}
return zip.generateAsync({ type: 'arraybuffer' })
}
describe('parseZip', () => {
it('extracts PPTX package parts into categorized maps', async () => {
const buffer = await createZip({
'[Content_Types].xml': '<Types />',
'ppt/presentation.xml': '<p:presentation />',
'ppt/_rels/presentation.xml.rels': '<Relationships />',
'ppt/slides/slide1.xml': '<p:sld />',
'ppt/slides/_rels/slide1.xml.rels': '<Relationships />',
'ppt/media/image1.png': new Uint8Array([1, 2, 3]),
})
const files = await parseZip(buffer)
expect(files.contentTypes).toBe('<Types />')
expect(files.presentation).toBe('<p:presentation />')
expect(files.slides.get('ppt/slides/slide1.xml')).toBe('<p:sld />')
expect(files.slideRels.get('ppt/slides/_rels/slide1.xml.rels')).toBe('<Relationships />')
expect(files.media.get('ppt/media/image1.png')).toEqual(new Uint8Array([1, 2, 3]))
})
it('rejects archives that exceed entry limits', async () => {
const buffer = await createZip({
'[Content_Types].xml': '<Types />',
'ppt/presentation.xml': '<p:presentation />',
})
await expect(parseZip(buffer, { maxEntries: 1 })).rejects.toThrow(
'PPTX zip limit exceeded: entries 2 > maxEntries 1'
)
})
it('rejects archives that exceed media byte limits', async () => {
const buffer = await createZip({
'ppt/media/image1.png': new Uint8Array([1, 2, 3, 4]),
})
await expect(parseZip(buffer, { maxMediaBytes: 3 })).rejects.toThrow('PPTX zip limit exceeded')
})
})
@@ -0,0 +1,269 @@
/**
* PPTX zip archive parser.
* Extracts and categorizes all files from a .pptx (which is a zip archive).
*/
import type { JSZipObject } from 'jszip'
import JSZip from 'jszip'
export interface PptxFiles {
contentTypes: string
presentation: string
presentationRels: string
slides: Map<string, string>
slideRels: Map<string, string>
slideLayouts: Map<string, string>
slideLayoutRels: Map<string, string>
slideMasters: Map<string, string>
slideMasterRels: Map<string, string>
themes: Map<string, string>
media: Map<string, Uint8Array>
tableStyles?: string
charts: Map<string, string> // ppt/charts/chart*.xml
chartStyles: Map<string, string> // ppt/charts/style*.xml
chartColors: Map<string, string> // ppt/charts/colors*.xml
diagramDrawings: Map<string, string> // ppt/diagrams/drawing*.xml (SmartArt fallback)
}
export interface ZipParseLimits {
/** Maximum number of non-directory entries in the zip archive. */
maxEntries?: number
/** Maximum uncompressed size for any single entry (bytes). */
maxEntryUncompressedBytes?: number
/** Maximum total uncompressed size across all entries (bytes). */
maxTotalUncompressedBytes?: number
/** Maximum uncompressed size across media entries under `ppt/media/` (bytes). */
maxMediaBytes?: number
/** Maximum concurrent zip entry reads during parsing. */
maxConcurrency?: number
}
function throwZipLimitExceeded(reason: string): never {
throw new Error(`PPTX zip limit exceeded: ${reason}`)
}
function readUncompressedSize(file: JSZipObject): number | undefined {
const data = (file as JSZipObject & { _data?: { uncompressedSize?: number } })._data
const size = data?.uncompressedSize
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
}
async function mapWithConcurrency<T>(
items: T[],
concurrency: number,
mapper: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return
const workerCount = Math.min(concurrency, items.length)
let cursor = 0
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const index = cursor++
if (index >= items.length) return
await mapper(items[index])
}
})
await Promise.all(workers)
}
/**
* Parse a .pptx file buffer and extract all relevant files, categorized by type.
*/
export async function parseZip(
buffer: ArrayBuffer,
limits: ZipParseLimits = {}
): Promise<PptxFiles> {
const maxConcurrency = limits.maxConcurrency ?? 8
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
throwZipLimitExceeded(`maxConcurrency ${limits.maxConcurrency} must be an integer >= 1`)
}
const zip = await JSZip.loadAsync(buffer)
const entries = Object.entries(zip.files).filter(([, file]) => !file.dir)
if (limits.maxEntries !== undefined && entries.length > limits.maxEntries) {
throwZipLimitExceeded(`entries ${entries.length} > maxEntries ${limits.maxEntries}`)
}
const knownSizeByPath = new Map<string, number>()
let knownTotalBytes = 0
let knownMediaBytes = 0
for (const [rawPath, file] of entries) {
const normalizedPath = rawPath.replace(/\\/g, '/')
const size = readUncompressedSize(file)
if (size === undefined) continue
knownSizeByPath.set(normalizedPath, size)
if (limits.maxEntryUncompressedBytes !== undefined && size > limits.maxEntryUncompressedBytes) {
throwZipLimitExceeded(
`${normalizedPath} is ${size} bytes > maxEntryUncompressedBytes ${limits.maxEntryUncompressedBytes}`
)
}
knownTotalBytes += size
if (
limits.maxTotalUncompressedBytes !== undefined &&
knownTotalBytes > limits.maxTotalUncompressedBytes
) {
throwZipLimitExceeded(
`total uncompressed bytes ${knownTotalBytes} > maxTotalUncompressedBytes ${limits.maxTotalUncompressedBytes}`
)
}
if (normalizedPath.startsWith('ppt/media/')) {
knownMediaBytes += size
if (limits.maxMediaBytes !== undefined && knownMediaBytes > limits.maxMediaBytes) {
throwZipLimitExceeded(
`media bytes ${knownMediaBytes} > maxMediaBytes ${limits.maxMediaBytes}`
)
}
}
}
const result: PptxFiles = {
contentTypes: '',
presentation: '',
presentationRels: '',
slides: new Map(),
slideRels: new Map(),
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(),
}
let unknownMediaBytes = 0
await mapWithConcurrency(entries, maxConcurrency, async ([path, file]) => {
const normalizedPath = path.replace(/\\/g, '/')
// --- Content Types ---
if (normalizedPath === '[Content_Types].xml') {
result.contentTypes = await file.async('string')
return
}
// --- Presentation ---
if (normalizedPath === 'ppt/presentation.xml') {
result.presentation = await file.async('string')
return
}
// --- Presentation Rels ---
if (normalizedPath === 'ppt/_rels/presentation.xml.rels') {
result.presentationRels = await file.async('string')
return
}
// --- Table Styles ---
if (normalizedPath === 'ppt/tableStyles.xml') {
result.tableStyles = await file.async('string')
return
}
// --- Media (binary) ---
if (normalizedPath.startsWith('ppt/media/')) {
const bytes = await file.async('uint8array')
if (!knownSizeByPath.has(normalizedPath)) {
const size = bytes.byteLength
if (
limits.maxEntryUncompressedBytes !== undefined &&
size > limits.maxEntryUncompressedBytes
) {
throwZipLimitExceeded(
`${normalizedPath} is ${size} bytes > maxEntryUncompressedBytes ${limits.maxEntryUncompressedBytes}`
)
}
unknownMediaBytes += size
if (
limits.maxMediaBytes !== undefined &&
knownMediaBytes + unknownMediaBytes > limits.maxMediaBytes
) {
throwZipLimitExceeded(
`media bytes ${knownMediaBytes + unknownMediaBytes} > maxMediaBytes ${limits.maxMediaBytes}`
)
}
}
result.media.set(normalizedPath, bytes)
return
}
// --- Slide Rels (must check before slides to avoid false match) ---
if (/^ppt\/slides\/_rels\/slide\d+\.xml\.rels$/.test(normalizedPath)) {
result.slideRels.set(normalizedPath, await file.async('string'))
return
}
// --- Slides ---
if (/^ppt\/slides\/slide\d+\.xml$/.test(normalizedPath)) {
result.slides.set(normalizedPath, await file.async('string'))
return
}
// --- Slide Layout Rels ---
if (/^ppt\/slideLayouts\/_rels\/slideLayout\d+\.xml\.rels$/.test(normalizedPath)) {
result.slideLayoutRels.set(normalizedPath, await file.async('string'))
return
}
// --- Slide Layouts ---
if (/^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(normalizedPath)) {
result.slideLayouts.set(normalizedPath, await file.async('string'))
return
}
// --- Slide Master Rels ---
if (/^ppt\/slideMasters\/_rels\/slideMaster\d+\.xml\.rels$/.test(normalizedPath)) {
result.slideMasterRels.set(normalizedPath, await file.async('string'))
return
}
// --- Slide Masters ---
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(normalizedPath)) {
result.slideMasters.set(normalizedPath, await file.async('string'))
return
}
// --- Themes ---
if (/^ppt\/theme\/theme\d+\.xml$/.test(normalizedPath)) {
result.themes.set(normalizedPath, await file.async('string'))
return
}
// --- Charts ---
if (/^ppt\/charts\/chart\d+\.xml$/.test(normalizedPath)) {
result.charts.set(normalizedPath, await file.async('string'))
return
}
// --- Chart Styles ---
if (/^ppt\/charts\/style\d+\.xml$/.test(normalizedPath)) {
result.chartStyles.set(normalizedPath, await file.async('string'))
return
}
// --- Chart Colors ---
if (/^ppt\/charts\/colors\d+\.xml$/.test(normalizedPath)) {
result.chartColors.set(normalizedPath, await file.async('string'))
return
}
// --- Diagram Drawings (SmartArt fallback) ---
if (/^ppt\/diagrams\/drawing\d+\.xml$/.test(normalizedPath)) {
result.diagramDrawings.set(normalizedPath, await file.async('string'))
return
}
})
return result
}