1use crate::segment::Segment;
12use crate::terminal_theme::TerminalTheme;
13
14pub 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
35fn escape(text: &str) -> String {
37 text.replace('&', "&")
38 .replace('<', "<")
39 .replace('>', ">")
40 .replace('"', """)
41 .replace('\'', "'")
42}
43
44#[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
58pub 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 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
110pub 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
116pub 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
124pub 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 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 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 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<b>&"'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 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}