| 1 | import { unzipSync } from 'fflate' |
| 2 | import { |
| 3 | parseOoxmlCustomGeometryXml, |
| 4 | type OoxmlCustomGeometry |
| 5 | } from '@arcsin1/pptx-ooxml-geometry' |
| 6 | |
| 7 | export type PptxXmlShapeMetadata = { |
| 8 | id: string |
| 9 | name: string |
| 10 | preset: string |
| 11 | isCustomGeometry?: boolean |
| 12 | customGeometry?: OoxmlCustomGeometry |
| 13 | fillColor?: string |
| 14 | lineColor?: string |
| 15 | lineWidth?: number |
| 16 | headEnd?: string |
| 17 | tailEnd?: string |
| 18 | flipH?: boolean |
| 19 | flipV?: boolean |
| 20 | left?: number |
| 21 | top?: number |
| 22 | width?: number |
| 23 | height?: number |
| 24 | rotate?: number |
| 25 | adjustments?: Record<string, number> |
| 26 | textInsets?: { |
| 27 | top?: number |
| 28 | right?: number |
| 29 | bottom?: number |
| 30 | left?: number |
| 31 | } |
| 32 | textAnchor?: string |
| 33 | } |
| 34 | |
| 35 | export type PptxXmlSlideMetadata = { |
| 36 | byName: Map<string, PptxXmlShapeMetadata> |
| 37 | } |
| 38 | |
| 39 | export type PptxXmlDeckMetadata = { |
| 40 | slides: Map<number, PptxXmlSlideMetadata> |
| 41 | themeColors: Map<string, string> |
| 42 | } |
| 43 | |
| 44 | const decodeUtf8 = (data: Uint8Array): string => new TextDecoder().decode(data) |
| 45 | |
| 46 | const clampNumber = (value: unknown, fallback = 0): number => { |
| 47 | const n = Number(value) |
| 48 | return Number.isFinite(n) ? n : fallback |
| 49 | } |
| 50 | |
| 51 | const parseXmlAttributes = (tag: string): Record<string, string> => { |
| 52 | const attrs: Record<string, string> = {} |
| 53 | const attrRe = /([\w:-]+)=["']([^"']*)["']/g |
| 54 | let match: RegExpExecArray | null |
| 55 | while ((match = attrRe.exec(tag)) !== null) attrs[match[1]] = match[2] |
| 56 | return attrs |
| 57 | } |
| 58 | |
| 59 | const dirname = (path: string): string => path.replace(/\/[^/]*$/, '') |
| 60 | |
| 61 | const normalizeZipPath = (basePath: string, target: string): string => { |
| 62 | const parts = `${dirname(basePath)}/${target}`.split('/') |
| 63 | const normalized: string[] = [] |
| 64 | for (const part of parts) { |
| 65 | if (!part || part === '.') continue |
| 66 | if (part === '..') normalized.pop() |
| 67 | else normalized.push(part) |
| 68 | } |
| 69 | return normalized.join('/') |
| 70 | } |
| 71 | |
| 72 | const relsPathFor = (path: string): string => { |
| 73 | const file = path.split('/').pop() || path |
| 74 | return `${dirname(path)}/_rels/${file}.rels` |
| 75 | } |
| 76 | |
| 77 | const parseRelationships = (xml: string): Map<string, { type: string; target: string }> => { |
| 78 | const relationships = new Map<string, { type: string; target: string }>() |
| 79 | const relRe = /<Relationship\b[^>]*>/g |
| 80 | let match: RegExpExecArray | null |
| 81 | while ((match = relRe.exec(xml)) !== null) { |
| 82 | const attrs = parseXmlAttributes(match[0]) |
| 83 | if (!attrs.Id || !attrs.Target) continue |
| 84 | relationships.set(attrs.Id, { |
| 85 | type: (attrs.Type || '').split('/').pop() || '', |
| 86 | target: attrs.Target |
| 87 | }) |
| 88 | } |
| 89 | return relationships |
| 90 | } |
| 91 | |
| 92 | const applyColorTransform = (hex: string, colorXml: string): string => { |
| 93 | const normalized = hex.replace(/^#/, '').padStart(6, '0').slice(0, 6) |
| 94 | const tint = colorXml.match(/<a:tint\b[^>]*\bval=["'](\d+)["']/)?.[1] |
| 95 | const shade = colorXml.match(/<a:shade\b[^>]*\bval=["'](\d+)["']/)?.[1] |
| 96 | const lumMod = colorXml.match(/<a:lumMod\b[^>]*\bval=["'](\d+)["']/)?.[1] |
| 97 | const lumOff = colorXml.match(/<a:lumOff\b[^>]*\bval=["'](\d+)["']/)?.[1] |
| 98 | const alpha = colorXml.match(/<a:alpha\b[^>]*\bval=["'](\d+)["']/)?.[1] |
| 99 | let channels = [0, 2, 4].map((index) => parseInt(normalized.slice(index, index + 2), 16)) |
| 100 | if (tint) { |
| 101 | const ratio = clampNumber(tint, 100000) / 100000 |
| 102 | channels = channels.map((value) => Math.round(value + (255 - value) * ratio)) |
| 103 | } |
| 104 | if (shade) { |
| 105 | const ratio = clampNumber(shade, 100000) / 100000 |
| 106 | channels = channels.map((value) => Math.round(value * ratio)) |
| 107 | } |
| 108 | if (lumMod || lumOff) { |
| 109 | const mod = clampNumber(lumMod, 100000) / 100000 |
| 110 | const off = clampNumber(lumOff, 0) / 100000 |
| 111 | channels = channels.map((value) => Math.round(value * mod + 255 * off)) |
| 112 | } |
| 113 | const color = channels |
| 114 | .map((value) => Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0')) |
| 115 | .join('') |
| 116 | .toUpperCase() |
| 117 | if (!alpha || clampNumber(alpha, 100000) >= 100000) return `#${color}` |
| 118 | const alphaHex = Math.round((clampNumber(alpha) / 100000) * 255) |
| 119 | .toString(16) |
| 120 | .padStart(2, '0') |
| 121 | .toUpperCase() |
| 122 | return `#${color}${alphaHex}` |
| 123 | } |
| 124 | |
| 125 | const parseOoxmlColor = ( |
| 126 | xml: string, |
| 127 | themeColors: Map<string, string> |
| 128 | ): string | undefined => { |
| 129 | if (/<a:noFill\b/.test(xml)) return undefined |
| 130 | const srgbMatch = xml.match( |
| 131 | /<a:srgbClr\b[^>]*\bval=["']([0-9A-Fa-f]{6})["'][^>]*\/>|<a:srgbClr\b[^>]*\bval=["']([0-9A-Fa-f]{6})["'][^>]*>[\s\S]*?<\/a:srgbClr>/ |
| 132 | ) |
| 133 | if (srgbMatch) return applyColorTransform(srgbMatch[1] || srgbMatch[2], srgbMatch[0]) |
| 134 | const schemeMatch = xml.match( |
| 135 | /<a:schemeClr\b[^>]*\bval=["']([^"']+)["'][^>]*\/>|<a:schemeClr\b[^>]*\bval=["']([^"']+)["'][^>]*>[\s\S]*?<\/a:schemeClr>/ |
| 136 | ) |
| 137 | if (schemeMatch) { |
| 138 | const theme = themeColors.get(schemeMatch[1] || schemeMatch[2]) |
| 139 | return theme ? applyColorTransform(theme, schemeMatch[0]) : undefined |
| 140 | } |
| 141 | const presetMatch = xml.match(/<a:prstClr\b[^>]*\bval=["']([^"']+)["']/) |
| 142 | if (presetMatch?.[1] === 'black') return '#000000' |
| 143 | if (presetMatch?.[1] === 'white') return '#ffffff' |
| 144 | return undefined |
| 145 | } |
| 146 | |
| 147 | const parseGeometryAdjustments = (xml: string): Record<string, number> | undefined => { |
| 148 | const adjustments: Record<string, number> = {} |
| 149 | const gdRe = /<a:gd\b[^>]*>/g |
| 150 | let match: RegExpExecArray | null |
| 151 | while ((match = gdRe.exec(xml)) !== null) { |
| 152 | const attrs = parseXmlAttributes(match[0]) |
| 153 | const value = attrs.fmla?.match(/^val\s+(-?\d+(?:\.\d+)?)$/)?.[1] |
| 154 | if (attrs.name && value !== undefined) adjustments[attrs.name] = clampNumber(value) |
| 155 | } |
| 156 | return Object.keys(adjustments).length ? adjustments : undefined |
| 157 | } |
| 158 | |
| 159 | const parseTextInset = (value: string | undefined): number | undefined => |
| 160 | value === undefined ? undefined : clampNumber(value) / 12700 |
| 161 | |
| 162 | const parsePptxThemeColors = (files: Record<string, Uint8Array>, themeName: string): Map<string, string> => { |
| 163 | const colors = new Map<string, string>() |
| 164 | if (!themeName) return colors |
| 165 | const xml = decodeUtf8(files[themeName]) |
| 166 | const colorRe = /<a:(dk1|lt1|dk2|lt2|accent\d|hlink|folHlink)\b[^>]*>[\s\S]*?<\/a:\1>/g |
| 167 | let match: RegExpExecArray | null |
| 168 | while ((match = colorRe.exec(xml)) !== null) { |
| 169 | const value = match[0].match( |
| 170 | /<(?:a:srgbClr|a:sysClr)\b[^>]*(?:val|lastClr)=["']([0-9A-Fa-f]{6})["']/ |
| 171 | )?.[1] |
| 172 | if (value) colors.set(match[1], value) |
| 173 | } |
| 174 | colors.set('tx1', colors.get('dk1') || '000000') |
| 175 | colors.set('tx2', colors.get('dk2') || '000000') |
| 176 | colors.set('bg1', colors.get('lt1') || 'FFFFFF') |
| 177 | colors.set('bg2', colors.get('lt2') || 'FFFFFF') |
| 178 | return colors |
| 179 | } |
| 180 | |
| 181 | const parseAllPptxThemeColors = ( |
| 182 | files: Record<string, Uint8Array> |
| 183 | ): Map<string, Map<string, string>> => { |
| 184 | const themes = new Map<string, Map<string, string>>() |
| 185 | for (const name of Object.keys(files)) { |
| 186 | if (!/^ppt\/theme\/theme\d+\.xml$/i.test(name)) continue |
| 187 | themes.set(name, parsePptxThemeColors(files, name)) |
| 188 | } |
| 189 | return themes |
| 190 | } |
| 191 | |
| 192 | const findThemeForSlide = ( |
| 193 | files: Record<string, Uint8Array>, |
| 194 | slidePath: string, |
| 195 | fallbackThemePath: string |
| 196 | ): string => { |
| 197 | const slideRels = files[relsPathFor(slidePath)] |
| 198 | if (!slideRels) return fallbackThemePath |
| 199 | const layoutRel = [...parseRelationships(decodeUtf8(slideRels)).values()].find( |
| 200 | (rel) => rel.type === 'slideLayout' |
| 201 | ) |
| 202 | if (!layoutRel) return fallbackThemePath |
| 203 | const layoutPath = normalizeZipPath(slidePath, layoutRel.target) |
| 204 | const layoutRels = files[relsPathFor(layoutPath)] |
| 205 | if (!layoutRels) return fallbackThemePath |
| 206 | const masterRel = [...parseRelationships(decodeUtf8(layoutRels)).values()].find( |
| 207 | (rel) => rel.type === 'slideMaster' |
| 208 | ) |
| 209 | if (!masterRel) return fallbackThemePath |
| 210 | const masterPath = normalizeZipPath(layoutPath, masterRel.target) |
| 211 | const masterRels = files[relsPathFor(masterPath)] |
| 212 | if (!masterRels) return fallbackThemePath |
| 213 | const themeRel = [...parseRelationships(decodeUtf8(masterRels)).values()].find( |
| 214 | (rel) => rel.type === 'theme' |
| 215 | ) |
| 216 | return themeRel ? normalizeZipPath(masterPath, themeRel.target) : fallbackThemePath |
| 217 | } |
| 218 | |
| 219 | export const parsePptxXmlDeckMetadata = (buffer: Buffer): PptxXmlDeckMetadata => { |
| 220 | let files: Record<string, Uint8Array> |
| 221 | try { |
| 222 | files = unzipSync(new Uint8Array(buffer)) |
| 223 | } catch { |
| 224 | return { slides: new Map(), themeColors: new Map() } |
| 225 | } |
| 226 | const themePaths = Object.keys(files) |
| 227 | .filter((name) => /^ppt\/theme\/theme\d+\.xml$/i.test(name)) |
| 228 | .sort() |
| 229 | const fallbackThemePath = themePaths[0] || '' |
| 230 | const themes = parseAllPptxThemeColors(files) |
| 231 | const themeColors = themes.get(fallbackThemePath) || new Map<string, string>() |
| 232 | const slides = new Map<number, PptxXmlSlideMetadata>() |
| 233 | for (const name of Object.keys(files)) { |
| 234 | const slideMatch = name.match(/^ppt\/slides\/slide(\d+)\.xml$/i) |
| 235 | if (!slideMatch) continue |
| 236 | const xml = decodeUtf8(files[name]) |
| 237 | const slideThemePath = findThemeForSlide(files, name, fallbackThemePath) |
| 238 | const slideThemeColors = themes.get(slideThemePath) || themeColors |
| 239 | const byName = new Map<string, PptxXmlShapeMetadata>() |
| 240 | const shapeRe = /<p:(sp|cxnSp)\b[\s\S]*?<\/p:\1>/g |
| 241 | let shapeMatch: RegExpExecArray | null |
| 242 | while ((shapeMatch = shapeRe.exec(xml)) !== null) { |
| 243 | const shapeXml = shapeMatch[0] |
| 244 | const cNvPr = shapeXml.match(/<p:cNvPr\b[^>]*>/)?.[0] || '' |
| 245 | const attrs = parseXmlAttributes(cNvPr) |
| 246 | const preset = shapeXml.match(/<a:prstGeom\b[^>]*\bprst=["']([^"']+)["']/)?.[1] || '' |
| 247 | const customGeometryXml = shapeXml.match(/<a:custGeom\b[\s\S]*?<\/a:custGeom>/)?.[0] || '' |
| 248 | const customGeometry = customGeometryXml |
| 249 | ? parseOoxmlCustomGeometryXml(customGeometryXml) |
| 250 | : undefined |
| 251 | const isCustomGeometry = Boolean(customGeometryXml) |
| 252 | const prstGeomXml = shapeXml.match(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/)?.[0] || '' |
| 253 | const spPr = shapeXml.match(/<p:spPr\b[\s\S]*?<\/p:spPr>/)?.[0] || '' |
| 254 | const xfrmAttrs = parseXmlAttributes(spPr.match(/<a:xfrm\b[^>]*>/)?.[0] || '') |
| 255 | const offAttrs = parseXmlAttributes(spPr.match(/<a:off\b[^>]*>/)?.[0] || '') |
| 256 | const extAttrs = parseXmlAttributes(spPr.match(/<a:ext\b[^>]*>/)?.[0] || '') |
| 257 | const fillXml = spPr.match(/<a:solidFill\b[\s\S]*?<\/a:solidFill>/)?.[0] || '' |
| 258 | const lineXml = spPr.match(/<a:ln\b[\s\S]*?<\/a:ln>/)?.[0] || '' |
| 259 | const styleXml = shapeXml.match(/<p:style\b[\s\S]*?<\/p:style>/)?.[0] || '' |
| 260 | const lineRefXml = styleXml.match(/<a:lnRef\b[\s\S]*?<\/a:lnRef>/)?.[0] || '' |
| 261 | const lineAttrs = parseXmlAttributes(lineXml.match(/<a:ln\b[^>]*>/)?.[0] || '') |
| 262 | const headEndAttrs = parseXmlAttributes(lineXml.match(/<a:headEnd\b[^>]*>/)?.[0] || '') |
| 263 | const tailEndAttrs = parseXmlAttributes(lineXml.match(/<a:tailEnd\b[^>]*>/)?.[0] || '') |
| 264 | const bodyPrAttrs = parseXmlAttributes(shapeXml.match(/<a:bodyPr\b[^>]*>/)?.[0] || '') |
| 265 | const metadata: PptxXmlShapeMetadata = { |
| 266 | id: attrs.id || '', |
| 267 | name: attrs.name || '', |
| 268 | preset, |
| 269 | isCustomGeometry, |
| 270 | customGeometry, |
| 271 | fillColor: fillXml ? parseOoxmlColor(fillXml, slideThemeColors) : undefined, |
| 272 | lineColor: lineXml |
| 273 | ? parseOoxmlColor(lineXml, slideThemeColors) || |
| 274 | (/<a:noFill\b/.test(lineXml) ? undefined : parseOoxmlColor(lineRefXml, slideThemeColors)) |
| 275 | : undefined, |
| 276 | lineWidth: lineAttrs.w ? clampNumber(lineAttrs.w) / 12700 : undefined, |
| 277 | headEnd: headEndAttrs.type, |
| 278 | tailEnd: tailEndAttrs.type, |
| 279 | flipH: xfrmAttrs.flipH === '1', |
| 280 | flipV: xfrmAttrs.flipV === '1', |
| 281 | left: offAttrs.x ? clampNumber(offAttrs.x) / 12700 : undefined, |
| 282 | top: offAttrs.y ? clampNumber(offAttrs.y) / 12700 : undefined, |
| 283 | width: extAttrs.cx ? clampNumber(extAttrs.cx) / 12700 : undefined, |
| 284 | height: extAttrs.cy ? clampNumber(extAttrs.cy) / 12700 : undefined, |
| 285 | rotate: xfrmAttrs.rot ? clampNumber(xfrmAttrs.rot) / 60000 : undefined, |
| 286 | adjustments: parseGeometryAdjustments(prstGeomXml), |
| 287 | textInsets: { |
| 288 | top: parseTextInset(bodyPrAttrs.tIns), |
| 289 | right: parseTextInset(bodyPrAttrs.rIns), |
| 290 | bottom: parseTextInset(bodyPrAttrs.bIns), |
| 291 | left: parseTextInset(bodyPrAttrs.lIns) |
| 292 | }, |
| 293 | textAnchor: bodyPrAttrs.anchor |
| 294 | } |
| 295 | if (metadata.name) byName.set(metadata.name, metadata) |
| 296 | } |
| 297 | slides.set(Number(slideMatch[1]), { byName }) |
| 298 | } |
| 299 | return { slides, themeColors } |
| 300 | } |
| 301 |