use crate::color::Color;
use crate::segment::Segment;
#[derive(Debug, Clone)]
pub struct ExportTheme {
pub background: (u8, u8, u8),
pub foreground: (u8, u8, u8),
pub ansi_colors: [(u8, u8, u8); 16],
}
impl Default for ExportTheme {
fn default() -> Self {
ExportTheme {
background: (0, 0, 0),
foreground: (255, 255, 255),
ansi_colors: [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ],
}
}
}
pub const EXPORT_THEME_MONOKAI: ExportTheme = ExportTheme {
background: (39, 40, 34),
foreground: (248, 248, 242),
ansi_colors: [
(39, 40, 34), (249, 38, 114), (166, 226, 46), (230, 219, 116), (102, 217, 239), (174, 129, 255), (161, 239, 228), (248, 248, 242), (117, 113, 94), (249, 38, 114), (166, 226, 46), (230, 219, 116), (102, 217, 239), (174, 129, 255), (161, 239, 228), (248, 248, 242), ],
};
pub const EXPORT_THEME_DIMMED_MONOKAI: ExportTheme = ExportTheme {
background: (35, 35, 35),
foreground: (185, 188, 186),
ansi_colors: [
(35, 35, 35), (190, 63, 72), (135, 154, 59), (197, 166, 56), (79, 118, 161), (133, 92, 141), (87, 143, 164), (185, 188, 186), (83, 83, 83), (240, 80, 80), (148, 166, 73), (215, 180, 66), (108, 147, 177), (152, 117, 171), (101, 164, 179), (230, 235, 235), ],
};
pub const EXPORT_THEME_NIGHT_OWLISH: ExportTheme = ExportTheme {
background: (1, 22, 39),
foreground: (214, 222, 235),
ansi_colors: [
(1, 22, 39), (255, 88, 116), (173, 219, 103), (255, 203, 107), (130, 170, 255), (199, 146, 234), (137, 221, 255), (214, 222, 235), (84, 94, 109), (255, 88, 116), (173, 219, 103), (255, 203, 107), (130, 170, 255), (199, 146, 234), (137, 221, 255), (255, 255, 255), ],
};
pub const EXPORT_THEME_SVG: ExportTheme = ExportTheme {
background: (255, 255, 255),
foreground: (0, 0, 0),
ansi_colors: [
(0, 0, 0), (204, 0, 0), (0, 170, 0), (204, 102, 0), (0, 0, 204), (170, 0, 170), (0, 170, 170), (170, 170, 170), (102, 102, 102), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ],
};
pub const CONSOLE_HTML_FORMAT: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rusty-rich</title>
<style>
body {{
margin: 0;
padding: 0;
}}
pre.rich-html {{
font-family: {font_family};
font-size: {font_size}px;
line-height: {line_height};
color: {foreground};
background-color: {background};
margin: 0;
padding: 16px 24px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-x: auto;
}}
</style>
</head>
<body>
<pre class="rich-html">
{code}
</pre>
</body>
</html>"#;
#[derive(Debug, Clone)]
pub struct ExportHtmlOptions {
pub font_family: String,
pub font_size: u32,
pub line_height: f64,
pub theme: ExportTheme,
pub code: String,
}
impl Default for ExportHtmlOptions {
fn default() -> Self {
Self {
font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Source Code Pro', Menlo, Consolas, monospace".into(),
font_size: 14,
line_height: 1.45,
theme: ExportTheme::default(),
code: String::new(),
}
}
}
pub fn export_html(options: &ExportHtmlOptions) -> String {
let fg = options.theme.foreground;
let bg = options.theme.background;
CONSOLE_HTML_FORMAT
.replace("{font_family}", &options.font_family)
.replace("{font_size}", &options.font_size.to_string())
.replace("{line_height}", &options.line_height.to_string())
.replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
.replace("{background}", &format!("rgb({},{},{})", bg.0, bg.1, bg.2))
.replace("{code}", &escape_html(&options.code))
}
pub fn save_html(path: impl AsRef<std::path::Path>, options: &ExportHtmlOptions) -> std::io::Result<()> {
std::fs::write(path.as_ref(), export_html(options))
}
pub 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}">
<style>
text {{ font-family: {font_family}; font-size: {font_size}px; }}
</style>
<rect width="100%" height="100%" fill="{background}"/>
<text x="0" y="{baseline}" xml:space="preserve">
{code}
</text>
</svg>"#;
#[derive(Debug, Clone)]
pub struct ExportSvgOptions {
pub font_family: String,
pub font_size: u32,
pub theme: ExportTheme,
pub code: String,
pub width: u32,
pub height: u32,
}
impl Default for ExportSvgOptions {
fn default() -> Self {
Self {
font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace".into(),
font_size: 14,
theme: EXPORT_THEME_SVG,
code: String::new(),
width: 800,
height: 600,
}
}
}
pub fn export_svg(options: &ExportSvgOptions) -> String {
let fg = options.theme.foreground;
let bg = options.theme.background;
let baseline = options.font_size as f64 * 1.2;
CONSOLE_SVG_FORMAT
.replace("{font_family}", &options.font_family)
.replace("{font_size}", &options.font_size.to_string())
.replace("{width}", &options.width.to_string())
.replace("{height}", &options.height.to_string())
.replace("{background}", &format!("rgb({},{},{})", bg.0, bg.1, bg.2))
.replace("{baseline}", &format!("{:.0}", baseline))
.replace("{code}", &escape_xml(&options.code))
.replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
}
pub fn save_svg(path: impl AsRef<std::path::Path>, options: &ExportSvgOptions) -> std::io::Result<()> {
std::fs::write(path.as_ref(), export_svg(options))
}
#[derive(Debug, Clone)]
pub struct ExportTextOptions {
pub text: String,
pub strip_ansi: bool,
}
impl Default for ExportTextOptions {
fn default() -> Self {
Self {
text: String::new(),
strip_ansi: true,
}
}
}
pub fn export_text(options: &ExportTextOptions) -> String {
if options.strip_ansi {
strip_ansi_escapes(&options.text)
} else {
options.text.clone()
}
}
pub fn save_text(
path: impl AsRef<std::path::Path>,
options: &ExportTextOptions,
) -> std::io::Result<()> {
std::fs::write(path.as_ref(), export_text(options))
}
pub fn escape_html(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn escape_xml(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn strip_ansi_escapes(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next(); while let Some(&c) = chars.peek() {
if c.is_ascii_digit() || c == ';' || c == '?' || c == '!' {
chars.next();
} else {
break;
}
}
chars.next();
}
} else {
result.push(ch);
}
}
result
}
pub fn segments_to_html(
segments: &[Segment],
theme: &ExportTheme,
) -> String {
let mut html = String::new();
for seg in segments {
let mut styles: Vec<String> = Vec::new();
if let Some(ref style) = seg.style {
if let Some(color) = &style.color {
let rgb = resolve_color(color, theme);
styles.push(format!("color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
} else {
let fg = theme.foreground;
styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
}
if let Some(bgcolor) = &style.bgcolor {
let rgb = resolve_color(bgcolor, theme);
styles.push(format!("background-color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
}
let attrs = &style.attributes;
if attrs.get(crate::style::Attributes::BOLD) {
styles.push("font-weight:bold".into());
}
if attrs.get(crate::style::Attributes::ITALIC) {
styles.push("font-style:italic".into());
}
if attrs.get(crate::style::Attributes::UNDERLINE)
|| attrs.get(crate::style::Attributes::UNDERLINE2)
{
styles.push("text-decoration:underline".into());
}
if attrs.get(crate::style::Attributes::STRIKE) {
styles.push("text-decoration:line-through".into());
}
if attrs.get(crate::style::Attributes::DIM) {
styles.push("opacity:0.7".into());
}
if attrs.get(crate::style::Attributes::CONCEAL) {
styles.push("visibility:hidden".into());
}
if let Some(ref link) = style.link {
let escaped_link = escape_html(link);
let style_attr = if styles.is_empty() {
String::new()
} else {
format!(" style=\"{}\"", styles.join("; "))
};
html.push_str(&format!(
"<a href=\"{}\"{}>{}</a>",
escaped_link,
style_attr,
escape_html(&seg.text)
));
continue; }
} else {
let fg = theme.foreground;
styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
}
if styles.is_empty() {
html.push_str(&escape_html(&seg.text));
} else {
let style_attr = styles.join("; ");
html.push_str(&format!(
"<span style=\"{}\">{}</span>",
style_attr,
escape_html(&seg.text)
));
}
}
html
}
fn resolve_color(color: &Color, theme: &ExportTheme) -> (u8, u8, u8) {
match color.color_type {
crate::color::ColorType::Default => theme.foreground,
crate::color::ColorType::Standard => {
let idx = color.number.unwrap_or(7) as usize % 16;
theme.ansi_colors[idx]
}
crate::color::ColorType::EightBit => {
let idx = color.number.unwrap_or(0) as usize % 256;
rgb_for_8bit(idx)
}
crate::color::ColorType::TrueColor => {
if let Some(ref triplet) = color.triplet {
(triplet.0, triplet.1, triplet.2)
} else {
theme.foreground
}
}
}
}
fn rgb_for_8bit(index: usize) -> (u8, u8, u8) {
if index < 16 {
crate::color::STANDARD_PALETTE
.get(index)
.copied()
.unwrap_or((0, 0, 0))
} else if index < 232 {
let idx = index - 16;
let r = (idx / 36) as u8 * 51;
let g = ((idx / 6) % 6) as u8 * 51;
let b = (idx % 6) as u8 * 51;
(r, g, b)
} else {
let g = ((index - 232) * 10 + 8) as u8;
(g, g, g)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
use crate::color::Color;
#[test]
fn test_escape_html_basic() {
assert_eq!(escape_html("<hello>"), "<hello>");
assert_eq!(escape_html("\"a\" & 'b'"), ""a" & 'b'");
}
#[test]
fn test_strip_ansi_escapes() {
let input = "\x1b[31mred\x1b[0m normal";
assert_eq!(strip_ansi_escapes(input), "red normal");
}
#[test]
fn test_strip_ansi_complex() {
let input = "\x1b[1;31mBold Red\x1b[0m \x1b[4munderlined\x1b[0m";
assert_eq!(strip_ansi_escapes(input), "Bold Red underlined");
}
#[test]
fn test_strip_ansi_no_escapes() {
assert_eq!(strip_ansi_escapes("plain text"), "plain text");
}
#[test]
fn test_export_html_basic() {
let opts = ExportHtmlOptions {
code: "Hello World".into(),
..Default::default()
};
let html = export_html(&opts);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Hello World"));
assert!(html.contains("rich-html"));
assert!(html.contains("font-family"));
}
#[test]
fn test_export_html_escapes_markup() {
let opts = ExportHtmlOptions {
code: "<script>alert('xss')</script>".into(),
..Default::default()
};
let html = export_html(&opts);
assert!(!html.contains("<script>"));
assert!(html.contains("<script>"));
}
#[test]
fn test_export_svg_basic() {
let opts = ExportSvgOptions {
code: "SVG text".into(),
..Default::default()
};
let svg = export_svg(&opts);
assert!(svg.contains("<svg"));
assert!(svg.contains("SVG text"));
assert!(svg.contains("rich-svg"));
}
#[test]
fn test_export_svg_theme() {
let opts = ExportSvgOptions {
code: "test".into(),
theme: EXPORT_THEME_SVG,
..Default::default()
};
let svg = export_svg(&opts);
assert!(svg.contains("rgb(255,255,255)")); }
#[test]
fn test_export_text_strip() {
let opts = ExportTextOptions {
text: "\x1b[1;32mGreen Bold\x1b[0m".into(),
strip_ansi: true,
};
assert_eq!(export_text(&opts), "Green Bold");
}
#[test]
fn test_export_text_keep() {
let ansi = "\x1b[31mred\x1b[0m";
let opts = ExportTextOptions {
text: ansi.into(),
strip_ansi: false,
};
assert_eq!(export_text(&opts), ansi);
}
#[test]
fn test_rgb_for_8bit_standard() {
assert_eq!(rgb_for_8bit(0), (0, 0, 0)); assert_eq!(rgb_for_8bit(1), (128, 0, 0)); assert_eq!(rgb_for_8bit(15), (255, 255, 255)); }
#[test]
fn test_rgb_for_8bit_cube() {
assert_eq!(rgb_for_8bit(16), (0, 0, 0));
let idx = 16 + 1 * 36 + 2 * 6 + 3; assert_eq!(rgb_for_8bit(idx), (51, 102, 153));
}
#[test]
fn test_rgb_for_8bit_greyscale() {
assert_eq!(rgb_for_8bit(232), (8, 8, 8));
assert_eq!(rgb_for_8bit(255), (238, 238, 238));
}
#[test]
fn test_segments_to_html_styled() {
let seg = Segment::styled(
"hello",
Style::new()
.color(Color::parse("red").unwrap())
.bold(true),
);
let html = segments_to_html(&[seg], &ExportTheme::default());
assert!(html.contains("color:rgb(128,0,0)"));
assert!(html.contains("font-weight:bold"));
assert!(html.contains("hello"));
}
#[test]
fn test_segments_to_html_plain() {
let seg = Segment::new("plain");
let html = segments_to_html(&[seg], &ExportTheme::default());
assert!(html.contains("plain"));
assert!(html.contains("color:rgb(255,255,255)"));
}
#[test]
fn test_export_theme_defaults() {
let theme = ExportTheme::default();
assert_eq!(theme.background, (0, 0, 0));
assert_eq!(theme.foreground, (255, 255, 255));
}
#[test]
fn test_save_to_disk() {
let dir = std::env::temp_dir();
let path = dir.join("test_export.html");
let opts = ExportHtmlOptions {
code: "test".into(),
..Default::default()
};
save_html(&path, &opts).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("test"));
std::fs::remove_file(&path).unwrap();
}
}