diff --git a/README.md b/README.md index 79c78a3..601d0ed 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,15 @@ UPDATE_EXPECTED=1 cargo test ## Examples -### tosvg +### tohtml -`tosvg` is a command-line tool that converts all glyphs in a font file to -individual SVG files. +`tohtml` is a command-line tool that converts all glyphs in a font file to +an HTML file of SVG. ```shell # Convert all glyphs in a font to SVGs in /tmp/output -cargo run --example tosvg -- --font path/to/font.ttf --output-dir /tmp/output +cargo run --example tosvg -- --font path/to/font.ttf --output-path /tmp/output.html # Convert with specific variational coordinates (e.g., for a variable font) -cargo run --example tosvg -- --font path/to/font.ttf --coords 0.5,1.0 +cargo run --example tosvg -- --font path/to/font.ttf --output-path /tmp/output.html --coords 0.5,1.0 ``` diff --git a/examples/tohtml.css b/examples/tohtml.css new file mode 100644 index 0000000..c564447 --- /dev/null +++ b/examples/tohtml.css @@ -0,0 +1,64 @@ +body { + font-family: sans-serif; + margin: 20px; + background-color: #f5f5f5; + color: #333; +} +h1 { + color: #111; +} +.glyph-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 15px; + margin-top: 20px; +} +.glyph-card { + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 10px; + text-align: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + height: 140px; +} +.glyph-svg { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 80px; +} +.glyph-svg svg { + max-width: 100%; + max-height: 100%; +} +.glyph-name { + font-size: 12px; + font-family: monospace; + color: #666; + word-break: break-all; + margin-top: 5px; +} +.errors-section { + margin-top: 40px; + border-top: 2px solid #ffcccc; + padding-top: 20px; +} +.errors-title { + color: #cc0000; +} +.error-log { + background: #fff5f5; + border: 1px solid #ffcccc; + padding: 15px; + border-radius: 5px; + font-family: monospace; + white-space: pre-wrap; + color: #990000; +} diff --git a/examples/tohtml.rs b/examples/tohtml.rs new file mode 100644 index 0000000..5fe0312 --- /dev/null +++ b/examples/tohtml.rs @@ -0,0 +1,205 @@ +//! A command-line tool that converts all glyphs in a font file to individual SVG files. +use anyhow::{Context, Result}; +use clap::Parser; +use rayon::prelude::*; +use skrifa::prelude::NormalizedCoord; +use skrifa::{instance::LocationRef, FontRef, MetadataProvider}; +use sleipnir::{ + draw_icon::{DrawIcon, DrawOptions, DrawType}, + iconid::IconIdentifier, + pathstyle::SvgPathStyle, +}; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Parser)] +struct Args { + /// Path to the ttf/otf font file + #[arg(short, long)] + font: PathBuf, + + /// Path to output html file. + #[arg(short, long, default_value = "/tmp/sleipnir.html")] + output_path: PathBuf, + + /// Icon size in pixels + #[arg(short, long, default_value_t = 64.0)] + size: f32, + + /// Variational design space coordinates + #[arg(short, long, value_parser = parse_coords)] + coords: Option>, +} + +struct Item { + glyph_name: String, + svg: String, +} + +fn main() -> Result<()> { + let args = Args::parse(); + println!("Font file: {:?}", args.font); + + let data = fs::read(&args.font) + .with_context(|| format!("Failed to read font file: {:?}", args.font))?; + + let font = FontRef::new(&data) + .with_context(|| format!("Failed to parse font file: {:?}", args.font))?; + + let font_name = font + .localized_strings(skrifa::string::StringId::FAMILY_NAME) + .english_or_first() + .map(|s| s.to_string()) + .unwrap_or_else(|| { + args.font + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Unknown Font".to_string()) + }); + + let glyph_names: Vec<_> = font.glyph_names().iter().collect(); + if glyph_names.is_empty() { + panic!("No glyphs found in font."); + } + + let coords = args.normalized_coords(); + let total_glyphs = glyph_names.len(); + let processed_count = AtomicUsize::new(0); + let results: Vec<_> = glyph_names + .into_par_iter() + .map(|(gid, name)| -> Result { + let options = DrawOptions::new( + IconIdentifier::GlyphId(gid), + args.size, + LocationRef::new(&coords), + SvgPathStyle::Compact(2), + DrawType::Svg, + ); + let svg = font + .draw_icon(&options) + .map_err(|e| anyhow::anyhow!("Failed to draw icon for glyph {}: {:?}", name, e))?; + Ok(Item { + glyph_name: name.to_string(), + svg, + }) + }) + .inspect(|item_or_err| { + let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1; + if current.is_multiple_of(1000) || current == total_glyphs { + println!("Processed {}/{} glyphs...", current, total_glyphs); + } + if let Err(err) = item_or_err { + eprintln!("{:?}", err); + } + }) + .collect(); + + let (mut items, mut errors) = (Vec::new(), Vec::new()); + for res in results { + match res { + Ok(item) => items.push(item), + Err(err) => errors.push(err), + } + } + + println!( + "Processed {} glyphs successfully with {} errors", + items.len(), + errors.len() + ); + + // Generate HTML + write_html_output(&args.output_path, &font_name, &items, &errors)?; + + Ok(()) +} + +const HTML_STYLE: &str = include_str!("tohtml.css"); + +fn write_html_output( + output_path: &std::path::Path, + font_name: &str, + items: &[Item], + errors: &[anyhow::Error], +) -> Result<()> { + let mut html = String::new(); + html.push_str("\n\n\n"); + html.push_str(" \n"); + html.push_str(&format!( + " Font: {}\n", + html_escape(font_name) + )); + html.push_str(" \n"); + html.push_str("\n\n"); + html.push_str(&format!("

Font: {}

\n", html_escape(font_name))); + html.push_str(&format!( + "

Total glyphs: {} (Success: {}, Errors: {})

\n", + items.len() + errors.len(), + items.len(), + errors.len() + )); + + html.push_str("
\n"); + for item in items { + html.push_str(&render_glyph_card(item)); + } + html.push_str("
\n"); + + if !errors.is_empty() { + html.push_str("
\n"); + html.push_str(&format!( + "

Errors ({})

\n", + errors.len() + )); + html.push_str("
\n");
+        for err in errors {
+            html.push_str(&format!("{:?}\n", err));
+        }
+        html.push_str("    
\n
\n"); + } + html.push_str("\n\n"); + + fs::write(output_path, html) + .with_context(|| format!("Failed to write HTML to {:?}", output_path))?; + + println!("Wrote HTML output to {:?}", output_path); + + Ok(()) +} + +impl Args { + fn normalized_coords(&self) -> Vec { + match self.coords.as_ref() { + Some(c) => c.iter().copied().map(NormalizedCoord::from_f32).collect(), + None => Vec::new(), + } + } +} + +fn parse_coords(s: &str) -> Result, String> { + s.split(',') + .map(|p| { + p.parse::() + .map_err(|e| format!("Failed to parse coordinate value '{}': {}", p, e)) + }) + .collect() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn render_glyph_card(item: &Item) -> String { + format!( + "
\n
\n{}\n
\n
{}
\n
\n", + item.svg, + html_escape(&item.glyph_name) + ) +} diff --git a/examples/tosvg.rs b/examples/tosvg.rs deleted file mode 100644 index ae69f9c..0000000 --- a/examples/tosvg.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! A command-line tool that converts all glyphs in a font file to individual SVG files. -use anyhow::{Context, Result}; -use clap::Parser; -use rayon::prelude::*; -use skrifa::prelude::NormalizedCoord; -use skrifa::{instance::LocationRef, FontRef, MetadataProvider}; -use sleipnir::{ - draw_icon::{DrawIcon, DrawOptions, DrawType}, - iconid::IconIdentifier, - pathstyle::SvgPathStyle, -}; -use std::fs; -use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; - -#[derive(Parser)] -struct Args { - /// Path to the ttf/otf font file - #[arg(short, long)] - font: PathBuf, - - /// Directory to output the SVG files - #[arg(short, long, default_value = "/tmp/sleipnir-svg")] - output_dir: PathBuf, - - /// Icon size in pixels - #[arg(short, long, default_value_t = 64.0)] - size: f32, - - /// Variational design space coordinates - #[arg(short, long, value_parser = parse_coords)] - coords: Option>, -} - -fn main() -> Result<()> { - let args = Args::parse(); - println!("Font file: {:?}", args.font); - - let data = fs::read(&args.font) - .with_context(|| format!("Failed to read font file: {:?}", args.font))?; - - let font = FontRef::new(&data) - .with_context(|| format!("Failed to parse font file: {:?}", args.font))?; - - let glyph_names: Vec<_> = font.glyph_names().iter().collect(); - if glyph_names.is_empty() { - println!("No glyphs found in font."); - return Ok(()); - } - - let coords = args.normalized_coords(); - let total_glyphs = glyph_names.len(); - let processed_count = AtomicUsize::new(0); - fs::create_dir_all(&args.output_dir) - .with_context(|| format!("Failed to create output directory: {:?}", args.output_dir))?; - let errors: Vec<_> = glyph_names - .into_par_iter() - .map(|(gid, name)| -> Result<()> { - let options = DrawOptions::new( - IconIdentifier::GlyphId(gid), - args.size, - LocationRef::new(&coords), - SvgPathStyle::Compact(2), - DrawType::Svg, - ); - let svg = font - .draw_icon(&options) - .map_err(|e| anyhow::anyhow!("Failed to draw icon for glyph {}: {:?}", name, e))?; - let output_path = args.output_dir.join(format!("{}.svg", name)); - fs::write(&output_path, svg) - .with_context(|| format!("Failed to write SVG to {:?}", output_path))?; - - Ok(()) - }) - .inspect(|_| { - let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1; - if current.is_multiple_of(1000) || current == total_glyphs { - println!("Processed {}/{} glyphs...", current, total_glyphs); - } - }) - .filter_map(Result::err) - .collect(); - - for err in errors.iter() { - eprintln!("{:?}", err); - } - println!( - "Wrote {} glyphs to {:?}", - total_glyphs - errors.len(), - args.output_dir - ); - if !errors.is_empty() { - anyhow::bail!("Failed to process {} glyphs", errors.len()); - } - - Ok(()) -} - -impl Args { - fn normalized_coords(&self) -> Vec { - match self.coords.as_ref() { - Some(c) => c.iter().copied().map(NormalizedCoord::from_f32).collect(), - None => Vec::new(), - } - } -} - -fn parse_coords(s: &str) -> Result, String> { - s.split(',') - .map(|p| { - p.parse::() - .map_err(|e| format!("Failed to parse coordinate value '{}': {}", p, e)) - }) - .collect() -}