返回 presentation-ai
pptx-theme-extractor.ts
根目录 / src / lib / presentation / pptx-theme-extractor.ts
1 import JSZip from "jszip";
2
3 import { type ThemeMode, type ThemeProperties } from "./themes";
4
5 // ============ Office Font → Web Font Mapping ============
6
7 const OFFICE_FONT_MAP: Record<string, string> = {
8 Calibri: "Inter",
9 "Calibri Light": "Inter",
10 Cambria: "Merriweather",
11 Arial: "Inter",
12 "Times New Roman": "Lora",
13 Verdana: "Open Sans",
14 Georgia: "Source Serif Pro",
15 "Trebuchet MS": "Montserrat",
16 Tahoma: "Inter",
17 "Century Gothic": "Poppins",
18 Garamond: "Cormorant Garamond",
19 "Book Antiqua": "Libre Baskerville",
20 Palatino: "Libre Baskerville",
21 "Franklin Gothic Medium": "Manrope",
22 Impact: "Sora",
23 "Lucida Sans": "Nunito",
24 };
25
26 function mapFont(fontName: string): string {
27 return OFFICE_FONT_MAP[fontName] ?? fontName;
28 }
29
30 // ============ Color Utilities ============
31
32 function getLuminance(hex: string): number {
33 const r = parseInt(hex.slice(1, 3), 16) / 255;
34 const g = parseInt(hex.slice(3, 5), 16) / 255;
35 const b = parseInt(hex.slice(5, 7), 16) / 255;
36 const toLinear = (c: number) =>
37 c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
38 return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
39 }
40
41 function clampHex(n: number): string {
42 return Math.max(0, Math.min(255, Math.round(n)))
43 .toString(16)
44 .padStart(2, "0");
45 }
46
47 function lighten(hex: string, amount: number): string {
48 const r = parseInt(hex.slice(1, 3), 16);
49 const g = parseInt(hex.slice(3, 5), 16);
50 const b = parseInt(hex.slice(5, 7), 16);
51 return `#${clampHex(r + (255 - r) * amount)}${clampHex(g + (255 - g) * amount)}${clampHex(b + (255 - b) * amount)}`;
52 }
53
54 function colorDistance(a: string, b: string): number {
55 const ar = parseInt(a.slice(1, 3), 16);
56 const ag = parseInt(a.slice(3, 5), 16);
57 const ab = parseInt(a.slice(5, 7), 16);
58 const br = parseInt(b.slice(1, 3), 16);
59 const bg = parseInt(b.slice(3, 5), 16);
60 const bb = parseInt(b.slice(5, 7), 16);
61 return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
62 }
63
64 function darken(hex: string, amount: number): string {
65 const r = parseInt(hex.slice(1, 3), 16);
66 const g = parseInt(hex.slice(3, 5), 16);
67 const b = parseInt(hex.slice(5, 7), 16);
68 return `#${clampHex(r * (1 - amount))}${clampHex(g * (1 - amount))}${clampHex(b * (1 - amount))}`;
69 }
70
71 function averageHex(a: string, b: string): string {
72 const ar = parseInt(a.slice(1, 3), 16);
73 const ag = parseInt(a.slice(3, 5), 16);
74 const ab = parseInt(a.slice(5, 7), 16);
75 const br = parseInt(b.slice(1, 3), 16);
76 const bg = parseInt(b.slice(3, 5), 16);
77 const bb = parseInt(b.slice(5, 7), 16);
78 return `#${clampHex((ar + br) / 2)}${clampHex((ag + bg) / 2)}${clampHex((ab + bb) / 2)}`;
79 }
80
81 /** Convert hex to HSL [0-360, 0-1, 0-1] */
82 function hexToHsl(hex: string): [number, number, number] {
83 const r = parseInt(hex.slice(1, 3), 16) / 255;
84 const g = parseInt(hex.slice(3, 5), 16) / 255;
85 const b = parseInt(hex.slice(5, 7), 16) / 255;
86 const max = Math.max(r, g, b);
87 const min = Math.min(r, g, b);
88 const l = (max + min) / 2;
89 if (max === min) return [0, 0, l];
90 const d = max - min;
91 const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
92 let h = 0;
93 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
94 else if (max === g) h = ((b - r) / d + 2) / 6;
95 else h = ((r - g) / d + 4) / 6;
96 return [h * 360, s, l];
97 }
98
99 /** Convert HSL to hex */
100 function hslToHex(h: number, s: number, l: number): string {
101 h = ((h % 360) + 360) % 360;
102 s = Math.max(0, Math.min(1, s));
103 l = Math.max(0, Math.min(1, l));
104 const hue2rgb = (p: number, q: number, t: number) => {
105 if (t < 0) t += 1;
106 if (t > 1) t -= 1;
107 if (t < 1 / 6) return p + (q - p) * 6 * t;
108 if (t < 1 / 2) return q;
109 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
110 return p;
111 };
112 if (s === 0) {
113 const v = Math.round(l * 255);
114 return `#${clampHex(v)}${clampHex(v)}${clampHex(v)}`;
115 }
116 const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
117 const p = 2 * l - q;
118 const r = Math.round(hue2rgb(p, q, h / 360 + 1 / 3) * 255);
119 const g = Math.round(hue2rgb(p, q, h / 360) * 255);
120 const b = Math.round(hue2rgb(p, q, h / 360 - 1 / 3) * 255);
121 return `#${clampHex(r)}${clampHex(g)}${clampHex(b)}`;
122 }
123
124 /**
125 * Apply OOXML color modifiers (lumMod, lumOff, tint, shade) to a base hex color.
126 * Modifier values in OOXML are expressed as 1/1000th percent (e.g., 75000 = 75%).
127 */
128 function applyColorModifiers(
129 hex: string,
130 modifiers: { type: string; val: number }[],
131 ): string {
132 let [h, s, l] = hexToHsl(hex);
133
134 for (const mod of modifiers) {
135 const pct = mod.val / 100000; // OOXML uses 100000 = 100%
136 switch (mod.type) {
137 case "lumMod":
138 l = l * pct;
139 break;
140 case "lumOff":
141 l = l + pct;
142 break;
143 case "tint":
144 // Tint: mix towards white
145 l = l + (1 - l) * pct;
146 break;
147 case "shade":
148 // Shade: mix towards black
149 l = l * pct;
150 break;
151 case "satMod":
152 s = s * pct;
153 break;
154 case "satOff":
155 s = s + pct;
156 break;
157 // alpha is ignored — we don't want transparency
158 }
159 }
160
161 return hslToHex(h, s, l);
162 }
163
164 // ============ XML Utilities ============
165
166 /** Cache-friendly element search by localName on a single parent's children */
167 function findChildByLocalName(
168 parent: Element | null,
169 localName: string,
170 ): Element | null {
171 if (!parent) return null;
172 for (let i = 0; i < parent.children.length; i++) {
173 const child = parent.children[i];
174 if (child && child.localName === localName) return child;
175 }
176 return null;
177 }
178
179 /** Deep search by localName — use sparingly on small subtrees */
180 function findByLocalName(
181 root: Document | Element | null,
182 localName: string,
183 ): Element | null {
184 if (!root) return null;
185 const all = root.getElementsByTagName("*");
186 for (let i = 0; i < all.length; i++) {
187 const el = all[i];
188 if (el && el.localName === localName) return el;
189 }
190 return null;
191 }
192
193 /** Get hex color from an OOXML color element (handles srgbClr + sysClr) */
194 function getColorFromElement(parent: Element | null): string | null {
195 if (!parent) return null;
196
197 const srgb = findByLocalName(parent, "srgbClr");
198 if (srgb) {
199 const val = srgb.getAttribute("val");
200 if (val) return `#${val.toUpperCase()}`;
201 }
202
203 const sys = findByLocalName(parent, "sysClr");
204 if (sys) {
205 const lastClr = sys.getAttribute("lastClr");
206 if (lastClr) return `#${lastClr.toUpperCase()}`;
207 }
208
209 return null;
210 }
211
212 /** Collect OOXML modifier children from a color element (srgbClr or schemeClr) */
213 function collectChildModifiers(el: Element): { type: string; val: number }[] {
214 const modifiers: { type: string; val: number }[] = [];
215 for (let i = 0; i < el.children.length; i++) {
216 const child = el.children[i];
217 if (!child) continue;
218 const modVal = child.getAttribute("val");
219 if (modVal && child.localName) {
220 modifiers.push({ type: child.localName, val: parseInt(modVal, 10) });
221 }
222 }
223 return modifiers;
224 }
225
226 /** Resolve a color from a solidFill element (handles srgbClr + schemeClr with modifiers) */
227 function resolveColorFromFill(
228 solidFillEl: Element | null,
229 palette: ThemePalette | null,
230 clrMap: Record<string, ThemeColorSlot>,
231 ): string | null {
232 if (!solidFillEl) return null;
233
234 // Direct srgbClr
235 const srgb = findChildByLocalName(solidFillEl, "srgbClr");
236 if (srgb) {
237 const val = srgb.getAttribute("val");
238 if (val) {
239 const base = `#${val.toUpperCase()}`;
240 const modifiers = collectChildModifiers(srgb);
241 return modifiers.length > 0 ? applyColorModifiers(base, modifiers) : base;
242 }
243 }
244
245 // schemeClr (needs palette to resolve)
246 if (palette) {
247 const schemeClr = findChildByLocalName(solidFillEl, "schemeClr");
248 if (schemeClr) {
249 const val = schemeClr.getAttribute("val");
250 if (val) {
251 const slot = clrMap[val] ?? (val as ThemeColorSlot);
252 const baseColor = palette[slot];
253 if (baseColor) {
254 const modifiers = collectChildModifiers(schemeClr);
255 return modifiers.length > 0
256 ? applyColorModifiers(baseColor, modifiers)
257 : baseColor;
258 }
259 }
260 }
261 }
262
263 return null;
264 }
265
266 /** Parse a DOMParser XML string */
267 function parseXml(xmlString: string): Document {
268 return new DOMParser().parseFromString(xmlString, "application/xml");
269 }
270
271 // ============ Design Defaults (matches "standard" style) ============
272
273 const STANDARD_DESIGN = {
274 borderRadius: {
275 card: "0.5rem",
276 slide: "0.5rem",
277 button: "0.375rem",
278 },
279 transitions: {
280 default: "all 0.2s ease-in-out",
281 },
282 shadows: {
283 card: "0 1px 3px rgba(0,0,0,0.05)",
284 button: "0 1px 2px rgba(0,0,0,0.03)",
285 slide: "0 2px 4px rgba(0,0,0,0.04)",
286 },
287 } as const;
288
289 // ============ OOXML Theme Palette ============
290
291 /** The 12 standard OOXML theme color slots */
292 type ThemeColorSlot =
293 | "dk1"
294 | "lt1"
295 | "dk2"
296 | "lt2"
297 | "accent1"
298 | "accent2"
299 | "accent3"
300 | "accent4"
301 | "accent5"
302 | "accent6"
303 | "hlink"
304 | "folHlink";
305
306 const THEME_COLOR_SLOTS: ThemeColorSlot[] = [
307 "dk1",
308 "lt1",
309 "dk2",
310 "lt2",
311 "accent1",
312 "accent2",
313 "accent3",
314 "accent4",
315 "accent5",
316 "accent6",
317 "hlink",
318 "folHlink",
319 ];
320
321 type ThemePalette = Record<ThemeColorSlot, string | null>;
322
323 /**
324 * clrMap maps logical names (bg1, tx1, bg2, tx2, accent1...) to
325 * theme palette slots (lt1, dk1, lt2, dk2, accent1...).
326 * Default Office mapping if no clrMap is found.
327 */
328 const DEFAULT_CLR_MAP: Record<string, ThemeColorSlot> = {
329 bg1: "lt1",
330 tx1: "dk1",
331 bg2: "lt2",
332 tx2: "dk2",
333 accent1: "accent1",
334 accent2: "accent2",
335 accent3: "accent3",
336 accent4: "accent4",
337 accent5: "accent5",
338 accent6: "accent6",
339 hlink: "hlink",
340 folHlink: "folHlink",
341 };
342
343 // ============ Step 1: Resolve theme via OOXML relationships ============
344
345 /**
346 * Follow the OOXML relationship chain to find the correct theme XML:
347 * presentation.xml.rels → slideMaster → slideMaster.rels → theme.xml
348 */
349 async function resolveThemeXml(zip: JSZip): Promise<{
350 themeXml: string | null;
351 slideMasterXml: string | null;
352 }> {
353 // Read presentation.xml.rels to find slide masters
354 const presRelsEntry = zip.file("ppt/_rels/presentation.xml.rels");
355 if (!presRelsEntry) {
356 console.log("[PPTX Resolve] No presentation.xml.rels found");
357 return { themeXml: null, slideMasterXml: null };
358 }
359
360 const presRelsXml = parseXml(await presRelsEntry.async("string"));
361 const presRels = presRelsXml.getElementsByTagName("Relationship");
362
363 // Find first slideMaster relationship
364 let slideMasterPath: string | null = null;
365 for (let i = 0; i < presRels.length; i++) {
366 const rel = presRels[i];
367 const type = rel?.getAttribute("Type") ?? "";
368 if (type.includes("/slideMaster")) {
369 const target = rel?.getAttribute("Target");
370 if (target) {
371 // Target is relative to ppt/, e.g. "slideMasters/slideMaster1.xml"
372 slideMasterPath = target.startsWith("ppt/") ? target : `ppt/${target}`;
373 break;
374 }
375 }
376 }
377
378 if (!slideMasterPath) {
379 console.log("[PPTX Resolve] No slideMaster relationship found");
380 return { themeXml: null, slideMasterXml: null };
381 }
382
383 console.log("[PPTX Resolve] Found slideMaster:", slideMasterPath);
384
385 // Read the slide master XML (needed for clrMap)
386 const smEntry = zip.file(slideMasterPath);
387 const slideMasterXml = smEntry ? await smEntry.async("string") : null;
388
389 // Read slideMaster .rels to find the theme
390 const smDir = slideMasterPath.substring(0, slideMasterPath.lastIndexOf("/"));
391 const smFileName = slideMasterPath.substring(
392 slideMasterPath.lastIndexOf("/") + 1,
393 );
394 const smRelsPath = `${smDir}/_rels/${smFileName}.rels`;
395 const smRelsEntry = zip.file(smRelsPath);
396
397 if (!smRelsEntry) {
398 console.log("[PPTX Resolve] No slideMaster .rels found at:", smRelsPath);
399 return { themeXml: null, slideMasterXml };
400 }
401
402 const smRelsXml = parseXml(await smRelsEntry.async("string"));
403 const smRels = smRelsXml.getElementsByTagName("Relationship");
404
405 let themePath: string | null = null;
406 for (let i = 0; i < smRels.length; i++) {
407 const rel = smRels[i];
408 const type = rel?.getAttribute("Type") ?? "";
409 if (type.includes("/theme")) {
410 const target = rel?.getAttribute("Target");
411 if (target) {
412 // Target is relative to slideMasters/, e.g. "../theme/theme1.xml"
413 themePath = resolveRelativePath(smDir, target);
414 break;
415 }
416 }
417 }
418
419 if (!themePath) {
420 console.log("[PPTX Resolve] No theme relationship in slideMaster .rels");
421 return { themeXml: null, slideMasterXml };
422 }
423
424 console.log("[PPTX Resolve] Resolved theme path:", themePath);
425
426 const themeEntry = zip.file(themePath);
427 const themeXml = themeEntry ? await themeEntry.async("string") : null;
428
429 return { themeXml, slideMasterXml };
430 }
431
432 /** Resolve a relative path like "../theme/theme1.xml" from a base directory */
433 function resolveRelativePath(baseDir: string, relative: string): string {
434 const parts = baseDir.split("/");
435 const relParts = relative.split("/");
436 for (const seg of relParts) {
437 if (seg === "..") parts.pop();
438 else if (seg !== ".") parts.push(seg);
439 }
440 return parts.join("/");
441 }
442
443 // ============ Step 2: Parse clrMap from slide master ============
444
445 function parseClrMap(
446 slideMasterXml: string | null,
447 ): Record<string, ThemeColorSlot> {
448 if (!slideMasterXml) return { ...DEFAULT_CLR_MAP };
449
450 const doc = parseXml(slideMasterXml);
451 const clrMapEl = findByLocalName(doc, "clrMap");
452 if (!clrMapEl) {
453 console.log("[PPTX clrMap] No clrMap element found, using defaults");
454 return { ...DEFAULT_CLR_MAP };
455 }
456
457 const map: Record<string, ThemeColorSlot> = { ...DEFAULT_CLR_MAP };
458 const attrs = clrMapEl.attributes;
459 for (let i = 0; i < attrs.length; i++) {
460 const attr = attrs[i];
461 if (attr && THEME_COLOR_SLOTS.includes(attr.value as ThemeColorSlot)) {
462 map[attr.name] = attr.value as ThemeColorSlot;
463 }
464 }
465
466 console.log("[PPTX clrMap] Parsed color map:", map);
467 return map;
468 }
469
470 // ============ Step 3: Parse theme palette from theme XML ============
471
472 function parseThemePalette(themeXml: string): {
473 palette: ThemePalette;
474 headingFont: string;
475 bodyFont: string;
476 } {
477 const doc = parseXml(themeXml);
478 const clrScheme = findByLocalName(doc, "clrScheme");
479
480 const palette: ThemePalette = {
481 dk1: null,
482 lt1: null,
483 dk2: null,
484 lt2: null,
485 accent1: null,
486 accent2: null,
487 accent3: null,
488 accent4: null,
489 accent5: null,
490 accent6: null,
491 hlink: null,
492 folHlink: null,
493 };
494
495 for (const slot of THEME_COLOR_SLOTS) {
496 palette[slot] = getColorFromElement(findChildByLocalName(clrScheme, slot));
497 }
498
499 console.log("[PPTX Palette] Raw theme colors:", palette);
500
501 // Extract fonts
502 const fontScheme = findByLocalName(doc, "fontScheme");
503 const majorFont = findByLocalName(fontScheme, "majorFont");
504 const minorFont = findByLocalName(fontScheme, "minorFont");
505
506 const rawHeading = majorFont
507 ? findByLocalName(majorFont, "latin")?.getAttribute("typeface")
508 : null;
509 const rawBody = minorFont
510 ? findByLocalName(minorFont, "latin")?.getAttribute("typeface")
511 : null;
512 const headingFont = mapFont(rawHeading ?? "Inter");
513 const bodyFont = mapFont(rawBody ?? "Inter");
514
515 console.log("[PPTX Palette] Fonts:", {
516 rawHeading,
517 rawBody,
518 mappedHeading: headingFont,
519 mappedBody: bodyFont,
520 });
521
522 return { palette, headingFont, bodyFont };
523 }
524
525 // ============ Step 4: Semantic slide content analysis ============
526
527 interface SlideContentAnalysis {
528 headingColors: Map<string, number>;
529 bodyColors: Map<string, number>;
530 backgroundColors: Map<string, number>;
531 shapeFillColors: Map<string, number>;
532 headingFonts: Map<string, number>;
533 bodyFonts: Map<string, number>;
534 }
535
536 /** Font size threshold in OOXML hundredths of a point. >= 3000 = heading */
537 const HEADING_SIZE_THRESHOLD = 1800;
538 const BIG_HEADING_SIZE_THRESHOLD = 2500;
539
540 /**
541 * Scan actual slide content to extract colors by semantic role:
542 * - heading: text runs with fontSize >= 3000 (30pt)
543 * - body: text runs with fontSize < 3000
544 * - background: slide background fills
545 * - shapeFill: non-text shape fills (for primary color detection)
546 */
547 async function scanSlideContent(
548 zip: JSZip,
549 palette: ThemePalette | null,
550 clrMap: Record<string, ThemeColorSlot>,
551 themeHeadingFont: string | null,
552 themeBodyFont: string | null,
553 ): Promise<SlideContentAnalysis> {
554 const analysis: SlideContentAnalysis = {
555 headingColors: new Map(),
556 bodyColors: new Map(),
557 backgroundColors: new Map(),
558 shapeFillColors: new Map(),
559 headingFonts: new Map(),
560 bodyFonts: new Map(),
561 };
562
563 const slidePaths = Object.keys(zip.files).filter(
564 (p) => p.startsWith("ppt/slides/slide") && p.endsWith(".xml"),
565 );
566
567 for (const path of slidePaths) {
568 const entry = zip.file(path);
569 if (!entry) continue;
570 const xmlString = await entry.async("string");
571 const doc = parseXml(xmlString);
572 const allElements = doc.getElementsByTagName("*");
573 const slideNumberMatch = path.match(/slide(\d+)\.xml$/i);
574 const slideNumber = slideNumberMatch
575 ? Math.max(1, parseInt(slideNumberMatch[1]!, 10))
576 : 1;
577 const slideWeight = 1 / Math.sqrt(slideNumber);
578 const slideHeadingWeights = new Map<string, number>();
579 const slideLargeHeadingColors = new Set<string>();
580
581 // 1. Extract slide background
582 const bg = findByLocalName(doc, "bg");
583 if (bg) {
584 const bgFill = findByLocalName(bg, "solidFill");
585 const bgColor = resolveColorFromFill(bgFill, palette, clrMap);
586 if (bgColor) {
587 const upper = bgColor.toUpperCase();
588 analysis.backgroundColors.set(
589 upper,
590 (analysis.backgroundColors.get(upper) || 0) + 1,
591 );
592 }
593 }
594
595 // 2. Collect shape fills (spPr > solidFill) for primary color detection
596 for (let i = 0; i < allElements.length; i++) {
597 const el = allElements[i];
598 if (!el || el.localName !== "spPr") continue;
599 const fill = findChildByLocalName(el, "solidFill");
600 const color = resolveColorFromFill(fill, palette, clrMap);
601 if (color) {
602 const upper = color.toUpperCase();
603 analysis.shapeFillColors.set(
604 upper,
605 (analysis.shapeFillColors.get(upper) || 0) + 1,
606 );
607 }
608 }
609
610 // 3. Scan text runs — classify as heading or body by font size
611 for (let i = 0; i < allElements.length; i++) {
612 const el = allElements[i];
613 if (!el || el.localName !== "r") continue;
614
615 // Only count runs with actual text content
616 const textEl = findChildByLocalName(el, "t");
617 if (!textEl || !textEl.textContent?.trim()) continue;
618
619 const rPr = findChildByLocalName(el, "rPr");
620
621 // --- Resolve font size ---
622 // Priority: rPr.sz → parent <a:p> pPr.defRPr.sz → default 1800 (18pt body)
623 let fontSize = rPr ? parseInt(rPr.getAttribute("sz") || "0", 10) : 0;
624
625 if (!fontSize && el.parentElement) {
626 const pPr = findChildByLocalName(el.parentElement, "pPr");
627 if (pPr) {
628 const defRPr = findChildByLocalName(pPr, "defRPr");
629 if (defRPr) {
630 fontSize = parseInt(defRPr.getAttribute("sz") || "0", 10);
631 }
632 }
633 }
634 if (!fontSize) fontSize = 1800; // Default 18pt (body)
635
636 // --- Resolve text color ---
637 // Priority: rPr > solidFill → parent pPr > defRPr > solidFill
638 let color: string | null = null;
639
640 if (rPr) {
641 const fill = findChildByLocalName(rPr, "solidFill");
642 color = resolveColorFromFill(fill, palette, clrMap);
643 }
644
645 if (!color && el.parentElement) {
646 const pPr = findChildByLocalName(el.parentElement, "pPr");
647 if (pPr) {
648 const defRPr = findChildByLocalName(pPr, "defRPr");
649 if (defRPr) {
650 const fill = findChildByLocalName(defRPr, "solidFill");
651 color = resolveColorFromFill(fill, palette, clrMap);
652 }
653 }
654 }
655
656 // --- Classify color by size ---
657 if (color) {
658 const upper = color.toUpperCase();
659 if (fontSize >= HEADING_SIZE_THRESHOLD) {
660 const fontSizeWeight = Math.max(1, fontSize);
661 const weighted = slideWeight * fontSizeWeight;
662 slideHeadingWeights.set(
663 upper,
664 (slideHeadingWeights.get(upper) || 0) + weighted,
665 );
666 if (fontSize >= BIG_HEADING_SIZE_THRESHOLD) {
667 slideLargeHeadingColors.add(upper);
668 }
669 } else {
670 analysis.bodyColors.set(
671 upper,
672 (analysis.bodyColors.get(upper) || 0) + 1,
673 );
674 }
675 }
676
677 // --- Resolve font name ---
678 // Priority: rPr > latin[@typeface] → parent pPr > defRPr > latin[@typeface]
679 // Resolve theme references: +mj-* → themeHeadingFont, +mn-* → themeBodyFont
680 let fontName: string | null = null;
681
682 if (rPr) {
683 const latin = findChildByLocalName(rPr, "latin");
684 const tf = latin?.getAttribute("typeface");
685 if (tf) {
686 if (tf.startsWith("+mj")) fontName = themeHeadingFont;
687 else if (tf.startsWith("+mn")) fontName = themeBodyFont;
688 else fontName = tf;
689 }
690 }
691
692 if (!fontName && el.parentElement) {
693 const pPr = findChildByLocalName(el.parentElement, "pPr");
694 if (pPr) {
695 const defRPr = findChildByLocalName(pPr, "defRPr");
696 if (defRPr) {
697 const latin = findChildByLocalName(defRPr, "latin");
698 const tf = latin?.getAttribute("typeface");
699 if (tf) {
700 if (tf.startsWith("+mj")) fontName = themeHeadingFont;
701 else if (tf.startsWith("+mn")) fontName = themeBodyFont;
702 else fontName = tf;
703 }
704 }
705 }
706 }
707
708 if (fontName) {
709 const mapped = mapFont(fontName);
710 if (fontSize >= HEADING_SIZE_THRESHOLD) {
711 analysis.headingFonts.set(
712 mapped,
713 (analysis.headingFonts.get(mapped) || 0) + 1,
714 );
715 } else {
716 analysis.bodyFonts.set(
717 mapped,
718 (analysis.bodyFonts.get(mapped) || 0) + 1,
719 );
720 }
721 }
722 }
723
724 if (slideHeadingWeights.size > 0) {
725 const largeCount = slideLargeHeadingColors.size;
726 const penaltyFactor = largeCount > 1 ? 1 / largeCount : 1;
727 for (const [color, weight] of slideHeadingWeights) {
728 const adjusted =
729 penaltyFactor !== 1 && slideLargeHeadingColors.has(color)
730 ? weight * penaltyFactor
731 : weight;
732 analysis.headingColors.set(
733 color,
734 (analysis.headingColors.get(color) || 0) + adjusted,
735 );
736 }
737 }
738 }
739
740 return analysis;
741 }
742
743 // ============ Helpers ============
744
745 /** Get the most frequent entry from a frequency map */
746 function getTopColor(map: Map<string, number>): string | null {
747 let top: string | null = null;
748 let topCount = 0;
749 for (const [color, count] of map) {
750 if (count > topCount) {
751 top = color;
752 topCount = count;
753 }
754 }
755 return top;
756 }
757
758 function getTopColorExcluding(
759 map: Map<string, number>,
760 exclude: string | null,
761 ): string | null {
762 if (!exclude) return getTopColor(map);
763 let top: string | null = null;
764 let topCount = 0;
765 for (const [color, count] of map) {
766 if (color === exclude) continue;
767 if (count > topCount) {
768 top = color;
769 topCount = count;
770 }
771 }
772 return top;
773 }
774
775 function logAnalysis(label: string, analysis: SlideContentAnalysis): void {
776 const sortEntries = (m: Map<string, number>) =>
777 [...m.entries()].sort((a, b) => b[1] - a[1]);
778
779 console.log(`[PPTX ${label}] Slide content analysis:`, {
780 headingColors: sortEntries(analysis.headingColors)
781 .slice(0, 10)
782 .map(([c, n]) => `${c} (×${n})`),
783 bodyColors: sortEntries(analysis.bodyColors)
784 .slice(0, 10)
785 .map(([c, n]) => `${c} (×${n})`),
786 backgroundColors: sortEntries(analysis.backgroundColors).map(
787 ([c, n]) => `${c} (×${n})`,
788 ),
789 shapeFillColors: sortEntries(analysis.shapeFillColors)
790 .slice(0, 10)
791 .map(([c, n]) => `${c} (×${n})`),
792 headingFonts: sortEntries(analysis.headingFonts)
793 .slice(0, 5)
794 .map(([f, n]) => `${f} (×${n})`),
795 bodyFonts: sortEntries(analysis.bodyFonts)
796 .slice(0, 5)
797 .map(([f, n]) => `${f} (×${n})`),
798 });
799 }
800
801 // ============ Build final ThemeProperties (weighted) ============
802
803 /**
804 * Build theme using a weighted approach:
805 * 1. Start with palette-derived initial values
806 * 2. Override with most-frequent slide-scanned colors for heading, body, background, primary
807 */
808 function buildTheme(
809 palette: ThemePalette,
810 clrMap: Record<string, ThemeColorSlot>,
811 headingFont: string,
812 bodyFont: string,
813 themeName: string,
814 analysis: SlideContentAnalysis,
815 ): ThemeProperties {
816 const resolveColor = (logicalName: string): string | null => {
817 const slot = clrMap[logicalName];
818 return slot ? palette[slot] : null;
819 };
820
821 // --- Background ---
822 // Initial: palette bg1. Override: most frequent slide background.
823 let background = resolveColor("bg1") ?? palette.lt1 ?? "#FFFFFF";
824 const topBg = getTopColor(analysis.backgroundColors);
825 if (topBg) background = topBg;
826
827 const mode: ThemeMode = getLuminance(background) > 0.5 ? "light" : "dark";
828
829 // --- Body text color ---
830 // Initial: palette tx1. Override: most frequent body text color from slides.
831 let text =
832 resolveColor("tx1") ??
833 palette.dk1 ??
834 (mode === "light" ? "#1F2937" : "#E5E7EB");
835 const topBody = getTopColor(analysis.bodyColors);
836 if (topBody) text = topBody;
837
838 // --- Heading color ---
839 // Initial: palette tx2. Override: most frequent heading text color from slides.
840 let heading = resolveColor("tx2") ?? palette.dk2 ?? text;
841 const topHeading = getTopColorExcluding(analysis.headingColors, text);
842 const topHeadingRaw = getTopColor(analysis.headingColors);
843 if (topHeading) {
844 heading = topHeading;
845 } else if (topHeadingRaw) {
846 heading = topHeadingRaw;
847 }
848
849 // --- Primary color = same as heading ---
850 const primary = heading;
851
852 // --- Smart layout & Card background from shape fills ---
853 // Close to primary/heading → smartLayout, close to background → cardBackground
854 const CLOSE_THRESHOLD = 120;
855 let smartLayout: string | null = null;
856 let cardBackground: string | null = null;
857
858 if (analysis.shapeFillColors.size > 0) {
859 const sortedFills = [...analysis.shapeFillColors.entries()].sort(
860 (a, b) => b[1] - a[1],
861 );
862 for (const [fillColor] of sortedFills) {
863 if (
864 !cardBackground &&
865 colorDistance(fillColor, background) < CLOSE_THRESHOLD
866 ) {
867 cardBackground = fillColor;
868 }
869 if (!smartLayout && colorDistance(fillColor, primary) < CLOSE_THRESHOLD) {
870 smartLayout = fillColor;
871 }
872 if (smartLayout && cardBackground) break;
873 }
874 }
875
876 // Fallback: use primary for smartLayout, tone down bg for cardBackground
877 if (!smartLayout) {
878 smartLayout = primary;
879 }
880 if (!cardBackground) {
881 cardBackground =
882 resolveColor("bg2") ??
883 palette.lt2 ??
884 (mode === "light" ? darken(background, 0.04) : lighten(background, 0.06));
885 }
886
887 // Accent (secondary) from palette, with fallback when it clashes with primary
888 const SECONDARY_OFF_THRESHOLD = 160;
889 const linkColor = palette[clrMap.hlink ?? "hlink"];
890 let accent = palette[clrMap.accent2 ?? "accent2"] ?? primary;
891 if (
892 accent &&
893 linkColor &&
894 colorDistance(accent, primary) > SECONDARY_OFF_THRESHOLD
895 ) {
896 accent = averageHex(primary, linkColor);
897 }
898
899 // --- Fonts ---
900 // Initial: theme XML fonts. Override: most frequent slide fonts.
901 let finalHeadingFont = headingFont;
902 const topHeadingFont = getTopColor(analysis.headingFonts);
903 if (topHeadingFont) finalHeadingFont = topHeadingFont;
904
905 let finalBodyFont = bodyFont;
906 const topBodyFont = getTopColor(analysis.bodyFonts);
907 if (topBodyFont) finalBodyFont = topBodyFont;
908
909 console.log("[PPTX Build] Weighted theme result:", {
910 background,
911 text,
912 primary,
913 accent,
914 heading,
915 cardBackground,
916 smartLayout,
917 mode,
918 headingFont: finalHeadingFont,
919 bodyFont: finalBodyFont,
920 overrides: {
921 background: topBg ? `overridden → ${topBg}` : "from palette",
922 text: topBody ? `overridden → ${topBody}` : "from palette",
923 heading: topHeading ? `overridden → ${topHeading}` : "from palette",
924 primary: "= heading",
925 smartLayout:
926 analysis.shapeFillColors.size > 0
927 ? `from shape fills (${analysis.shapeFillColors.size} unique)`
928 : "= primary (no shape fills)",
929 headingFont: topHeadingFont
930 ? `overridden → ${topHeadingFont}`
931 : "from theme XML",
932 bodyFont: topBodyFont ? `overridden → ${topBodyFont}` : "from theme XML",
933 },
934 });
935
936 return {
937 name: themeName,
938 description: "Imported from PPTX",
939 mode,
940 colors: {
941 primary,
942 accent,
943 background,
944 text,
945 heading,
946 smartLayout,
947 cardBackground,
948 },
949 fonts: { heading: finalHeadingFont, body: finalBodyFont },
950 ...STANDARD_DESIGN,
951 };
952 }
953
954 // ============ Fallback: build theme from slide analysis only (no palette) ============
955
956 function buildThemeFromSlideAnalysis(
957 analysis: SlideContentAnalysis,
958 themeName: string,
959 ): ThemeProperties {
960 const topBg = getTopColor(analysis.backgroundColors);
961 const background = topBg ?? "#FFFFFF";
962 const mode: ThemeMode = getLuminance(background) > 0.5 ? "light" : "dark";
963
964 const topBody = getTopColor(analysis.bodyColors);
965 const text = topBody ?? (mode === "light" ? "#1F2937" : "#E5E7EB");
966
967 const topHeading = getTopColor(analysis.headingColors);
968 const heading =
969 getTopColorExcluding(analysis.headingColors, text) ?? topHeading ?? text;
970
971 // Primary = same as heading
972 const primary = heading;
973
974 // Smart layout & card background from shape fills
975 const CLOSE_THRESHOLD = 120;
976 let smartLayout: string | null = null;
977 let cardBackground: string | null = null;
978
979 if (analysis.shapeFillColors.size > 0) {
980 const sortedFills = [...analysis.shapeFillColors.entries()].sort(
981 (a, b) => b[1] - a[1],
982 );
983 for (const [fillColor] of sortedFills) {
984 if (
985 !cardBackground &&
986 colorDistance(fillColor, background) < CLOSE_THRESHOLD
987 ) {
988 cardBackground = fillColor;
989 }
990 if (!smartLayout && colorDistance(fillColor, primary) < CLOSE_THRESHOLD) {
991 smartLayout = fillColor;
992 }
993 if (smartLayout && cardBackground) break;
994 }
995 }
996
997 if (!smartLayout) {
998 smartLayout = primary;
999 }
1000 if (!cardBackground) {
1001 cardBackground =
1002 mode === "light" ? darken(background, 0.04) : lighten(background, 0.06);
1003 }
1004
1005 const accent = primary;
1006
1007 // Fonts from slide content
1008 const headingFont = getTopColor(analysis.headingFonts) ?? "Inter";
1009 const bodyFont = getTopColor(analysis.bodyFonts) ?? "Inter";
1010
1011 console.log("[PPTX Fallback] Theme from slide analysis:", {
1012 background,
1013 text,
1014 primary,
1015 heading,
1016 mode,
1017 headingFont,
1018 bodyFont,
1019 });
1020
1021 return {
1022 name: themeName,
1023 description: "Imported from PPTX",
1024 mode,
1025 colors: {
1026 primary,
1027 accent,
1028 background,
1029 text,
1030 heading,
1031 smartLayout,
1032 cardBackground,
1033 },
1034 fonts: { heading: headingFont, body: bodyFont },
1035 ...STANDARD_DESIGN,
1036 };
1037 }
1038
1039 // ============ Main Entry Point ============
1040
1041 export async function extractThemeFromPptx(
1042 file: File,
1043 ): Promise<ThemeProperties> {
1044 const zip = await JSZip.loadAsync(file);
1045
1046 const themeName = `Imported: ${file.name.replace(/\.pptx$/i, "").replace(/[_-]/g, " ")}`;
1047
1048 const allFiles = Object.keys(zip.files);
1049 console.log(
1050 "[PPTX Import] Files in archive:",
1051 allFiles.filter((f) => f.startsWith("ppt/")),
1052 );
1053
1054 // Step 1: Resolve theme XML via OOXML relationship chain
1055 const { themeXml, slideMasterXml } = await resolveThemeXml(zip);
1056
1057 if (themeXml) {
1058 // Step 2: Parse clrMap from slide master
1059 const clrMap = parseClrMap(slideMasterXml);
1060
1061 // Step 3: Parse theme palette
1062 const { palette, headingFont, bodyFont } = parseThemePalette(themeXml);
1063
1064 // Step 4: Scan slide content for semantic color + font analysis
1065 const analysis = await scanSlideContent(
1066 zip,
1067 palette,
1068 clrMap,
1069 headingFont,
1070 bodyFont,
1071 );
1072 logAnalysis("Import", analysis);
1073
1074 // Step 5: Build theme using weighted approach (palette + slide content)
1075 return buildTheme(
1076 palette,
1077 clrMap,
1078 headingFont,
1079 bodyFont,
1080 themeName,
1081 analysis,
1082 );
1083 }
1084
1085 // Fallback: try any theme file directly (no relationship chain)
1086 const themeFiles = allFiles.filter(
1087 (p) => p.startsWith("ppt/theme/") && p.endsWith(".xml"),
1088 );
1089 if (themeFiles[0]) {
1090 console.log(
1091 "[PPTX Import] Fallback: using theme file directly:",
1092 themeFiles[0],
1093 );
1094 const entry = zip.file(themeFiles[0]);
1095 if (entry) {
1096 const xmlString = await entry.async("string");
1097 const { palette, headingFont, bodyFont } = parseThemePalette(xmlString);
1098
1099 // Still scan slides for weighted analysis
1100 const analysis = await scanSlideContent(
1101 zip,
1102 palette,
1103 DEFAULT_CLR_MAP,
1104 headingFont,
1105 bodyFont,
1106 );
1107 logAnalysis("Fallback", analysis);
1108
1109 return buildTheme(
1110 palette,
1111 DEFAULT_CLR_MAP,
1112 headingFont,
1113 bodyFont,
1114 themeName,
1115 analysis,
1116 );
1117 }
1118 }
1119
1120 // Last resort: slide content scan only (no palette available)
1121 console.log(
1122 "[PPTX Import] No theme XML found, falling back to slide content scan",
1123 );
1124 const analysis = await scanSlideContent(
1125 zip,
1126 null,
1127 DEFAULT_CLR_MAP,
1128 null,
1129 null,
1130 );
1131 logAnalysis("NoTheme", analysis);
1132 return buildThemeFromSlideAnalysis(analysis, themeName);
1133 }
1134
1134 lines TYPESCRIPT