Skip to main content

rich/
export.rs

1//! Exporting rendered output to HTML.
2//!
3//! Port of `rich/console.py`'s `export_html` + the `_export_format.py` template.
4//! Turns a recorded stream of [`Segment`]s (captured via
5//! [`Console::export_html`](crate::console::Console::export_html)) into a
6//! self-contained HTML document, using a [`TerminalTheme`] to resolve colors.
7//!
8//! Scope: `inline_styles` (each span carries its own `style="…"`). The CSS-class
9//! variant is a follow-up (see docs/DIVERGENCES.md).
10
11use crate::segment::Segment;
12use crate::terminal_theme::TerminalTheme;
13
14/// The HTML document template. Port of `_export_format.CONSOLE_HTML_FORMAT`
15/// (placeholders are substituted, not `format!`-ed, to avoid brace escaping).
16const CONSOLE_HTML_FORMAT: &str = r#"<!DOCTYPE html>
17<html>
18<head>
19<meta charset="UTF-8">
20<style>
21{stylesheet}
22body {
23    color: {foreground};
24    background-color: {background};
25}
26</style>
27</head>
28<body>
29    <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><code style="font-family:inherit">{code}</code></pre>
30</body>
31</html>
32"#;
33
34/// HTML-escape `text` (matching Python's `html.escape`, `quote=True`).
35fn escape(text: &str) -> String {
36    text.replace('&', "&amp;")
37        .replace('<', "&lt;")
38        .replace('>', "&gt;")
39        .replace('"', "&quot;")
40        .replace('\'', "&#x27;")
41}
42
43/// Substitute the four template placeholders. Port of the `.format(...)` call
44/// (done via `replace` to avoid escaping the CSS braces).
45fn fill_template(code: &str, stylesheet: &str, theme: &TerminalTheme) -> String {
46    CONSOLE_HTML_FORMAT
47        .replace("{stylesheet}", stylesheet)
48        .replace("{foreground}", &theme.foreground.hex())
49        .replace("{background}", &theme.background.hex())
50        .replace("{code}", code)
51}
52
53/// Render `segments` to a self-contained HTML document with inline styles.
54/// Port of `Console.export_html(inline_styles=True)`.
55pub fn export_html_inline(segments: &[Segment], theme: &TerminalTheme) -> String {
56    let simplified = Segment::simplify(segments);
57    let mut code = String::new();
58    for segment in &simplified {
59        if segment.control {
60            continue;
61        }
62        let text = escape(&segment.text);
63        match &segment.style {
64            Some(style) if !style.is_null() => {
65                let rule = style.get_html_style(theme);
66                if rule.is_empty() {
67                    code.push_str(&text);
68                } else {
69                    code.push_str(&format!("<span style=\"{rule}\">{text}</span>"));
70                }
71            }
72            _ => code.push_str(&text),
73        }
74    }
75    fill_template(&code, "", theme)
76}
77
78/// Render `segments` to a self-contained HTML document using CSS classes and a
79/// generated stylesheet. Port of `Console.export_html(inline_styles=False)`
80/// (upstream's default). Distinct styles are numbered `.r1`, `.r2`, … in the
81/// order first seen.
82pub fn export_html_classes(segments: &[Segment], theme: &TerminalTheme) -> String {
83    let simplified = Segment::simplify(segments);
84    // (rule → class number), in insertion order.
85    let mut styles: Vec<(String, usize)> = Vec::new();
86    let mut code = String::new();
87    for segment in &simplified {
88        if segment.control {
89            continue;
90        }
91        let text = escape(&segment.text);
92        match &segment.style {
93            Some(style) if !style.is_null() => {
94                let rule = style.get_html_style(theme);
95                if rule.is_empty() {
96                    code.push_str(&text);
97                } else {
98                    let number = match styles.iter().find(|(existing, _)| *existing == rule) {
99                        Some((_, n)) => *n,
100                        None => {
101                            let n = styles.len() + 1;
102                            styles.push((rule, n));
103                            n
104                        }
105                    };
106                    code.push_str(&format!("<span class=\"r{number}\">{text}</span>"));
107                }
108            }
109            _ => code.push_str(&text),
110        }
111    }
112    let stylesheet = styles
113        .iter()
114        .map(|(rule, number)| format!(".r{number} {{{rule}}}"))
115        .collect::<Vec<_>>()
116        .join("\n");
117    fill_template(&code, &stylesheet, theme)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::style::Style;
124
125    #[test]
126    fn escapes_html_special_chars() {
127        assert_eq!(escape("a<b>&\"'c"), "a&lt;b&gt;&amp;&quot;&#x27;c");
128    }
129
130    #[test]
131    fn bold_red_html_style() {
132        // Captured from real rich 15.0.0 Style.get_html_style(DEFAULT_TERMINAL_THEME).
133        let style = Style::parse("bold red").unwrap();
134        assert_eq!(
135            style.get_html_style(&crate::terminal_theme::DEFAULT_TERMINAL_THEME),
136            "color: #800000; text-decoration-color: #800000; font-weight: bold"
137        );
138    }
139}