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}