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 if self.name == "Office Theme" && self.colors == ThemeColorScheme::office() {
236 return office_theme_xml().to_string();
237 }
238
239 let c = &self.colors;
240 let color_slot = |tag: &str, hex: &str| {
241 format!(r#"<a:{tag}><a:srgbClr val="{hex}"/></a:{tag}>"#)
242 };
243 let colors_xml = [
244 color_slot("dk1", &c.dk1),
245 color_slot("lt1", &c.lt1),
246 color_slot("dk2", &c.dk2),
247 color_slot("lt2", &c.lt2),
248 color_slot("accent1", &c.accent1),
249 color_slot("accent2", &c.accent2),
250 color_slot("accent3", &c.accent3),
251 color_slot("accent4", &c.accent4),
252 color_slot("accent5", &c.accent5),
253 color_slot("accent6", &c.accent6),
254 color_slot("hlink", &c.hlink),
255 color_slot("folHlink", &c.fol_hlink),
256 ]
257 .join("");
258 let scheme_name = escape_xml_attr(&self.name);
259 let clr_scheme = format!(r#"<a:clrScheme name="{scheme_name}">{colors_xml}</a:clrScheme>"#);
260
261 let mut xml = office_theme_xml().to_string();
262 if let (Some(start), Some(end)) = (
263 xml.find("<a:clrScheme"),
264 xml.find("</a:clrScheme>").map(|i| i + "</a:clrScheme>".len()),
265 ) {
266 xml.replace_range(start..end, &clr_scheme);
267 }
268
269 let theme_name = escape_xml_attr(&self.name);
270 if let Some(start) = xml.find(r#"name=""#) {
271 let name_start = start + 6;
272 if let Some(end) = xml[name_start..].find('"') {
273 xml.replace_range(name_start..name_start + end, &theme_name);
274 }
275 }
276
277 replace_font_latin(&mut xml, "majorFont", &self.fonts.major);
278 replace_font_latin(&mut xml, "minorFont", &self.fonts.minor);
279
280 xml
281 }
282}
283
284fn replace_font_latin(xml: &mut String, font_tag: &str, typeface: &str) {
285 const LATIN_PREFIX: &str = r#"<a:latin typeface=""#;
286 let marker = format!("<a:{font_tag}>");
287 let Some(start) = xml.find(&marker) else {
288 return;
289 };
290 let section = &xml[start..];
291 let Some(rel) = section.find(LATIN_PREFIX) else {
292 return;
293 };
294 let abs = start + rel + LATIN_PREFIX.len();
295 if let Some(end) = xml[abs..].find('"') {
296 let escaped = escape_xml_attr(typeface);
297 xml.replace_range(abs..abs + end, &escaped);
298 }
299}
300
301pub fn office_theme_xml() -> &'static str {
303 include_str!("office_theme.xml")
304}
305
306fn normalize_hex(hex: &str) -> String {
307 hex.trim().trim_start_matches('#').to_uppercase()
308}
309
310fn escape_xml_attr(s: &str) -> String {
311 s.replace('&', "&")
312 .replace('"', """)
313 .replace('<', "<")
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_corporate_theme_xml_contains_colors() {
322 let theme = PresentationTheme::corporate().major_font("Arial").minor_font("Arial");
323 let xml = theme.to_theme_xml();
324 assert!(xml.contains("1565C0"));
325 assert!(xml.contains("FF6F00"));
326 assert!(xml.contains(r#"name="Corporate""#));
327 assert!(xml.contains(r#"typeface="Arial""#));
328 }
329
330 #[test]
331 fn test_dark_theme_background() {
332 let theme = PresentationTheme::dark();
333 let xml = theme.to_theme_xml();
334 assert!(xml.contains("121212"));
335 assert!(xml.contains("FFFFFF"));
336 }
337
338 #[test]
339 fn test_custom_accent_override() {
340 let colors = ThemeColorScheme::office().accent1("AABBCC");
341 let theme = PresentationTheme::new("Custom").colors(colors);
342 let xml = theme.to_theme_xml();
343 assert!(xml.contains("AABBCC"));
344 }
345
346 #[test]
347 fn test_normalize_hex_strips_hash() {
348 assert_eq!(normalize_hex("#ff8040"), "FF8040");
349 }
350}