返回 presentation-ai
generated-theme.ts
根目录 / src / lib / presentation / generated-theme.ts
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
176 lines TYPESCRIPT