| 1 | import { |
| 2 | type ThemeBorderRadius, |
| 3 | type ThemeColors, |
| 4 | type ThemeFonts, |
| 5 | type ThemeMode, |
| 6 | type ThemeProperties, |
| 7 | type ThemeShadows, |
| 8 | } from "./themes"; |
| 9 | |
| 10 | const THEME_XML_PATTERN = /<THEME\b[^>]*>[\s\S]*?<\/THEME>/i; |
| 11 | const PARTIAL_THEME_XML_PATTERN = /<THEME\b[^>]*>[\s\S]*$/i; |
| 12 | const PARTIAL_THEME_OPEN_TAG_PATTERN = /<T(?:H(?:E(?:M(?:E)?)?)?)?$/i; |
| 13 | const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; |
| 14 | |
| 15 | type GeneratedThemeColors = Pick< |
| 16 | ThemeColors, |
| 17 | | "accent" |
| 18 | | "background" |
| 19 | | "cardBackground" |
| 20 | | "heading" |
| 21 | | "primary" |
| 22 | | "smartLayout" |
| 23 | | "text" |
| 24 | >; |
| 25 | |
| 26 | function extractTagValue(xml: string, tagName: string): string | null { |
| 27 | const match = xml.match( |
| 28 | new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i"), |
| 29 | ); |
| 30 | return match?.[1]?.trim() ?? null; |
| 31 | } |
| 32 | |
| 33 | function normalizeHexColor(value: string | null): string | null { |
| 34 | if (!value) { |
| 35 | return null; |
| 36 | } |
| 37 | |
| 38 | const trimmed = value.trim(); |
| 39 | return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toUpperCase() : null; |
| 40 | } |
| 41 | |
| 42 | function getColor( |
| 43 | xml: string, |
| 44 | tagName: keyof GeneratedThemeColors, |
| 45 | ): string | null { |
| 46 | return normalizeHexColor(extractTagValue(xml, tagName)); |
| 47 | } |
| 48 | |
| 49 | function getTextValue(xml: string, tagName: string, fallback: string): string { |
| 50 | const value = extractTagValue(xml, tagName); |
| 51 | return value && value.length > 0 ? value : fallback; |
| 52 | } |
| 53 | |
| 54 | function getWeightValue( |
| 55 | xml: string, |
| 56 | tagName: string, |
| 57 | fallback: number, |
| 58 | ): number { |
| 59 | const value = Number.parseInt(extractTagValue(xml, tagName) ?? "", 10); |
| 60 | if (!Number.isFinite(value)) { |
| 61 | return fallback; |
| 62 | } |
| 63 | |
| 64 | return Math.min(900, Math.max(100, Math.round(value / 100) * 100)); |
| 65 | } |
| 66 | |
| 67 | function hexToRgb(hex: string): { blue: number; green: number; red: number } { |
| 68 | const normalized = hex.replace("#", ""); |
| 69 | return { |
| 70 | red: Number.parseInt(normalized.slice(0, 2), 16), |
| 71 | green: Number.parseInt(normalized.slice(2, 4), 16), |
| 72 | blue: Number.parseInt(normalized.slice(4, 6), 16), |
| 73 | }; |
| 74 | } |
| 75 | |
| 76 | function rgba(hex: string, alpha: number): string { |
| 77 | const { red, green, blue } = hexToRgb(hex); |
| 78 | return `rgba(${red},${green},${blue},${alpha})`; |
| 79 | } |
| 80 | |
| 81 | function buildGeneratedTheme( |
| 82 | colors: GeneratedThemeColors, |
| 83 | xml: string, |
| 84 | ): ThemeProperties { |
| 85 | const rawMode = extractTagValue(xml, "mode")?.toLowerCase(); |
| 86 | const mode: ThemeMode = rawMode === "dark" ? "dark" : "light"; |
| 87 | const name = extractTagValue(xml, "name") ?? "AI Generated"; |
| 88 | const description = |
| 89 | extractTagValue(xml, "description") ?? |
| 90 | "Generated from the presentation outline"; |
| 91 | const fonts: ThemeFonts = { |
| 92 | heading: getTextValue(xml, "headingFont", "Inter"), |
| 93 | body: getTextValue(xml, "bodyFont", "Inter"), |
| 94 | headingWeight: getWeightValue(xml, "headingWeight", 700), |
| 95 | bodyWeight: getWeightValue(xml, "bodyWeight", 400), |
| 96 | }; |
| 97 | const borderRadius: ThemeBorderRadius = { |
| 98 | card: getTextValue(xml, "cardRadius", "0.75rem"), |
| 99 | slide: getTextValue(xml, "slideRadius", "1rem"), |
| 100 | button: getTextValue(xml, "buttonRadius", "0.17rem"), |
| 101 | }; |
| 102 | const shadows: ThemeShadows = { |
| 103 | card: |
| 104 | extractTagValue(xml, "cardShadow") ?? |
| 105 | `0 8px 24px ${rgba(colors.primary, 0.12)}, 0 1px 2px rgba(0,0,0,0.08)`, |
| 106 | button: |
| 107 | extractTagValue(xml, "buttonShadow") ?? |
| 108 | `0 2px 8px ${rgba(colors.primary, 0.18)}`, |
| 109 | slide: |
| 110 | extractTagValue(xml, "slideShadow") ?? |
| 111 | `0 16px 48px ${rgba(colors.primary, 0.14)}, 0 4px 12px rgba(0,0,0,0.08)`, |
| 112 | }; |
| 113 | |
| 114 | return { |
| 115 | name, |
| 116 | description, |
| 117 | mode, |
| 118 | colors, |
| 119 | fonts, |
| 120 | borderRadius, |
| 121 | transitions: { default: "all 0.2s ease-in-out" }, |
| 122 | shadows, |
| 123 | background: { |
| 124 | type: "radial", |
| 125 | override: ` |
| 126 | radial-gradient(circle at 12% 12%, ${colors.primary}18 0%, transparent 34%), |
| 127 | radial-gradient(circle at 86% 18%, ${colors.accent}18 0%, transparent 38%), |
| 128 | ${colors.background} |
| 129 | `, |
| 130 | }, |
| 131 | }; |
| 132 | } |
| 133 | |
| 134 | export function extractGeneratedPresentationTheme(input: string): { |
| 135 | cleanContent: string; |
| 136 | themeData: ThemeProperties | null; |
| 137 | } { |
| 138 | const match = input.match(THEME_XML_PATTERN); |
| 139 | if (!match?.[0]) { |
| 140 | return { |
| 141 | cleanContent: input |
| 142 | .replace(PARTIAL_THEME_XML_PATTERN, "") |
| 143 | .replace(PARTIAL_THEME_OPEN_TAG_PATTERN, "") |
| 144 | .trim(), |
| 145 | themeData: null, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | const xml = match[0]; |
| 150 | const colors: GeneratedThemeColors = { |
| 151 | primary: getColor(xml, "primary") ?? "", |
| 152 | accent: getColor(xml, "accent") ?? "", |
| 153 | background: getColor(xml, "background") ?? "", |
| 154 | text: getColor(xml, "text") ?? "", |
| 155 | heading: getColor(xml, "heading") ?? "", |
| 156 | smartLayout: getColor(xml, "smartLayout") ?? "", |
| 157 | cardBackground: getColor(xml, "cardBackground") ?? "", |
| 158 | }; |
| 159 | |
| 160 | const hasAllColors = Object.values(colors).every((color) => color.length > 0); |
| 161 | const cleanContent = input |
| 162 | .replace(THEME_XML_PATTERN, "") |
| 163 | .replace(PARTIAL_THEME_XML_PATTERN, "") |
| 164 | .replace(PARTIAL_THEME_OPEN_TAG_PATTERN, "") |
| 165 | .trim(); |
| 166 | |
| 167 | if (!hasAllColors) { |
| 168 | return { cleanContent, themeData: null }; |
| 169 | } |
| 170 | |
| 171 | return { |
| 172 | cleanContent, |
| 173 | themeData: buildGeneratedTheme(colors, xml), |
| 174 | }; |
| 175 | } |
| 176 |