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};
22
23#[cfg(feature = "font-subset")]
24pub use self::subset::{FontFace, FontSubsetter, SubsettingError};
25use self::{
26    data::CompleteHandlebarsData,
27    helpers::register_helpers,
28    write::{LineWriter, StyledLine},
29};
30pub use self::{
31    data::{CreatorData, HandlebarsData, SerializedInteraction},
32    font::{EmbeddedFont, EmbeddedFontFace, FontEmbedder, FontMetrics},
33    options::{
34        BlinkOptions, ContinuedLineNumbers, LineNumberingOptions, LineNumbers, ScrollOptions,
35        TemplateOptions, ValidTemplateOptions, WindowOptions, WrapOptions,
36    },
37    palette::{NamedPalette, NamedPaletteParseError, Palette, TermColors},
38};
39pub use crate::style::{RgbColor, RgbColorParseError};
40use crate::{term::TermOutputParser, Captured, TermError, Transcript};
41
42mod data;
43mod font;
44mod helpers;
45mod options;
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
66impl TemplateOptions {
67    #[cfg_attr(
68        feature = "tracing",
69        tracing::instrument(level = "debug", skip(transcript), err)
70    )]
71    fn render_data<'s>(
72        &'s self,
73        transcript: &'s Transcript,
74    ) -> Result<HandlebarsData<'s>, TermError> {
75        let rendered_outputs = self.render_outputs(transcript)?;
76        let mut has_failures = false;
77
78        let mut used_chars = BTreeSet::new();
79        for interaction in transcript.interactions() {
80            let output = interaction.output().to_plaintext()?;
81            used_chars.extend(output.chars());
82
83            let input = interaction.input();
84            if !input.hidden {
85                let prompt = input.prompt.as_deref();
86                let input_chars = iter::once(input.text.as_str())
87                    .chain(prompt)
88                    .flat_map(str::chars);
89                used_chars.extend(input_chars);
90            }
91        }
92        if let Some(line_numbers) = &self.line_numbers {
93            used_chars.extend('0'..='9');
94            let additional_chars = match &line_numbers.continued {
95                ContinuedLineNumbers::Mark(mark) => mark.as_ref(),
96                ContinuedLineNumbers::Inherit => "",
97            };
98            used_chars.extend(additional_chars.chars());
99        }
100        if let Some(wrap) = &self.wrap {
101            let additional_chars = match wrap {
102                WrapOptions::HardBreakAt { mark, .. } => mark.as_ref(),
103            };
104            used_chars.extend(additional_chars.chars());
105        }
106
107        let embedded_font = self
108            .font_embedder
109            .as_deref()
110            .map(|embedder| embedder.embed_font(used_chars))
111            .transpose()
112            .map_err(TermError::FontEmbedding)?;
113
114        let interactions: Vec<_> = transcript
115            .interactions()
116            .iter()
117            .zip(rendered_outputs)
118            .map(|(interaction, output)| {
119                let failure = interaction
120                    .exit_status()
121                    .is_some_and(|status| !status.is_success());
122                has_failures = has_failures || failure;
123                SerializedInteraction {
124                    input: interaction.input(),
125                    output,
126                    exit_status: interaction.exit_status().map(|status| status.0),
127                    failure,
128                }
129            })
130            .collect();
131
132        Ok(HandlebarsData {
133            creator: CreatorData::default(),
134            interactions,
135            options: self,
136            has_failures,
137            embedded_font,
138        })
139    }
140
141    #[cfg_attr(
142        feature = "tracing",
143        tracing::instrument(level = "debug", skip_all, err)
144    )]
145    fn render_outputs(&self, transcript: &Transcript) -> Result<Vec<Vec<StyledLine>>, TermError> {
146        let max_width = self.wrap.as_ref().map(|wrap_options| match wrap_options {
147            WrapOptions::HardBreakAt { chars, .. } => chars.get(),
148        });
149
150        transcript
151            .interactions
152            .iter()
153            .map(|interaction| interaction.output().to_lines(max_width))
154            .collect()
155    }
156}
157
158/// Template for rendering [`Transcript`]s, e.g. into an [SVG] image.
159///
160/// # Available templates
161///
162/// When using a template created with [`Self::new()`], a transcript is rendered into SVG
163/// with the text content embedded as an HTML fragment. This is because SVG is not good
164/// at laying out multiline texts and text backgrounds, while HTML excels at both.
165/// As a downside of this approach, the resulting SVG requires for its viewer to support
166/// HTML embedding; while web browsers *a priori* support such embedding, some other SVG viewers
167/// may not.
168///
169/// A template created with [`Self::pure_svg()`] renders a transcript into pure SVG,
170/// in which text is laid out manually. The resulting SVG is
171/// supported by more viewers, but it may look incorrectly in certain corner cases.
172///
173/// [Snapshot testing](crate::test) functionality produces snapshots using [`Self::new()`]
174/// (i.e., with HTML embedding); pure SVG templates cannot be tested.
175///
176/// # Customization
177///
178/// A custom [Handlebars] template can be supplied via [`Self::custom()`]. This can be used
179/// to partially or completely change rendering logic, including the output format (e.g.,
180/// to render to HTML instead of SVG).
181///
182/// Data supplied to a template is [`HandlebarsData`].
183///
184/// Besides [built-in Handlebars helpers][rust-helpers] (a superset of [standard helpers]),
185/// custom templates have access to the following additional helpers. All the helpers are
186/// extensively used by the [default template]; thus, studying it may be a good place to start
187/// customizing. Another example is an [HTML template] from the crate examples.
188///
189/// ## Arithmetic operations: `add`, `sub`, `mul`, `div`
190///
191/// Perform the specified arithmetic operation on the supplied args.
192/// `add` and `mul` support any number of numeric args; `sub` and `div` exactly 2 numeric args.
193///
194/// ```handlebars
195/// {{add 2 3 5}}
196/// {{div (len xs) 3}}
197/// ```
198///
199/// ## Rounding
200///
201/// Rounds the provided value with a configurable number of decimal digits. Also allows specifying
202/// the rounding mode: up / ceil, down / floor, or nearest / round (default).
203///
204/// ```handlebars
205/// {{round 7.8}} {{! 8 }}
206/// {{round 7.13 digits=1}} {{! 7.1 }}
207/// {{round 7.13 digits=1 mode="up"}} {{! 7.2 }}
208/// ```
209///
210/// ## Counting lines: `count_lines`
211///
212/// Counts the number of lines in the supplied string.
213///
214/// ```handlebars
215/// {{count_lines test}}
216/// ```
217///
218/// ## Integer ranges: `range`
219///
220/// Creates an array with integers in the range specified by the 2 provided integer args.
221/// The "from" bound is inclusive, the "to" one is exclusive.
222///
223/// ```handlebars
224/// {{#each (range 0 3)}}{{@index}}, {{/each}}
225/// {{! Will output `0, 1, 2,` }}
226/// ```
227///
228/// ## Variable scope: `scope`, `set`
229///
230/// A block helper that creates a scope with variables specified in the options hash.
231/// In the block, each variable can be obtained using local variable syntax (e.g., `@test`).
232/// Variables can be set with the `set` helper:
233///
234/// - If `set` is called as a block helper, the variable is set to the contents
235///   of the block, which is treated as JSON.
236/// - If `set` is called as a block helper with `append=true`, then the contents of the block
237///   is appended to the var, which must be a string.
238/// - If the `set` helper is called as an inline helper, it sets values of the listed variables.
239///
240/// ```handlebars
241/// {{#scope test=""}}
242///   {{set test="Hello"}}
243///   {{@test}} {{! Outputs `Hello` }}
244///   {{#set "test"}}"{{@test}}, world!"{{/set}}
245///   {{@test}} {{! Outputs `Hello, world!` }}
246/// {{/scope}}
247/// ```
248///
249/// [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG
250/// [Handlebars]: https://handlebarsjs.com/
251/// [rust-helpers]: https://docs.rs/handlebars/latest/handlebars/index.html#built-in-helpers
252/// [standard helpers]: https://handlebarsjs.com/guide/builtin-helpers.html
253/// [default template]: https://github.com/slowli/term-transcript/blob/master/src/svg/default.svg.handlebars
254/// [HTML template]: https://github.com/slowli/term-transcript/blob/master/examples/custom.html.handlebars
255///
256/// # Examples
257///
258/// ```
259/// use term_transcript::{svg::*, Transcript, UserInput};
260///
261/// let mut transcript = Transcript::new();
262/// transcript.add_interaction(
263///     UserInput::command("test"),
264///     "Hello, \u{1b}[32mworld\u{1b}[0m!",
265/// );
266///
267/// let template_options = TemplateOptions {
268///     palette: NamedPalette::Dracula.into(),
269///     ..TemplateOptions::default()
270/// }
271/// .validated()?;
272/// let mut buffer = vec![];
273/// Template::new(template_options).render(&transcript, &mut buffer)?;
274/// let buffer = String::from_utf8(buffer)?;
275/// assert!(buffer.contains(r#"Hello, <span class="fg2">world</span>!"#));
276/// # anyhow::Ok(())
277/// ```
278pub struct Template {
279    options: TemplateOptions,
280    handlebars: Handlebars<'static>,
281    constants: HashMap<&'static str, u32>,
282}
283
284impl fmt::Debug for Template {
285    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
286        formatter
287            .debug_struct("Template")
288            .field("options", &self.options)
289            .field("constants", &self.constants)
290            .finish_non_exhaustive()
291    }
292}
293
294impl Default for Template {
295    fn default() -> Self {
296        Self::new(ValidTemplateOptions::default())
297    }
298}
299
300impl Template {
301    const STD_CONSTANTS: &'static [(&'static str, u32)] = &[
302        ("BLOCK_MARGIN", 6),
303        ("USER_INPUT_PADDING", 2),
304        ("WINDOW_PADDING", 10),
305        ("FONT_SIZE", 14),
306        ("WINDOW_FRAME_HEIGHT", 22),
307        ("LN_WIDTH", 22),
308        ("LN_PADDING", 7),
309        ("SCROLLBAR_RIGHT_OFFSET", 7),
310    ];
311
312    /// Initializes the default template based on provided `options`.
313    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
314    pub fn new(options: ValidTemplateOptions) -> Self {
315        let template = HandlebarsTemplate::compile(DEFAULT_TEMPLATE)
316            .expect("Default template should be valid");
317        Self {
318            constants: Self::STD_CONSTANTS.iter().copied().collect(),
319            ..Self::custom(template, options)
320        }
321    }
322
323    /// Initializes the pure SVG template based on provided `options`.
324    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
325    pub fn pure_svg(options: ValidTemplateOptions) -> Self {
326        let template =
327            HandlebarsTemplate::compile(PURE_TEMPLATE).expect("Pure template should be valid");
328        Self {
329            constants: Self::STD_CONSTANTS.iter().copied().collect(),
330            ..Self::custom(template, options)
331        }
332    }
333
334    /// Initializes a custom template.
335    #[allow(clippy::missing_panics_doc)] // Panic should never be triggered
336    pub fn custom(template: HandlebarsTemplate, options: ValidTemplateOptions) -> Self {
337        let mut handlebars = Handlebars::new();
338        handlebars.set_strict_mode(true);
339        register_helpers(&mut handlebars);
340        handlebars.register_template(MAIN_TEMPLATE_NAME, template);
341        let helpers = HandlebarsTemplate::compile(COMMON_HELPERS).unwrap();
342        handlebars.register_template("_helpers", helpers);
343        Self {
344            options: options.into_inner(),
345            handlebars,
346            constants: HashMap::new(),
347        }
348    }
349
350    /// Renders the `transcript` using the template (usually as an SVG image, although
351    /// custom templates may use a different output format).
352    ///
353    /// # Errors
354    ///
355    /// Returns a Handlebars rendering error, if any. Normally, the only errors could be
356    /// related to I/O (e.g., the output cannot be written to a file).
357    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
358    pub fn render<W: Write>(
359        &self,
360        transcript: &Transcript,
361        destination: W,
362    ) -> Result<(), RenderError> {
363        let data = self
364            .options
365            .render_data(transcript)
366            .map_err(|err| RenderErrorReason::NestedError(Box::new(err)))?;
367        let data = CompleteHandlebarsData {
368            inner: data,
369            constants: &self.constants,
370        };
371        #[cfg(feature = "tracing")]
372        tracing::debug!(?data, "using Handlebars data");
373
374        #[cfg(feature = "tracing")]
375        let _entered = tracing::debug_span!("render_to_write").entered();
376        self.handlebars
377            .render_to_write(MAIN_TEMPLATE_NAME, &data, destination)
378    }
379}