Skip to main content

rusty_rich/
export.rs

1//! HTML and SVG export — equivalent to Rich's `_export_format.py` and
2//! Console export methods.
3//!
4//! Converts rendered console output into HTML and SVG documents, preserving
5//! colors, styles, and layout. Uses `TerminalTheme` to map ANSI colors to
6//! CSS-compatible RGB values.
7
8use crate::color::Color;
9use crate::segment::Segment;
10
11// ---------------------------------------------------------------------------
12// Terminal theme presets (matching Python Rich defaults)
13// ---------------------------------------------------------------------------
14
15/// A terminal color theme used for HTML/SVG export.
16#[derive(Debug, Clone)]
17pub struct ExportTheme {
18    pub background: (u8, u8, u8),
19    pub foreground: (u8, u8, u8),
20    /// ANSI palette: 16 standard colors
21    pub ansi_colors: [(u8, u8, u8); 16],
22}
23
24impl Default for ExportTheme {
25    fn default() -> Self {
26        ExportTheme {
27            background: (0, 0, 0),
28            foreground: (255, 255, 255),
29            ansi_colors: [
30                (0, 0, 0),       // 0: black
31                (128, 0, 0),     // 1: red
32                (0, 128, 0),     // 2: green
33                (128, 128, 0),   // 3: yellow
34                (0, 0, 128),     // 4: blue
35                (128, 0, 128),   // 5: magenta
36                (0, 128, 128),   // 6: cyan
37                (192, 192, 192), // 7: white
38                (128, 128, 128), // 8: bright black
39                (255, 0, 0),     // 9: bright red
40                (0, 255, 0),     // 10: bright green
41                (255, 255, 0),   // 11: bright yellow
42                (0, 0, 255),     // 12: bright blue
43                (255, 0, 255),   // 13: bright magenta
44                (0, 255, 255),   // 14: bright cyan
45                (255, 255, 255), // 15: bright white
46            ],
47        }
48    }
49}
50
51/// Monokai-inspired dark theme for HTML/SVG export.
52pub const EXPORT_THEME_MONOKAI: ExportTheme = ExportTheme {
53    background: (39, 40, 34),
54    foreground: (248, 248, 242),
55    ansi_colors: [
56        (39, 40, 34),   // 0: black (bg)
57        (249, 38, 114), // 1: red
58        (166, 226, 46), // 2: green
59        (230, 219, 116),// 3: yellow
60        (102, 217, 239),// 4: blue
61        (174, 129, 255),// 5: magenta
62        (161, 239, 228),// 6: cyan
63        (248, 248, 242),// 7: white
64        (117, 113, 94), // 8: bright black
65        (249, 38, 114), // 9: bright red
66        (166, 226, 46), // 10: bright green
67        (230, 219, 116),// 11: bright yellow
68        (102, 217, 239),// 12: bright blue
69        (174, 129, 255),// 13: bright magenta
70        (161, 239, 228),// 14: bright cyan
71        (248, 248, 242),// 15: bright white
72    ],
73};
74
75/// Dimmed Monokai variant -- lower contrast, suitable for comfortable reading.
76pub const EXPORT_THEME_DIMMED_MONOKAI: ExportTheme = ExportTheme {
77    background: (35, 35, 35),
78    foreground: (185, 188, 186),
79    ansi_colors: [
80        (35, 35, 35),   // 0
81        (190, 63, 72),  // 1
82        (135, 154, 59), // 2
83        (197, 166, 56), // 3
84        (79, 118, 161), // 4
85        (133, 92, 141), // 5
86        (87, 143, 164), // 6
87        (185, 188, 186),// 7
88        (83, 83, 83),   // 8
89        (240, 80, 80),  // 9
90        (148, 166, 73), // 10
91        (215, 180, 66), // 11
92        (108, 147, 177),// 12
93        (152, 117, 171),// 13
94        (101, 164, 179),// 14
95        (230, 235, 235),// 15
96    ],
97};
98
99/// Night Owl-inspired dark theme with deep blue background.
100pub const EXPORT_THEME_NIGHT_OWLISH: ExportTheme = ExportTheme {
101    background: (1, 22, 39),
102    foreground: (214, 222, 235),
103    ansi_colors: [
104        (1, 22, 39),    // 0
105        (255, 88, 116), // 1
106        (173, 219, 103),// 2
107        (255, 203, 107),// 3
108        (130, 170, 255),// 4
109        (199, 146, 234),// 5
110        (137, 221, 255),// 6
111        (214, 222, 235),// 7
112        (84, 94, 109),  // 8
113        (255, 88, 116), // 9
114        (173, 219, 103),// 10
115        (255, 203, 107),// 11
116        (130, 170, 255),// 12
117        (199, 146, 234),// 13
118        (137, 221, 255),// 14
119        (255, 255, 255),// 15
120    ],
121};
122
123/// Light theme with white background, suitable for SVG export snippets.
124pub const EXPORT_THEME_SVG: ExportTheme = ExportTheme {
125    background: (255, 255, 255),
126    foreground: (0, 0, 0),
127    ansi_colors: [
128        (0, 0, 0),       // 0: black
129        (204, 0, 0),     // 1: red
130        (0, 170, 0),     // 2: green
131        (204, 102, 0),   // 3: yellow
132        (0, 0, 204),     // 4: blue
133        (170, 0, 170),   // 5: magenta
134        (0, 170, 170),   // 6: cyan
135        (170, 170, 170), // 7: white
136        (102, 102, 102), // 8: bright black
137        (255, 0, 0),     // 9: bright red
138        (0, 255, 0),     // 10: bright green
139        (255, 255, 0),   // 11: bright yellow
140        (0, 0, 255),     // 12: bright blue
141        (255, 0, 255),   // 13: bright magenta
142        (0, 255, 255),   // 14: bright cyan
143        (255, 255, 255), // 15: bright white
144    ],
145};
146
147// ---------------------------------------------------------------------------
148// HTML export
149// ---------------------------------------------------------------------------
150
151/// The HTML document template used by `export_html`.
152pub const CONSOLE_HTML_FORMAT: &str = r#"<!DOCTYPE html>
153<html lang="en">
154<head>
155<meta charset="UTF-8">
156<meta name="viewport" content="width=device-width, initial-scale=1.0">
157<title>rusty-rich</title>
158<style>
159    body {{
160        margin: 0;
161        padding: 0;
162    }}
163    pre.rich-html {{
164        font-family: {font_family};
165        font-size: {font_size}px;
166        line-height: {line_height};
167        color: {foreground};
168        background-color: {background};
169        margin: 0;
170        padding: 16px 24px;
171        white-space: pre-wrap;
172        word-wrap: break-word;
173        overflow-x: auto;
174    }}
175</style>
176</head>
177<body>
178<pre class="rich-html">
179{code}
180</pre>
181</body>
182</html>"#;
183
184/// Options for HTML export.
185#[derive(Debug, Clone)]
186pub struct ExportHtmlOptions {
187    /// Font family for the output.
188    pub font_family: String,
189    /// Font size in pixels.
190    pub font_size: u32,
191    /// Line height multiplier.
192    pub line_height: f64,
193    /// Terminal color theme.
194    pub theme: ExportTheme,
195    /// Code block to insert.
196    pub code: String,
197}
198
199impl Default for ExportHtmlOptions {
200    fn default() -> Self {
201        Self {
202            font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Source Code Pro', Menlo, Consolas, monospace".into(),
203            font_size: 14,
204            line_height: 1.45,
205            theme: ExportTheme::default(),
206            code: String::new(),
207        }
208    }
209}
210
211/// Generate a full HTML document from rendered terminal output.
212///
213/// # Example
214///
215/// ```rust,no_run
216/// use rusty_rich::export::{export_html, ExportHtmlOptions};
217///
218/// let html = export_html(&ExportHtmlOptions {
219///     code: "[bold red]Hello[/bold red]".into(),
220///     ..Default::default()
221/// });
222/// std::fs::write("output.html", html).unwrap();
223/// ```
224pub fn export_html(options: &ExportHtmlOptions) -> String {
225    let fg = options.theme.foreground;
226    let bg = options.theme.background;
227
228    CONSOLE_HTML_FORMAT
229        .replace("{font_family}", &options.font_family)
230        .replace("{font_size}", &options.font_size.to_string())
231        .replace("{line_height}", &options.line_height.to_string())
232        .replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
233        .replace("{background}", &format!("rgb({},{},{})", bg.0, bg.1, bg.2))
234        .replace("{code}", &escape_html(&options.code))
235}
236
237/// Save rendered output as an HTML file.
238///
239/// Convenience wrapper around `export_html` that writes to disk.
240pub fn save_html(path: impl AsRef<std::path::Path>, options: &ExportHtmlOptions) -> std::io::Result<()> {
241    std::fs::write(path.as_ref(), export_html(options))
242}
243
244// ---------------------------------------------------------------------------
245// SVG export
246// ---------------------------------------------------------------------------
247
248/// The SVG document template used by `export_svg`.
249pub const CONSOLE_SVG_FORMAT: &str = r#"<svg class="rich-svg" xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
250<style>
251    text {{ font-family: {font_family}; font-size: {font_size}px; }}
252</style>
253<rect width="100%" height="100%" fill="{background}"/>
254<text x="0" y="{baseline}" xml:space="preserve">
255{code}
256</text>
257</svg>"#;
258
259/// Options for SVG export.
260#[derive(Debug, Clone)]
261pub struct ExportSvgOptions {
262    /// Font family for the output.
263    pub font_family: String,
264    /// Font size in pixels.
265    pub font_size: u32,
266    /// Terminal color theme.
267    pub theme: ExportTheme,
268    /// Code block to insert.
269    pub code: String,
270    /// SVG canvas width.
271    pub width: u32,
272    /// SVG canvas height.
273    pub height: u32,
274}
275
276impl Default for ExportSvgOptions {
277    fn default() -> Self {
278        Self {
279            font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace".into(),
280            font_size: 14,
281            theme: EXPORT_THEME_SVG,
282            code: String::new(),
283            width: 800,
284            height: 600,
285        }
286    }
287}
288
289/// Generate a full SVG document from rendered terminal output.
290///
291/// # Example
292///
293/// ```rust,no_run
294/// use rusty_rich::export::{export_svg, ExportSvgOptions};
295///
296/// let svg = export_svg(&ExportSvgOptions {
297///     code: "[bold blue]Hello SVG[/bold blue]".into(),
298///     ..Default::default()
299/// });
300/// std::fs::write("output.svg", svg).unwrap();
301/// ```
302pub fn export_svg(options: &ExportSvgOptions) -> String {
303    let fg = options.theme.foreground;
304    let bg = options.theme.background;
305    let baseline = options.font_size as f64 * 1.2; // approximate first-line baseline
306
307    CONSOLE_SVG_FORMAT
308        .replace("{font_family}", &options.font_family)
309        .replace("{font_size}", &options.font_size.to_string())
310        .replace("{width}", &options.width.to_string())
311        .replace("{height}", &options.height.to_string())
312        .replace("{background}", &format!("rgb({},{},{})", bg.0, bg.1, bg.2))
313        .replace("{baseline}", &format!("{:.0}", baseline))
314        .replace("{code}", &escape_xml(&options.code))
315        .replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
316}
317
318/// Save rendered output as an SVG file.
319pub fn save_svg(path: impl AsRef<std::path::Path>, options: &ExportSvgOptions) -> std::io::Result<()> {
320    std::fs::write(path.as_ref(), export_svg(options))
321}
322
323// ---------------------------------------------------------------------------
324// Text export
325// ---------------------------------------------------------------------------
326
327/// Options for plain-text export (strips ANSI escape codes).
328#[derive(Debug, Clone)]
329pub struct ExportTextOptions {
330    /// The text to export (may contain ANSI escapes).
331    pub text: String,
332    /// If true, strip ANSI escape sequences. If false, keep them.
333    pub strip_ansi: bool,
334}
335
336impl Default for ExportTextOptions {
337    fn default() -> Self {
338        Self {
339            text: String::new(),
340            strip_ansi: true,
341        }
342    }
343}
344
345/// Export text, optionally stripping ANSI escape sequences. Returns plain text.
346pub fn export_text(options: &ExportTextOptions) -> String {
347    if options.strip_ansi {
348        strip_ansi_escapes(&options.text)
349    } else {
350        options.text.clone()
351    }
352}
353
354/// Save text output to a file, optionally stripping ANSI escape sequences.
355pub fn save_text(
356    path: impl AsRef<std::path::Path>,
357    options: &ExportTextOptions,
358) -> std::io::Result<()> {
359    std::fs::write(path.as_ref(), export_text(options))
360}
361
362// ---------------------------------------------------------------------------
363// Utilities
364// ---------------------------------------------------------------------------
365
366/// Escape special HTML characters in text.
367pub fn escape_html(text: &str) -> String {
368    text.replace('&', "&amp;")
369        .replace('<', "&lt;")
370        .replace('>', "&gt;")
371        .replace('"', "&quot;")
372}
373
374/// Escape special XML characters (`&`, `<`, `>`, `"`, `'`) in text.
375pub fn escape_xml(text: &str) -> String {
376    text.replace('&', "&amp;")
377        .replace('<', "&lt;")
378        .replace('>', "&gt;")
379        .replace('"', "&quot;")
380        .replace('\'', "&apos;")
381}
382
383/// Strip ANSI escape sequences from text, returning plain text.
384pub fn strip_ansi_escapes(text: &str) -> String {
385    let mut result = String::with_capacity(text.len());
386    let mut chars = text.chars().peekable();
387
388    while let Some(ch) = chars.next() {
389        if ch == '\x1b' {
390            // Consume the escape sequence
391            if chars.peek() == Some(&'[') {
392                chars.next(); // consume '['
393                // Read parameter bytes (digits, semicolons, etc.)
394                while let Some(&c) = chars.peek() {
395                    if c.is_ascii_digit() || c == ';' || c == '?' || c == '!' {
396                        chars.next();
397                    } else {
398                        break;
399                    }
400                }
401                // Consume the final byte (letter)
402                chars.next();
403            }
404        } else {
405            result.push(ch);
406        }
407    }
408
409    result
410}
411
412/// Convert Rich styled segments to HTML with inline CSS spans.
413///
414/// Each segment's foreground color, background color, bold, italic, etc.
415/// are mapped to `<span style="...">` elements.
416pub fn segments_to_html(
417    segments: &[Segment],
418    theme: &ExportTheme,
419) -> String {
420    let mut html = String::new();
421
422    for seg in segments {
423        let mut styles: Vec<String> = Vec::new();
424
425        if let Some(ref style) = seg.style {
426            // Foreground color
427            if let Some(color) = &style.color {
428                let rgb = resolve_color(color, theme);
429                styles.push(format!("color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
430            } else {
431                // Use foreground default
432                let fg = theme.foreground;
433                styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
434            }
435
436            // Background color
437            if let Some(bgcolor) = &style.bgcolor {
438                let rgb = resolve_color(bgcolor, theme);
439                styles.push(format!("background-color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
440            }
441
442            // Text attributes
443            let attrs = &style.attributes;
444            if attrs.get(crate::style::Attributes::BOLD) {
445                styles.push("font-weight:bold".into());
446            }
447            if attrs.get(crate::style::Attributes::ITALIC) {
448                styles.push("font-style:italic".into());
449            }
450            if attrs.get(crate::style::Attributes::UNDERLINE)
451                || attrs.get(crate::style::Attributes::UNDERLINE2)
452            {
453                styles.push("text-decoration:underline".into());
454            }
455            if attrs.get(crate::style::Attributes::STRIKE) {
456                styles.push("text-decoration:line-through".into());
457            }
458            if attrs.get(crate::style::Attributes::DIM) {
459                styles.push("opacity:0.7".into());
460            }
461            if attrs.get(crate::style::Attributes::CONCEAL) {
462                styles.push("visibility:hidden".into());
463            }
464
465            // Hyperlink
466            if let Some(ref link) = style.link {
467                let escaped_link = escape_html(link);
468                let style_attr = if styles.is_empty() {
469                    String::new()
470                } else {
471                    format!(" style=\"{}\"", styles.join("; "))
472                };
473                html.push_str(&format!(
474                    "<a href=\"{}\"{}>{}</a>",
475                    escaped_link,
476                    style_attr,
477                    escape_html(&seg.text)
478                ));
479                continue; // skip normal span handling for links
480            }
481        } else {
482            // No style — use theme defaults
483            let fg = theme.foreground;
484            styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
485        }
486
487        // Emit styled span
488        if styles.is_empty() {
489            html.push_str(&escape_html(&seg.text));
490        } else {
491            let style_attr = styles.join("; ");
492            html.push_str(&format!(
493                "<span style=\"{}\">{}</span>",
494                style_attr,
495                escape_html(&seg.text)
496            ));
497        }
498    }
499
500    html
501}
502
503/// Resolve a color to an RGB triplet given a terminal theme.
504fn resolve_color(color: &Color, theme: &ExportTheme) -> (u8, u8, u8) {
505    match color.color_type {
506        crate::color::ColorType::Default => theme.foreground,
507        crate::color::ColorType::Standard => {
508            let idx = color.number.unwrap_or(7) as usize % 16;
509            theme.ansi_colors[idx]
510        }
511        crate::color::ColorType::EightBit => {
512            let idx = color.number.unwrap_or(0) as usize % 256;
513            rgb_for_8bit(idx)
514        }
515        crate::color::ColorType::TrueColor => {
516            if let Some(ref triplet) = color.triplet {
517                (triplet.0, triplet.1, triplet.2)
518            } else {
519                theme.foreground
520            }
521        }
522    }
523}
524
525/// Map an 8-bit (256) color index to an RGB triplet.
526fn rgb_for_8bit(index: usize) -> (u8, u8, u8) {
527    if index < 16 {
528        // Standard ANSI colors
529        crate::color::STANDARD_PALETTE
530            .get(index)
531            .copied()
532            .unwrap_or((0, 0, 0))
533    } else if index < 232 {
534        // 6×6×6 color cube
535        let idx = index - 16;
536        let r = (idx / 36) as u8 * 51;
537        let g = ((idx / 6) % 6) as u8 * 51;
538        let b = (idx % 6) as u8 * 51;
539        (r, g, b)
540    } else {
541        // Greyscale ramp (232–255)
542        let g = ((index - 232) * 10 + 8) as u8;
543        (g, g, g)
544    }
545}
546
547// ---------------------------------------------------------------------------
548// Tests
549// ---------------------------------------------------------------------------
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::style::Style;
555    use crate::color::Color;
556
557    #[test]
558    fn test_escape_html_basic() {
559        assert_eq!(escape_html("<hello>"), "&lt;hello&gt;");
560        assert_eq!(escape_html("\"a\" & 'b'"), "&quot;a&quot; &amp; 'b'");
561    }
562
563    #[test]
564    fn test_strip_ansi_escapes() {
565        let input = "\x1b[31mred\x1b[0m normal";
566        assert_eq!(strip_ansi_escapes(input), "red normal");
567    }
568
569    #[test]
570    fn test_strip_ansi_complex() {
571        let input = "\x1b[1;31mBold Red\x1b[0m \x1b[4munderlined\x1b[0m";
572        assert_eq!(strip_ansi_escapes(input), "Bold Red underlined");
573    }
574
575    #[test]
576    fn test_strip_ansi_no_escapes() {
577        assert_eq!(strip_ansi_escapes("plain text"), "plain text");
578    }
579
580    #[test]
581    fn test_export_html_basic() {
582        let opts = ExportHtmlOptions {
583            code: "Hello World".into(),
584            ..Default::default()
585        };
586        let html = export_html(&opts);
587        assert!(html.contains("<!DOCTYPE html>"));
588        assert!(html.contains("Hello World"));
589        assert!(html.contains("rich-html"));
590        assert!(html.contains("font-family"));
591    }
592
593    #[test]
594    fn test_export_html_escapes_markup() {
595        let opts = ExportHtmlOptions {
596            code: "<script>alert('xss')</script>".into(),
597            ..Default::default()
598        };
599        let html = export_html(&opts);
600        assert!(!html.contains("<script>"));
601        assert!(html.contains("&lt;script&gt;"));
602    }
603
604    #[test]
605    fn test_export_svg_basic() {
606        let opts = ExportSvgOptions {
607            code: "SVG text".into(),
608            ..Default::default()
609        };
610        let svg = export_svg(&opts);
611        assert!(svg.contains("<svg"));
612        assert!(svg.contains("SVG text"));
613        assert!(svg.contains("rich-svg"));
614    }
615
616    #[test]
617    fn test_export_svg_theme() {
618        let opts = ExportSvgOptions {
619            code: "test".into(),
620            theme: EXPORT_THEME_SVG,
621            ..Default::default()
622        };
623        let svg = export_svg(&opts);
624        assert!(svg.contains("rgb(255,255,255)")); // background
625    }
626
627    #[test]
628    fn test_export_text_strip() {
629        let opts = ExportTextOptions {
630            text: "\x1b[1;32mGreen Bold\x1b[0m".into(),
631            strip_ansi: true,
632        };
633        assert_eq!(export_text(&opts), "Green Bold");
634    }
635
636    #[test]
637    fn test_export_text_keep() {
638        let ansi = "\x1b[31mred\x1b[0m";
639        let opts = ExportTextOptions {
640            text: ansi.into(),
641            strip_ansi: false,
642        };
643        assert_eq!(export_text(&opts), ansi);
644    }
645
646    #[test]
647    fn test_rgb_for_8bit_standard() {
648        assert_eq!(rgb_for_8bit(0), (0, 0, 0));     // black
649        assert_eq!(rgb_for_8bit(1), (128, 0, 0));   // red
650        assert_eq!(rgb_for_8bit(15), (255, 255, 255)); // bright white
651    }
652
653    #[test]
654    fn test_rgb_for_8bit_cube() {
655        assert_eq!(rgb_for_8bit(16), (0, 0, 0));
656        let idx = 16 + 1 * 36 + 2 * 6 + 3; // R=1, G=2, B=3
657        assert_eq!(rgb_for_8bit(idx), (51, 102, 153));
658    }
659
660    #[test]
661    fn test_rgb_for_8bit_greyscale() {
662        assert_eq!(rgb_for_8bit(232), (8, 8, 8));
663        assert_eq!(rgb_for_8bit(255), (238, 238, 238));
664    }
665
666    #[test]
667    fn test_segments_to_html_styled() {
668        let seg = Segment::styled(
669            "hello",
670            Style::new()
671                .color(Color::parse("red").unwrap())
672                .bold(true),
673        );
674        let html = segments_to_html(&[seg], &ExportTheme::default());
675        assert!(html.contains("color:rgb(128,0,0)"));
676        assert!(html.contains("font-weight:bold"));
677        assert!(html.contains("hello"));
678    }
679
680    #[test]
681    fn test_segments_to_html_plain() {
682        let seg = Segment::new("plain");
683        let html = segments_to_html(&[seg], &ExportTheme::default());
684        assert!(html.contains("plain"));
685        assert!(html.contains("color:rgb(255,255,255)"));
686    }
687
688    #[test]
689    fn test_export_theme_defaults() {
690        let theme = ExportTheme::default();
691        assert_eq!(theme.background, (0, 0, 0));
692        assert_eq!(theme.foreground, (255, 255, 255));
693    }
694
695    #[test]
696    fn test_save_to_disk() {
697        let dir = std::env::temp_dir();
698        let path = dir.join("test_export.html");
699        let opts = ExportHtmlOptions {
700            code: "test".into(),
701            ..Default::default()
702        };
703        save_html(&path, &opts).unwrap();
704        let contents = std::fs::read_to_string(&path).unwrap();
705        assert!(contents.contains("test"));
706        std::fs::remove_file(&path).unwrap();
707    }
708}