use crate::segment::Segment;
use crate::terminal_theme::TerminalTheme;
pub const CONSOLE_HTML_FORMAT: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
{stylesheet}
body {{
color: {foreground};
background-color: {background};
}}
</style>
</head>
<body>
<pre style="font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"><code style="font-family:inherit">{code}</code></pre>
</body>
</html>
"#;
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportFormatError(pub String);
impl std::fmt::Display for ExportFormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid code_format: {}", self.0)
}
}
impl std::error::Error for ExportFormatError {}
pub fn format_template(
template: &str,
fields: &[(&str, &str)],
) -> Result<String, ExportFormatError> {
let mut out = String::with_capacity(template.len());
let mut chars = template.char_indices().peekable();
while let Some((index, c)) = chars.next() {
match c {
'{' if chars.peek().map(|&(_, next)| next) == Some('{') => {
chars.next();
out.push('{');
}
'{' => {
let rest = &template[index + 1..];
let Some(close) = rest.find('}') else {
return Err(ExportFormatError(
"expected '}' before end of string".to_string(),
));
};
let name = &rest[..close];
if name.contains(['{', '!', ':', '[', '.']) || name.is_empty() {
return Err(ExportFormatError(format!(
"unsupported replacement field {{{name}}}"
)));
}
let Some((_, value)) = fields.iter().find(|(field, _)| *field == name) else {
return Err(ExportFormatError(format!("unknown field {name:?}")));
};
out.push_str(value);
for _ in 0..name.chars().count() + 1 {
chars.next();
}
}
'}' if chars.peek().map(|&(_, next)| next) == Some('}') => {
chars.next();
out.push('}');
}
'}' => {
return Err(ExportFormatError(
"Single '}' encountered in format string".to_string(),
))
}
c => out.push(c),
}
}
Ok(out)
}
pub fn export_html_inline(segments: &[Segment], theme: &TerminalTheme) -> String {
export_html_with(segments, theme, None, true).expect("the built-in template is valid")
}
pub fn export_html_classes(segments: &[Segment], theme: &TerminalTheme) -> String {
export_html_with(segments, theme, None, false).expect("the built-in template is valid")
}
pub fn export_html_with(
segments: &[Segment],
theme: &TerminalTheme,
code_format: Option<&str>,
inline_styles: bool,
) -> Result<String, ExportFormatError> {
let simplified = Segment::simplify(segments);
let mut code = String::new();
let mut styles: Vec<(String, usize)> = Vec::new();
for segment in &simplified {
if segment.control {
continue;
}
let mut text = escape(&segment.text);
if let Some(style) = segment.style.as_ref().filter(|style| !style.is_null()) {
let rule = style.get_html_style(theme);
if inline_styles {
if let Some(link) = style.link() {
text = format!("<a href=\"{link}\">{text}</a>");
}
if !rule.is_empty() {
text = format!("<span style=\"{rule}\">{text}</span>");
}
} else {
let number = match styles.iter().find(|(existing, _)| *existing == rule) {
Some((_, n)) => *n,
None => {
let n = styles.len() + 1;
styles.push((rule, n));
n
}
};
text = match style.link() {
Some(link) => format!("<a class=\"r{number}\" href=\"{link}\">{text}</a>"),
None => format!("<span class=\"r{number}\">{text}</span>"),
};
}
}
code.push_str(&text);
}
let stylesheet = styles
.iter()
.filter(|(rule, _)| !rule.is_empty())
.map(|(rule, number)| format!(".r{number} {{{rule}}}"))
.collect::<Vec<_>>()
.join("\n");
format_template(
code_format.unwrap_or(CONSOLE_HTML_FORMAT),
&[
("code", &code),
("stylesheet", &stylesheet),
("foreground", &theme.foreground.hex()),
("background", &theme.background.hex()),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
#[test]
fn escapes_html_special_chars() {
assert_eq!(escape("a<b>&\"'c"), "a<b>&"'c");
}
#[test]
fn format_template_follows_python_str_format() {
let fields = [("a", "1"), ("b", "2")];
assert_eq!(
format_template("{{x}} {a}-{b}", &fields).unwrap(),
"{x} 1-2"
);
assert!(format_template("{c}", &fields).is_err());
assert!(format_template("{}", &fields).is_err());
assert!(format_template("{a!r}", &fields).is_err());
assert!(format_template("a } b", &fields).is_err());
assert!(format_template("a { b", &fields).is_err());
}
#[test]
fn bold_red_html_style() {
let style = Style::parse("bold red").unwrap();
assert_eq!(
style.get_html_style(&crate::terminal_theme::DEFAULT_TERMINAL_THEME),
"color: #800000; text-decoration-color: #800000; font-weight: bold"
);
}
}