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}