use crate::cells::cell_len;
use crate::segment::Segment;
use crate::terminal_theme::TerminalTheme;
const CHAR_HEIGHT: f64 = 20.0;
const FONT_ASPECT_RATIO: f64 = 0.61;
const MARGIN: i64 = 1;
const PADDING_TOP: i64 = 40;
const PADDING_SIDE: i64 = 8;
const CONSOLE_SVG_FORMAT: &str = r#"<svg class="rich-terminal" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.{unique_id}-matrix {
font-family: Fira Code, monospace;
font-size: {char_height}px;
line-height: {line_height}px;
font-variant-east-asian: full-width;
}
.{unique_id}-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
{styles}
</style>
<defs>
<clipPath id="{unique_id}-clip-terminal">
<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />
</clipPath>
{lines}
</defs>
{chrome}
<g transform="translate({terminal_x}, {terminal_y})" clip-path="url(#{unique_id}-clip-terminal)">
{backgrounds}
<g class="{unique_id}-matrix">
{matrix}
</g>
</g>
</svg>
"#;
fn fmt_g(v: f64) -> String {
if v == 0.0 {
return "0".to_string();
}
let exp = v.abs().log10().floor() as i32;
let decimals = (5 - exp).max(0) as usize;
let mut s = format!("{v:.decimals$}");
if s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
s
}
fn fmt_str(v: f64) -> String {
let s = format!("{v}");
if s.contains('.') || s.contains('e') {
s
} else {
format!("{s}.0")
}
}
fn escape_text(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
.replace(' ', " ")
}
fn split_and_crop_lines(segments: &[Segment], length: usize) -> Vec<Vec<Segment>> {
let mut result: Vec<Vec<Segment>> = Vec::new();
let mut line: Vec<Segment> = Vec::new();
for seg in segments {
if !seg.control && seg.text.contains('\n') {
let mut text = seg.text.as_str();
loop {
match text.find('\n') {
Some(idx) => {
let before = &text[..idx];
if !before.is_empty() {
line.push(Segment::new(before, seg.style.clone()));
}
let mut cropped = Segment::adjust_line_length(&line, length, None);
cropped.push(Segment::new("\n", None));
result.push(cropped);
line = Vec::new();
text = &text[idx + 1..];
}
None => {
if !text.is_empty() {
line.push(Segment::new(text, seg.style.clone()));
}
break;
}
}
}
} else {
line.push(seg.clone());
}
}
if !line.is_empty() {
result.push(Segment::adjust_line_length(&line, length, None));
}
result
}
fn class_number(classes: &mut Vec<(String, usize)>, rules: &str) -> usize {
match classes.iter().find(|(existing, _)| existing == rules) {
Some((_, n)) => *n,
None => {
let n = classes.len() + 1;
classes.push((rules.to_string(), n));
n
}
}
}
pub fn export_svg(
segments: &[Segment],
theme: &TerminalTheme,
title: &str,
unique_id: &str,
width: usize,
) -> String {
let char_width = CHAR_HEIGHT * FONT_ASPECT_RATIO;
let line_height = CHAR_HEIGHT * 1.22;
let padding_width = PADDING_SIDE + PADDING_SIDE;
let padding_height = PADDING_TOP + PADDING_SIDE;
let margin_width = MARGIN + MARGIN;
let margin_height = MARGIN + MARGIN;
let filtered: Vec<Segment> = segments.iter().filter(|s| !s.control).cloned().collect();
let mut classes: Vec<(String, usize)> = Vec::new();
let mut text_backgrounds: Vec<String> = Vec::new();
let mut text_group: Vec<String> = Vec::new();
let lines = split_and_crop_lines(&filtered, width);
let mut last_y = 0usize;
for (y, line) in lines.iter().enumerate() {
last_y = y;
let mut x = 0usize;
for seg in line {
let style = seg.style.clone().unwrap_or_default();
let rules = style.get_svg_style(theme);
let class_no = class_number(&mut classes, &rules);
let (has_background, background) = if style.attr(6) == Some(true) {
let hex = style
.color()
.map_or(theme.foreground, |c| theme.resolve(c, true))
.hex();
(true, hex)
} else {
let has_bg = style.bgcolor().is_some_and(|c| !c.is_default());
let hex = style
.bgcolor()
.map_or(theme.background, |c| theme.resolve(c, false))
.hex();
(has_bg, hex)
};
let text_length = cell_len(&seg.text);
if has_background {
text_backgrounds.push(format!(
r#"<rect fill="{background}" x="{x}" y="{y2}" width="{w}" height="{h}" shape-rendering="crispEdges"/>"#,
x = fmt_g(x as f64 * char_width),
y2 = fmt_g(y as f64 * line_height + 1.5),
w = fmt_g(char_width * text_length as f64),
h = fmt_g(line_height + 0.25),
));
}
if !seg.text.chars().all(|c| c == ' ') {
let char_count = seg.text.chars().count();
text_group.push(format!(
r#"<text class="{unique_id}-r{class_no}" x="{x}" y="{y2}" textLength="{tl}" clip-path="url(#{unique_id}-line-{y})">{content}</text>"#,
x = fmt_g(x as f64 * char_width),
y2 = fmt_g(y as f64 * line_height + CHAR_HEIGHT),
tl = fmt_g(char_width * char_count as f64),
content = escape_text(&seg.text),
));
}
x += text_length;
}
}
let lines_svg: Vec<String> = (0..last_y)
.map(|line_no| {
let offset = line_no as f64 * line_height + 1.5;
format!(
"<clipPath id=\"{unique_id}-line-{line_no}\">\n <rect x=\"0\" y=\"{y}\" width=\"{w}\" height=\"{h}\"/>\n </clipPath>",
y = fmt_g(offset),
w = fmt_g(char_width * width as f64),
h = fmt_g(line_height + 0.25),
)
})
.collect();
let lines_str = lines_svg.join("\n");
let styles = classes
.iter()
.map(|(css, n)| format!(".{unique_id}-r{n} {{ {css} }}"))
.collect::<Vec<_>>()
.join("\n");
let backgrounds = text_backgrounds.concat();
let matrix = text_group.concat();
let terminal_width_local = (width as f64 * char_width + padding_width as f64).ceil() as i64;
let terminal_height_local = (last_y as f64 + 1.0) * line_height + padding_height as f64;
let mut chrome = format!(
r#"<rect fill="{bg}" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="{ml}" y="{mt}" width="{tw}" height="{th}" rx="8"/>"#,
bg = theme.background.hex(),
ml = MARGIN,
mt = MARGIN,
tw = terminal_width_local,
th = fmt_g(terminal_height_local),
);
if !title.is_empty() {
chrome.push_str(&format!(
r#"<text class="{unique_id}-title" fill="{fg}" text-anchor="middle" x="{x}" y="{y}">{title}</text>"#,
fg = theme.foreground.hex(),
x = terminal_width_local / 2,
y = MARGIN + CHAR_HEIGHT as i64 + 6,
title = escape_text(title),
));
}
chrome.push_str(
"\n <g transform=\"translate(26,22)\">\n <circle cx=\"0\" cy=\"0\" r=\"7\" fill=\"#ff5f57\"/>\n <circle cx=\"22\" cy=\"0\" r=\"7\" fill=\"#febc2e\"/>\n <circle cx=\"44\" cy=\"0\" r=\"7\" fill=\"#28c840\"/>\n </g>\n ",
);
CONSOLE_SVG_FORMAT
.replace("{unique_id}", unique_id)
.replace("{char_height}", &(CHAR_HEIGHT as i64).to_string())
.replace("{line_height}", &fmt_str(line_height))
.replace(
"{terminal_width}",
&fmt_str(char_width * width as f64 - 1.0),
)
.replace(
"{terminal_height}",
&fmt_str((last_y as f64 + 1.0) * line_height - 1.0),
)
.replace(
"{width}",
&(terminal_width_local + margin_width).to_string(),
)
.replace(
"{height}",
&fmt_str(terminal_height_local + margin_height as f64),
)
.replace("{terminal_x}", &(MARGIN + PADDING_SIDE).to_string())
.replace("{terminal_y}", &(MARGIN + PADDING_TOP).to_string())
.replace("{styles}", &styles)
.replace("{chrome}", &chrome)
.replace("{backgrounds}", &backgrounds)
.replace("{matrix}", &matrix)
.replace("{lines}", &lines_str)
}
#[cfg(test)]
mod tests {
use crate::color::ColorSystem;
use crate::console::Console;
#[test]
fn export_svg_matches_upstream() {
let console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(10)
.no_color(false)
.build();
let svg = console.export_svg("X", "test", |c| c.print_str("[bold red]Hi[/] ok"));
let expected = include_str!("../tests/golden/svg_export.svg").replace("\r\n", "\n");
assert_eq!(svg, expected);
}
}