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}