diff --git a/Cargo.lock b/Cargo.lock index 299d0ec..bd869d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,6 +793,7 @@ dependencies = [ "regex", "serde", "serde_json", + "ttf-parser", ] [[package]] @@ -911,6 +912,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index b15c516..93e875a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,5 @@ regex = "1.12.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" crossterm = "0.28" +ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } dialoguer = { version = "0.11", default-features = false } diff --git a/README.md b/README.md index e57ed8d..cf45085 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Colors accept ANSI-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). ### Screenshots -Screenshots render a snapshot of the session in the current terminal by default, but can render an SVG using the `-o` output flag +Screenshots render a snapshot of the session in the current terminal by default, but can render an SVG using the `-o` output flag. Nerd Font icons are embedded as vector paths, so SVGs remain self-contained without changing the font stack for regular text.

full-color SVG screenshot of a TUI rendered by shell-use diff --git a/assets/nerd-fonts/LICENSE b/assets/nerd-fonts/LICENSE new file mode 100644 index 0000000..06eb073 --- /dev/null +++ b/assets/nerd-fonts/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf b/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf new file mode 100644 index 0000000..d898eea Binary files /dev/null and b/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf differ diff --git a/src/render/mod.rs b/src/render/mod.rs index 429f996..15e83ec 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -1 +1,2 @@ +mod nerd_font; pub mod svg; diff --git a/src/render/nerd_font.rs b/src/render/nerd_font.rs new file mode 100644 index 0000000..0b035a0 --- /dev/null +++ b/src/render/nerd_font.rs @@ -0,0 +1,278 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; + +use ttf_parser::{Face, OutlineBuilder}; + +use crate::terminal::emu::EmuCell; + +// Nerd Fonts Symbols v3.4.0 is MIT-licensed; see the adjacent LICENSE. +const FONT_DATA: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf" +)); + +struct Glyph { + path: String, + advance: u16, + bounds: ttf_parser::Rect, +} + +#[derive(Default)] +struct SvgPathBuilder { + path: String, +} + +impl OutlineBuilder for SvgPathBuilder { + fn move_to(&mut self, x: f32, y: f32) { + let _ = write!(self.path, "M{x:.1},{y:.1}"); + } + + fn line_to(&mut self, x: f32, y: f32) { + let _ = write!(self.path, "L{x:.1},{y:.1}"); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let _ = write!(self.path, "Q{x1:.1},{y1:.1},{x:.1},{y:.1}"); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let _ = write!(self.path, "C{x1:.1},{y1:.1},{x2:.1},{y2:.1},{x:.1},{y:.1}"); + } + + fn close(&mut self) { + self.path.push('Z'); + } +} + +struct GlyphTransform { + x: f32, + y: f32, + scale_x: f32, + scale_y: f32, +} + +pub(super) struct NerdFont { + glyphs: BTreeMap, + scale: f32, +} + +impl NerdFont { + pub fn new(rows: &[Vec], font_size: f32) -> Self { + let chars: BTreeSet = rows + .iter() + .flatten() + .flat_map(|cell| cell.ch.chars()) + .filter(|c| is_private_use(*c)) + .collect(); + if chars.is_empty() { + return Self { + glyphs: BTreeMap::new(), + scale: 1.0, + }; + } + + let face = Face::parse(FONT_DATA, 0).expect("bundled Nerd Font must be valid"); + let mut glyphs = BTreeMap::new(); + for c in chars { + let Some(glyph_id) = face.glyph_index(c) else { + continue; + }; + let mut builder = SvgPathBuilder::default(); + let Some(bounds) = face.outline_glyph(glyph_id, &mut builder) else { + continue; + }; + if builder.path.is_empty() { + continue; + } + glyphs.insert( + c, + Glyph { + path: builder.path, + advance: face + .glyph_hor_advance(glyph_id) + .unwrap_or(face.units_per_em()), + bounds, + }, + ); + } + + Self { + glyphs, + scale: font_size / face.units_per_em() as f32, + } + } + + pub fn write_defs(&self, out: &mut String) { + if self.glyphs.is_empty() { + return; + } + out.push_str(""); + for (c, glyph) in &self.glyphs { + let codepoint = *c as u32; + let _ = write!(out, r#""#, glyph.path); + } + out.push_str(""); + } + + pub fn prepare_run(&self, text: &str, width: f32, cell_width: f32) -> (String, f32) { + let mut masked = String::with_capacity(text.len()); + let mut extra_width = 0.0; + for c in text.chars() { + if let Some(glyph) = self.glyphs.get(&c) { + masked.push(' '); + if !is_powerline_separator(c) { + extra_width += glyph.advance as f32 * self.scale - cell_width; + } + } else { + masked.push(c); + } + } + let natural_width = width + extra_width; + let x_adjust = if natural_width > 0.0 { + width / natural_width + } else { + 1.0 + }; + (masked, x_adjust) + } + + pub fn write_use( + &self, + out: &mut String, + c: char, + origin: (f32, f32), + size: (f32, f32), + run_x_adjust: f32, + fill: &str, + ) { + let Some(transform) = self.transform(c, origin, size, run_x_adjust) else { + return; + }; + let codepoint = c as u32; + let _ = write!( + out, + r##""##, + x = transform.x, + y = transform.y, + scale_x = transform.scale_x, + scale_y = transform.scale_y, + ); + } + + fn transform( + &self, + c: char, + (cell_x, cell_y): (f32, f32), + (cell_width, cell_height): (f32, f32), + run_x_adjust: f32, + ) -> Option { + let glyph = self.glyphs.get(&c)?; + let bounds_width = (glyph.bounds.x_max - glyph.bounds.x_min) as f32; + let bounds_height = (glyph.bounds.y_max - glyph.bounds.y_min) as f32; + if is_powerline_separator(c) { + let scale_x = cell_width / bounds_width; + let scale_y = cell_height / bounds_height; + Some(GlyphTransform { + x: cell_x - glyph.bounds.x_min as f32 * scale_x, + y: cell_y + glyph.bounds.y_max as f32 * scale_y, + scale_x, + scale_y, + }) + } else { + let scale_x = self.scale * run_x_adjust; + let rendered_advance = glyph.advance as f32 * scale_x; + let rendered_height = bounds_height * self.scale; + Some(GlyphTransform { + x: cell_x + (cell_width - rendered_advance) / 2.0, + y: cell_y + + (cell_height - rendered_height) / 2.0 + + glyph.bounds.y_max as f32 * self.scale, + scale_x, + scale_y: self.scale, + }) + } + } +} + +fn is_private_use(c: char) -> bool { + matches!( + c as u32, + 0xe000..=0xf8ff | 0xf0000..=0xffffd | 0x100000..=0x10fffd + ) +} + +fn is_powerline_separator(c: char) -> bool { + matches!(c as u32, 0xe0b0..=0xe0d4) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::emu::Color; + + fn cell(ch: &str) -> EmuCell { + EmuCell { + ch: ch.to_string(), + fg: Color::Default, + bg: Color::Default, + bold: false, + dim: false, + italic: false, + underline: false, + inverse: false, + invisible: false, + strike: false, + } + } + + #[test] + fn powerline_glyphs_fill_the_terminal_cell() { + let rows = vec![vec![cell("\u{e0b0}")]]; + let font = NerdFont::new(&rows, 17.0); + let transform = font + .transform('\u{e0b0}', (15.0, 38.0), (10.0, 21.0), 1.0) + .unwrap(); + let glyph = &font.glyphs[&'\u{e0b0}']; + + let left = transform.x + glyph.bounds.x_min as f32 * transform.scale_x; + let right = transform.x + glyph.bounds.x_max as f32 * transform.scale_x; + let top = transform.y - glyph.bounds.y_max as f32 * transform.scale_y; + let bottom = transform.y - glyph.bounds.y_min as f32 * transform.scale_y; + assert!((left - 15.0).abs() < 0.001); + assert!((right - 25.0).abs() < 0.001); + assert!((top - 38.0).abs() < 0.001); + assert!((bottom - 59.0).abs() < 0.001); + } + + #[test] + fn regular_glyphs_are_vertically_centered() { + let rows = vec![vec![cell("\u{f115}")]]; + let font = NerdFont::new(&rows, 17.0); + let transform = font + .transform('\u{f115}', (15.0, 38.0), (10.0, 21.0), 1.0) + .unwrap(); + let glyph = &font.glyphs[&'\u{f115}']; + + let top = transform.y - glyph.bounds.y_max as f32 * transform.scale_y; + let bottom = transform.y - glyph.bounds.y_min as f32 * transform.scale_y; + assert!(((top - 38.0) - (59.0 - bottom)).abs() < 0.001); + } + + #[test] + fn powerline_glyphs_do_not_adjust_run_width() { + let rows = vec![vec![cell("\u{e0b0}")]]; + let font = NerdFont::new(&rows, 17.0); + let (_, x_adjust) = font.prepare_run("\u{e0b0}", 10.0, 10.0); + assert_eq!(x_adjust, 1.0); + } + + #[test] + fn bundled_font_includes_its_mit_license() { + let license = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/nerd-fonts/LICENSE" + )); + assert!(license.starts_with("The MIT License (MIT)")); + assert!(license.contains("Copyright (c) 2014 Ryan L McIntyre")); + } +} diff --git a/src/render/svg.rs b/src/render/svg.rs index 5342785..d14a070 100644 --- a/src/render/svg.rs +++ b/src/render/svg.rs @@ -1,19 +1,22 @@ //! Crisp, full-color SVG screenshot of a terminal grid, styled after //! `svg-term-cli`: a rounded window panel with macOS-style controls. //! -//! Vector output renders sharply at any zoom and needs no bundled font (the -//! viewer supplies a monospace face). Colors, bold/italic/underline/strike, -//! inverse, and dim are all preserved. Each run of same-styled cells is forced -//! to its exact column width via `textLength`, so alignment is independent of -//! the rendering font's metrics. +//! Vector output renders sharply at any zoom. The viewer supplies a monospace +//! face for text, while Nerd Font icons are emitted from a bundled symbols font +//! as SVG paths. Colors, bold/italic/underline/strike, inverse, and dim are all +//! preserved. Each run of same-styled cells is forced to its exact column width +//! via `textLength`, so alignment is independent of the rendering font's +//! metrics. use std::fmt::Write; +use super::nerd_font::NerdFont; use crate::terminal::emu::{Color, EmuCell}; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; const FONT_SIZE: f32 = 17.0; +const FONT_BASELINE: f32 = (CELL_H - FONT_SIZE) / 2.0 + FONT_SIZE * 0.78; const MARGIN_X: f32 = 15.0; const HEADER_H: f32 = 38.0; const MARGIN_BOTTOM: f32 = 14.0; @@ -150,9 +153,23 @@ fn escape(s: &str) -> String { out } +fn run_text(row: &[EmuCell], start: usize, end: usize) -> String { + let mut text = String::with_capacity(end - start); + for i in start..end { + let ch = &cell_at(row, i).ch; + if ch.is_empty() { + text.push(' '); + } else { + text.push_str(ch); + } + } + text +} + /// Render a grid to a standalone SVG document. pub fn render_svg(rows: &[Vec], cols: u16) -> String { let theme = Theme::default(); + let nerd_font = NerdFont::new(rows, FONT_SIZE); let cols = cols as usize; let x0 = MARGIN_X; let y0 = HEADER_H; @@ -164,6 +181,7 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { out, r#""# ); + nerd_font.write_defs(&mut out); let _ = write!( out, r#""#, @@ -201,7 +219,7 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { } for (y, row) in rows.iter().enumerate() { - let baseline = y0 + y as f32 * CELL_H + FONT_SIZE * 0.78; + let baseline = y0 + y as f32 * CELL_H + FONT_BASELINE; let mut x = 0; while x < cols { let style = style_of(cell_at(row, x), &theme); @@ -210,19 +228,13 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { run += 1; } if !style.invisible { - let text: String = (x..x + run) - .map(|i| { - let ch = &cell_at(row, i).ch; - if ch.is_empty() { - " ".to_string() - } else { - ch.clone() - } - }) - .collect(); - if !text.trim().is_empty() { - let tx = x0 + x as f32 * CELL_W; - let tl = run as f32 * CELL_W; + let fg = hex(style.fg); + let tx = x0 + x as f32 * CELL_W; + let tl = run as f32 * CELL_W; + let original_text = run_text(row, x, x + run); + let (text, run_x_adjust) = nerd_font.prepare_run(&original_text, tl, CELL_W); + // Preserve decoration for runs containing only vector glyphs. + if !original_text.trim().is_empty() { let weight = if style.bold { r#" font-weight="bold""# } else { @@ -242,10 +254,21 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { let _ = write!( out, r#"{esc}"#, - fg = hex(style.fg), esc = escape(&text) ); } + for i in x..x + run { + for c in cell_at(row, i).ch.chars() { + nerd_font.write_use( + &mut out, + c, + (x0 + i as f32 * CELL_W, y0 + y as f32 * CELL_H), + (CELL_W, CELL_H), + run_x_adjust, + &fg, + ); + } + } } x += run; } @@ -279,6 +302,8 @@ mod tests { assert!(svg.contains("textLength")); assert!(svg.contains(&hex((232, 131, 136)))); assert!(svg.contains(">hi")); + assert!(!svg.contains("")); + assert!(!svg.contains("a b")); + assert!(!svg.contains(glyph)); + assert!(svg.contains(&format!(r#"font-family="{FONT_STACK}" font-size="#))); + } + + #[test] + fn defines_repeated_nerd_font_glyph_once() { + let glyph = "\u{f115}"; + let svg = render_svg( + &[vec![ + cell(glyph, Color::Default, Color::Default), + cell(glyph, Color::Default, Color::Default), + ]], + 2, + ); + + assert_eq!(svg.matches(r#"")); + assert!(!svg.contains(r#"