Skip to main content

ppt_rs/generator/
presentation_theme.rs

1//! Customizable presentation themes for PPTX output
2//!
3//! Maps semantic color roles and fonts into ECMA-376 `ppt/theme/theme1.xml`.
4
5/// ECMA-376 color scheme (12 slots used by PowerPoint theme)
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct ThemeColorScheme {
8    /// Dark 1 — primary text
9    pub dk1: String,
10    /// Light 1 — primary background
11    pub lt1: String,
12    /// Dark 2 — secondary dark
13    pub dk2: String,
14    /// Light 2 — secondary light
15    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    /// Default Microsoft Office color scheme
28    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    /// Build a scheme from semantic palette roles (matches `prelude::themes::Theme`)
46    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/// Theme font pair (major = headings, minor = body)
96#[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/// Full presentation theme embedded in generated PPTX files
119#[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    /// Generate `ppt/theme/theme1.xml` content using the full Office theme template.
235    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
302/// Full Office theme XML extracted from a PowerPoint-compatible reference file.
303pub 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('&', "&amp;")
313        .replace('"', "&quot;")
314        .replace('<', "&lt;")
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}