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}