Skip to content

Commit

Permalink
feat\!: render SVG based on font metrics
Browse files Browse the repository at this point in the history
  • Loading branch information
tomcur committed Jul 26, 2024
1 parent 0d24ace commit 3d1d52c
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 31 deletions.
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<metric>` 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
Expand Down
49 changes: 44 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +49,32 @@ fn with_raw<F: AsFd, R>(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.
///
Expand All @@ -57,7 +83,7 @@ fn with_raw<F: AsFd, R>(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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = &[
Expand All @@ -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(())
Expand Down
147 changes: 129 additions & 18 deletions termsnap-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -162,14 +261,15 @@ fn fmt_rect(
x1: u16,
y1: u16,
color: Rgb,
font_metrics: &CalculatedFontMetrics,
) -> std::fmt::Result {
writeln!(
f,
r#"<rect x="{x}" y="{y}" width="{width}" height="{height}" style="fill: {color};" />"#,
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,
)
}
Expand All @@ -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#"<text x="{x}" y="{y}" textLength="{text_length}" style="fill: {color};"#,
x = f32::from(x) * FONT_ASPECT_RATIO,
y = f32::from(y) + FONT_ASCENT,
x = f32::from(x) * font_metrics.advance,
y = f32::from(y + 1) * font_metrics.line_height - font_metrics.descent,
color = style.fg,
)?;

Expand Down Expand Up @@ -245,17 +346,24 @@ impl Screen {
///
/// The SVG is generated once [std::fmt::Display::fmt] is called; cache the call's output if
/// you want to use it multiple times.
pub fn to_svg<'s, 'f>(&'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,
Expand All @@ -265,8 +373,8 @@ impl Screen {
write!(
f,
r#"<svg viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg">"#,
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(
Expand All @@ -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;
}}
</style>
<g class="screen">
"#,
Expand All @@ -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
Expand Down Expand Up @@ -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)?;
}
}

Expand All @@ -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_;
Expand All @@ -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();
}
}
Expand All @@ -410,6 +520,7 @@ impl Screen {
Svg {
screen: self,
fonts,
font_metrics: font_metrics.at_font_size(FONT_SIZE_PX),
}
}

Expand Down

0 comments on commit 3d1d52c

Please sign in to comment.