ppt_rs/cli/
syntax.rs

1//! Syntax highlighting for code blocks
2//!
3//! Uses syntect to provide syntax highlighting for code blocks in presentations.
4
5use syntect::highlighting::{ThemeSet, Style};
6use syntect::parsing::SyntaxSet;
7use syntect::easy::HighlightLines;
8
9/// A highlighted text segment with color
10#[derive(Debug, Clone)]
11pub struct HighlightedSegment {
12    pub text: String,
13    pub color: String, // RGB hex color
14    pub bold: bool,
15    pub italic: bool,
16}
17
18/// Highlight code with syntax coloring
19pub fn highlight_code(code: &str, language: &str) -> Vec<Vec<HighlightedSegment>> {
20    let ps = SyntaxSet::load_defaults_newlines();
21    let ts = ThemeSet::load_defaults();
22    
23    // Map common language names to syntect syntax names
24    let syntax_name = match language.to_lowercase().as_str() {
25        "rust" | "rs" => "Rust",
26        "python" | "py" => "Python",
27        "javascript" | "js" => "JavaScript",
28        "typescript" | "ts" => "TypeScript",
29        "java" => "Java",
30        "c" => "C",
31        "cpp" | "c++" => "C++",
32        "csharp" | "c#" | "cs" => "C#",
33        "go" => "Go",
34        "ruby" | "rb" => "Ruby",
35        "php" => "PHP",
36        "swift" => "Swift",
37        "kotlin" | "kt" => "Kotlin",
38        "scala" => "Scala",
39        "html" => "HTML",
40        "css" => "CSS",
41        "json" => "JSON",
42        "yaml" | "yml" => "YAML",
43        "xml" => "XML",
44        "sql" => "SQL",
45        "bash" | "sh" | "shell" => "Bourne Again Shell (bash)",
46        "powershell" | "ps1" => "PowerShell",
47        "markdown" | "md" => "Markdown",
48        "toml" => "TOML",
49        _ => "Plain Text",
50    };
51    
52    let syntax = ps.find_syntax_by_name(syntax_name)
53        .or_else(|| ps.find_syntax_by_extension(language))
54        .unwrap_or_else(|| ps.find_syntax_plain_text());
55    
56    // Use Solarized (dark) theme for vibrant syntax colors
57    let theme = &ts.themes["Solarized (dark)"];
58    let mut highlighter = HighlightLines::new(syntax, theme);
59    
60    let mut lines = Vec::new();
61    
62    for line in code.lines() {
63        let ranges = highlighter.highlight_line(line, &ps).unwrap_or_default();
64        let segments: Vec<HighlightedSegment> = ranges.iter().map(|(style, text)| {
65            HighlightedSegment {
66                text: text.to_string(),
67                color: style_to_hex(style),
68                bold: style.font_style.contains(syntect::highlighting::FontStyle::BOLD),
69                italic: style.font_style.contains(syntect::highlighting::FontStyle::ITALIC),
70            }
71        }).collect();
72        lines.push(segments);
73    }
74    
75    lines
76}
77
78/// Convert syntect Style to hex color
79fn style_to_hex(style: &Style) -> String {
80    format!("{:02X}{:02X}{:02X}", style.foreground.r, style.foreground.g, style.foreground.b)
81}
82
83/// Generate PPTX XML for highlighted code
84pub fn generate_highlighted_code_xml(code: &str, language: &str) -> String {
85    let highlighted = highlight_code(code, language);
86    let mut xml = String::new();
87    
88    for line_segments in highlighted {
89        xml.push_str("<a:p><a:pPr algn=\"l\"/>");
90        
91        if line_segments.is_empty() {
92            // Empty line - use Solarized base0 color (solidFill before latin)
93            xml.push_str(r#"<a:r><a:rPr lang="en-US" sz="1400" dirty="0"><a:solidFill><a:srgbClr val="839496"/></a:solidFill><a:latin typeface="Consolas"/></a:rPr><a:t> </a:t></a:r>"#);
94        } else {
95            for segment in line_segments {
96                let bold = if segment.bold { r#" b="1""# } else { "" };
97                let italic = if segment.italic { r#" i="1""# } else { "" };
98                let text = escape_xml(&segment.text);
99                
100                // OOXML order: solidFill must come before latin font
101                xml.push_str(&format!(
102                    r#"<a:r><a:rPr lang="en-US" sz="1400" dirty="0"{}{}><a:solidFill><a:srgbClr val="{}"/></a:solidFill><a:latin typeface="Consolas"/></a:rPr><a:t>{}</a:t></a:r>"#,
103                    bold, italic, segment.color, text
104                ));
105            }
106        }
107        
108        xml.push_str("</a:p>");
109    }
110    
111    xml
112}
113
114/// Escape XML special characters
115fn escape_xml(s: &str) -> String {
116    s.replace('&', "&amp;")
117        .replace('<', "&lt;")
118        .replace('>', "&gt;")
119        .replace('"', "&quot;")
120        .replace('\'', "&apos;")
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_highlight_rust() {
129        let code = "fn main() {\n    println!(\"Hello\");\n}";
130        let highlighted = highlight_code(code, "rust");
131        assert_eq!(highlighted.len(), 3);
132        assert!(!highlighted[0].is_empty());
133    }
134
135    #[test]
136    fn test_highlight_python() {
137        let code = "def hello():\n    print('Hello')";
138        let highlighted = highlight_code(code, "python");
139        assert_eq!(highlighted.len(), 2);
140    }
141
142    #[test]
143    fn test_highlight_unknown() {
144        let code = "some text";
145        let highlighted = highlight_code(code, "unknown");
146        assert_eq!(highlighted.len(), 1);
147    }
148
149    #[test]
150    fn test_generate_xml() {
151        let xml = generate_highlighted_code_xml("let x = 1;", "rust");
152        assert!(xml.contains("<a:p>"));
153        assert!(xml.contains("Consolas"));
154    }
155
156    #[test]
157    fn test_highlight_colors_not_black() {
158        let code = "fn main() {\n    println!(\"Hello\");\n}";
159        let highlighted = highlight_code(code, "rust");
160        
161        // Check that we have some non-black colors
162        let mut has_non_black = false;
163        for line in &highlighted {
164            for segment in line {
165                // Black would be "000000"
166                if segment.color != "000000" && !segment.text.trim().is_empty() {
167                    has_non_black = true;
168                }
169            }
170        }
171        assert!(has_non_black, "Syntax highlighting should produce non-black colors");
172    }
173
174    #[test]
175    fn test_xml_has_solarized_colors() {
176        let xml = generate_highlighted_code_xml("fn main() {}", "rust");
177        // Solarized colors should appear (not black 000000)
178        // Common Solarized colors: 859900 (green), 268BD2 (blue), 2AA198 (cyan), etc.
179        let has_color = xml.contains("859900") || xml.contains("268BD2") || 
180                        xml.contains("2AA198") || xml.contains("B58900") ||
181                        xml.contains("CB4B16") || xml.contains("DC322F") ||
182                        xml.contains("D33682") || xml.contains("6C71C4") ||
183                        xml.contains("839496") || xml.contains("93A1A1");
184        assert!(has_color, "XML should contain Solarized theme colors, got: {}", xml);
185    }
186}