1//! Convert ANSI escape codes to SVG
2//!
3//! See [`Term`]
4//!
5//! # Example
6//!
7//! ```
8//! # use anstyle_svg::Term;
9//! let vte = std::fs::read_to_string("tests/rainbow.vte").unwrap();
10//! let svg = Term::new().render_svg(&vte);
11//! ```
12//!
13//! ![demo of supported styles](https://raw.githubusercontent.com/rust-cli/anstyle/main/crates/anstyle-svg/tests/rainbow.svg "Example output")
14
15#![cfg_attr(docsrs, feature(doc_auto_cfg))]
16#![warn(missing_docs)]
17#![warn(clippy::print_stderr)]
18#![warn(clippy::print_stdout)]
19
20pub use anstyle_lossy::palette::Palette;
21pub use anstyle_lossy::palette::VGA;
22pub use anstyle_lossy::palette::WIN10_CONSOLE;
23
24/// Define the terminal-like settings for rendering outpu
25#[derive(Copy, Clone, Debug)]
26pub struct Term {
27 palette: Palette,
28 fg_color: anstyle::Color,
29 bg_color: anstyle::Color,
30 background: bool,
31 font_family: &'static str,
32 min_width_px: usize,
33 padding_px: usize,
34}
35
36impl Term {
37 /// Default terminal settings
38 pub const fn new() -> Self {
39 Self {
40 palette: VGA,
41 fg_color: FG_COLOR,
42 bg_color: BG_COLOR,
43 background: true,
44 font_family: "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
45 min_width_px: 720,
46 padding_px: 10,
47 }
48 }
49
50 /// Select the color palette for [`anstyle::AnsiColor`]
51 pub const fn palette(mut self, palette: Palette) -> Self {
52 self.palette = palette;
53 self
54 }
55
56 /// Select the default foreground color
57 pub const fn fg_color(mut self, color: anstyle::Color) -> Self {
58 self.fg_color = color;
59 self
60 }
61
62 /// Select the default background color
63 pub const fn bg_color(mut self, color: anstyle::Color) -> Self {
64 self.bg_color = color;
65 self
66 }
67
68 /// Toggle default background off with `false`
69 pub const fn background(mut self, yes: bool) -> Self {
70 self.background = yes;
71 self
72 }
73
74 /// Minimum width for the text
75 pub const fn min_width_px(mut self, px: usize) -> Self {
76 self.min_width_px = px;
77 self
78 }
79
80 /// Render the SVG with the terminal defined
81 ///
82 /// **Note:** Lines are not wrapped. This is intentional as this attempts to convey the exact
83 /// output with escape codes translated to SVG elements.
84 pub fn render_svg(&self, ansi: &str) -> String {
85 use std::fmt::Write as _;
86 use unicode_width::UnicodeWidthStr as _;
87
88 const FG: &str = "fg";
89 const BG: &str = "bg";
90
91 let mut styled = anstream::adapter::WinconBytes::new();
92 let mut styled = styled.extract_next(ansi.as_bytes()).collect::<Vec<_>>();
93 let mut effects_in_use = anstyle::Effects::new();
94 for (style, _) in &mut styled {
95 // Pre-process INVERT to make fg/bg calculations easier
96 if style.get_effects().contains(anstyle::Effects::INVERT) {
97 *style = style
98 .fg_color(Some(style.get_bg_color().unwrap_or(self.bg_color)))
99 .bg_color(Some(style.get_fg_color().unwrap_or(self.fg_color)))
100 .effects(style.get_effects().remove(anstyle::Effects::INVERT));
101 }
102 effects_in_use |= style.get_effects();
103 }
104 let styled_lines = split_lines(&styled);
105
106 let fg_color = rgb_value(self.fg_color, self.palette);
107 let bg_color = rgb_value(self.bg_color, self.palette);
108 let font_family = self.font_family;
109
110 let line_height = 18;
111 let height = styled_lines.len() * line_height + self.padding_px * 2;
112 let max_width = styled_lines
113 .iter()
114 .map(|l| l.iter().map(|(_, t)| t.width()).sum())
115 .max()
116 .unwrap_or(0);
117 let width_px = (max_width as f64 * 8.4).ceil() as usize;
118 let width_px = std::cmp::max(width_px, self.min_width_px) + self.padding_px * 2;
119
120 let mut buffer = String::new();
121 writeln!(
122 &mut buffer,
123 r#"<svg width="{width_px}px" height="{height}px" xmlns="http://www.w3.org/2000/svg">"#
124 )
125 .unwrap();
126 writeln!(&mut buffer, r#" <style>"#).unwrap();
127 writeln!(&mut buffer, r#" .{FG} {{ fill: {fg_color} }}"#).unwrap();
128 writeln!(&mut buffer, r#" .{BG} {{ background: {bg_color} }}"#).unwrap();
129 for (name, rgb) in color_styles(&styled, self.palette) {
130 if name.starts_with(FG_PREFIX) {
131 writeln!(&mut buffer, r#" .{name} {{ fill: {rgb} }}"#).unwrap();
132 }
133 if name.starts_with(BG_PREFIX) {
134 writeln!(
135 &mut buffer,
136 r#" .{name} {{ stroke: {rgb}; fill: {rgb}; user-select: none; }}"#
137 )
138 .unwrap();
139 }
140 if name.starts_with(UNDERLINE_PREFIX) {
141 writeln!(
142 &mut buffer,
143 r#" .{name} {{ text-decoration-line: underline; text-decoration-color: {rgb} }}"#
144 )
145 .unwrap();
146 }
147 }
148 writeln!(&mut buffer, r#" .container {{"#).unwrap();
149 writeln!(&mut buffer, r#" padding: 0 10px;"#).unwrap();
150 writeln!(&mut buffer, r#" line-height: {line_height}px;"#).unwrap();
151 writeln!(&mut buffer, r#" }}"#).unwrap();
152 if effects_in_use.contains(anstyle::Effects::BOLD) {
153 writeln!(&mut buffer, r#" .bold {{ font-weight: bold; }}"#).unwrap();
154 }
155 if effects_in_use.contains(anstyle::Effects::ITALIC) {
156 writeln!(&mut buffer, r#" .italic {{ font-style: italic; }}"#).unwrap();
157 }
158 if effects_in_use.contains(anstyle::Effects::UNDERLINE) {
159 writeln!(
160 &mut buffer,
161 r#" .underline {{ text-decoration-line: underline; }}"#
162 )
163 .unwrap();
164 }
165 if effects_in_use.contains(anstyle::Effects::DOUBLE_UNDERLINE) {
166 writeln!(
167 &mut buffer,
168 r#" .double-underline {{ text-decoration-line: underline; text-decoration-style: double; }}"#
169 )
170 .unwrap();
171 }
172 if effects_in_use.contains(anstyle::Effects::CURLY_UNDERLINE) {
173 writeln!(
174 &mut buffer,
175 r#" .curly-underline {{ text-decoration-line: underline; text-decoration-style: wavy; }}"#
176 )
177 .unwrap();
178 }
179 if effects_in_use.contains(anstyle::Effects::DOTTED_UNDERLINE) {
180 writeln!(
181 &mut buffer,
182 r#" .dotted-underline {{ text-decoration-line: underline; text-decoration-style: dotted; }}"#
183 )
184 .unwrap();
185 }
186 if effects_in_use.contains(anstyle::Effects::DASHED_UNDERLINE) {
187 writeln!(
188 &mut buffer,
189 r#" .dashed-underline {{ text-decoration-line: underline; text-decoration-style: dashed; }}"#
190 )
191 .unwrap();
192 }
193 if effects_in_use.contains(anstyle::Effects::STRIKETHROUGH) {
194 writeln!(
195 &mut buffer,
196 r#" .strikethrough {{ text-decoration-line: line-through; }}"#
197 )
198 .unwrap();
199 }
200 if effects_in_use.contains(anstyle::Effects::DIMMED) {
201 writeln!(&mut buffer, r#" .dimmed {{ opacity: 0.7; }}"#).unwrap();
202 }
203 if effects_in_use.contains(anstyle::Effects::HIDDEN) {
204 writeln!(&mut buffer, r#" .hidden {{ opacity: 0; }}"#).unwrap();
205 }
206 writeln!(&mut buffer, r#" tspan {{"#).unwrap();
207 writeln!(&mut buffer, r#" font: 14px {font_family};"#).unwrap();
208 writeln!(&mut buffer, r#" white-space: pre;"#).unwrap();
209 writeln!(&mut buffer, r#" line-height: {line_height}px;"#).unwrap();
210 writeln!(&mut buffer, r#" }}"#).unwrap();
211 writeln!(&mut buffer, r#" </style>"#).unwrap();
212 writeln!(&mut buffer).unwrap();
213
214 if self.background {
215 writeln!(
216 &mut buffer,
217 r#" <rect width="100%" height="100%" y="0" rx="4.5" class="{BG}" />"#
218 )
219 .unwrap();
220 writeln!(&mut buffer).unwrap();
221 }
222
223 let text_x = self.padding_px;
224 let mut text_y = self.padding_px + line_height;
225 writeln!(
226 &mut buffer,
227 r#" <text xml:space="preserve" class="container {FG}">"#
228 )
229 .unwrap();
230 for line in &styled_lines {
231 if line.iter().any(|(s, _)| s.get_bg_color().is_some()) {
232 write!(&mut buffer, r#" <tspan x="{text_x}px" y="{text_y}px">"#).unwrap();
233 for (style, fragment) in line {
234 if fragment.is_empty() {
235 continue;
236 }
237 write_bg_span(&mut buffer, style, fragment);
238 }
239 // HACK: must close tspan on newline to include them in copy/paste
240 writeln!(&mut buffer).unwrap();
241 writeln!(&mut buffer, r#"</tspan>"#).unwrap();
242 }
243
244 write!(&mut buffer, r#" <tspan x="{text_x}px" y="{text_y}px">"#).unwrap();
245 for (style, fragment) in line {
246 if fragment.is_empty() {
247 continue;
248 }
249 write_fg_span(&mut buffer, style, fragment);
250 }
251 // HACK: must close tspan on newline to include them in copy/paste
252 writeln!(&mut buffer).unwrap();
253 writeln!(&mut buffer, r#"</tspan>"#).unwrap();
254
255 text_y += line_height;
256 }
257 writeln!(&mut buffer, r#" </text>"#).unwrap();
258 writeln!(&mut buffer).unwrap();
259
260 writeln!(&mut buffer, r#"</svg>"#).unwrap();
261 buffer
262 }
263}
264
265const FG_COLOR: anstyle::Color = anstyle::Color::Ansi(anstyle::AnsiColor::White);
266const BG_COLOR: anstyle::Color = anstyle::Color::Ansi(anstyle::AnsiColor::Black);
267
268fn write_fg_span(buffer: &mut String, style: &anstyle::Style, fragment: &str) {
269 use std::fmt::Write as _;
270 let fg_color = style.get_fg_color().map(|c| color_name(FG_PREFIX, c));
271 let underline_color = style
272 .get_underline_color()
273 .map(|c| color_name(UNDERLINE_PREFIX, c));
274 let effects = style.get_effects();
275 let underline = effects.contains(anstyle::Effects::UNDERLINE);
276 let double_underline = effects.contains(anstyle::Effects::DOUBLE_UNDERLINE);
277 let curly_underline = effects.contains(anstyle::Effects::CURLY_UNDERLINE);
278 let dotted_underline = effects.contains(anstyle::Effects::DOTTED_UNDERLINE);
279 let dashed_underline = effects.contains(anstyle::Effects::DASHED_UNDERLINE);
280 let strikethrough = effects.contains(anstyle::Effects::STRIKETHROUGH);
281 // skipping INVERT as that was handled earlier
282 let bold = effects.contains(anstyle::Effects::BOLD);
283 let italic = effects.contains(anstyle::Effects::ITALIC);
284 let dimmed = effects.contains(anstyle::Effects::DIMMED);
285 let hidden = effects.contains(anstyle::Effects::HIDDEN);
286
287 let fragment = html_escape::encode_text(fragment);
288 let mut classes = Vec::new();
289 if let Some(class) = fg_color.as_deref() {
290 classes.push(class);
291 }
292 if let Some(class) = underline_color.as_deref() {
293 classes.push(class);
294 }
295 if underline {
296 classes.push("underline");
297 }
298 if double_underline {
299 classes.push("double-underline");
300 }
301 if curly_underline {
302 classes.push("curly-underline");
303 }
304 if dotted_underline {
305 classes.push("dotted-underline");
306 }
307 if dashed_underline {
308 classes.push("dashed-underline");
309 }
310 if strikethrough {
311 classes.push("strikethrough");
312 }
313 if bold {
314 classes.push("bold");
315 }
316 if italic {
317 classes.push("italic");
318 }
319 if dimmed {
320 classes.push("dimmed");
321 }
322 if hidden {
323 classes.push("hidden");
324 }
325
326 write!(buffer, r#"<tspan"#).unwrap();
327 if !classes.is_empty() {
328 let classes = classes.join(" ");
329 write!(buffer, r#" class="{classes}""#).unwrap();
330 }
331 write!(buffer, r#">"#).unwrap();
332 write!(buffer, "{fragment}").unwrap();
333 write!(buffer, r#"</tspan>"#).unwrap();
334}
335
336fn write_bg_span(buffer: &mut String, style: &anstyle::Style, fragment: &str) {
337 use std::fmt::Write as _;
338 use unicode_width::UnicodeWidthStr;
339
340 let bg_color: Option = style.get_bg_color().map(|c: Color| color_name(BG_PREFIX, color:c));
341
342 let fill: &'static str = if bg_color.is_some() { "█" } else { " " };
343
344 let fragment: Cow<'_, str> = html_escape::encode_text(fragment);
345 let width: usize = fragment.width();
346 let fragment: String = fill.repeat(width);
347 let mut classes: Vec<&str> = Vec::new();
348 if let Some(class: &str) = bg_color.as_deref() {
349 classes.push(class);
350 }
351 write!(buffer, r#"<tspan"#).unwrap();
352 if !classes.is_empty() {
353 let classes: String = classes.join(sep:" ");
354 write!(buffer, r#" class="{classes}""#).unwrap();
355 }
356 write!(buffer, r#">"#).unwrap();
357 write!(buffer, "{fragment}").unwrap();
358 write!(buffer, r#"</tspan>"#).unwrap();
359}
360
361impl Default for Term {
362 fn default() -> Self {
363 Self::new()
364 }
365}
366
367const ANSI_NAMES: [&str; 16] = [
368 "black",
369 "red",
370 "green",
371 "yellow",
372 "blue",
373 "magenta",
374 "cyan",
375 "white",
376 "bright-black",
377 "bright-red",
378 "bright-green",
379 "bright-yellow",
380 "bright-blue",
381 "bright-magenta",
382 "bright-cyan",
383 "bright-white",
384];
385
386fn rgb_value(color: anstyle::Color, palette: Palette) -> String {
387 let color: RgbColor = anstyle_lossy::color_to_rgb(color, palette);
388 let anstyle::RgbColor(r: u8, g: u8, b: u8) = color;
389 format!("#{r:02X}{g:02X}{b:02X}")
390}
391
392const FG_PREFIX: &str = "fg";
393const BG_PREFIX: &str = "bg";
394const UNDERLINE_PREFIX: &str = "underline";
395
396fn color_name(prefix: &str, color: anstyle::Color) -> String {
397 match color {
398 anstyle::Color::Ansi(color: AnsiColor) => {
399 let color: Ansi256Color = anstyle::Ansi256Color::from_ansi(color);
400 let index: usize = color.index() as usize;
401 let name: &str = ANSI_NAMES[index];
402 format!("{prefix}-{name}")
403 }
404 anstyle::Color::Ansi256(color: Ansi256Color) => {
405 let index: u8 = color.index();
406 format!("{prefix}-ansi256-{index:03}")
407 }
408 anstyle::Color::Rgb(color: RgbColor) => {
409 let anstyle::RgbColor(r: u8, g: u8, b: u8) = color;
410 format!("{prefix}-rgb-{r:02X}{g:02X}{b:02X}")
411 }
412 }
413}
414
415fn color_styles(
416 styled: &[(anstyle::Style, String)],
417 palette: Palette,
418) -> impl Iterator<Item = (String, String)> {
419 let mut colors: BTreeMap = std::collections::BTreeMap::new();
420 for (style: &Style, _) in styled {
421 if let Some(color: Color) = style.get_fg_color() {
422 colors.insert(key:color_name(FG_PREFIX, color), rgb_value(color, palette));
423 }
424 if let Some(color: Color) = style.get_bg_color() {
425 colors.insert(key:color_name(BG_PREFIX, color), rgb_value(color, palette));
426 }
427 if let Some(color: Color) = style.get_underline_color() {
428 colors.insert(
429 key:color_name(UNDERLINE_PREFIX, color),
430 rgb_value(color, palette),
431 );
432 }
433 }
434
435 colors.into_iter()
436}
437
438fn split_lines(styled: &[(anstyle::Style, String)]) -> Vec<Vec<(anstyle::Style, &str)>> {
439 let mut lines: Vec> = Vec::new();
440 let mut current_line: Vec<(Style, &str)> = Vec::new();
441 for (style: Style, mut next: &str) in styled.iter().map(|(s: &Style, t: &String)| (*s, t.as_str())) {
442 while let Some((current: &str, remaining: &str)) = next.split_once(delimiter:'\n') {
443 let current: &str = current.strip_suffix('\r').unwrap_or(default:current);
444 current_line.push((style, current));
445 lines.push(current_line);
446 current_line = Vec::new();
447 next = remaining;
448 }
449 current_line.push((style, next));
450 }
451 if !current_line.is_empty() {
452 lines.push(current_line);
453 }
454 lines
455}
456