Skip to main content

ppt_rs/generator/slide/
formatting.rs

1//! Text formatting utilities for slide XML generation
2//!
3//! Handles inline markdown formatting (bold, italic, code) and
4//! generates corresponding PPTX XML text runs.
5
6use crate::core::escape_xml;
7
8/// A text segment with formatting
9#[derive(Debug, Clone)]
10pub struct TextSegment {
11    pub text: String,
12    pub bold: bool,
13    pub italic: bool,
14    pub code: bool,
15}
16
17/// Parse markdown-style inline formatting into segments
18pub fn parse_inline_formatting(text: &str) -> Vec<TextSegment> {
19    let mut segments = Vec::new();
20    let mut current_text = String::new();
21    let mut chars = text.chars().peekable();
22    let mut bold = false;
23    let mut italic = false;
24    let mut code = false;
25    
26    while let Some(c) = chars.next() {
27        match c {
28            '`' if !code => {
29                if !current_text.is_empty() {
30                    segments.push(TextSegment {
31                        text: current_text.clone(),
32                        bold,
33                        italic,
34                        code: false,
35                    });
36                    current_text.clear();
37                }
38                code = true;
39            }
40            '`' if code => {
41                segments.push(TextSegment {
42                    text: current_text.clone(),
43                    bold: false,
44                    italic: false,
45                    code: true,
46                });
47                current_text.clear();
48                code = false;
49            }
50            '*' | '_' if !code => {
51                if chars.peek() == Some(&c) {
52                    chars.next();
53                    if !current_text.is_empty() {
54                        segments.push(TextSegment {
55                            text: current_text.clone(),
56                            bold,
57                            italic,
58                            code: false,
59                        });
60                        current_text.clear();
61                    }
62                    bold = !bold;
63                } else {
64                    if !current_text.is_empty() {
65                        segments.push(TextSegment {
66                            text: current_text.clone(),
67                            bold,
68                            italic,
69                            code: false,
70                        });
71                        current_text.clear();
72                    }
73                    italic = !italic;
74                }
75            }
76            _ => {
77                current_text.push(c);
78            }
79        }
80    }
81    
82    if !current_text.is_empty() {
83        segments.push(TextSegment {
84            text: current_text,
85            bold,
86            italic,
87            code,
88        });
89    }
90    
91    if segments.is_empty() {
92        segments.push(TextSegment {
93            text: text.to_string(),
94            bold: false,
95            italic: false,
96            code: false,
97        });
98    }
99    
100    segments
101}
102
103/// Generate XML runs for rich text with inline formatting
104pub fn generate_rich_text_runs(
105    text: &str,
106    base_size: u32,
107    base_bold: bool,
108    base_italic: bool,
109    base_color: Option<&str>,
110) -> String {
111    let segments = parse_inline_formatting(text);
112    let mut xml = String::new();
113    
114    for segment in segments {
115        let size = base_size;
116        let bold = base_bold || segment.bold;
117        let italic = base_italic || segment.italic;
118        let escaped_text = escape_xml(&segment.text);
119        
120        if segment.code {
121            xml.push_str(&format!(
122                r#"<a:r><a:rPr lang="en-US" sz="{}" dirty="0"><a:latin typeface="Consolas"/><a:solidFill><a:srgbClr val="C7254E"/></a:solidFill></a:rPr><a:t>{}</a:t></a:r>"#,
123                size, escaped_text
124            ));
125        } else {
126            let mut props = format!(
127                r#"<a:rPr lang="en-US" sz="{}" b="{}" i="{}" dirty="0""#,
128                size,
129                if bold { "1" } else { "0" },
130                if italic { "1" } else { "0" }
131            );
132            
133            if let Some(color) = base_color {
134                props.push('>');
135                let clean_color = color.trim_start_matches('#').to_uppercase();
136                props.push_str(&format!(r#"<a:solidFill><a:srgbClr val="{}"/></a:solidFill>"#, clean_color));
137                props.push_str("</a:rPr>");
138            } else {
139                props.push_str("/>");
140            }
141            
142            xml.push_str(&format!(r#"<a:r>{}<a:t>{}</a:t></a:r>"#, props, escaped_text));
143        }
144    }
145    
146    xml
147}
148
149/// Generate text properties XML with formatting
150pub fn generate_text_props(
151    size: u32,
152    bold: bool,
153    italic: bool,
154    underline: bool,
155    color: Option<&str>,
156) -> String {
157    let mut props = format!(
158        r#"<a:rPr lang="en-US" sz="{}" b="{}" i="{}" dirty="0""#,
159        size,
160        if bold { "1" } else { "0" },
161        if italic { "1" } else { "0" }
162    );
163
164    if underline {
165        props.push_str(r#" u="sng""#);
166    }
167
168    props.push('>');
169
170    if let Some(hex_color) = color {
171        let clean_color = hex_color.trim_start_matches('#').to_uppercase();
172        props.push_str(&format!(
173            r#"<a:solidFill><a:srgbClr val="{clean_color}"/></a:solidFill>"#
174        ));
175    }
176
177    props.push_str("</a:rPr>");
178    props
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_parse_plain_text() {
187        let segments = parse_inline_formatting("Hello world");
188        assert_eq!(segments.len(), 1);
189        assert_eq!(segments[0].text, "Hello world");
190        assert!(!segments[0].bold);
191        assert!(!segments[0].italic);
192    }
193
194    #[test]
195    fn test_parse_bold() {
196        let segments = parse_inline_formatting("Hello **bold** world");
197        assert_eq!(segments.len(), 3);
198        assert_eq!(segments[1].text, "bold");
199        assert!(segments[1].bold);
200    }
201
202    #[test]
203    fn test_parse_italic() {
204        let segments = parse_inline_formatting("Hello *italic* world");
205        assert_eq!(segments.len(), 3);
206        assert_eq!(segments[1].text, "italic");
207        assert!(segments[1].italic);
208    }
209
210    #[test]
211    fn test_parse_code() {
212        let segments = parse_inline_formatting("Hello `code` world");
213        assert_eq!(segments.len(), 3);
214        assert_eq!(segments[1].text, "code");
215        assert!(segments[1].code);
216    }
217
218    #[test]
219    fn test_generate_rich_text() {
220        let xml = generate_rich_text_runs("Hello **bold**", 1400, false, false, None);
221        assert!(xml.contains("b=\"1\""));
222        assert!(xml.contains("Hello"));
223        assert!(xml.contains("bold"));
224    }
225
226    #[test]
227    fn test_generate_text_props() {
228        let props = generate_text_props(1400, true, false, false, Some("FF0000"));
229        assert!(props.contains("b=\"1\""));
230        assert!(props.contains("FF0000"));
231    }
232}