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