返回 oh-my-ppt
xml-shape-metadata.ts
根目录 / src / main / io / pptx-import / xml-shape-metadata.ts
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
301 lines TYPESCRIPT