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