1#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct ThemeColorScheme {
8 pub dk1: String,
10 pub lt1: String,
12 pub dk2: String,
14 pub lt2: String,
16 pub accent1: String,
17 pub accent2: String,
18 pub accent3: String,
19 pub accent4: String,
20 pub accent5: String,
21 pub accent6: String,
22 pub hlink: String,
23 pub fol_hlink: String,
24}
25
26impl ThemeColorScheme {
27 pub fn office() -> Self {
29 Self {
30 dk1: "000000".into(),
31 lt1: "FFFFFF".into(),
32 dk2: "1F497D".into(),
33 lt2: "EEECE1".into(),
34 accent1: "4F81BD".into(),
35 accent2: "C0504D".into(),
36 accent3: "9BBB59".into(),
37 accent4: "8064A2".into(),
38 accent5: "4BACC6".into(),
39 accent6: "F79646".into(),
40 hlink: "0000FF".into(),
41 fol_hlink: "800080".into(),
42 }
43 }
44
45 pub fn from_palette(
47 primary: &str,
48 secondary: &str,
49 accent: &str,
50 background: &str,
51 text: &str,
52 light: &str,
53 dark: &str,
54 ) -> Self {
55 let primary = normalize_hex(primary);
56 let secondary = normalize_hex(secondary);
57 let accent = normalize_hex(accent);
58 Self {
59 dk1: normalize_hex(text),
60 lt1: normalize_hex(background),
61 dk2: normalize_hex(dark),
62 lt2: normalize_hex(light),
63 accent1: primary.clone(),
64 accent2: secondary,
65 accent3: accent.clone(),
66 accent4: primary.clone(),
67 accent5: accent,
68 accent6: primary.clone(),
69 hlink: primary,
70 fol_hlink: "954F72".into(),
71 }
72 }
73
74 pub fn accent1(mut self, hex: impl AsRef<str>) -> Self {
75 self.accent1 = normalize_hex(hex.as_ref());
76 self
77 }
78
79 pub fn accent2(mut self, hex: impl AsRef<str>) -> Self {
80 self.accent2 = normalize_hex(hex.as_ref());
81 self
82 }
83
84 pub fn accent3(mut self, hex: impl AsRef<str>) -> Self {
85 self.accent3 = normalize_hex(hex.as_ref());
86 self
87 }
88
89 pub fn hyperlink(mut self, hex: impl AsRef<str>) -> Self {
90 self.hlink = normalize_hex(hex.as_ref());
91 self
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct ThemeFonts {
98 pub major: String,
99 pub minor: String,
100}
101
102impl ThemeFonts {
103 pub fn office() -> Self {
104 Self {
105 major: "Calibri Light".into(),
106 minor: "Calibri".into(),
107 }
108 }
109
110 pub fn new(major: impl Into<String>, minor: impl Into<String>) -> Self {
111 Self {
112 major: major.into(),
113 minor: minor.into(),
114 }
115 }
116}
117
118#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct PresentationTheme {
121 pub name: String,
122 pub colors: ThemeColorScheme,
123 pub fonts: ThemeFonts,
124}
125
126impl Default for PresentationTheme {
127 fn default() -> Self {
128 Self::office()
129 }
130}
131
132impl PresentationTheme {
133 pub fn new(name: impl Into<String>) -> Self {
134 Self {
135 name: name.into(),
136 colors: ThemeColorScheme::office(),
137 fonts: ThemeFonts::office(),
138 }
139 }
140
141 pub fn office() -> Self {
142 Self::new("Office Theme")
143 }
144
145 pub fn colors(mut self, colors: ThemeColorScheme) -> Self {
146 self.colors = colors;
147 self
148 }
149
150 pub fn fonts(mut self, fonts: ThemeFonts) -> Self {
151 self.fonts = fonts;
152 self
153 }
154
155 pub fn major_font(mut self, typeface: impl Into<String>) -> Self {
156 self.fonts.major = typeface.into();
157 self
158 }
159
160 pub fn minor_font(mut self, typeface: impl Into<String>) -> Self {
161 self.fonts.minor = typeface.into();
162 self
163 }
164
165 pub fn from_palette(
166 name: impl Into<String>,
167 primary: &str,
168 secondary: &str,
169 accent: &str,
170 background: &str,
171 text: &str,
172 light: &str,
173 dark: &str,
174 ) -> Self {
175 Self {
176 name: name.into(),
177 colors: ThemeColorScheme::from_palette(
178 primary, secondary, accent, background, text, light, dark,
179 ),
180 fonts: ThemeFonts::office(),
181 }
182 }
183
184 pub fn corporate() -> Self {
185 Self::from_palette(
186 "Corporate",
187 "1565C0", "1976D2", "FF6F00", "FFFFFF", "212121", "E3F2FD", "0D47A1",
188 )
189 }
190
191 pub fn modern() -> Self {
192 Self::from_palette(
193 "Modern",
194 "212121", "757575", "00BCD4", "FAFAFA", "212121", "F5F5F5", "424242",
195 )
196 }
197
198 pub fn vibrant() -> Self {
199 Self::from_palette(
200 "Vibrant",
201 "E91E63", "9C27B0", "FF9800", "FFFFFF", "212121", "FCE4EC", "880E4F",
202 )
203 }
204
205 pub fn dark() -> Self {
206 Self::from_palette(
207 "Dark",
208 "BB86FC", "03DAC6", "CF6679", "121212", "FFFFFF", "1E1E1E", "000000",
209 )
210 }
211
212 pub fn nature() -> Self {
213 Self::from_palette(
214 "Nature",
215 "2E7D32", "4CAF50", "8BC34A", "FFFFFF", "1B5E20", "E8F5E9", "1B5E20",
216 )
217 }
218
219 pub fn tech() -> Self {
220 Self::from_palette(
221 "Tech",
222 "0D47A1", "1976D2", "00E676", "FAFAFA", "263238", "E3F2FD", "01579B",
223 )
224 }
225
226 pub fn carbon() -> Self {
227 Self::from_palette(
228 "Carbon",
229 "0043CE", "4589FF", "24A148", "FFFFFF", "161616", "E0E0E0", "161616",
230 )
231 }
232
233 pub fn to_theme_xml(&self) -> String {
235 let c = &self.colors;
236 let color_slot = |tag: &str, hex: &str| {
237 format!(r#"<a:{tag}><a:srgbClr val="{hex}"/></a:{tag}>"#)
238 };
239
240 let colors_xml = [
241 color_slot("dk1", &c.dk1),
242 color_slot("lt1", &c.lt1),
243 color_slot("dk2", &c.dk2),
244 color_slot("lt2", &c.lt2),
245 color_slot("accent1", &c.accent1),
246 color_slot("accent2", &c.accent2),
247 color_slot("accent3", &c.accent3),
248 color_slot("accent4", &c.accent4),
249 color_slot("accent5", &c.accent5),
250 color_slot("accent6", &c.accent6),
251 color_slot("hlink", &c.hlink),
252 color_slot("folHlink", &c.fol_hlink),
253 ]
254 .join("\n");
255
256 format!(
257 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
258<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="{name}">
259<a:themeElements>
260<a:clrScheme name="{scheme_name}">
261{colors_xml}
262</a:clrScheme>
263<a:fontScheme name="Office">
264<a:majorFont>
265<a:latin typeface="{major}"/>
266<a:ea typeface=""/>
267<a:cs typeface=""/>
268</a:majorFont>
269<a:minorFont>
270<a:latin typeface="{minor}"/>
271<a:ea typeface=""/>
272<a:cs typeface=""/>
273</a:minorFont>
274</a:fontScheme>
275<a:fmtScheme name="Office">
276<a:fillStyleLst>
277<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
278<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="50000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="35000"><a:schemeClr val="phClr"><a:tint val="37000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="15000"/><a:satMod val="350000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="1"/></a:gradFill>
279<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:shade val="51000"/><a:satMod val="130000"/></a:schemeClr></a:gs><a:gs pos="80000"><a:schemeClr val="phClr"><a:shade val="93000"/><a:satMod val="130000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="94000"/><a:satMod val="135000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="0"/></a:gradFill>
280</a:fillStyleLst>
281<a:lnStyleLst>
282<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"><a:shade val="95000"/><a:satMod val="105000"/></a:schemeClr></a:solidFill><a:prstDash val="solid"/></a:ln>
283<a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>
284<a:ln w="38100" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>
285</a:lnStyleLst>
286<a:effectStyleLst>
287<a:effectStyle><a:effectLst/></a:effectStyle>
288<a:effectStyle><a:effectLst/></a:effectStyle>
289<a:effectStyle><a:effectLst/></a:effectStyle>
290</a:effectStyleLst>
291<a:bgFillStyleLst>
292<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
293<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="40000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="40000"><a:schemeClr val="phClr"><a:tint val="45000"/><a:shade val="99000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="20000"/><a:satMod val="255000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="-80000" r="50000" b="180000"/></a:path></a:gradFill>
294<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="80000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="30000"/><a:satMod val="200000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>
295</a:bgFillStyleLst>
296</a:fmtScheme>
297</a:themeElements>
298<a:objectDefaults/>
299<a:extraClrSchemeLst/>
300</a:theme>"#,
301 name = escape_xml_attr(&self.name),
302 scheme_name = escape_xml_attr(&self.name),
303 colors_xml = colors_xml,
304 major = escape_xml_attr(&self.fonts.major),
305 minor = escape_xml_attr(&self.fonts.minor),
306 )
307 }
308}
309
310fn normalize_hex(hex: &str) -> String {
311 hex.trim().trim_start_matches('#').to_uppercase()
312}
313
314fn escape_xml_attr(s: &str) -> String {
315 s.replace('&', "&")
316 .replace('"', """)
317 .replace('<', "<")
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_corporate_theme_xml_contains_colors() {
326 let theme = PresentationTheme::corporate().major_font("Arial").minor_font("Arial");
327 let xml = theme.to_theme_xml();
328 assert!(xml.contains("1565C0"));
329 assert!(xml.contains("FF6F00"));
330 assert!(xml.contains(r#"name="Corporate""#));
331 assert!(xml.contains(r#"typeface="Arial""#));
332 }
333
334 #[test]
335 fn test_dark_theme_background() {
336 let theme = PresentationTheme::dark();
337 let xml = theme.to_theme_xml();
338 assert!(xml.contains("121212"));
339 assert!(xml.contains("FFFFFF"));
340 }
341
342 #[test]
343 fn test_custom_accent_override() {
344 let colors = ThemeColorScheme::office().accent1("AABBCC");
345 let theme = PresentationTheme::new("Custom").colors(colors);
346 let xml = theme.to_theme_xml();
347 assert!(xml.contains("AABBCC"));
348 }
349
350 #[test]
351 fn test_normalize_hex_strips_hash() {
352 assert_eq!(normalize_hex("#ff8040"), "FF8040");
353 }
354}