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.
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#"