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    num::NonZeroUsize,
20    ops,
21};
22
23use anyhow::Context;
24use handlebars::{Handlebars, RenderError, RenderErrorReason, Template as HandlebarsTemplate};
25use serde::{Deserialize, Serialize};
26
27#[cfg(feature = "font-subset")]
28pub use self::subset::{FontFace, FontSubsetter, SubsettingError};
29use self::{
30    data::CompleteHandlebarsData,
31    font::BoxedErrorEmbedder,
32    helpers::register_helpers,
33    write::{LineWriter, StyledLine},
34};
35pub use self::{
36    data::{CreatorData, HandlebarsData, SerializedInteraction},
37    font::{EmbeddedFont, EmbeddedFontFace, FontEmbedder, FontMetrics},
38    palette::{NamedPalette, NamedPaletteParseError, Palette, TermColors},
39};
40pub use crate::style::{RgbColor, RgbColorParseError};
41use crate::{term::TermOutputParser, BoxedError, Captured, TermError, Transcript};
42
43mod data;
44mod font;
45mod helpers;
46mod palette;
47#[cfg(feature = "font-subset")]
48mod subset;
49#[cfg(test)]
50mod tests;
51pub(crate) mod write;
52
53const COMMON_HELPERS: &str = include_str!("common.handlebars");
54const DEFAULT_TEMPLATE: &str = include_str!("default.svg.handlebars");
55const PURE_TEMPLATE: &str = include_str!("pure.svg.handlebars");
56const MAIN_TEMPLATE_NAME: &str = "main";
57
58impl Captured {
59    fn to_lines(&self, wrap_width: Option<usize>) -> Result<Vec<StyledLine>, TermError> {
60        let mut writer = LineWriter::new(wrap_width);
61        TermOutputParser::new(&mut writer).parse(self.as_ref().as_bytes())?;
62        Ok(writer.into_lines())
63    }
64}
65
66/// Line numbering options.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69#[non_exhaustive]
70pub enum LineNumbers {
71    /// Number lines in each output separately. Inputs are not numbered.
72    EachOutput,
73    /// Use continuous numbering for the lines in all outputs. Inputs are not numbered.
74    ContinuousOutputs,
75    /// Use continuous numbering for the lines in all displayed inputs (i.e., ones that
76    /// are not [hidden](crate::UserInput::hide())) and outputs.
77    Continuous,
78}
79
80/// Configurable options of a [`Template`].
81///
82/// # Serialization
83///
84/// Options can be deserialized from `serde`-supported encoding formats, such as TOML. This is used
85/// in the CLI app to read options from a file:
86///
87/// ```
88/// # use assert_matches::assert_matches;
89/// # use term_transcript::svg::{RgbColor, TemplateOptions, WrapOptions};
90/// let options_toml = r#"
91/// width = 900
92/// window_frame = true
93/// line_numbers = 'continuous'
94/// wrap.hard_break_at = 100
95/// scroll = { max_height = 300, pixels_per_scroll = 18, interval = 1.5 }
96///
97/// [palette.colors]
98/// black = '#3c3836'
99/// red = '#b85651'
100/// green = '#8f9a52'
101/// yellow = '#c18f41'
102/// blue = '#68948a'
103/// magenta = '#ab6c7d'
104/// cyan = '#72966c'
105/// white = '#a89984'
106///
107/// [palette.intense_colors]
108/// black = '#5a524c'
109/// red = '#b85651'
110/// green = '#a9b665'
111/// yellow = '#d8a657'
112/// blue = '#7daea3'
113/// magenta = '#d3869b'
114/// cyan = '#89b482'
115/// white = '#ddc7a1'
116/// "#;
117///
118/// let options: TemplateOptions = toml::from_str(options_toml)?;
119/// assert_eq!(options.width.get(), 900);
120/// assert_matches!(
121///     options.wrap,
122///     Some(WrapOptions::HardBreakAt(width)) if width.get() == 100
123/// );
124/// assert_eq!(
125///     options.palette.colors.green,
126///     RgbColor(0x8f, 0x9a, 0x52)
127/// );
128/// # anyhow::Ok(())
129/// ```
130#[derive(Debug, Serialize, Deserialize)]
131pub struct TemplateOptions {
132    /// Width of the rendered terminal window in pixels. Excludes the line numbers width if line
133    /// numbering is enabled. The default value is `720`.
134    #[serde(default = "TemplateOptions::default_width")]
135    pub width: NonZeroUsize,
136    /// Line height relative to the font size. If not specified, will be taken from font metrics (if a font is embedded),
137    /// or set to 1.2 otherwise.
138    pub line_height: Option<f64>,
139    /// Advance width of a font relative to the font size (i.e., in em units). If not specified, will be taken from font metrics (if a font is embedded),
140    /// or set to 8px (~0.57em) otherwise.
141    ///
142    /// For now, advance width is only applied to the pure SVG template.
143    // FIXME: extract to pure SVG options?
144    pub advance_width: Option<f64>,
145    /// Palette of terminal colors. The default value of [`Palette`] is used by default.
146    #[serde(default)]
147    pub palette: Palette,
148    /// Opacity of dimmed text. The default value is 0.7.
149    #[serde(default = "TemplateOptions::default_dim_opacity")]
150    pub dim_opacity: f64,
151    /// Blink options.
152    #[serde(default)]
153    pub blink: BlinkOptions,
154    /// CSS instructions to add at the beginning of the SVG `<style>` tag. This is mostly useful
155    /// to import fonts in conjunction with `font_family`.
156    ///
157    /// The value is not validated in any way, so supplying invalid CSS instructions can lead
158    /// to broken SVG rendering.
159    #[serde(skip_serializing_if = "str::is_empty", default)]
160    pub additional_styles: String,
161    /// Font family specification in the CSS format. Should be monospace.
162    #[serde(default = "TemplateOptions::default_font_family")]
163    pub font_family: String,
164    /// Indicates whether to display a window frame around the shell. Default value is `false`.
165    #[serde(default)]
166    pub window_frame: bool,
167    /// Options for the scroll animation. If set to `None` (which is the default),
168    /// no scrolling will be enabled, and the height of the generated image is not limited.
169    #[serde(skip_serializing_if = "Option::is_none", default)]
170    pub scroll: Option<ScrollOptions>,
171    /// Text wrapping options. The default value of [`WrapOptions`] is used by default.
172    #[serde(default = "TemplateOptions::default_wrap")]
173    pub wrap: Option<WrapOptions>,
174    /// Line numbering options.
175    #[serde(default)]
176    pub line_numbers: Option<LineNumbers>,
177    /// *Font embedder* that will embed the font into the SVG file via `@font-face` CSS.
178    /// This guarantees that the SVG will look identical on all platforms.
179    #[serde(skip)]
180    pub font_embedder: Option<Box<dyn FontEmbedder<Error = BoxedError>>>,
181}
182
183impl Default for TemplateOptions {
184    fn default() -> Self {
185        Self {
186            width: Self::default_width(),
187            line_height: None,
188            advance_width: None,
189            palette: Palette::default(),
190            dim_opacity: Self::default_dim_opacity(),
191            blink: BlinkOptions::default(),
192            additional_styles: String::new(),
193            font_family: Self::default_font_family(),
194            window_frame: false,
195            scroll: None,
196            wrap: Self::default_wrap(),
197            line_numbers: None,
198            font_embedder: None,
199        }
200    }
201}
202
203impl TemplateOptions {
204    fn validate(&self) -> anyhow::Result<()> {
205        anyhow::ensure!(
206            self.dim_opacity > 0.0 && self.dim_opacity < 1.0,
207            "invalid dimmed text opacity ({:?}), should be in (0, 1)",
208            self.dim_opacity
209        );
210
211        if let Some(line_height) = self.line_height {
212            anyhow::ensure!(line_height > 0.0, "line_height must be positive");
213            #[cfg(feature = "tracing")]
214            if line_height > 2.0 {
215                tracing::warn!(
216                    line_height,
217                    "line_height is too large, the produced SVG may look broken"
218                );
219            }
220        }
221
222        if let Some(advance_width) = self.advance_width {
223            anyhow::ensure!(advance_width > 0.0, "advance_width must be positive");
224            #[cfg(feature = "tracing")]
225            if advance_width > 0.7 {
226                tracing::warn!(
227                    advance_width,
228                    "advance_width is too large, the produced SVG may look broken"
229                );
230            }
231            #[cfg(feature = "tracing")]
232            if advance_width < 0.5 {
233                tracing::warn!(
234                    advance_width,
235                    "advance_width is too small, the produced SVG may look broken"
236                );
237            }
238        }
239
240        if let Some(scroll_options) = &self.scroll {
241            scroll_options
242                .validate()
243                .context("invalid scroll options")?;
244        }
245
246        self.blink.validate().context("invalid blink options")?;
247
248        Ok(())
249    }
250
251    /// Sets the font embedder to be used.
252    #[must_use]
253    pub fn with_font_embedder(mut self, embedder: impl FontEmbedder) -> Self {
254        self.font_embedder = Some(Box::new(BoxedErrorEmbedder(embedder)));
255        self
256    }
257
258    /// Sets the [standard font embedder / subsetter](FontSubsetter).
259    #[cfg(feature = "font-subset")]
260    #[must_use]
261    pub fn with_font_subsetting(self, options: FontSubsetter) -> Self {
262        self.with_font_embedder(options)
263    }
264
265    const fn default_width() -> NonZeroUsize {
266        NonZeroUsize::new(720).unwrap()
267    }
268
269    const fn default_dim_opacity() -> f64 {
270        0.7
271    }
272
273    fn default_font_family() -> String {
274        "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace".to_owned()
275    }
276
277    #[allow(clippy::unnecessary_wraps)] // required by serde
278    fn default_wrap() -> Option<WrapOptions> {
279        Some(WrapOptions::default())
280    }
281
282    /// Validates these options. This is equivalent to using [`TryInto`].
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if options are invalid.
287    pub fn validated(self) -> anyhow::Result<ValidTemplateOptions> {
288        self.try_into()
289    }
290
291    #[cfg_attr(
292        feature = "tracing",
293        tracing::instrument(level = "debug", skip(transcript), err)
294    )]
295    fn render_data<'s>(
296        &'s self,
297        transcript: &'s Transcript,
298    ) -> Result<HandlebarsData<'s>, TermError> {
299        let rendered_outputs = self.render_outputs(transcript)?;
300        let mut has_failures = false;
301
302        let mut used_chars = BTreeSet::new();
303        for interaction in transcript.interactions() {
304            let output = interaction.output().to_plaintext()?;
305            used_chars.extend(output.chars());
306
307            let input = interaction.input();
308            if !input.hidden {
309                let prompt = input.prompt.as_deref();
310                let input_chars = iter::once(input.text.as_str())
311                    .chain(prompt)
312                    .flat_map(str::chars);
313                used_chars.extend(input_chars);
314            }
315        }
316        if self.line_numbers.is_some() {
317            used_chars.extend('0'..='9');
318        }
319        if self.wrap.is_some() {
320            used_chars.insert('»');
321        }
322
323        let embedded_font = self
324            .font_embedder
325            .as_deref()
326            .map(|embedder| embedder.embed_font(used_chars))
327            .transpose()
328            .map_err(TermError::FontEmbedding)?;
329
330        let interactions: Vec<_> = transcript
331            .interactions()
332            .iter()
333            .zip(rendered_outputs)
334            .map(|(interaction, output)| {
335                let failure = interaction
336                    .exit_status()
337                    .is_some_and(|status| !status.is_success());
338                has_failures = has_failures || failure;
339                SerializedInteraction {
340                    input: interaction.input(),
341                    output,
342                    exit_status: interaction.exit_status().map(|status| status.0),
343                    failure,
344                }
345            })
346            .collect();
347
348        Ok(HandlebarsData {
349            creator: CreatorData::default(),
350            interactions,
351            options: self,
352            has_failures,
353            embedded_font,
354        })
355    }
356
357    #[cfg_attr(
358        feature = "tracing",
359        tracing::instrument(level = "debug", skip_all, err)
360    )]
361    fn render_outputs(&self, transcript: &Transcript) -> Result<Vec<Vec<StyledLine>>, TermError> {
362        let max_width = self.wrap.as_ref().map(|wrap_options| match wrap_options {
363            WrapOptions::HardBreakAt(width) => width.get(),
364        });
365
366        transcript
367            .interactions
368            .iter()
369            .map(|interaction| interaction.output().to_lines(max_width))
370            .collect()
371    }
372}
373
374/// Options that influence the scrolling animation.
375///
376/// The animation is only displayed if the console exceeds [`Self::max_height`]. In this case,
377/// the console will be scrolled vertically by [`Self::pixels_per_scroll`]
378/// with the interval of [`Self::interval`] seconds between every frame.
379#[derive(Debug, Clone, Deserialize, Serialize)]
380#[cfg_attr(test, derive(PartialEq))]
381pub struct ScrollOptions {
382    /// Maximum height of the console, in pixels. The default value allows to fit 19 lines
383    /// of text into the view with the default template (potentially, slightly less because
384    /// of vertical margins around user inputs).
385    #[serde(default = "ScrollOptions::default_max_height")]
386    pub max_height: NonZeroUsize,
387    /// Minimum scrollbar height in pixels. The default value is 14px (1em).
388    #[serde(default = "ScrollOptions::default_min_scrollbar_height")]
389    pub min_scrollbar_height: NonZeroUsize,
390    /// Number of pixels moved each scroll. Default value is 52 (~3 lines of text with the default template).
391    #[serde(default = "ScrollOptions::default_pixels_per_scroll")]
392    pub pixels_per_scroll: NonZeroUsize,
393    /// Interval between keyframes in seconds. The default value is `4`.
394    #[serde(default = "ScrollOptions::default_interval")]
395    pub interval: f64,
396    /// Threshold to elide the penultimate scroll keyframe, relative to `pixels_per_scroll`.
397    /// If the last scroll keyframe would scroll the view by less than this value (which can happen because
398    /// the last scroll always aligns the scrolled view bottom with the viewport bottom), it will be
399    /// combined with the penultimate keyframe.
400    ///
401    /// The threshold must be in [0, 1). 0 means never eliding the penultimate keyframe. The default value is 0.25.
402    #[serde(default = "ScrollOptions::default_elision_threshold")]
403    pub elision_threshold: f64,
404}
405
406impl Default for ScrollOptions {
407    fn default() -> Self {
408        Self::DEFAULT
409    }
410}
411
412impl ScrollOptions {
413    /// Default options.
414    pub const DEFAULT: Self = Self {
415        max_height: Self::default_max_height(),
416        min_scrollbar_height: Self::default_min_scrollbar_height(),
417        pixels_per_scroll: Self::default_pixels_per_scroll(),
418        interval: Self::default_interval(),
419        elision_threshold: Self::default_elision_threshold(),
420    };
421
422    const fn default_max_height() -> NonZeroUsize {
423        NonZeroUsize::new(18 * 19).unwrap()
424    }
425
426    const fn default_min_scrollbar_height() -> NonZeroUsize {
427        NonZeroUsize::new(14).unwrap()
428    }
429
430    const fn default_pixels_per_scroll() -> NonZeroUsize {
431        NonZeroUsize::new(52).unwrap()
432    }
433
434    const fn default_interval() -> f64 {
435        4.0
436    }
437
438    const fn default_elision_threshold() -> f64 {
439        0.25
440    }
441
442    fn validate(&self) -> anyhow::Result<()> {
443        anyhow::ensure!(self.interval > 0.0, "interval must be positive");
444        anyhow::ensure!(
445            self.elision_threshold >= 0.0 && self.elision_threshold < 1.0,
446            "elision_threshold must be in [0, 1)"
447        );
448
449        anyhow::ensure!(
450            self.min_scrollbar_height < self.max_height,
451            "min_scrollbar_height={} must be lesser than max_height={}",
452            self.min_scrollbar_height,
453            self.max_height
454        );
455        Ok(())
456    }
457}
458
459/// Text wrapping options.
460#[derive(Debug, Clone, Deserialize, Serialize)]
461#[non_exhaustive]
462#[serde(rename_all = "snake_case")]
463pub enum WrapOptions {
464    /// Perform a hard break at the specified width of output. The [`Default`] implementation
465    /// returns this variant with width 80.
466    HardBreakAt(NonZeroUsize),
467}
468
469impl Default for WrapOptions {
470    fn default() -> Self {
471        Self::HardBreakAt(NonZeroUsize::new(80).unwrap())
472    }
473}
474
475/// Blink options.
476#[derive(Debug, Clone, Deserialize, Serialize)]
477pub struct BlinkOptions {
478    /// Interval between blinking animation keyframes in seconds.
479    #[serde(default = "BlinkOptions::default_interval")]
480    pub interval: f64,
481    /// Lower value of blink opacity. Must be in `[0, 1]`.
482    #[serde(default = "TemplateOptions::default_dim_opacity")]
483    pub opacity: f64,
484}
485
486impl Default for BlinkOptions {
487    fn default() -> Self {
488        Self {
489            interval: Self::default_interval(),
490            opacity: TemplateOptions::default_dim_opacity(),
491        }
492    }
493}
494
495impl BlinkOptions {
496    const fn default_interval() -> f64 {
497        1.0
498    }
499
500    fn validate(&self) -> anyhow::Result<()> {
501        anyhow::ensure!(self.interval > 0.0, "interval must be positive");
502        anyhow::ensure!(
503            self.opacity >= 0.0 && self.opacity <= 1.0,
504            "opacity must be in [0, 1]"
505        );
506        Ok(())
507    }
508}
509
510/// Valid wrapper for [`TemplateOptions`]. The only way to construct this wrapper is to convert
511/// [`TemplateOptions`] via [`validated()`](TemplateOptions::validated()) or [`TryInto`].
512#[derive(Debug, Default)]
513pub struct ValidTemplateOptions(TemplateOptions);
514
515impl ops::Deref for ValidTemplateOptions {
516    type Target = TemplateOptions;
517
518    fn deref(&self) -> &Self::Target {
519        &self.0
520    }
521}
522
523impl TryFrom<TemplateOptions> for ValidTemplateOptions {
524    type Error = anyhow::Error;
525
526    fn try_from(options: TemplateOptions) -> Result<Self, Self::Error> {
527        options.validate()?;
528        Ok(Self(options))
529    }
530}
531
532impl ValidTemplateOptions {
533    /// Generates data for rendering.
534    ///
535    /// # Errors
536    ///
537    /// Returns an error if output cannot be rendered to HTML (e.g., it contains invalid
538    /// SGR sequences).
539    pub fn render_data<'s>(
540        &'s self,
541        transcript: &'s Transcript,
542    ) -> Result<HandlebarsData<'s>, TermError> {
543        self.0.render_data(transcript)
544    }
545
546    /// Unwraps the contained options.
547    pub fn into_inner(self) -> TemplateOptions {
548        self.0
549    }
550}
551
552/// Template for rendering [`Transcript`]s, e.g. into an [SVG] image.
553///
554/// # Available templates
555///
556/// When using a template created with [`Self::new()`], a transcript is rendered into SVG
557/// with the text content embedded as an HTML fragment. This is because SVG is not good
558/// at laying out multiline texts and text backgrounds, while HTML excels at both.
559/// As a downside of this approach, the resulting SVG requires for its viewer to support
560/// HTML embedding; while web browsers *a priori* support such embedding, some other SVG viewers
561/// may not.
562///
563/// A template created with [`Self::pure_svg()`] renders a transcript into pure SVG,
564/// in which text is laid out manually and backgrounds use a hack (lines of text with
565/// appropriately colored `█` chars placed behind the content lines). The resulting SVG is
566/// supported by more viewers, but it may look incorrectly in certain corner cases. For example,
567/// if the font family used in the template does not contain `█` or some chars
568/// used in the transcript, the background may be mispositioned.
569///
570/// [Snapshot testing](crate::test) functionality produces snapshots using [`Self::new()`]
571/// (i.e., with HTML embedding); pure SVG templates cannot be tested.
572///
573/// # Customization
574///
575/// A custom [Handlebars] template can be supplied via [`Self::custom()`]. This can be used
576/// to partially or completely change rendering logic, including the output format (e.g.,
577/// to render to HTML instead of SVG).
578///
579/// Data supplied to a template is [`HandlebarsData`].
580///
581/// Besides [built-in Handlebars helpers][rust-helpers] (a superset of [standard helpers]),
582/// custom templates have access to the following additional helpers. All the helpers are
583/// extensively used by the [default template]; thus, studying it may be a good place to start
584/// customizing. Another example is an [HTML template] from the crate examples.
585///
586/// ## Arithmetic helpers: `add`, `sub`, `mul`, `div`
587///
588/// Perform the specified arithmetic operation on the supplied args.
589/// `add` and `mul` support any number of numeric args; `sub` and `div` exactly 2 numeric args.
590/// `div` also supports rounding via `round` hash option. `round=true` rounds to the nearest
591/// integer; `round="up"` / `round="down"` perform rounding in the specified direction.
592///
593/// ```handlebars
594/// {{add 2 3 5}}
595/// {{div (len xs) 3 round="up"}}
596/// ```
597///
598/// ## Counting lines: `count_lines`
599///
600/// Counts the number of lines in the supplied string. If `format="html"` hash option is included,
601/// line breaks introduced by `<br/>` tags are also counted.
602///
603/// ```handlebars
604/// {{count_lines test}}
605/// {{count_lines test format="html"}}
606/// ```
607///
608/// ## Integer ranges: `range`
609///
610/// Creates an array with integers in the range specified by the 2 provided integer args.
611/// The "from" bound is inclusive, the "to" one is exclusive.
612///
613/// ```handlebars
614/// {{#each (range 0 3)}}{{@index}}, {{/each}}
615/// {{! Will output `0, 1, 2,` }}
616/// ```
617///
618/// ## Variable scope: `scope`
619///
620/// A block helper that creates a scope with variables specified in the options hash.
621/// In the block, each variable can be obtained or set using an eponymous helper:
622///
623/// - If the variable helper is called as a block helper, the variable is set to the contents
624///   of the block, which is treated as JSON.
625/// - If the variable helper is called as an inline helper with the `set` option, the variable
626///   is set to the value of the option.
627/// - Otherwise, the variable helper acts as a getter for the current value of the variable.
628///
629/// ```handlebars
630/// {{#scope test=""}}
631///   {{test set="Hello"}}
632///   {{test}} {{! Outputs `Hello` }}
633///   {{#test}}{{test}}, world!{{/test}}
634///   {{test}} {{! Outputs `Hello, world!` }}
635/// {{/scope}}
636/// ```
637///
638/// Since variable getters are helpers, not "real" variables, they should be enclosed
639/// in parentheses `()` if used as args / options for other helpers, e.g. `{{add (test) 2}}`.
640///
641/// ## Partial evaluation: `eval`
642///
643/// Evaluates a partial with the provided name and parses its output as JSON. This can be used
644/// to define "functions" for better code structuring. Function args can be supplied in options
645/// hash.
646///
647/// ```handlebars
648/// {{#*inline "some_function"}}
649///   {{add x y}}
650/// {{/inline}}
651/// {{#with (eval "some_function" x=3 y=5) as |sum|}}
652///   {{sum}} {{! Outputs 8 }}
653/// {{/with}}
654/// ```
655///
656/// [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG
657/// [Handlebars]: https://handlebarsjs.com/
658/// [rust-helpers]: https://docs.rs/handlebars/latest/handlebars/index.html#built-in-helpers
659/// [standard helpers]: https://handlebarsjs.com/guide/builtin-helpers.html
660/// [default template]: https://github.com/slowli/term-transcript/blob/master/src/svg/default.svg.handlebars
661/// [HTML template]: https://github.com/slowli/term-transcript/blob/master/examples/custom.html.handlebars
662///
663/// # Examples
664///
665/// ```
666/// use term_transcript::{svg::*, Transcript, UserInput};
667///
668/// let mut transcript = Transcript::new();
669/// transcript.add_interaction(
670///     UserInput::command("test"),
671///     "Hello, \u{1b}[32mworld\u{1b}[0m!",
672/// );
673///
674/// let template_options = TemplateOptions {
675///     palette: NamedPalette::Dracula.into(),
676///     ..TemplateOptions::default()
677/// }
678/// .validated()?;
679/// let mut buffer = vec![];
680/// Template::new(template_options).render(&transcript, &mut buffer)?;
681/// let buffer = String::from_utf8(buffer)?;
682/// assert!(buffer.contains(r#"Hello, <span class="fg2">world</span>!"#));
683/// # anyhow::Ok(())
684/// ```
685pub struct Template {
686    options: TemplateOptions,
687    handlebars: Handlebars<'static>,
688    constants: HashMap<&'static str, u32>,
689}
690
691impl fmt::Debug for Template {
692    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
693        formatter
694            .debug_struct("Template")
695            .field("options", &self.options)
696            .field("constants", &self.constants)
697            .finish_non_exhaustive()
698    }
699}
700
701impl Default for Template {
702    fn default() -> Self {
703        Self::new(ValidTemplateOptions::default())
704    }
705}
706
707impl Template {
708    const STD_CONSTANTS: &'static [(&'static str, u32)] = &[
709        ("BLOCK_MARGIN", 6),
710        ("USER_INPUT_PADDING", 2),
711        ("WINDOW_PADDING", 10),
712        ("FONT_SIZE", 14),
713        ("WINDOW_FRAME_HEIGHT", 22),
714        ("LN_WIDTH", 22),
715        ("LN_PADDING", 7),
716        ("SCROLLBAR_RIGHT_OFFSET", 7),
717    ];
718
719    /// Initializes the default template based on provided `options`.
720    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
721    pub fn new(options: ValidTemplateOptions) -> Self {
722        let template = HandlebarsTemplate::compile(DEFAULT_TEMPLATE)
723            .expect("Default template should be valid");
724        Self {
725            constants: Self::STD_CONSTANTS.iter().copied().collect(),
726            ..Self::custom(template, options)
727        }
728    }
729
730    /// Initializes the pure SVG template based on provided `options`.
731    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
732    pub fn pure_svg(options: ValidTemplateOptions) -> Self {
733        let template =
734            HandlebarsTemplate::compile(PURE_TEMPLATE).expect("Pure template should be valid");
735        Self {
736            constants: Self::STD_CONSTANTS.iter().copied().collect(),
737            ..Self::custom(template, options)
738        }
739    }
740
741    /// Initializes a custom template.
742    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
743    pub fn custom(template: HandlebarsTemplate, options: ValidTemplateOptions) -> Self {
744        let mut handlebars = Handlebars::new();
745        handlebars.set_strict_mode(true);
746        register_helpers(&mut handlebars);
747        handlebars.register_template(MAIN_TEMPLATE_NAME, template);
748        let helpers = HandlebarsTemplate::compile(COMMON_HELPERS).unwrap();
749        handlebars.register_template("_helpers", helpers);
750        Self {
751            options: options.0,
752            handlebars,
753            constants: HashMap::new(),
754        }
755    }
756
757    /// Renders the `transcript` using the template (usually as an SVG image, although
758    /// custom templates may use a different output format).
759    ///
760    /// # Errors
761    ///
762    /// Returns a Handlebars rendering error, if any. Normally, the only errors could be
763    /// related to I/O (e.g., the output cannot be written to a file).
764    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
765    pub fn render<W: Write>(
766        &self,
767        transcript: &Transcript,
768        destination: W,
769    ) -> Result<(), RenderError> {
770        let data = self
771            .options
772            .render_data(transcript)
773            .map_err(|err| RenderErrorReason::NestedError(Box::new(err)))?;
774        let data = CompleteHandlebarsData {
775            inner: data,
776            constants: &self.constants,
777        };
778        #[cfg(feature = "tracing")]
779        tracing::debug!(?data, "using Handlebars data");
780
781        #[cfg(feature = "tracing")]
782        let _entered = tracing::debug_span!("render_to_write").entered();
783        self.handlebars
784            .render_to_write(MAIN_TEMPLATE_NAME, &data, destination)
785    }
786}