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}