/** * 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 slideRels: Map slideLayouts: Map slideLayoutRels: Map slideMasters: Map slideMasterRels: Map themes: Map media: Map tableStyles?: string charts: Map // ppt/charts/chart*.xml chartStyles: Map // ppt/charts/style*.xml chartColors: Map // ppt/charts/colors*.xml diagramDrawings: Map // 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( items: T[], concurrency: number, mapper: (item: T) => Promise ): Promise { 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 { 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() 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 }