term_transcript/svg/
mod.rs

1//! Provides templating logic for rendering terminal output in a visual format.
2//!
3//! The included templating logic allows rendering SVG images. Templating is based on [Handlebars],
4//! and can be [customized](Template#customization) to support differing layout or even
5//! data formats (e.g., HTML). The default template supports [a variety of options](TemplateOptions)
6//! controlling output aspects, e.g. image dimensions and scrolling animation.
7//!
8//! [Handlebars]: https://handlebarsjs.com/
9//!
10//! # Examples
11//!
12//! See [`Template`] for examples of usage.
13
14use std::{
15    collections::{BTreeSet, HashMap},
16    fmt,
17    io::Write,
18    iter,
19};
20
21use handlebars::{Handlebars, RenderError, RenderErrorReason, Template as HandlebarsTemplate};
22use serde::{Deserialize, Serialize};
23
24#[cfg(feature = "font-subset")]
25pub use self::subset::{FontFace, FontSubsetter, SubsettingError};
26use self::{
27    data::CompleteHandlebarsData,
28    font::BoxedErrorEmbedder,
29    helpers::register_helpers,
30    write::{LineWriter, StyledLine},
31};
32pub use self::{
33    data::{CreatorData, HandlebarsData, SerializedInteraction},
34    font::{EmbeddedFont, EmbeddedFontFace, FontEmbedder, FontMetrics},
35    palette::{NamedPalette, NamedPaletteParseError, Palette, TermColors},
36};
37pub use crate::utils::{RgbColor, RgbColorParseError};
38use crate::{term::TermOutputParser, BoxedError, Captured, TermError, Transcript};
39
40mod data;
41mod font;
42mod helpers;
43mod palette;
44#[cfg(feature = "font-subset")]
45mod subset;
46#[cfg(test)]
47mod tests;
48pub(crate) mod write;
49
50const COMMON_HELPERS: &str = include_str!("common.handlebars");
51const DEFAULT_TEMPLATE: &str = include_str!("default.svg.handlebars");
52const PURE_TEMPLATE: &str = include_str!("pure.svg.handlebars");
53const MAIN_TEMPLATE_NAME: &str = "main";
54
55impl Captured {
56    fn to_lines(&self, wrap_width: Option<usize>) -> Result<Vec<StyledLine>, TermError> {
57        let mut writer = LineWriter::new(wrap_width);
58        TermOutputParser::new(&mut writer).parse(self.as_ref().as_bytes())?;
59        Ok(writer.into_lines())
60    }
61}
62
63/// Line numbering options.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum LineNumbers {
68    /// Number lines in each output separately. Inputs are not numbered.
69    EachOutput,
70    /// Use continuous numbering for the lines in all outputs. Inputs are not numbered.
71    ContinuousOutputs,
72    /// Use continuous numbering for the lines in all displayed inputs (i.e., ones that
73    /// are not [hidden](crate::UserInput::hide())) and outputs.
74    Continuous,
75}
76
77/// Configurable options of a [`Template`].
78///
79/// # Serialization
80///
81/// Options can be deserialized from `serde`-supported encoding formats, such as TOML. This is used
82/// in the CLI app to read options from a file:
83///
84/// ```
85/// # use assert_matches::assert_matches;
86/// # use term_transcript::svg::{RgbColor, TemplateOptions, WrapOptions};
87/// let options_toml = r#"
88/// width = 900
89/// window_frame = true
90/// line_numbers = 'continuous'
91/// wrap.hard_break_at = 100
92/// scroll = { max_height = 300, pixels_per_scroll = 18, interval = 1.5 }
93///
94/// [palette.colors]
95/// black = '#3c3836'
96/// red = '#b85651'
97/// green = '#8f9a52'
98/// yellow = '#c18f41'
99/// blue = '#68948a'
100/// magenta = '#ab6c7d'
101/// cyan = '#72966c'
102/// white = '#a89984'
103///
104/// [palette.intense_colors]
105/// black = '#5a524c'
106/// red = '#b85651'
107/// green = '#a9b665'
108/// yellow = '#d8a657'
109/// blue = '#7daea3'
110/// magenta = '#d3869b'
111/// cyan = '#89b482'
112/// white = '#ddc7a1'
113/// "#;
114///
115/// let options: TemplateOptions = toml::from_str(options_toml)?;
116/// assert_eq!(options.width, 900);
117/// assert_matches!(options.wrap, Some(WrapOptions::HardBreakAt(100)));
118/// assert_eq!(
119///     options.palette.colors.green,
120///     RgbColor(0x8f, 0x9a, 0x52)
121/// );
122/// # anyhow::Ok(())
123/// ```
124#[derive(Debug, Serialize, Deserialize)]
125pub struct TemplateOptions {
126    /// Width of the rendered terminal window in pixels. The default value is `720`.
127    #[serde(default = "TemplateOptions::default_width")]
128    pub width: usize,
129    /// Palette of terminal colors. The default value of [`Palette`] is used by default.
130    #[serde(default)]
131    pub palette: Palette,
132    /// CSS instructions to add at the beginning of the SVG `<style>` tag. This is mostly useful
133    /// to import fonts in conjunction with `font_family`.
134    ///
135    /// The value is not validated in any way, so supplying invalid CSS instructions can lead
136    /// to broken SVG rendering.
137    #[serde(skip_serializing_if = "str::is_empty", default)]
138    pub additional_styles: String,
139    /// Font family specification in the CSS format. Should be monospace.
140    #[serde(default = "TemplateOptions::default_font_family")]
141    pub font_family: String,
142    /// Indicates whether to display a window frame around the shell. Default value is `false`.
143    #[serde(default)]
144    pub window_frame: bool,
145    /// Options for the scroll animation. If set to `None` (which is the default),
146    /// no scrolling will be enabled, and the height of the generated image is not limited.
147    #[serde(skip_serializing_if = "Option::is_none", default)]
148    pub scroll: Option<ScrollOptions>,
149    /// Text wrapping options. The default value of [`WrapOptions`] is used by default.
150    #[serde(default = "TemplateOptions::default_wrap")]
151    pub wrap: Option<WrapOptions>,
152    /// Line numbering options.
153    #[serde(default)]
154    pub line_numbers: Option<LineNumbers>,
155    /// *Font embedder* that will embed the font into the SVG file via `@font-face` CSS.
156    /// This guarantees that the SVG will look identical on all platforms.
157    #[serde(skip)]
158    pub font_embedder: Option<Box<dyn FontEmbedder<Error = BoxedError>>>,
159}
160
161impl Default for TemplateOptions {
162    fn default() -> Self {
163        Self {
164            width: Self::default_width(),
165            palette: Palette::default(),
166            additional_styles: String::new(),
167            font_family: Self::default_font_family(),
168            window_frame: false,
169            scroll: None,
170            wrap: Self::default_wrap(),
171            line_numbers: None,
172            font_embedder: None,
173        }
174    }
175}
176
177impl TemplateOptions {
178    /// Sets the font embedder to be used.
179    #[must_use]
180    pub fn with_font_embedder(mut self, embedder: impl FontEmbedder) -> Self {
181        self.font_embedder = Some(Box::new(BoxedErrorEmbedder(embedder)));
182        self
183    }
184
185    /// Sets the [standard font embedder / subsetter](FontSubsetter).
186    #[cfg(feature = "font-subset")]
187    #[must_use]
188    pub fn with_font_subsetting(self, options: FontSubsetter) -> Self {
189        self.with_font_embedder(options)
190    }
191
192    fn default_width() -> usize {
193        720
194    }
195
196    fn default_font_family() -> String {
197        "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace".to_owned()
198    }
199
200    #[allow(clippy::unnecessary_wraps)] // required by serde
201    fn default_wrap() -> Option<WrapOptions> {
202        Some(WrapOptions::default())
203    }
204
205    /// Generates data for rendering.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if output cannot be rendered to HTML (e.g., it contains invalid
210    /// SGR sequences).
211    #[cfg_attr(
212        feature = "tracing",
213        tracing::instrument(level = "debug", skip(transcript), err)
214    )]
215    pub fn render_data<'s>(
216        &'s self,
217        transcript: &'s Transcript,
218    ) -> Result<HandlebarsData<'s>, TermError> {
219        let rendered_outputs = self.render_outputs(transcript)?;
220        let mut has_failures = false;
221
222        let mut used_chars = BTreeSet::new();
223        for interaction in transcript.interactions() {
224            let output = interaction.output().to_plaintext()?;
225            used_chars.extend(output.chars());
226
227            let input = interaction.input();
228            if !input.hidden {
229                let prompt = input.prompt.as_deref();
230                let input_chars = iter::once(input.text.as_str())
231                    .chain(prompt)
232                    .flat_map(str::chars);
233                used_chars.extend(input_chars);
234            }
235        }
236        if self.line_numbers.is_some() {
237            used_chars.extend('0'..='9');
238        }
239        if self.wrap.is_some() {
240            used_chars.insert('»');
241        }
242
243        let embedded_font = self
244            .font_embedder
245            .as_deref()
246            .map(|embedder| embedder.embed_font(used_chars))
247            .transpose()
248            .map_err(TermError::FontEmbedding)?;
249
250        let interactions: Vec<_> = transcript
251            .interactions()
252            .iter()
253            .zip(rendered_outputs)
254            .map(|(interaction, output)| {
255                let failure = interaction
256                    .exit_status()
257                    .is_some_and(|status| !status.is_success());
258                has_failures = has_failures || failure;
259                SerializedInteraction {
260                    input: interaction.input(),
261                    output,
262                    exit_status: interaction.exit_status().map(|status| status.0),
263                    failure,
264                }
265            })
266            .collect();
267
268        Ok(HandlebarsData {
269            creator: CreatorData::default(),
270            interactions,
271            options: self,
272            has_failures,
273            embedded_font,
274        })
275    }
276
277    #[cfg_attr(
278        feature = "tracing",
279        tracing::instrument(level = "debug", skip_all, err)
280    )]
281    fn render_outputs(&self, transcript: &Transcript) -> Result<Vec<Vec<StyledLine>>, TermError> {
282        let max_width = self.wrap.as_ref().map(|wrap_options| match wrap_options {
283            WrapOptions::HardBreakAt(width) => *width,
284        });
285
286        transcript
287            .interactions
288            .iter()
289            .map(|interaction| interaction.output().to_lines(max_width))
290            .collect()
291    }
292}
293
294/// Options that influence the scrolling animation.
295///
296/// The animation is only displayed if the console exceeds [`Self::max_height`]. In this case,
297/// the console will be scrolled vertically by [`Self::pixels_per_scroll`]
298/// with the interval of [`Self::interval`] seconds between every frame.
299#[derive(Debug, Clone, Deserialize, Serialize)]
300pub struct ScrollOptions {
301    /// Maximum height of the console, in pixels. The default value allows to fit 19 lines
302    /// of text into the view with the default template (potentially, slightly less because
303    /// of vertical margins around user inputs).
304    pub max_height: usize,
305    /// Number of pixels moved each scroll. Default value is 52 (~3 lines of text with the default template).
306    pub pixels_per_scroll: usize,
307    /// Interval between keyframes in seconds. The default value is `4`.
308    pub interval: f32,
309}
310
311impl Default for ScrollOptions {
312    fn default() -> Self {
313        const DEFAULT_LINE_HEIGHT: usize = 18; // from the default template
314        Self {
315            max_height: DEFAULT_LINE_HEIGHT * 19,
316            pixels_per_scroll: 52,
317            interval: 4.0,
318        }
319    }
320}
321
322/// Text wrapping options.
323#[derive(Debug, Clone, Deserialize, Serialize)]
324#[non_exhaustive]
325#[serde(rename_all = "snake_case")]
326pub enum WrapOptions {
327    /// Perform a hard break at the specified width of output. The [`Default`] implementation
328    /// returns this variant with width 80.
329    HardBreakAt(usize),
330}
331
332impl Default for WrapOptions {
333    fn default() -> Self {
334        Self::HardBreakAt(80)
335    }
336}
337
338/// Template for rendering [`Transcript`]s, e.g. into an [SVG] image.
339///
340/// # Available templates
341///
342/// When using a template created with [`Self::new()`], a transcript is rendered into SVG
343/// with the text content embedded as an HTML fragment. This is because SVG is not good
344/// at laying out multiline texts and text backgrounds, while HTML excels at both.
345/// As a downside of this approach, the resulting SVG requires for its viewer to support
346/// HTML embedding; while web browsers *a priori* support such embedding, some other SVG viewers
347/// may not.
348///
349/// A template created with [`Self::pure_svg()`] renders a transcript into pure SVG,
350/// in which text is laid out manually and backgrounds use a hack (lines of text with
351/// appropriately colored `█` chars placed behind the content lines). The resulting SVG is
352/// supported by more viewers, but it may look incorrectly in certain corner cases. For example,
353/// if the font family used in the template does not contain `█` or some chars
354/// used in the transcript, the background may be mispositioned.
355///
356/// [Snapshot testing](crate::test) functionality produces snapshots using [`Self::new()`]
357/// (i.e., with HTML embedding); pure SVG templates cannot be tested.
358///
359/// # Customization
360///
361/// A custom [Handlebars] template can be supplied via [`Self::custom()`]. This can be used
362/// to partially or completely change rendering logic, including the output format (e.g.,
363/// to render to HTML instead of SVG).
364///
365/// Data supplied to a template is [`HandlebarsData`].
366///
367/// Besides [built-in Handlebars helpers][rust-helpers] (a superset of [standard helpers]),
368/// custom templates have access to the following additional helpers. All the helpers are
369/// extensively used by the [default template]; thus, studying it may be a good place to start
370/// customizing. Another example is an [HTML template] from the crate examples.
371///
372/// ## Arithmetic helpers: `add`, `sub`, `mul`, `div`
373///
374/// Perform the specified arithmetic operation on the supplied args.
375/// `add` and `mul` support any number of numeric args; `sub` and `div` exactly 2 numeric args.
376/// `div` also supports rounding via `round` hash option. `round=true` rounds to the nearest
377/// integer; `round="up"` / `round="down"` perform rounding in the specified direction.
378///
379/// ```handlebars
380/// {{add 2 3 5}}
381/// {{div (len xs) 3 round="up"}}
382/// ```
383///
384/// ## Counting lines: `count_lines`
385///
386/// Counts the number of lines in the supplied string. If `format="html"` hash option is included,
387/// line breaks introduced by `<br/>` tags are also counted.
388///
389/// ```handlebars
390/// {{count_lines test}}
391/// {{count_lines test format="html"}}
392/// ```
393///
394/// ## Integer ranges: `range`
395///
396/// Creates an array with integers in the range specified by the 2 provided integer args.
397/// The "from" bound is inclusive, the "to" one is exclusive.
398///
399/// ```handlebars
400/// {{#each (range 0 3)}}{{@index}}, {{/each}}
401/// {{! Will output `0, 1, 2,` }}
402/// ```
403///
404/// ## Variable scope: `scope`
405///
406/// A block helper that creates a scope with variables specified in the options hash.
407/// In the block, each variable can be obtained or set using an eponymous helper:
408///
409/// - If the variable helper is called as a block helper, the variable is set to the contents
410///   of the block, which is treated as JSON.
411/// - If the variable helper is called as an inline helper with the `set` option, the variable
412///   is set to the value of the option.
413/// - Otherwise, the variable helper acts as a getter for the current value of the variable.
414///
415/// ```handlebars
416/// {{#scope test=""}}
417///   {{test set="Hello"}}
418///   {{test}} {{! Outputs `Hello` }}
419///   {{#test}}{{test}}, world!{{/test}}
420///   {{test}} {{! Outputs `Hello, world!` }}
421/// {{/scope}}
422/// ```
423///
424/// Since variable getters are helpers, not "real" variables, they should be enclosed
425/// in parentheses `()` if used as args / options for other helpers, e.g. `{{add (test) 2}}`.
426///
427/// ## Partial evaluation: `eval`
428///
429/// Evaluates a partial with the provided name and parses its output as JSON. This can be used
430/// to define "functions" for better code structuring. Function args can be supplied in options
431/// hash.
432///
433/// ```handlebars
434/// {{#*inline "some_function"}}
435///   {{add x y}}
436/// {{/inline}}
437/// {{#with (eval "some_function" x=3 y=5) as |sum|}}
438///   {{sum}} {{! Outputs 8 }}
439/// {{/with}}
440/// ```
441///
442/// [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG
443/// [Handlebars]: https://handlebarsjs.com/
444/// [rust-helpers]: https://docs.rs/handlebars/latest/handlebars/index.html#built-in-helpers
445/// [standard helpers]: https://handlebarsjs.com/guide/builtin-helpers.html
446/// [default template]: https://github.com/slowli/term-transcript/blob/master/src/svg/default.svg.handlebars
447/// [HTML template]: https://github.com/slowli/term-transcript/blob/master/examples/custom.html.handlebars
448///
449/// # Examples
450///
451/// ```
452/// use term_transcript::{svg::*, Transcript, UserInput};
453///
454/// # fn main() -> anyhow::Result<()> {
455/// let mut transcript = Transcript::new();
456/// transcript.add_interaction(
457///     UserInput::command("test"),
458///     "Hello, \u{1b}[32mworld\u{1b}[0m!",
459/// );
460///
461/// let template_options = TemplateOptions {
462///     palette: NamedPalette::Dracula.into(),
463///     ..TemplateOptions::default()
464/// };
465/// let mut buffer = vec![];
466/// Template::new(template_options).render(&transcript, &mut buffer)?;
467/// let buffer = String::from_utf8(buffer)?;
468/// assert!(buffer.contains(r#"Hello, <span class="fg2">world</span>!"#));
469/// # Ok(())
470/// # }
471/// ```
472pub struct Template {
473    options: TemplateOptions,
474    handlebars: Handlebars<'static>,
475    constants: HashMap<&'static str, u32>,
476}
477
478impl fmt::Debug for Template {
479    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
480        formatter
481            .debug_struct("Template")
482            .field("options", &self.options)
483            .field("constants", &self.constants)
484            .finish_non_exhaustive()
485    }
486}
487
488impl Default for Template {
489    fn default() -> Self {
490        Self::new(TemplateOptions::default())
491    }
492}
493
494impl Template {
495    const STD_CONSTANTS: &'static [(&'static str, u32)] = &[
496        ("BLOCK_MARGIN", 6),
497        ("USER_INPUT_PADDING", 2),
498        ("WINDOW_PADDING", 10),
499        ("LINE_HEIGHT", 18),
500        ("WINDOW_FRAME_HEIGHT", 22),
501        ("SCROLLBAR_RIGHT_OFFSET", 7),
502        ("SCROLLBAR_HEIGHT", 40),
503    ];
504
505    const PURE_SVG_CONSTANTS: &'static [(&'static str, u32)] =
506        &[("LN_WIDTH", 24), ("LN_PADDING", 8)];
507
508    /// Initializes the default template based on provided `options`.
509    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
510    pub fn new(options: TemplateOptions) -> Self {
511        let template = HandlebarsTemplate::compile(DEFAULT_TEMPLATE)
512            .expect("Default template should be valid");
513        Self {
514            constants: Self::STD_CONSTANTS.iter().copied().collect(),
515            ..Self::custom(template, options)
516        }
517    }
518
519    /// Initializes the pure SVG template based on provided `options`.
520    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
521    pub fn pure_svg(options: TemplateOptions) -> Self {
522        let template =
523            HandlebarsTemplate::compile(PURE_TEMPLATE).expect("Pure template should be valid");
524        Self {
525            constants: Self::STD_CONSTANTS
526                .iter()
527                .chain(Self::PURE_SVG_CONSTANTS)
528                .copied()
529                .collect(),
530            ..Self::custom(template, options)
531        }
532    }
533
534    /// Initializes a custom template.
535    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
536    pub fn custom(template: HandlebarsTemplate, options: TemplateOptions) -> Self {
537        let mut handlebars = Handlebars::new();
538        handlebars.set_strict_mode(true);
539        register_helpers(&mut handlebars);
540        handlebars.register_template(MAIN_TEMPLATE_NAME, template);
541        let helpers = HandlebarsTemplate::compile(COMMON_HELPERS).unwrap();
542        handlebars.register_template("_helpers", helpers);
543        Self {
544            options,
545            handlebars,
546            constants: HashMap::new(),
547        }
548    }
549
550    /// Renders the `transcript` using the template (usually as an SVG image, although
551    /// custom templates may use a different output format).
552    ///
553    /// # Errors
554    ///
555    /// Returns a Handlebars rendering error, if any. Normally, the only errors could be
556    /// related to I/O (e.g., the output cannot be written to a file).
557    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
558    pub fn render<W: Write>(
559        &self,
560        transcript: &Transcript,
561        destination: W,
562    ) -> Result<(), RenderError> {
563        let data = self
564            .options
565            .render_data(transcript)
566            .map_err(|err| RenderErrorReason::NestedError(Box::new(err)))?;
567        let data = CompleteHandlebarsData {
568            inner: data,
569            constants: &self.constants,
570        };
571        #[cfg(feature = "tracing")]
572        tracing::debug!(?data, "using Handlebars data");
573
574        #[cfg(feature = "tracing")]
575        let _entered = tracing::debug_span!("render_to_write").entered();
576        self.handlebars
577            .render_to_write(MAIN_TEMPLATE_NAME, &data, destination)
578    }
579}