termsnap_lib/
lib.rs

1//! In-memory emulation of ANSI-escaped terminal data and rendering emulated terminal screens to
2//! SVG files.
3//!
4//! ```rust
5//! use termsnap_lib::{FontMetrics, Term, VoidPtyWriter};
6//!
7//! // Create a new terminal emulator and process some bytes.
8//! let mut term = Term::new(24, 80, VoidPtyWriter);
9//! for byte in b"a line of \x1B[32mcolored\x1B[0m terminal data" {
10//!     term.process(*byte);
11//! }
12//!
13//! // Create a snapshot of the terminal screen grid.
14//! let screen = term.current_screen();
15//!
16//! let text: String = screen.cells().map(|c| c.c).collect();
17//! assert_eq!(text.trim(), "a line of colored terminal data");
18//!
19//! assert_eq!(&format!("{}", screen.get(0, 0).unwrap().fg), "#839496");
20//! assert_eq!(&format!("{}", screen.get(0, 10).unwrap().fg), "#859900");
21//!
22//! // Render the screen to SVG.
23//! println!("{}", screen.to_svg(&[], FontMetrics::DEFAULT));
24//! ```
25
26#![forbid(unsafe_code)]
27use std::fmt::{Display, Write};
28
29use alacritty_terminal::{
30    term::{
31        cell::{Cell as AlacrittyCell, Flags},
32        test::TermSize,
33        Config, Term as AlacrittyTerm,
34    },
35    vte::{self, ansi::Processor},
36};
37
38mod ansi;
39mod colors;
40
41pub use ansi::AnsiSignal;
42use colors::Colors;
43
44/// A sensible default font size, in case some renderers don't automatically scale up the SVG.
45const FONT_SIZE_PX: f32 = 12.;
46
47/// Metrics for rendering a monospaced font.
48#[derive(Clone, Copy, Debug)]
49pub struct FontMetrics {
50    /// The number of font units per Em. To scale the font to a specific size, the font metrics are
51    /// scaled relative to this unit. For example, the line height in pixels for a font at size
52    /// 12px would be:
53    ///
54    /// `line_height / units_per_em * 12`
55    pub units_per_em: u16,
56    /// The amount of horizontal advance between characters.
57    pub advance: f32,
58    /// Height between the baselines of two lines of text.
59    pub line_height: f32,
60    /// Space below the text baseline. This is the distance between the text baseline of a line
61    /// and the top of the next line.
62    pub descent: f32,
63}
64
65impl FontMetrics {
66    /// Font metrics that should work for fonts that are similar to, e.g., Liberation mono, Consolas
67    /// or Menlo. If this is not accurate, it will be noticeable as overlap or gaps between box
68    /// drawing characters.
69    ///
70    /// ```norun
71    /// FontMetrics {
72    ///     units_per_em: 1000,
73    ///     advance: 600.0,
74    ///     line_height: 1200.0,
75    ///     descent: 300.0,
76    /// }
77    /// ```
78    pub const DEFAULT: FontMetrics = FontMetrics {
79        units_per_em: 1000,
80        advance: 600.,
81        line_height: 1200.,
82        descent: 300.,
83
84        // Metrics of some fonts:
85        // - Liberation mono:
86        //     units_per_em: 2048,  1.000
87        //     advance: 1229.,      0.600
88        //     line_height: 2320.,  1.133
89        //     descent: 615.,       0.300
90        //
91        // - Consolas:
92        //     units_per_em: 2048,  1.000
93        //     advance: 1226,       0.599
94        //     line_height: 2398,   1.171
95        //     descent: 514,        0.251
96        //
97        // - Menlo:
98        //     units_per_em: 2048,  1.000
99        //     advance: 1233,       0.602
100        //     line_height: 2384,   1.164
101        //     descent: 483,        0.236
102        //
103        // - Source Code Pro
104        //     units_per_em: 1000,  1.000
105        //     advance: 600.,       0.600
106        //     line_height: 1257.,  1.257
107        //     descent: 273.,       0.273
108
109        // - Iosevka extended
110        //     units_per_em: 1000,  1.000
111        //     advance: 600.,       0.600
112        //     line_height: 1250.,  1.250
113        //     descent: 285.,       0.285
114    };
115}
116
117impl Default for FontMetrics {
118    fn default() -> Self {
119        FontMetrics::DEFAULT
120    }
121}
122
123/// Metrics for a font at a specific font size. Calculated from [FontMetrics].
124#[derive(Clone, Copy)]
125struct CalculatedFontMetrics {
126    /// The amount of horizontal advance between characters.
127    advance: f32,
128    /// Height of a line of text. Lines of text directly touch each other, i.e., it is assumed
129    /// the text "leading" is 0.
130    line_height: f32,
131    /// Distance below the text baseline. This is the distance between the text baseline of a line
132    /// and the top of the next line.It is assumed there is no
133    descent: f32,
134}
135
136impl FontMetrics {
137    /// Get the font metrics at a specific font size.
138    fn at_font_size(self, font_size: f32) -> CalculatedFontMetrics {
139        let scale_factor = font_size / f32::from(self.units_per_em);
140        CalculatedFontMetrics {
141            advance: self.advance * scale_factor,
142            line_height: self.line_height * scale_factor,
143            descent: self.descent * scale_factor,
144        }
145    }
146}
147
148/// A color in the sRGB color space.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub struct Rgb {
151    pub r: u8,
152    pub g: u8,
153    pub b: u8,
154}
155
156impl Display for Rgb {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        write!(f, "#{:02x?}{:02x?}{:02x}", self.r, self.g, self.b)
159    }
160}
161
162/// The unicode character and style of a single cell in the terminal grid.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub struct Cell {
165    pub c: char,
166    pub fg: Rgb,
167    pub bg: Rgb,
168    pub bold: bool,
169    pub italic: bool,
170    pub underline: bool,
171    pub strikethrough: bool,
172}
173
174impl Cell {
175    fn from_alacritty_cell(colors: &Colors, cell: &AlacrittyCell) -> Self {
176        Cell {
177            c: cell.c,
178            fg: colors.to_rgb(cell.fg),
179            bg: colors.to_rgb(cell.bg),
180            bold: cell.flags.intersects(Flags::BOLD),
181            italic: cell.flags.intersects(Flags::ITALIC),
182            underline: cell.flags.intersects(Flags::ALL_UNDERLINES),
183            strikethrough: cell.flags.intersects(Flags::STRIKEOUT),
184        }
185    }
186}
187
188#[derive(PartialEq)]
189struct TextStyle {
190    fg: Rgb,
191    bold: bool,
192    italic: bool,
193    underline: bool,
194    strikethrough: bool,
195}
196
197impl TextStyle {
198    /// private conversion from alacritty Cell to Style
199    fn from_cell(cell: &Cell) -> Self {
200        let Cell {
201            fg,
202            bold,
203            italic,
204            underline,
205            strikethrough,
206            ..
207        } = *cell;
208
209        TextStyle {
210            fg,
211            bold,
212            italic,
213            underline,
214            strikethrough,
215        }
216    }
217}
218
219struct TextLine {
220    text: Vec<char>,
221}
222
223impl TextLine {
224    fn with_capacity(capacity: usize) -> Self {
225        TextLine {
226            text: Vec::with_capacity(capacity),
227        }
228    }
229
230    fn push_cell(&mut self, char: char) {
231        self.text.push(char);
232    }
233
234    fn clear(&mut self) {
235        self.text.clear();
236    }
237
238    fn len(&self) -> usize {
239        self.text.len()
240    }
241
242    fn is_empty(&self) -> bool {
243        self.len() == 0
244    }
245
246    /// Get the character cells of this text line, discarding trailing whitespace.
247    fn chars(&self) -> &[char] {
248        let trailing_whitespace_chars = self
249            .text
250            .iter()
251            .rev()
252            .position(|c| !c.is_whitespace())
253            .unwrap_or(self.text.len());
254        let end = self.text.len() - trailing_whitespace_chars;
255        &self.text[..end]
256    }
257}
258
259fn fmt_rect(
260    f: &mut std::fmt::Formatter<'_>,
261    x0: u16,
262    y0: u16,
263    x1: u16,
264    y1: u16,
265    color: Rgb,
266    font_metrics: &CalculatedFontMetrics,
267) -> std::fmt::Result {
268    writeln!(
269        f,
270        r#"<rect x="{x}" y="{y}" width="{width}" height="{height}" style="fill: {color};" />"#,
271        x = f32::from(x0) * font_metrics.advance,
272        y = f32::from(y0) * font_metrics.line_height,
273        width = f32::from(x1 - x0 + 1) * font_metrics.advance,
274        height = f32::from(y1 - y0 + 1) * font_metrics.line_height,
275        color = color,
276    )
277}
278
279fn fmt_text(
280    f: &mut std::fmt::Formatter<'_>,
281    x: u16,
282    y: u16,
283    text: &TextLine,
284    style: &TextStyle,
285    font_metrics: &CalculatedFontMetrics,
286) -> std::fmt::Result {
287    let chars = text.chars();
288    let text_length = chars.len() as f32 * font_metrics.advance;
289    write!(
290        f,
291        r#"<text x="{x}" y="{y}" textLength="{text_length}" style="fill: {color};"#,
292        x = f32::from(x) * font_metrics.advance,
293        y = f32::from(y + 1) * font_metrics.line_height - font_metrics.descent,
294        color = style.fg,
295    )?;
296
297    if style.bold {
298        f.write_str(" font-weight: 600;")?;
299    }
300    if style.italic {
301        f.write_str(" font-style: italic;")?;
302    }
303    if style.underline || style.strikethrough {
304        f.write_char(' ')?;
305        if style.underline {
306            f.write_str(" underline")?;
307        }
308        if style.strikethrough {
309            f.write_str(" line-through")?;
310        }
311    }
312
313    f.write_str(r#"">"#)?;
314    let mut prev_char_was_space = false;
315    for char in chars {
316        match *char {
317            ' ' => {
318                if prev_char_was_space {
319                    // non-breaking space
320                    f.write_str("&#160;")?
321                } else {
322                    f.write_char(' ')?
323                }
324            }
325            // escape tag opening
326            '<' => f.write_str("&lt;")?,
327            '&' => f.write_str("&amp;")?,
328            c => f.write_char(c)?,
329        }
330
331        prev_char_was_space = *char == ' ';
332    }
333    f.write_str("</text>\n")?;
334
335    Ok(())
336}
337
338/// A static snapshot of a terminal screen.
339pub struct Screen {
340    lines: u16,
341    columns: u16,
342    cells: Vec<Cell>,
343}
344
345impl Screen {
346    /// Get a [std::fmt::Display] that prints an SVG when formatted. Set `fonts` to specify fonts
347    /// to be included in the SVG's `font-family` style. `font-family` always includes `monospace`.
348    ///
349    /// The SVG is generated once [std::fmt::Display::fmt] is called; cache the call's output if
350    /// you want to use it multiple times.
351    pub fn to_svg<'s, 'f>(
352        &'s self,
353        fonts: &'f [&'f str],
354        font_metrics: FontMetrics,
355    ) -> impl Display + 's
356    where
357        'f: 's,
358    {
359        struct Svg<'s> {
360            screen: &'s Screen,
361            fonts: &'s [&'s str],
362            font_metrics: CalculatedFontMetrics,
363        }
364
365        impl<'s> Display for Svg<'s> {
366            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367                let font_metrics = self.font_metrics;
368
369                let Screen {
370                    lines,
371                    columns,
372                    ref cells,
373                } = self.screen;
374
375                write!(
376                    f,
377                    r#"<svg viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg">"#,
378                    f32::from(*columns) * font_metrics.advance,
379                    f32::from(*lines) * font_metrics.line_height,
380                )?;
381
382                f.write_str(
383                    "
384<style>
385  .screen {
386    font-family: ",
387                )?;
388
389                for font in self.fonts {
390                    f.write_char('"')?;
391                    f.write_str(font)?;
392                    f.write_str("\", ")?;
393                }
394
395                write!(
396                    f,
397                    r#"monospace;
398    font-size: {FONT_SIZE_PX}px;
399  }}
400</style>
401<g class="screen">
402"#,
403                )?;
404
405                let main_bg = colors::most_common_color(self.screen);
406                fmt_rect(
407                    f,
408                    0,
409                    0,
410                    self.screen.columns().saturating_sub(1),
411                    self.screen.lines().saturating_sub(1),
412                    main_bg,
413                    &font_metrics,
414                )?;
415
416                // find background rectangles to draw by greedily flooding lines then flooding down columns
417                let mut drawn = vec![false; usize::from(*lines) * usize::from(*columns)];
418                for y0 in 0..*lines {
419                    for x0 in 0..*columns {
420                        let idx = self.screen.idx(y0, x0);
421
422                        if drawn[idx] {
423                            continue;
424                        }
425
426                        let cell = &cells[idx];
427                        let bg = cell.bg;
428
429                        if bg == main_bg {
430                            continue;
431                        }
432
433                        let mut end_x = x0;
434                        let mut end_y = y0;
435
436                        for x1 in x0 + 1..*columns {
437                            let idx = self.screen.idx(y0, x1);
438                            let cell = &cells[idx];
439                            if cell.bg == bg {
440                                end_x = x1;
441                            } else {
442                                break;
443                            }
444                        }
445
446                        for y1 in y0 + 1..*lines {
447                            let mut all = true;
448                            for x1 in x0 + 1..*columns {
449                                let idx = self.screen.idx(y1, x1);
450                                let cell = &cells[idx];
451                                if cell.bg != bg {
452                                    all = false;
453                                    break;
454                                }
455                            }
456                            if !all {
457                                break;
458                            }
459                            end_y = y1;
460                        }
461
462                        {
463                            for y in y0..=end_y {
464                                for x in x0..=end_x {
465                                    let idx = self.screen.idx(y, x);
466                                    drawn[idx] = true;
467                                }
468                            }
469                        }
470
471                        fmt_rect(f, x0, y0, end_x, end_y, bg, &font_metrics)?;
472                    }
473                }
474
475                // write text
476                let mut text_line =
477                    TextLine::with_capacity(usize::from(*columns).next_power_of_two());
478                for y in 0..*lines {
479                    let idx = self.screen.idx(y, 0);
480                    let cell = &cells[idx];
481                    let mut style = TextStyle::from_cell(cell);
482                    let mut start_x = 0;
483
484                    for x in 0..*columns {
485                        let idx = self.screen.idx(y, x);
486                        let cell = &cells[idx];
487                        let style_ = TextStyle::from_cell(cell);
488
489                        if style_ != style {
490                            if !text_line.is_empty() {
491                                fmt_text(f, start_x, y, &text_line, &style, &font_metrics)?;
492                            }
493                            text_line.clear();
494                            style = style_;
495                        }
496
497                        if text_line.is_empty() {
498                            start_x = x;
499                            if cell.c == ' ' {
500                                continue;
501                            }
502                        }
503
504                        text_line.push_cell(cell.c);
505                    }
506
507                    if !text_line.is_empty() {
508                        fmt_text(f, start_x, y, &text_line, &style, &font_metrics)?;
509                        text_line.clear();
510                    }
511                }
512
513                f.write_str(
514                    "</g>
515</svg>",
516                )?;
517
518                Ok(())
519            }
520        }
521
522        Svg {
523            screen: self,
524            fonts,
525            font_metrics: font_metrics.at_font_size(FONT_SIZE_PX),
526        }
527    }
528
529    #[inline(always)]
530    fn idx(&self, y: u16, x: u16) -> usize {
531        usize::from(y) * usize::from(self.columns) + usize::from(x)
532    }
533
534    /// The number of screen lines in this snapshot.
535    pub fn lines(&self) -> u16 {
536        self.lines
537    }
538
539    /// The number of screen columns in this snapshot.
540    pub fn columns(&self) -> u16 {
541        self.columns
542    }
543
544    /// An iterator over all cells in the terminal grid. This iterates over all columns in the
545    /// first line from left to right, then the second line, etc.
546    pub fn cells(&self) -> impl Iterator<Item = &Cell> {
547        self.cells.iter()
548    }
549
550    /// Get the cell at the terminal grid position specified by `line` and `column`.
551    pub fn get(&self, line: u16, column: u16) -> Option<&Cell> {
552        self.cells.get(self.idx(line, column))
553    }
554}
555
556/// A sink for responses sent by the [terminal emulator](Term). The terminal emulator sends
557/// responses to ANSI requests. Implement this trait to process these responses, e.g., by sending
558/// them to the requesting pseudoterminal.
559pub trait PtyWriter {
560    /// Write `text` on the terminal in response to an ANSI request.
561    fn write(&mut self, text: String);
562}
563
564impl<F: FnMut(String)> PtyWriter for F {
565    fn write(&mut self, text: String) {
566        self(text)
567    }
568}
569
570/// A [`PtyWriter`] that ignores all responses.
571pub struct VoidPtyWriter;
572
573impl PtyWriter for VoidPtyWriter {
574    fn write(&mut self, _text: String) {}
575}
576
577struct EventProxy<Ev> {
578    handler: std::cell::RefCell<Ev>,
579}
580
581impl<W: PtyWriter> alacritty_terminal::event::EventListener for EventProxy<W> {
582    fn send_event(&self, event: alacritty_terminal::event::Event) {
583        use alacritty_terminal::event::Event as AEvent;
584        match event {
585            AEvent::PtyWrite(text) => self.handler.borrow_mut().write(text),
586            _ev => {}
587        }
588    }
589}
590
591/// An in-memory terminal emulator.
592pub struct Term<W: PtyWriter> {
593    lines: u16,
594    columns: u16,
595    term: AlacrittyTerm<EventProxy<W>>,
596    processor: Option<vte::ansi::Processor<vte::ansi::StdSyncHandler>>,
597}
598
599impl<W: PtyWriter> Term<W> {
600    /// Create a new emulated terminal with a cell matrix of `lines` by `columns`.
601    ///
602    /// [`pty_writer`](PtyWriter) is used to send output from the emulated terminal in reponse to ANSI requests.
603    /// Use [`VoidPtyWriter`] if you do not need to send responses to status requests.
604    pub fn new(lines: u16, columns: u16, pty_writer: W) -> Self {
605        let term = AlacrittyTerm::new(
606            Config::default(),
607            &TermSize {
608                columns: columns.into(),
609                screen_lines: lines.into(),
610            },
611            EventProxy {
612                handler: pty_writer.into(),
613            },
614        );
615
616        Term {
617            lines,
618            columns,
619            term,
620            processor: Some(Processor::new()),
621        }
622    }
623
624    /// Process one byte of ANSI-escaped terminal data.
625    pub fn process(&mut self, byte: u8) {
626        self.processor
627            .as_mut()
628            .expect("unreachable")
629            .advance(&mut self.term, byte);
630    }
631
632    /// Process one byte of ANSI-escaped terminal data. Some ANSI signals will trigger callback
633    /// `cb` to be called with a reference to the terminal and the signal that triggered the call,
634    /// right before applying the result of the ANSI signal to the terminal. This allows grabbing a
635    /// snapshot of the the terminal screen contents before application of the signal.
636    ///
637    /// See also [AnsiSignal].
638    pub fn process_with_callback(&mut self, byte: u8, mut cb: impl FnMut(&Self, AnsiSignal)) {
639        let mut processor = self.processor.take().expect("unreachable");
640
641        let mut handler = ansi::HandlerWrapper {
642            term: self,
643            cb: &mut cb,
644        };
645
646        processor.advance(&mut handler, byte);
647        self.processor = Some(processor);
648    }
649
650    /// Resize the terminal screen to the specified dimension.
651    pub fn resize(&mut self, lines: u16, columns: u16) {
652        let new_size = TermSize {
653            columns: columns.into(),
654            screen_lines: lines.into(),
655        };
656        self.lines = lines;
657        self.columns = columns;
658        self.term.resize(new_size);
659    }
660
661    /// Get a snapshot of the current terminal screen.
662    pub fn current_screen(&self) -> Screen {
663        // ideally users can define their own colors
664        let colors = Colors::default();
665
666        Screen {
667            lines: self.lines,
668            columns: self.columns,
669            cells: self
670                .term
671                .grid()
672                .display_iter()
673                .map(|point_cell| Cell::from_alacritty_cell(&colors, point_cell.cell))
674                .collect(),
675        }
676    }
677}
678
679/// Feed an ANSI sequence through a terminal emulator, returning the resulting terminal screen contents.
680pub fn emulate(lines: u16, columns: u16, ansi_sequence: &[u8]) -> Screen {
681    let mut term = Term::new(lines, columns, VoidPtyWriter);
682    for &byte in ansi_sequence {
683        term.process(byte);
684    }
685    term.current_screen()
686}
687
688#[cfg(test)]
689mod test {
690    #[test]
691    fn test() {
692        let screen = super::emulate(24, 80, include_bytes!("./tests/ls.txt"));
693        let expected = "total 60
694drwxr-xr-x  6 thomas users  4096 Jun 19 15:58 .
695drwxr-xr-x 34 thomas users  4096 Jun 16 10:28 ..
696-rw-r--r--  1 thomas users 19422 Jun 18 17:22 Cargo.lock
697-rw-r--r--  1 thomas users   749 Jun 19 11:33 Cargo.toml
698-rw-r--r--  1 thomas users  1940 Jun 16 11:19 flake.lock
699-rw-r--r--  1 thomas users   640 Jun 16 11:19 flake.nix
700drwxr-xr-x  7 thomas users  4096 Jun 16 11:19 .git
701-rw-r--r--  1 thomas users   231 Jun 16 11:30 README.md
702drwxr-xr-x  2 thomas users  4096 Jun 19 12:20 src
703drwxr-xr-x  3 thomas users  4096 Jun 18 14:36 target
704drwxr-xr-x  3 thomas users  4096 Jun 18 11:22 termsnap-lib";
705
706        let mut line = 0;
707        let mut column = 0;
708
709        for c in expected.chars() {
710            match c {
711                '\n' => {
712                    for column in column..80 {
713                        let idx = screen.idx(line, column);
714                        assert_eq!(screen.cells[idx].c, ' ', "failed at {line}x{column}");
715                    }
716                    column = 0;
717                    line += 1;
718                }
719                _ => {
720                    let idx = screen.idx(line, column);
721                    assert_eq!(screen.cells[idx].c, c, "failed at {line}x{column}");
722                    column += 1;
723                }
724            }
725        }
726    }
727}