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 #[allow(clippy::too_many_arguments)]
166 pub fn from_palette(
167 name: impl Into<String>,
168 primary: &str,
169 secondary: &str,
170 accent: &str,
171 background: &str,
172 text: &str,
173 light: &str,
174 dark: &str,
175 ) -> Self {
176 Self {
177 name: name.into(),
178 colors: ThemeColorScheme::from_palette(
179 primary, secondary, accent, background, text, light, dark,
180 ),
181 fonts: ThemeFonts::office(),
182 }
183 }
184
185 pub fn corporate() -> Self {
186 Self::from_palette(
187 "Corporate",
188 "1565C0", "1976D2", "FF6F00", "FFFFFF", "212121", "E3F2FD", "0D47A1",
189 )
190 }
191
192 pub fn modern() -> Self {
193 Self::from_palette(
194 "Modern",
195 "212121", "757575", "00BCD4", "FAFAFA", "212121", "F5F5F5", "424242",
196 )
197 }
198
199 pub fn vibrant() -> Self {
200 Self::from_palette(
201 "Vibrant",
202 "E91E63", "9C27B0", "FF9800", "FFFFFF", "212121", "FCE4EC", "880E4F",
203 )
204 }
205
206 pub fn dark() -> Self {
207 Self::from_palette(
208 "Dark",
209 "BB86FC", "03DAC6", "CF6679", "121212", "FFFFFF", "1E1E1E", "000000",
210 )
211 }
212
213 pub fn nature() -> Self {
214 Self::from_palette(
215 "Nature",
216 "2E7D32", "4CAF50", "8BC34A", "FFFFFF", "1B5E20", "E8F5E9", "1B5E20",
217 )
218 }
219
220 pub fn tech() -> Self {
221 Self::from_palette(
222 "Tech",
223 "0D47A1", "1976D2", "00E676", "FAFAFA", "263238", "E3F2FD", "01579B",
224 )
225 }
226
227 pub fn carbon() -> Self {
228 Self::from_palette(
229 "Carbon",
230 "0043CE", "4589FF", "24A148", "FFFFFF", "161616", "E0E0E0", "161616",
231 )
232 }
233
234 pub fn to_theme_xml(&self) -> String {
236 if self.name == "Office Theme" && self.colors == ThemeColorScheme::office() {
237 return office_theme_xml().to_string();
238 }
239
240 let c = &self.colors;
241 let color_slot = |tag: &str, hex: &str| {
242 format!(r#"<a:{tag}><a:srgbClr val="{hex}"/></a:{tag}>"#)
243 };
244 let colors_xml = [
245 color_slot("dk1", &c.dk1),
246 color_slot("lt1", &c.lt1),
247 color_slot("dk2", &c.dk2),
248 color_slot("lt2", &c.lt2),
249 color_slot("accent1", &c.accent1),
250 color_slot("accent2", &c.accent2),
251 color_slot("accent3", &c.accent3),
252 color_slot("accent4", &c.accent4),
253 color_slot("accent5", &c.accent5),
254 color_slot("accent6", &c.accent6),
255 color_slot("hlink", &c.hlink),
256 color_slot("folHlink", &c.fol_hlink),
257 ]
258 .join("");
259 let scheme_name = escape_xml_attr(&self.name);
260 let clr_scheme = format!(r#"<a:clrScheme name="{scheme_name}">{colors_xml}</a:clrScheme>"#);
261
262 let mut xml = office_theme_xml().to_string();
263 if let (Some(start), Some(end)) = (
264 xml.find("<a:clrScheme"),
265 xml.find("</a:clrScheme>").map(|i| i + "</a:clrScheme>".len()),
266 ) {
267 xml.replace_range(start..end, &clr_scheme);
268 }
269
270 let theme_name = escape_xml_attr(&self.name);
271 if let Some(start) = xml.find(r#"name=""#) {
272 let name_start = start + 6;
273 if let Some(end) = xml[name_start..].find('"') {
274 xml.replace_range(name_start..name_start + end, &theme_name);
275 }
276 }
277
278 replace_font_latin(&mut xml, "majorFont", &self.fonts.major);
279 replace_font_latin(&mut xml, "minorFont", &self.fonts.minor);
280
281 xml
282 }
283}
284
285fn replace_font_latin(xml: &mut String, font_tag: &str, typeface: &str) {
286 const LATIN_PREFIX: &str = r#"<a:latin typeface=""#;
287 let marker = format!("<a:{font_tag}>");
288 let Some(start) = xml.find(&marker) else {
289 return;
290 };
291 let section = &xml[start..];
292 let Some(rel) = section.find(LATIN_PREFIX) else {
293 return;
294 };
295 let abs = start + rel + LATIN_PREFIX.len();
296 if let Some(end) = xml[abs..].find('"') {
297 let escaped = escape_xml_attr(typeface);
298 xml.replace_range(abs..abs + end, &escaped);
299 }
300}
301
302pub fn office_theme_xml() -> &'static str {
304 include_str!("office_theme.xml")
305}
306
307fn normalize_hex(hex: &str) -> String {
308 hex.trim().trim_start_matches('#').to_uppercase()
309}
310
311fn escape_xml_attr(s: &str) -> String {
312 s.replace('&', "&")
313 .replace('"', """)
314 .replace('<', "<")
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_corporate_theme_xml_contains_colors() {
323 let theme = PresentationTheme::corporate().major_font("Arial").minor_font("Arial");
324 let xml = theme.to_theme_xml();
325 assert!(xml.contains("1565C0"));
326 assert!(xml.contains("FF6F00"));
327 assert!(xml.contains(r#"name="Corporate""#));
328 assert!(xml.contains(r#"typeface="Arial""#));
329 }
330
331 #[test]
332 fn test_dark_theme_background() {
333 let theme = PresentationTheme::dark();
334 let xml = theme.to_theme_xml();
335 assert!(xml.contains("121212"));
336 assert!(xml.contains("FFFFFF"));
337 }
338
339 #[test]
340 fn test_custom_accent_override() {
341 let colors = ThemeColorScheme::office().accent1("AABBCC");
342 let theme = PresentationTheme::new("Custom").colors(colors);
343 let xml = theme.to_theme_xml();
344 assert!(xml.contains("AABBCC"));
345 }
346
347 #[test]
348 fn test_normalize_hex_strips_hash() {
349 assert_eq!(normalize_hex("#ff8040"), "FF8040");
350 }
351}