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    // Safe ordered replacement: {code} is replaced FIRST to prevent injection
229    // via font_family/font_size containing literal placeholder strings (VULN-007)
230    CONSOLE_HTML_FORMAT
231        .replace("{code}", &escape_html(&options.code))
232        .replace("{font_family}", &escape_html(&options.font_family))
233        .replace("{font_size}", &escape_html(&options.font_size.to_string()))
234        .replace(
235            "{line_height}",
236            &escape_html(&options.line_height.to_string()),
237        )
238        .replace(
239            "{foreground}",
240            &escape_html(&format!("rgb({},{},{})", fg.0, fg.1, fg.2)),
241        )
242        .replace(
243            "{background}",
244            &escape_html(&format!("rgb({},{},{})", bg.0, bg.1, bg.2)),
245        )
246}
247
248/// Save rendered output as an HTML file.
249///
250/// Convenience wrapper around `export_html` that writes to disk.
251pub fn save_html(
252    path: impl AsRef<std::path::Path>,
253    options: &ExportHtmlOptions,
254) -> std::io::Result<()> {
255    std::fs::write(path.as_ref(), export_html(options))
256}
257
258// ---------------------------------------------------------------------------
259// SVG export
260// ---------------------------------------------------------------------------
261
262/// The SVG document template used by `export_svg`.
263pub 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}">
264<style>
265    text {{ font-family: {font_family}; font-size: {font_size}px; }}
266</style>
267<rect width="100%" height="100%" fill="{background}"/>
268<text x="0" y="{baseline}" xml:space="preserve">
269{code}
270</text>
271</svg>"#;
272
273/// Options for SVG export.
274#[derive(Debug, Clone)]
275pub struct ExportSvgOptions {
276    /// Font family for the output.
277    pub font_family: String,
278    /// Font size in pixels.
279    pub font_size: u32,
280    /// Terminal color theme.
281    pub theme: ExportTheme,
282    /// Code block to insert.
283    pub code: String,
284    /// SVG canvas width.
285    pub width: u32,
286    /// SVG canvas height.
287    pub height: u32,
288}
289
290impl Default for ExportSvgOptions {
291    fn default() -> Self {
292        Self {
293            font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace".into(),
294            font_size: 14,
295            theme: EXPORT_THEME_SVG,
296            code: String::new(),
297            width: 800,
298            height: 600,
299        }
300    }
301}
302
303/// Generate a full SVG document from rendered terminal output.
304///
305/// # Example
306///
307/// ```rust,no_run
308/// use rusty_rich::export::{export_svg, ExportSvgOptions};
309///
310/// let svg = export_svg(&ExportSvgOptions {
311///     code: "[bold blue]Hello SVG[/bold blue]".into(),
312///     ..Default::default()
313/// });
314/// std::fs::write("output.svg", svg).unwrap();
315/// ```
316pub fn export_svg(options: &ExportSvgOptions) -> String {
317    let fg = options.theme.foreground;
318    let bg = options.theme.background;
319    let baseline = options.font_size as f64 * 1.2; // approximate first-line baseline
320
321    // Safe ordered replacement: {code} goes FIRST, all values escaped (VULN-007)
322    CONSOLE_SVG_FORMAT
323        .replace("{code}", &escape_xml(&options.code))
324        .replace("{font_family}", &escape_xml(&options.font_family))
325        .replace("{font_size}", &escape_xml(&options.font_size.to_string()))
326        .replace("{width}", &escape_xml(&options.width.to_string()))
327        .replace("{height}", &escape_xml(&options.height.to_string()))
328        .replace(
329            "{background}",
330            &escape_xml(&format!("rgb({},{},{})", bg.0, bg.1, bg.2)),
331        )
332        .replace("{baseline}", &escape_xml(&format!("{:.0}", baseline)))
333        .replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
334}
335
336/// Save rendered output as an SVG file.
337pub fn save_svg(
338    path: impl AsRef<std::path::Path>,
339    options: &ExportSvgOptions,
340) -> std::io::Result<()> {
341    std::fs::write(path.as_ref(), export_svg(options))
342}
343
344// ---------------------------------------------------------------------------
345// Text export
346// ---------------------------------------------------------------------------
347
348/// Options for plain-text export (strips ANSI escape codes).
349#[derive(Debug, Clone)]
350pub struct ExportTextOptions {
351    /// The text to export (may contain ANSI escapes).
352    pub text: String,
353    /// If true, strip ANSI escape sequences. If false, keep them.
354    pub strip_ansi: bool,
355}
356
357impl Default for ExportTextOptions {
358    fn default() -> Self {
359        Self {
360            text: String::new(),
361            strip_ansi: true,
362        }
363    }
364}
365
366/// Export text, optionally stripping ANSI escape sequences. Returns plain text.
367pub fn export_text(options: &ExportTextOptions) -> String {
368    if options.strip_ansi {
369        strip_ansi_escapes(&options.text)
370    } else {
371        options.text.clone()
372    }
373}
374
375/// Save text output to a file, optionally stripping ANSI escape sequences.
376pub fn save_text(
377    path: impl AsRef<std::path::Path>,
378    options: &ExportTextOptions,
379) -> std::io::Result<()> {
380    std::fs::write(path.as_ref(), export_text(options))
381}
382
383// ---------------------------------------------------------------------------
384// Utilities
385// ---------------------------------------------------------------------------
386
387/// Escape special HTML characters in text.
388pub fn escape_html(text: &str) -> String {
389    text.replace('&', "&amp;")
390        .replace('<', "&lt;")
391        .replace('>', "&gt;")
392        .replace('"', "&quot;")
393}
394
395/// Escape special XML characters (`&`, `<`, `>`, `"`, `'`) in text.
396pub fn escape_xml(text: &str) -> String {
397    text.replace('&', "&amp;")
398        .replace('<', "&lt;")
399        .replace('>', "&gt;")
400        .replace('"', "&quot;")
401        .replace('\'', "&apos;")
402}
403
404/// Strip ANSI escape sequences from text, returning plain text.
405///
406/// Handles all ECMA-48 / ISO 6429 escape sequence types:
407/// - CSI: `ESC [` parameter-bytes intermediate-bytes final-byte
408/// - OSC: `ESC ]` ... `BEL` or `ESC \\`
409/// - DCS: `ESC P` ... `ESC \\`
410/// - APC: `ESC _` ... `ESC \\`
411/// - PM:  `ESC ^` ... `ESC \\`
412/// - SOS: `ESC X` ... `ESC \\`
413pub fn strip_ansi_escapes(text: &str) -> String {
414    let mut result = String::with_capacity(text.len());
415    let mut chars = text.chars().peekable();
416
417    while let Some(ch) = chars.next() {
418        if ch == '\x1b' {
419            match chars.peek() {
420                Some(&'[') => {
421                    chars.next(); // consume '['
422                                  // Consume parameter bytes (0x30-0x3F: digits, ;, ?, !, >)
423                    while let Some(&c) = chars.peek() {
424                        if c.is_ascii_digit() || c == ';' || c == '?' || c == '!' || c == '>' {
425                            chars.next();
426                        } else {
427                            break;
428                        }
429                    }
430                    // Consume intermediate bytes (0x20-0x2F)
431                    while let Some(&c) = chars.peek() {
432                        if (0x20..=0x2F).contains(&(c as u32)) {
433                            chars.next();
434                        } else {
435                            break;
436                        }
437                    }
438                    // Consume final byte (0x40-0x7E)
439                    chars.next();
440                }
441                // OSC, DCS, APC, PM, SOS — terminated by BEL or ST
442                Some(&']') | Some(&'P') | Some(&'_') | Some(&'^') | Some(&'X') => {
443                    chars.next(); // consume the type byte
444                    while let Some(&c) = chars.peek() {
445                        if c == '\x07' {
446                            chars.next();
447                            break;
448                        } else if c == '\x1b' {
449                            chars.next();
450                            if chars.peek() == Some(&'\\') {
451                                chars.next();
452                                break;
453                            }
454                        } else {
455                            chars.next();
456                        }
457                    }
458                }
459                // Unknown escape type — just consume ESC
460                _ => {}
461            }
462        } else {
463            result.push(ch);
464        }
465    }
466
467    result
468}
469
470/// Convert Rich styled segments to HTML with inline CSS spans.
471///
472/// Each segment's foreground color, background color, bold, italic, etc.
473/// are mapped to `<span style="...">` elements.
474pub fn segments_to_html(segments: &[Segment], theme: &ExportTheme) -> String {
475    let mut html = String::new();
476
477    for seg in segments {
478        let mut styles: Vec<String> = Vec::new();
479
480        if let Some(ref style) = seg.style {
481            // Foreground color
482            if let Some(color) = &style.color {
483                let rgb = resolve_color(color, theme);
484                styles.push(format!("color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
485            } else {
486                // Use foreground default
487                let fg = theme.foreground;
488                styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
489            }
490
491            // Background color
492            if let Some(bgcolor) = &style.bgcolor {
493                let rgb = resolve_color(bgcolor, theme);
494                styles.push(format!(
495                    "background-color:rgb({},{},{})",
496                    rgb.0, rgb.1, rgb.2
497                ));
498            }
499
500            // Text attributes
501            let attrs = &style.attributes;
502            if attrs.get(crate::style::Attributes::BOLD) {
503                styles.push("font-weight:bold".into());
504            }
505            if attrs.get(crate::style::Attributes::ITALIC) {
506                styles.push("font-style:italic".into());
507            }
508            if attrs.get(crate::style::Attributes::UNDERLINE)
509                || attrs.get(crate::style::Attributes::UNDERLINE2)
510            {
511                styles.push("text-decoration:underline".into());
512            }
513            if attrs.get(crate::style::Attributes::STRIKE) {
514                styles.push("text-decoration:line-through".into());
515            }
516            if attrs.get(crate::style::Attributes::DIM) {
517                styles.push("opacity:0.7".into());
518            }
519            if attrs.get(crate::style::Attributes::CONCEAL) {
520                styles.push("visibility:hidden".into());
521            }
522
523            // Hyperlink
524            if let Some(ref link) = style.link {
525                let escaped_link = escape_html(link);
526                let style_attr = if styles.is_empty() {
527                    String::new()
528                } else {
529                    format!(" style=\"{}\"", styles.join("; "))
530                };
531                html.push_str(&format!(
532                    "<a href=\"{}\"{}>{}</a>",
533                    escaped_link,
534                    style_attr,
535                    escape_html(&seg.text)
536                ));
537                continue; // skip normal span handling for links
538            }
539        } else {
540            // No style — use theme defaults
541            let fg = theme.foreground;
542            styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
543        }
544
545        // Emit styled span
546        if styles.is_empty() {
547            html.push_str(&escape_html(&seg.text));
548        } else {
549            let style_attr = styles.join("; ");
550            html.push_str(&format!(
551                "<span style=\"{}\">{}</span>",
552                style_attr,
553                escape_html(&seg.text)
554            ));
555        }
556    }
557
558    html
559}
560
561/// Convert Rich styled segments to SVG `<tspan>` elements with inline fill
562/// colors. Each segment's foreground color maps to a `fill` attribute, and
563/// text attributes (bold, italic, underline, strike, dim, conceal) are
564/// mapped to CSS properties.
565pub fn segments_to_svg(segments: &[Segment], theme: &ExportTheme) -> String {
566    let mut svg = String::new();
567
568    for seg in segments {
569        let mut styles: Vec<String> = Vec::new();
570
571        if let Some(ref style) = seg.style {
572            // Foreground color
573            if let Some(color) = &style.color {
574                let rgb = resolve_color(color, theme);
575                styles.push(format!("fill:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
576            } else {
577                let fg = theme.foreground;
578                styles.push(format!("fill:rgb({},{},{})", fg.0, fg.1, fg.2));
579            }
580
581            // Text attributes
582            let attrs = &style.attributes;
583            if attrs.get(crate::style::Attributes::BOLD) {
584                styles.push("font-weight:bold".into());
585            }
586            if attrs.get(crate::style::Attributes::ITALIC) {
587                styles.push("font-style:italic".into());
588            }
589            if attrs.get(crate::style::Attributes::UNDERLINE)
590                || attrs.get(crate::style::Attributes::UNDERLINE2)
591            {
592                styles.push("text-decoration:underline".into());
593            }
594            if attrs.get(crate::style::Attributes::STRIKE) {
595                styles.push("text-decoration:line-through".into());
596            }
597            if attrs.get(crate::style::Attributes::DIM) {
598                styles.push("opacity:0.7".into());
599            }
600            if attrs.get(crate::style::Attributes::CONCEAL) {
601                styles.push("visibility:hidden".into());
602            }
603        } else {
604            let fg = theme.foreground;
605            styles.push(format!("fill:rgb({},{},{})", fg.0, fg.1, fg.2));
606        }
607
608        if styles.is_empty() {
609            svg.push_str(&escape_xml(&seg.text));
610        } else {
611            let style_attr = styles.join("; ");
612            svg.push_str(&format!(
613                "<tspan style=\"{}\">{}</tspan>",
614                style_attr,
615                escape_xml(&seg.text)
616            ));
617        }
618    }
619
620    svg
621}
622
623/// Resolve a color to an RGB triplet given a terminal theme.
624fn resolve_color(color: &Color, theme: &ExportTheme) -> (u8, u8, u8) {
625    match color.color_type {
626        crate::color::ColorType::Default => theme.foreground,
627        crate::color::ColorType::Standard => {
628            let idx = color.number.unwrap_or(7) as usize % 16;
629            theme.ansi_colors[idx]
630        }
631        crate::color::ColorType::EightBit => {
632            let idx = color.number.unwrap_or(0) as usize % 256;
633            rgb_for_8bit(idx)
634        }
635        crate::color::ColorType::TrueColor => {
636            if let Some(ref triplet) = color.triplet {
637                (triplet.0, triplet.1, triplet.2)
638            } else {
639                theme.foreground
640            }
641        }
642    }
643}
644
645/// Map an 8-bit (256) color index to an RGB triplet.
646fn rgb_for_8bit(index: usize) -> (u8, u8, u8) {
647    if index < 16 {
648        // Standard ANSI colors
649        crate::color::STANDARD_PALETTE
650            .get(index)
651            .copied()
652            .unwrap_or((0, 0, 0))
653    } else if index < 232 {
654        // 6×6×6 color cube
655        let idx = index - 16;
656        let r = (idx / 36) as u8 * 51;
657        let g = ((idx / 6) % 6) as u8 * 51;
658        let b = (idx % 6) as u8 * 51;
659        (r, g, b)
660    } else {
661        // Greyscale ramp (232–255)
662        let g = ((index - 232) * 10 + 8) as u8;
663        (g, g, g)
664    }
665}
666
667// ---------------------------------------------------------------------------
668// Tests
669// ---------------------------------------------------------------------------
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::color::Color;
675    use crate::style::Style;
676
677    #[test]
678    fn test_escape_html_basic() {
679        assert_eq!(escape_html("<hello>"), "&lt;hello&gt;");
680        assert_eq!(escape_html("\"a\" & 'b'"), "&quot;a&quot; &amp; 'b'");
681    }
682
683    #[test]
684    fn test_strip_ansi_escapes() {
685        let input = "\x1b[31mred\x1b[0m normal";
686        assert_eq!(strip_ansi_escapes(input), "red normal");
687    }
688
689    #[test]
690    fn test_strip_ansi_complex() {
691        let input = "\x1b[1;31mBold Red\x1b[0m \x1b[4munderlined\x1b[0m";
692        assert_eq!(strip_ansi_escapes(input), "Bold Red underlined");
693    }
694
695    #[test]
696    fn test_strip_ansi_no_escapes() {
697        assert_eq!(strip_ansi_escapes("plain text"), "plain text");
698    }
699
700    #[test]
701    fn test_export_html_basic() {
702        let opts = ExportHtmlOptions {
703            code: "Hello World".into(),
704            ..Default::default()
705        };
706        let html = export_html(&opts);
707        assert!(html.contains("<!DOCTYPE html>"));
708        assert!(html.contains("Hello World"));
709        assert!(html.contains("rich-html"));
710        assert!(html.contains("font-family"));
711    }
712
713    #[test]
714    fn test_export_html_escapes_markup() {
715        let opts = ExportHtmlOptions {
716            code: "<script>alert('xss')</script>".into(),
717            ..Default::default()
718        };
719        let html = export_html(&opts);
720        assert!(!html.contains("<script>"));
721        assert!(html.contains("&lt;script&gt;"));
722    }
723
724    #[test]
725    fn test_export_svg_basic() {
726        let opts = ExportSvgOptions {
727            code: "SVG text".into(),
728            ..Default::default()
729        };
730        let svg = export_svg(&opts);
731        assert!(svg.contains("<svg"));
732        assert!(svg.contains("SVG text"));
733        assert!(svg.contains("rich-svg"));
734    }
735
736    #[test]
737    fn test_export_svg_theme() {
738        let opts = ExportSvgOptions {
739            code: "test".into(),
740            theme: EXPORT_THEME_SVG,
741            ..Default::default()
742        };
743        let svg = export_svg(&opts);
744        assert!(svg.contains("rgb(255,255,255)")); // background
745    }
746
747    #[test]
748    fn test_export_text_strip() {
749        let opts = ExportTextOptions {
750            text: "\x1b[1;32mGreen Bold\x1b[0m".into(),
751            strip_ansi: true,
752        };
753        assert_eq!(export_text(&opts), "Green Bold");
754    }
755
756    #[test]
757    fn test_export_text_keep() {
758        let ansi = "\x1b[31mred\x1b[0m";
759        let opts = ExportTextOptions {
760            text: ansi.into(),
761            strip_ansi: false,
762        };
763        assert_eq!(export_text(&opts), ansi);
764    }
765
766    #[test]
767    fn test_rgb_for_8bit_standard() {
768        assert_eq!(rgb_for_8bit(0), (0, 0, 0)); // black
769        assert_eq!(rgb_for_8bit(1), (128, 0, 0)); // red
770        assert_eq!(rgb_for_8bit(15), (255, 255, 255)); // bright white
771    }
772
773    #[test]
774    fn test_rgb_for_8bit_cube() {
775        assert_eq!(rgb_for_8bit(16), (0, 0, 0));
776        let idx = 16 + 1 * 36 + 2 * 6 + 3; // R=1, G=2, B=3
777        assert_eq!(rgb_for_8bit(idx), (51, 102, 153));
778    }
779
780    #[test]
781    fn test_rgb_for_8bit_greyscale() {
782        assert_eq!(rgb_for_8bit(232), (8, 8, 8));
783        assert_eq!(rgb_for_8bit(255), (238, 238, 238));
784    }
785
786    #[test]
787    fn test_segments_to_html_styled() {
788        let seg = Segment::styled(
789            "hello",
790            Style::new().color(Color::parse("red").unwrap()).bold(true),
791        );
792        let html = segments_to_html(&[seg], &ExportTheme::default());
793        assert!(html.contains("color:rgb(128,0,0)"));
794        assert!(html.contains("font-weight:bold"));
795        assert!(html.contains("hello"));
796    }
797
798    #[test]
799    fn test_segments_to_html_plain() {
800        let seg = Segment::new("plain");
801        let html = segments_to_html(&[seg], &ExportTheme::default());
802        assert!(html.contains("plain"));
803        assert!(html.contains("color:rgb(255,255,255)"));
804    }
805
806    #[test]
807    fn test_export_theme_defaults() {
808        let theme = ExportTheme::default();
809        assert_eq!(theme.background, (0, 0, 0));
810        assert_eq!(theme.foreground, (255, 255, 255));
811    }
812
813    #[test]
814    fn test_segments_to_svg_styled() {
815        let seg = Segment::styled(
816            "hello",
817            Style::new().color(Color::parse("red").unwrap()).bold(true),
818        );
819        let svg = segments_to_svg(&[seg], &ExportTheme::default());
820        assert!(svg.contains("fill:rgb(128,0,0)"));
821        assert!(svg.contains("font-weight:bold"));
822        assert!(svg.contains("hello"));
823        assert!(svg.contains("<tspan"));
824    }
825
826    #[test]
827    fn test_segments_to_svg_plain() {
828        let seg = Segment::new("plain");
829        let svg = segments_to_svg(&[seg], &ExportTheme::default());
830        assert!(svg.contains("plain"));
831        assert!(svg.contains("fill:rgb(255,255,255)"));
832    }
833
834    #[test]
835    fn test_save_to_disk() {
836        let dir = std::env::temp_dir();
837        let path = dir.join("test_export.html");
838        let opts = ExportHtmlOptions {
839            code: "test".into(),
840            ..Default::default()
841        };
842        save_html(&path, &opts).unwrap();
843        let contents = std::fs::read_to_string(&path).unwrap();
844        assert!(contents.contains("test"));
845        std::fs::remove_file(&path).unwrap();
846    }
847}