Skip to main content

ppt_rs/generator/slide_content/
embedded_fonts.rs

1//! Embedded font support for PPTX output
2//!
3//! Allows embedding font files into the presentation so they render correctly
4//! on systems that don't have the fonts installed. Generates proper
5//! `<p:embeddedFontLst>` XML in presentation.xml.
6
7/// Font style variant
8#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash)]
9pub enum FontStyle {
10    Regular,
11    Bold,
12    Italic,
13    BoldItalic,
14}
15
16impl FontStyle {
17    pub fn xml_element(&self) -> &'static str {
18        match self {
19            FontStyle::Regular => "regular",
20            FontStyle::Bold => "bold",
21            FontStyle::Italic => "italic",
22            FontStyle::BoldItalic => "boldItalic",
23        }
24    }
25
26    pub fn is_bold(&self) -> bool {
27        matches!(self, FontStyle::Bold | FontStyle::BoldItalic)
28    }
29
30    pub fn is_italic(&self) -> bool {
31        matches!(self, FontStyle::Italic | FontStyle::BoldItalic)
32    }
33}
34
35/// Character set for the font
36#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
37pub enum FontCharset {
38    #[default]
39    Ansi,
40    Symbol,
41    ShiftJis,
42    Hangul,
43    Gb2312,
44    ChineseBig5,
45    Greek,
46    Turkish,
47    Hebrew,
48    Arabic,
49    Baltic,
50    Russian,
51    Thai,
52    EastEurope,
53}
54
55impl FontCharset {
56    pub fn code(&self) -> u8 {
57        match self {
58            FontCharset::Ansi => 0x00,
59            FontCharset::Symbol => 0x02,
60            FontCharset::ShiftJis => 0x80,
61            FontCharset::Hangul => 0x81,
62            FontCharset::Gb2312 => 0x86,
63            FontCharset::ChineseBig5 => 0x88,
64            FontCharset::Greek => 0xA1,
65            FontCharset::Turkish => 0xA2,
66            FontCharset::Hebrew => 0xB1,
67            FontCharset::Arabic => 0xB2,
68            FontCharset::Baltic => 0xBA,
69            FontCharset::Russian => 0xCC,
70            FontCharset::Thai => 0xDE,
71            FontCharset::EastEurope => 0xEE,
72        }
73    }
74}
75
76/// A single embedded font entry
77#[derive(Clone, Debug)]
78pub struct EmbeddedFont {
79    pub typeface: String,
80    pub style: FontStyle,
81    pub charset: FontCharset,
82    pub panose: Option<String>,
83    pub pitch_family: u8,
84    pub data: Vec<u8>,
85    pub relationship_id: String,
86}
87
88impl EmbeddedFont {
89    /// Create a new embedded font entry
90    pub fn new(typeface: &str, style: FontStyle, data: Vec<u8>, rel_id: &str) -> Self {
91        Self {
92            typeface: typeface.to_string(),
93            style,
94            charset: FontCharset::default(),
95            panose: None,
96            pitch_family: 0x22, // Variable pitch, Roman family
97            data,
98            relationship_id: rel_id.to_string(),
99        }
100    }
101
102    pub fn charset(mut self, charset: FontCharset) -> Self {
103        self.charset = charset;
104        self
105    }
106
107    pub fn panose(mut self, panose: &str) -> Self {
108        self.panose = Some(panose.to_string());
109        self
110    }
111
112    pub fn pitch_family(mut self, pf: u8) -> Self {
113        self.pitch_family = pf;
114        self
115    }
116
117    /// Font data size in bytes
118    pub fn data_size(&self) -> usize {
119        self.data.len()
120    }
121
122    /// Content type for the embedded font part
123    pub fn content_type() -> &'static str {
124        "application/x-fontdata"
125    }
126
127    /// Part name in the ZIP archive
128    pub fn part_name(&self) -> String {
129        format!(
130            "ppt/fonts/{}-{}.fntdata",
131            self.typeface.replace(' ', ""),
132            self.style.xml_element()
133        )
134    }
135
136    /// Relationship target relative to `ppt/`.
137    pub fn rel_target(&self) -> String {
138        format!(
139            "fonts/{}-{}.fntdata",
140            self.typeface.replace(' ', ""),
141            self.style.xml_element()
142        )
143    }
144}
145
146/// Manages all embedded fonts for a presentation
147#[derive(Clone, Debug, Default)]
148pub struct EmbeddedFontList {
149    fonts: Vec<EmbeddedFont>,
150}
151
152impl EmbeddedFontList {
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Add an embedded font
158    pub fn add(&mut self, font: EmbeddedFont) {
159        self.fonts.push(font);
160    }
161
162    /// Get all fonts
163    pub fn fonts(&self) -> &[EmbeddedFont] {
164        &self.fonts
165    }
166
167    /// Number of embedded fonts
168    pub fn len(&self) -> usize {
169        self.fonts.len()
170    }
171
172    pub fn is_empty(&self) -> bool {
173        self.fonts.is_empty()
174    }
175
176    /// Assign sequential `rIdN` relationship IDs to fonts that don't already
177    /// have one. `start` is the first relationship number to use.
178    pub fn assign_relationship_ids(&mut self, start: usize) {
179        for (i, font) in self.fonts.iter_mut().enumerate() {
180            if font.relationship_id.is_empty() {
181                font.relationship_id = format!("rId{}", start + i);
182            }
183        }
184    }
185
186    /// Total size of all font data
187    pub fn total_size(&self) -> usize {
188        self.fonts.iter().map(|f| f.data_size()).sum()
189    }
190
191    /// Find fonts by typeface name
192    pub fn find_by_typeface(&self, typeface: &str) -> Vec<&EmbeddedFont> {
193        self.fonts.iter().filter(|f| f.typeface == typeface).collect()
194    }
195
196    /// Generate `<p:embeddedFontLst>` XML for presentation.xml
197    pub fn to_xml(&self) -> String {
198        if self.fonts.is_empty() {
199            return String::new();
200        }
201
202        let mut xml = String::from("<p:embeddedFontLst>");
203
204        // Group by typeface
205        let mut seen_typefaces: Vec<String> = Vec::new();
206        for font in &self.fonts {
207            if !seen_typefaces.contains(&font.typeface) {
208                seen_typefaces.push(font.typeface.clone());
209            }
210        }
211
212        for typeface in &seen_typefaces {
213            let variants: Vec<&EmbeddedFont> = self.fonts
214                .iter()
215                .filter(|f| &f.typeface == typeface)
216                .collect();
217
218            xml.push_str("<p:embeddedFont>");
219
220            // Font descriptor (from first variant)
221            if let Some(first) = variants.first() {
222                let panose_attr = first.panose.as_ref()
223                    .map(|p| format!(r#" panose="{}""#, p))
224                    .unwrap_or_default();
225                xml.push_str(&format!(
226                    r#"<p:font typeface="{}" charset="{}" pitchFamily="{}"{}/>"#,
227                    xml_escape(typeface),
228                    first.charset.code(),
229                    first.pitch_family,
230                    panose_attr,
231                ));
232            }
233
234            // Font data references per style
235            for font in &variants {
236                xml.push_str(&format!(
237                    r#"<p:{} r:id="{}"/>"#,
238                    font.style.xml_element(),
239                    font.relationship_id,
240                ));
241            }
242
243            xml.push_str("</p:embeddedFont>");
244        }
245
246        xml.push_str("</p:embeddedFontLst>");
247        xml
248    }
249}
250
251fn xml_escape(s: &str) -> String {
252    s.replace('&', "&amp;")
253        .replace('<', "&lt;")
254        .replace('>', "&gt;")
255        .replace('"', "&quot;")
256        .replace('\'', "&apos;")
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_font_style_xml() {
265        assert_eq!(FontStyle::Regular.xml_element(), "regular");
266        assert_eq!(FontStyle::Bold.xml_element(), "bold");
267        assert_eq!(FontStyle::Italic.xml_element(), "italic");
268        assert_eq!(FontStyle::BoldItalic.xml_element(), "boldItalic");
269    }
270
271    #[test]
272    fn test_font_style_flags() {
273        assert!(!FontStyle::Regular.is_bold());
274        assert!(!FontStyle::Regular.is_italic());
275        assert!(FontStyle::Bold.is_bold());
276        assert!(!FontStyle::Bold.is_italic());
277        assert!(!FontStyle::Italic.is_bold());
278        assert!(FontStyle::Italic.is_italic());
279        assert!(FontStyle::BoldItalic.is_bold());
280        assert!(FontStyle::BoldItalic.is_italic());
281    }
282
283    #[test]
284    fn test_font_charset_default() {
285        assert_eq!(FontCharset::default(), FontCharset::Ansi);
286        assert_eq!(FontCharset::Ansi.code(), 0x00);
287    }
288
289    #[test]
290    fn test_font_charset_codes() {
291        assert_eq!(FontCharset::Symbol.code(), 0x02);
292        assert_eq!(FontCharset::ShiftJis.code(), 0x80);
293        assert_eq!(FontCharset::Arabic.code(), 0xB2);
294        assert_eq!(FontCharset::Russian.code(), 0xCC);
295    }
296
297    #[test]
298    fn test_embedded_font_new() {
299        let font = EmbeddedFont::new("Arial", FontStyle::Regular, vec![1, 2, 3], "rId10");
300        assert_eq!(font.typeface, "Arial");
301        assert_eq!(font.style, FontStyle::Regular);
302        assert_eq!(font.data_size(), 3);
303        assert_eq!(font.relationship_id, "rId10");
304    }
305
306    #[test]
307    fn test_embedded_font_builder() {
308        let font = EmbeddedFont::new("Calibri", FontStyle::Bold, vec![0; 100], "rId1")
309            .charset(FontCharset::Russian)
310            .panose("020F0502020204030204")
311            .pitch_family(0x34);
312        assert_eq!(font.charset, FontCharset::Russian);
313        assert_eq!(font.panose.as_deref(), Some("020F0502020204030204"));
314        assert_eq!(font.pitch_family, 0x34);
315    }
316
317    #[test]
318    fn test_embedded_font_part_name() {
319        let font = EmbeddedFont::new("Times New Roman", FontStyle::BoldItalic, vec![], "rId1");
320        assert_eq!(font.part_name(), "ppt/fonts/TimesNewRoman-boldItalic.fntdata");
321    }
322
323    #[test]
324    fn test_embedded_font_content_type() {
325        assert_eq!(EmbeddedFont::content_type(), "application/x-fontdata");
326    }
327
328    #[test]
329    fn test_embedded_font_list_new() {
330        let list = EmbeddedFontList::new();
331        assert!(list.is_empty());
332        assert_eq!(list.len(), 0);
333        assert_eq!(list.total_size(), 0);
334    }
335
336    #[test]
337    fn test_embedded_font_list_add() {
338        let mut list = EmbeddedFontList::new();
339        list.add(EmbeddedFont::new("Arial", FontStyle::Regular, vec![0; 50], "rId1"));
340        list.add(EmbeddedFont::new("Arial", FontStyle::Bold, vec![0; 60], "rId2"));
341        assert_eq!(list.len(), 2);
342        assert_eq!(list.total_size(), 110);
343    }
344
345    #[test]
346    fn test_embedded_font_list_find() {
347        let mut list = EmbeddedFontList::new();
348        list.add(EmbeddedFont::new("Arial", FontStyle::Regular, vec![], "rId1"));
349        list.add(EmbeddedFont::new("Calibri", FontStyle::Regular, vec![], "rId2"));
350        list.add(EmbeddedFont::new("Arial", FontStyle::Bold, vec![], "rId3"));
351        assert_eq!(list.find_by_typeface("Arial").len(), 2);
352        assert_eq!(list.find_by_typeface("Calibri").len(), 1);
353        assert_eq!(list.find_by_typeface("Missing").len(), 0);
354    }
355
356    #[test]
357    fn test_embedded_font_list_xml_empty() {
358        let list = EmbeddedFontList::new();
359        assert_eq!(list.to_xml(), "");
360    }
361
362    #[test]
363    fn test_embedded_font_list_xml() {
364        let mut list = EmbeddedFontList::new();
365        list.add(EmbeddedFont::new("Arial", FontStyle::Regular, vec![0; 10], "rId10"));
366        list.add(EmbeddedFont::new("Arial", FontStyle::Bold, vec![0; 10], "rId11"));
367        let xml = list.to_xml();
368        assert!(xml.contains("<p:embeddedFontLst>"));
369        assert!(xml.contains("</p:embeddedFontLst>"));
370        assert!(xml.contains(r#"typeface="Arial""#));
371        assert!(xml.contains(r#"r:id="rId10""#));
372        assert!(xml.contains(r#"r:id="rId11""#));
373        assert!(xml.contains("<p:regular"));
374        assert!(xml.contains("<p:bold"));
375        // Should be grouped under one <p:embeddedFont>
376        assert_eq!(xml.matches("<p:embeddedFont>").count(), 1);
377    }
378
379    #[test]
380    fn test_embedded_font_list_xml_multiple_typefaces() {
381        let mut list = EmbeddedFontList::new();
382        list.add(EmbeddedFont::new("Arial", FontStyle::Regular, vec![], "rId1"));
383        list.add(EmbeddedFont::new("Calibri", FontStyle::Regular, vec![], "rId2"));
384        let xml = list.to_xml();
385        assert_eq!(xml.matches("<p:embeddedFont>").count(), 2);
386        assert!(xml.contains("Arial"));
387        assert!(xml.contains("Calibri"));
388    }
389
390    #[test]
391    fn test_embedded_font_list_xml_with_panose() {
392        let mut list = EmbeddedFontList::new();
393        list.add(
394            EmbeddedFont::new("Calibri", FontStyle::Regular, vec![], "rId1")
395                .panose("020F0502020204030204"),
396        );
397        let xml = list.to_xml();
398        assert!(xml.contains(r#"panose="020F0502020204030204""#));
399    }
400}