From 3d1d52cea1ed2cdb42b11af1dec66b2190796934 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Fri, 26 Jul 2024 16:17:14 +0200 Subject: [PATCH] feat\!: render SVG based on font metrics --- README.md | 25 ++++--- src/main.rs | 49 ++++++++++++-- termsnap-lib/src/lib.rs | 147 +++++++++++++++++++++++++++++++++++----- 3 files changed, 190 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 758dd23..005a918 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,23 @@ $ nix run nixpkgs#termsnap -- --interactive --out ./interactive-bash.svg -- bash ## A note on fonts -The SVG generated by Termsnap assumes the font used is monospace with a glyph -width/height ratio of 0.60 and a font ascent of 0.75. The font is not -embedded and the text not converted to paths. If the client rendering the SVG -can't find the specified font, the SVG may render incorrectly, especially if -the used font's dimensions do not match Termsnap's assumptions. You can use, -e.g., Inkscape to convert the text to paths---the downside is the text may lose -crispness when rendering at low resolutions. You can also convert the SVG to a -raster image. +The SVG generated by Termsnap makes assumptions about the metrics of the font +used for text rendering. Specifically, the font's character advance, line +height and descent metrics are used to determine how to lay out the terminal's +cells in the generated SVG. The default metrics can be overriden by passing +`--font-` arguments to termsnap. The font is not embedded and the text +not converted to paths. If the client rendering the SVG can't find the +specified font, the SVG may render incorrectly, especially if the metrics of +the font used for rendering vary signficantly from the metrics used to generate +the SVG. + +You can use, e.g., Inkscape to convert the text to paths---the downside is the +text may lose crispness when rendering at low resolutions. You can also convert +the SVG to a raster image. + +You can use the CLI program [font-info](https://github.com/tomcur/font-info) to +determine the metrics of the font you want to use. Alternatively, you can use +the font editor [Fontforge](https://github.com/fontforge/fontforge). ```bash # Text to path diff --git a/src/main.rs b/src/main.rs index c0fd89e..8f265b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,13 +13,13 @@ use alacritty_terminal::{ event::OnResize, tty::{EventedPty, EventedReadWrite, Pty}, }; -use clap::Parser; +use clap::{Args, Parser}; use rustix::{ event::{PollFd, PollFlags}, termios, }; -use termsnap_lib::{Screen, Term, VoidPtyWriter}; +use termsnap_lib::{FontMetrics, Screen, Term, VoidPtyWriter}; mod poll; mod ringbuffer; @@ -49,6 +49,32 @@ fn with_raw(mut fd: F, f: impl FnOnce(&mut F) -> R) -> R { r } +/// The SVG generated by Termsnap makes assumptions about the metrics of the font used for text +/// rendering. The user can override these metrics. +#[derive(Debug, Args)] +struct FontMetricsArg { + /// The number of font units per Em. To scale the font to a specific size, the font metrics are + /// scaled relative to this unit. For example, the line height in pixels for a font at size + /// 12px would be: + /// + /// `line_height / units_per_em * 12` + #[arg(long, default_value_t = FontMetrics::DEFAULT.units_per_em)] + font_units_per_em: u16, + + /// The amount of horizontal advance in font units between characters. + #[arg(long, default_value_t = FontMetrics::DEFAULT.advance)] + font_advance: f32, + + /// Height in font units between the baselines of two lines of text. + #[arg(long, default_value_t = FontMetrics::DEFAULT.line_height)] + font_line_height: f32, + + /// Height in font units below the text baseline. This is the distance between the text + /// baseline of a line and the top of the next line. + #[arg(long, default_value_t = FontMetrics::DEFAULT.descent)] + font_descent: f32, +} + /// Create an SVG of a command's output by running it in a pseudo-terminal (PTY) and interpreting /// the command's output by an in-memory terminal emulator. /// @@ -57,7 +83,7 @@ fn with_raw(mut fd: F, f: impl FnOnce(&mut F) -> R) -> R { /// non-interactively, data on standard input is sent by Termsnap as input to the child PTY (e.g., /// sending 0x03 (^C) causes the PTY driver to send the SIGINT interrupt to the child command). The /// child PTY's output is not shown. -#[derive(Debug, clap::Parser)] +#[derive(Debug, Parser)] #[command(version)] struct Cli { /// Run the command interactively. This prevents the SVG from being output on standard output. @@ -104,6 +130,9 @@ struct Cli { #[arg(long)] render_before_clear: bool, + #[command(flatten)] + font_metrics: FontMetricsArg, + /// The command to run. Its output will be turned into an SVG. If this argument is missing and /// Termsnap's STDIN is not a TTY, data on STDIN is interpreted by the terminal emulator and /// the result rendered. @@ -426,6 +455,16 @@ fn main() -> anyhow::Result<()> { } let out = cli.out.take(); + let font_metrics = { + let m = &cli.font_metrics; + FontMetrics { + units_per_em: m.font_units_per_em, + advance: m.font_advance, + line_height: m.font_line_height, + descent: m.font_descent, + } + }; + let screen = run(cli, &mut parent_stdin, &mut parent_stdout)?; let fonts = &[ @@ -441,9 +480,9 @@ fn main() -> anyhow::Result<()> { .truncate(true) .create(true) .open(out)?; - write!(file, "{}", screen.to_svg(fonts))?; + write!(file, "{}", screen.to_svg(fonts, font_metrics))?; } else { - println!("{}", screen.to_svg(fonts)) + println!("{}", screen.to_svg(fonts, font_metrics)) } Ok(()) diff --git a/termsnap-lib/src/lib.rs b/termsnap-lib/src/lib.rs index 4ead74f..c6f6bf0 100644 --- a/termsnap-lib/src/lib.rs +++ b/termsnap-lib/src/lib.rs @@ -41,8 +41,107 @@ mod colors; pub use ansi::AnsiSignal; use colors::Colors; -const FONT_ASPECT_RATIO: f32 = 0.6; -const FONT_ASCENT: f32 = 0.750; +/// A sensible default font size, in case some renderers don't automatically scale up the SVG. +const FONT_SIZE_PX: f32 = 12.; + +/// Metrics for rendering a monospaced font. +#[derive(Clone, Copy, Debug)] +pub struct FontMetrics { + /// The number of font units per Em. To scale the font to a specific size, the font metrics are + /// scaled relative to this unit. For example, the line height in pixels for a font at size + /// 12px would be: + /// + /// `line_height / units_per_em * 12` + pub units_per_em: u16, + /// The amount of horizontal advance between characters. + pub advance: f32, + /// Height between the baselines of two lines of text. + pub line_height: f32, + /// Space below the text baseline. This is the distance between the text baseline of a line + /// and the top of the next line. + pub descent: f32, +} + +impl FontMetrics { + /// Font metrics that should work for fonts that are similar to, e.g., Liberation mono, Consolas + /// or Menlo. If this is not accurate, it will be noticeable as overlap or gaps between box + /// drawing characters. + /// + /// ``` + /// units_per_em: 1000 + /// advance: 600.0 + /// line_height: 1200.0 + /// descent: 300.0 + /// ``` + pub const DEFAULT: FontMetrics = FontMetrics { + units_per_em: 1000, + advance: 600., + line_height: 1200., + descent: 300., + + // Metrics of some fonts: + // - Liberation mono: + // units_per_em: 2048, 1.000 + // advance: 1229., 0.600 + // line_height: 2320., 1.133 + // descent: 615., 0.300 + // + // - Consolas: + // units_per_em: 2048, 1.000 + // advance: 1226, 0.599 + // line_height: 2398, 1.171 + // descent: 514, 0.251 + // + // - Menlo: + // units_per_em: 2048, 1.000 + // advance: 1233, 0.602 + // line_height: 2384, 1.164 + // descent: 483, 0.236 + // + // - Source Code Pro + // units_per_em: 1000, 1.000 + // advance: 600., 0.600 + // line_height: 1257., 1.257 + // descent: 273., 0.273 + + // - Iosevka extended + // units_per_em: 1000, 1.000 + // advance: 600., 0.600 + // line_height: 1250., 1.250 + // descent: 285., 0.285 + }; +} + +impl Default for FontMetrics { + fn default() -> Self { + FontMetrics::DEFAULT + } +} + +/// Metrics for a font at a specific font size. Calculated from [FontMetrics]. +#[derive(Clone, Copy)] +struct CalculatedFontMetrics { + /// The amount of horizontal advance between characters. + advance: f32, + /// Height of a line of text. Lines of text directly touch each other, i.e., it is assumed + /// the text "leading" is 0. + line_height: f32, + /// Distance below the text baseline. This is the distance between the text baseline of a line + /// and the top of the next line.It is assumed there is no + descent: f32, +} + +impl FontMetrics { + /// Get the font metrics at a specific font size. + fn at_font_size(self, font_size: f32) -> CalculatedFontMetrics { + let scale_factor = font_size / f32::from(self.units_per_em); + CalculatedFontMetrics { + advance: self.advance * scale_factor, + line_height: self.line_height * scale_factor, + descent: self.descent * scale_factor, + } + } +} /// A color in the sRGB color space. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -162,14 +261,15 @@ fn fmt_rect( x1: u16, y1: u16, color: Rgb, + font_metrics: &CalculatedFontMetrics, ) -> std::fmt::Result { writeln!( f, r#""#, - x = f32::from(x0) * FONT_ASPECT_RATIO, - y = y0, - width = f32::from(x1 - x0 + 1) * FONT_ASPECT_RATIO, - height = y1 - y0 + 1, + x = f32::from(x0) * font_metrics.advance, + y = f32::from(y0) * font_metrics.line_height, + width = f32::from(x1 - x0 + 1) * font_metrics.advance, + height = f32::from(y1 - y0 + 1) * font_metrics.line_height, color = color, ) } @@ -180,14 +280,15 @@ fn fmt_text( y: u16, text: &TextLine, style: &TextStyle, + font_metrics: &CalculatedFontMetrics, ) -> std::fmt::Result { let chars = text.chars(); - let text_length = chars.len() as f32 * FONT_ASPECT_RATIO; + let text_length = chars.len() as f32 * font_metrics.advance; write!( f, r#"(&'s self, fonts: &'f [&'f str]) -> impl Display + 's + pub fn to_svg<'s, 'f>( + &'s self, + fonts: &'f [&'f str], + font_metrics: FontMetrics, + ) -> impl Display + 's where 'f: 's, { struct Svg<'s> { screen: &'s Screen, fonts: &'s [&'s str], + font_metrics: CalculatedFontMetrics, } impl<'s> Display for Svg<'s> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let font_metrics = self.font_metrics; + let Screen { lines, columns, @@ -265,8 +373,8 @@ impl Screen { write!( f, r#""#, - f32::from(self.screen.columns) * FONT_ASPECT_RATIO, - lines, + f32::from(*columns) * font_metrics.advance, + f32::from(*lines) * font_metrics.line_height, )?; f.write_str( @@ -282,10 +390,11 @@ impl Screen { f.write_str("\", ")?; } - f.write_str( + write!( + f, r#"monospace; - font-size: 1px; - } + font-size: {FONT_SIZE_PX}px; + }} "#, @@ -299,6 +408,7 @@ impl Screen { self.screen.columns().saturating_sub(1), self.screen.lines().saturating_sub(1), main_bg, + &font_metrics, )?; // find background rectangles to draw by greedily flooding lines then flooding down columns @@ -356,7 +466,7 @@ impl Screen { } } - fmt_rect(f, x0, y0, end_x, end_y, bg)?; + fmt_rect(f, x0, y0, end_x, end_y, bg, &font_metrics)?; } } @@ -376,7 +486,7 @@ impl Screen { if style_ != style { if !text_line.is_empty() { - fmt_text(f, start_x, y, &text_line, &style)?; + fmt_text(f, start_x, y, &text_line, &style, &font_metrics)?; } text_line.clear(); style = style_; @@ -393,7 +503,7 @@ impl Screen { } if !text_line.is_empty() { - fmt_text(f, start_x, y, &text_line, &style)?; + fmt_text(f, start_x, y, &text_line, &style, &font_metrics)?; text_line.clear(); } } @@ -410,6 +520,7 @@ impl Screen { Svg { screen: self, fonts, + font_metrics: font_metrics.at_font_size(FONT_SIZE_PX), } }