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