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}