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//! Both variants are ported: `inline_styles` (each span carries its own
9//! `style="…"`) and the CSS-class stylesheet, with links and `code_format`.
10
11use crate::segment::Segment;
12use crate::terminal_theme::TerminalTheme;
13
14/// The HTML document template. Port of `_export_format.CONSOLE_HTML_FORMAT`,
15/// verbatim: a Python format string, so literal braces are doubled. Pass it
16/// (or your own, as upstream's `code_format=`) to [`export_html_with`].
17pub const CONSOLE_HTML_FORMAT: &str = r#"<!DOCTYPE html>
18<html>
19<head>
20<meta charset="UTF-8">
21<style>
22{stylesheet}
23body {{
24    color: {foreground};
25    background-color: {background};
26}}
27</style>
28</head>
29<body>
30    <pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><code style="font-family:inherit">{code}</code></pre>
31</body>
32</html>
33"#;
34
35/// HTML-escape `text` (matching Python's `html.escape`, `quote=True`).
36fn escape(text: &str) -> String {
37    text.replace('&', "&amp;")
38        .replace('<', "&lt;")
39        .replace('>', "&gt;")
40        .replace('"', "&quot;")
41        .replace('\'', "&#x27;")
42}
43
44/// A `code_format` template that Python's `str.format` would reject: an
45/// unknown field (`KeyError`), a positional or formatted field, or an
46/// unmatched brace (`ValueError`).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ExportFormatError(pub String);
49
50impl std::fmt::Display for ExportFormatError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "invalid code_format: {}", self.0)
53    }
54}
55
56impl std::error::Error for ExportFormatError {}
57
58/// Substitute `{name}` fields in a Python format string, with `{{` and `}}`
59/// standing for literal braces: the subset of `str.format` upstream's export
60/// templates use. Conversions (`!r`) and format specs (`:>10`) are refused.
61pub fn format_template(
62    template: &str,
63    fields: &[(&str, &str)],
64) -> Result<String, ExportFormatError> {
65    let mut out = String::with_capacity(template.len());
66    let mut chars = template.char_indices().peekable();
67    while let Some((index, c)) = chars.next() {
68        match c {
69            '{' if chars.peek().map(|&(_, next)| next) == Some('{') => {
70                chars.next();
71                out.push('{');
72            }
73            '{' => {
74                let rest = &template[index + 1..];
75                let Some(close) = rest.find('}') else {
76                    return Err(ExportFormatError(
77                        "expected '}' before end of string".to_string(),
78                    ));
79                };
80                let name = &rest[..close];
81                if name.contains(['{', '!', ':', '[', '.']) || name.is_empty() {
82                    return Err(ExportFormatError(format!(
83                        "unsupported replacement field {{{name}}}"
84                    )));
85                }
86                let Some((_, value)) = fields.iter().find(|(field, _)| *field == name) else {
87                    return Err(ExportFormatError(format!("unknown field {name:?}")));
88                };
89                out.push_str(value);
90                // Skip the field name and its closing brace.
91                for _ in 0..name.chars().count() + 1 {
92                    chars.next();
93                }
94            }
95            '}' if chars.peek().map(|&(_, next)| next) == Some('}') => {
96                chars.next();
97                out.push('}');
98            }
99            '}' => {
100                return Err(ExportFormatError(
101                    "Single '}' encountered in format string".to_string(),
102                ))
103            }
104            c => out.push(c),
105        }
106    }
107    Ok(out)
108}
109
110/// Render `segments` to a self-contained HTML document with inline styles.
111/// Port of `Console.export_html(inline_styles=True)`.
112pub fn export_html_inline(segments: &[Segment], theme: &TerminalTheme) -> String {
113    export_html_with(segments, theme, None, true).expect("the built-in template is valid")
114}
115
116/// Render `segments` to a self-contained HTML document using CSS classes and a
117/// generated stylesheet. Port of `Console.export_html(inline_styles=False)`
118/// (upstream's default). Distinct styles are numbered `.r1`, `.r2`, … in the
119/// order first seen.
120pub fn export_html_classes(segments: &[Segment], theme: &TerminalTheme) -> String {
121    export_html_with(segments, theme, None, false).expect("the built-in template is valid")
122}
123
124/// Render `segments` to HTML with every option of upstream's
125/// `Console.export_html`: `code_format` replaces [`CONSOLE_HTML_FORMAT`]
126/// (fields `{code}`, `{stylesheet}`, `{foreground}`, `{background}`), and
127/// `inline_styles` chooses `style="…"` attributes over a class stylesheet.
128///
129/// A styled segment with a link becomes an `<a href="…">` (the URL is not
130/// escaped, as upstream does not escape it).
131pub fn export_html_with(
132    segments: &[Segment],
133    theme: &TerminalTheme,
134    code_format: Option<&str>,
135    inline_styles: bool,
136) -> Result<String, ExportFormatError> {
137    let simplified = Segment::simplify(segments);
138    let mut code = String::new();
139    // (rule → class number), in insertion order: `styles.setdefault(...)`.
140    let mut styles: Vec<(String, usize)> = Vec::new();
141    for segment in &simplified {
142        if segment.control {
143            continue;
144        }
145        let mut text = escape(&segment.text);
146        // `if style:` — a null style is falsy.
147        if let Some(style) = segment.style.as_ref().filter(|style| !style.is_null()) {
148            let rule = style.get_html_style(theme);
149            if inline_styles {
150                if let Some(link) = style.link() {
151                    text = format!("<a href=\"{link}\">{text}</a>");
152                }
153                if !rule.is_empty() {
154                    text = format!("<span style=\"{rule}\">{text}</span>");
155                }
156            } else {
157                // Upstream numbers every truthy style, even one whose rule is
158                // empty; only the stylesheet skips empty rules.
159                let number = match styles.iter().find(|(existing, _)| *existing == rule) {
160                    Some((_, n)) => *n,
161                    None => {
162                        let n = styles.len() + 1;
163                        styles.push((rule, n));
164                        n
165                    }
166                };
167                text = match style.link() {
168                    Some(link) => format!("<a class=\"r{number}\" href=\"{link}\">{text}</a>"),
169                    None => format!("<span class=\"r{number}\">{text}</span>"),
170                };
171            }
172        }
173        code.push_str(&text);
174    }
175    let stylesheet = styles
176        .iter()
177        .filter(|(rule, _)| !rule.is_empty())
178        .map(|(rule, number)| format!(".r{number} {{{rule}}}"))
179        .collect::<Vec<_>>()
180        .join("\n");
181    format_template(
182        code_format.unwrap_or(CONSOLE_HTML_FORMAT),
183        &[
184            ("code", &code),
185            ("stylesheet", &stylesheet),
186            ("foreground", &theme.foreground.hex()),
187            ("background", &theme.background.hex()),
188        ],
189    )
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::style::Style;
196
197    #[test]
198    fn escapes_html_special_chars() {
199        assert_eq!(escape("a<b>&\"'c"), "a&lt;b&gt;&amp;&quot;&#x27;c");
200    }
201
202    #[test]
203    fn format_template_follows_python_str_format() {
204        let fields = [("a", "1"), ("b", "2")];
205        assert_eq!(
206            format_template("{{x}} {a}-{b}", &fields).unwrap(),
207            "{x} 1-2"
208        );
209        assert!(format_template("{c}", &fields).is_err());
210        assert!(format_template("{}", &fields).is_err());
211        assert!(format_template("{a!r}", &fields).is_err());
212        assert!(format_template("a } b", &fields).is_err());
213        assert!(format_template("a { b", &fields).is_err());
214    }
215
216    #[test]
217    fn bold_red_html_style() {
218        // Captured from real rich 15.0.0 Style.get_html_style(DEFAULT_TERMINAL_THEME).
219        let style = Style::parse("bold red").unwrap();
220        assert_eq!(
221            style.get_html_style(&crate::terminal_theme::DEFAULT_TERMINAL_THEME),
222            "color: #800000; text-decoration-color: #800000; font-weight: bold"
223        );
224    }
225}