use super::ansi_parse::{Cell, CellGrid};
use super::themes::Theme;
const CELL_WIDTH: f64 = 9.6;
const CELL_HEIGHT: f64 = 20.0;
const PADDING: f64 = 16.0;
const CHROME_HEIGHT: f64 = 40.0;
const BUTTON_RADIUS: f64 = 6.0;
const BUTTON_Y: f64 = 20.0;
const BUTTON_START_X: f64 = 20.0;
const BUTTON_GAP: f64 = 20.0;
const CORNER_RADIUS: f64 = 10.0;
const DEFAULT_FONT_FAMILY: &str = "monospace";
const DEFAULT_FONT_SIZE: f64 = 14.0;
#[derive(Default)]
pub struct SvgOptions {
pub title: Option<String>,
pub font_family: Option<String>,
pub font_size: Option<f64>,
}
pub fn render(grid: &CellGrid, theme: &Theme, opts: &SvgOptions) -> String {
let font_family = opts.font_family.as_deref().unwrap_or(DEFAULT_FONT_FAMILY);
let font_size = opts.font_size.unwrap_or(DEFAULT_FONT_SIZE);
let (num_rows, num_cols) = (grid.rows.len(), grid.cols);
let content_width = num_cols as f64 * CELL_WIDTH;
let content_height = num_rows as f64 * CELL_HEIGHT;
let total_width = content_width + PADDING * 2.0;
let total_height = content_height + PADDING * 2.0 + CHROME_HEIGHT;
let mut svg = String::with_capacity(num_rows * num_cols * 80);
svg.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="{total_height}" viewBox="0 0 {total_width} {total_height}">"#,
));
svg.push_str(&format!(
r#"<rect width="{total_width}" height="{total_height}" rx="{CORNER_RADIUS}" fill="{bg}"/>"#,
bg = theme.background,
));
svg.push_str(&format!(
r#"<rect width="{total_width}" height="{CHROME_HEIGHT}" rx="{CORNER_RADIUS}" fill="{chrome_bg}"/>"#,
chrome_bg = theme.chrome_bg,
));
svg.push_str(&format!(
r#"<rect y="{y}" width="{total_width}" height="{CORNER_RADIUS}" fill="{chrome_bg}"/>"#,
y = CHROME_HEIGHT - CORNER_RADIUS,
chrome_bg = theme.chrome_bg,
));
for (i, color) in theme.chrome_buttons.iter().enumerate() {
let cx = BUTTON_START_X + i as f64 * BUTTON_GAP;
svg.push_str(&format!(
r#"<circle cx="{cx}" cy="{BUTTON_Y}" r="{BUTTON_RADIUS}" fill="{color}"/>"#,
));
}
if let Some(title) = &opts.title {
svg.push_str(&format!(
r#"<text x="{x}" y="{y}" font-family="{font_family}" font-size="13" fill="{fg}" text-anchor="middle">{title}</text>"#,
x = total_width / 2.0,
y = BUTTON_Y + 4.0,
fg = theme.foreground,
title = escape_xml(title),
));
}
let base_y = CHROME_HEIGHT + PADDING;
let base_x = PADDING;
for (row_idx, row) in grid.rows.iter().enumerate().take(num_rows) {
let y = base_y + row_idx as f64 * CELL_HEIGHT;
let bg_spans = collect_bg_spans(row, num_cols, theme);
for span in &bg_spans {
let x = base_x + span.start as f64 * CELL_WIDTH;
let w = (span.end - span.start) as f64 * CELL_WIDTH;
svg.push_str(&format!(
r#"<rect x="{x}" y="{y}" width="{w}" height="{CELL_HEIGHT}" fill="{color}"/>"#,
color = span.color,
));
}
let text_spans = collect_text_spans(row, num_cols, theme);
for span in &text_spans {
let x = base_x + span.start as f64 * CELL_WIDTH;
let text_y = y + CELL_HEIGHT - 5.0;
let mut style = format!("fill:{}", span.color);
if span.bold {
style.push_str(";font-weight:bold");
}
if span.italic {
style.push_str(";font-style:italic");
}
svg.push_str(&format!(
r#"<text x="{x}" y="{text_y}" font-family="{font_family}" font-size="{font_size}" style="{style}" xml:space="preserve">{text}</text>"#,
text = escape_xml(&span.text),
));
if span.underline {
let uy = text_y + 2.0;
let w = span.text.chars().count() as f64 * CELL_WIDTH;
svg.push_str(&format!(
r#"<line x1="{x}" y1="{uy}" x2="{x2}" y2="{uy}" stroke="{color}" stroke-width="1"/>"#,
x2 = x + w,
color = span.color,
));
}
}
}
svg.push_str("</svg>");
svg
}
struct BgSpan {
start: usize,
end: usize,
color: String,
}
struct TextSpan {
start: usize,
text: String,
color: String,
bold: bool,
italic: bool,
underline: bool,
}
fn resolve_fg_color(cell: &Cell, theme: &Theme) -> String {
if let Some((r, g, b)) = cell.resolve_fg_rgb() {
format!("rgb({r},{g},{b})")
} else {
cell.resolve_fg(theme).to_string()
}
}
fn resolve_bg_color(cell: &Cell, theme: &Theme) -> String {
if let Some((r, g, b)) = cell.resolve_bg_rgb() {
format!("rgb({r},{g},{b})")
} else {
cell.resolve_bg(theme).to_string()
}
}
fn collect_bg_spans(row: &[Cell], num_cols: usize, theme: &Theme) -> Vec<BgSpan> {
let mut spans = Vec::new();
let mut i = 0;
while i < num_cols && i < row.len() {
let color = resolve_bg_color(&row[i], theme);
if color == theme.background {
i += 1;
continue;
}
let start = i;
while i < num_cols && i < row.len() && resolve_bg_color(&row[i], theme) == color {
i += 1;
}
spans.push(BgSpan {
start,
end: i,
color,
});
}
spans
}
fn collect_text_spans(row: &[Cell], num_cols: usize, theme: &Theme) -> Vec<TextSpan> {
let mut spans = Vec::new();
let mut i = 0;
while i < num_cols && i < row.len() {
if row[i].ch == ' ' && !row[i].bold && !row[i].italic && !row[i].underline {
i += 1;
continue;
}
let color = resolve_fg_color(&row[i], theme);
let bold = row[i].bold;
let italic = row[i].italic;
let underline = row[i].underline;
let start = i;
let mut text = String::new();
while i < num_cols
&& i < row.len()
&& resolve_fg_color(&row[i], theme) == color
&& row[i].bold == bold
&& row[i].italic == italic
&& row[i].underline == underline
{
text.push(row[i].ch);
i += 1;
}
let trimmed = text.trim_end();
if !trimmed.is_empty() {
spans.push(TextSpan {
start,
text: trimmed.to_string(),
color,
bold,
italic,
underline,
});
}
}
spans
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::super::ansi_parse::parse;
use super::super::themes::DRACULA;
use super::*;
#[test]
fn renders_valid_svg() {
let grid = parse(b"Hello", 80);
let svg = render(&grid, &DRACULA, &SvgOptions::default());
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("Hello"));
}
#[test]
fn renders_with_title() {
let grid = parse(b"test", 80);
let svg = render(
&grid,
&DRACULA,
&SvgOptions {
title: Some("My Terminal".into()),
..Default::default()
},
);
assert!(svg.contains("My Terminal"));
}
#[test]
fn escapes_special_chars() {
let grid = parse(b"<script>&\"test\"</script>", 80);
let svg = render(&grid, &DRACULA, &SvgOptions::default());
assert!(!svg.contains("<script>"));
assert!(svg.contains("<script>"));
}
}