Annotate fonts with ruby (pinyin/romaji) and produce modified TTF/WOFF2 outputs.

feat: generic ruby renderer

+216 -140
+5
.gitignore
··· 1 1 /target 2 + 3 + *.ttf 4 + *.ttc 5 + *.otf 6 + *.woff2
+19 -19
Cargo.lock
··· 393 393 checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b" 394 394 395 395 [[package]] 396 - name = "pinyinify" 397 - version = "0.1.0" 398 - dependencies = [ 399 - "anyhow", 400 - "clap", 401 - "font-types", 402 - "fontcull", 403 - "fontcull-font-types", 404 - "fontcull-klippa", 405 - "fontcull-read-fonts", 406 - "kurbo", 407 - "pinyin", 408 - "read-fonts", 409 - "skrifa", 410 - "woofwoof", 411 - "write-fonts", 412 - ] 413 - 414 - [[package]] 415 396 name = "proc-macro2" 416 397 version = "1.0.104" 417 398 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 467 448 version = "0.8.8" 468 449 source = "registry+https://github.com/rust-lang/crates.io-index" 469 450 checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 451 + 452 + [[package]] 453 + name = "rubify" 454 + version = "0.1.0" 455 + dependencies = [ 456 + "anyhow", 457 + "clap", 458 + "font-types", 459 + "fontcull", 460 + "fontcull-font-types", 461 + "fontcull-klippa", 462 + "fontcull-read-fonts", 463 + "kurbo", 464 + "pinyin", 465 + "read-fonts", 466 + "skrifa", 467 + "woofwoof", 468 + "write-fonts", 469 + ] 470 470 471 471 [[package]] 472 472 name = "shlex"
+14 -10
Cargo.toml
··· 1 1 [package] 2 2 edition = "2024" 3 - name = "pinyinify" 3 + name = "rubify" 4 4 version = "0.1.0" 5 + 6 + [features] 7 + default = ["pinyin"] 8 + pinyin = ["dep:pinyin"] 5 9 6 10 [dependencies] 7 11 anyhow = "1.0" 8 12 clap = { version = "4.5", features = ["derive"] } 9 - font-types = "*" 10 - fontcull = "2.0.0" 11 - fontcull-font-types = "0.10.2" 12 - fontcull-klippa = "0.1.2" 13 - fontcull-read-fonts = "0.38.0" 13 + font-types = "0.10" 14 + fontcull = "2.0" 15 + fontcull-font-types = "0.10" 16 + fontcull-klippa = "0.1" 17 + fontcull-read-fonts = "0.38" 14 18 kurbo = "0.12" 15 - pinyin = "0.11" 19 + pinyin = { version = "0.11", optional = true } 16 20 read-fonts = { version = "*", features = ["experimental_traverse"] } 17 - skrifa = "*" 18 - woofwoof = "1.0.2" 19 - write-fonts = "*" 21 + skrifa = "0.39" 22 + woofwoof = "1.0" 23 + write-fonts = "0.44"
+30 -101
src/lib.rs
··· 5 5 // Imports for subsetting and woff2 6 6 use fontcull_read_fonts as frf; 7 7 use fontcull_read_fonts::collections::int_set::IntSet; 8 - use kurbo::{Affine, BezPath, Shape}; 9 - use pinyin::ToPinyin; 8 + use kurbo::BezPath; 10 9 use read_fonts::{ 11 10 FileRef, FontRef, TableProvider, 12 11 types::{GlyphId, Tag}, 13 12 }; 13 + 14 + #[cfg(feature = "pinyin")] 15 + mod ruby; 14 16 use skrifa::{MetadataProvider, outline::OutlinePen}; 15 17 use woofwoof; 16 18 use write_fonts::{ ··· 32 34 const TAG_LOCA: Tag = Tag::new(b"loca"); 33 35 const TAG_HEAD: Tag = Tag::new(b"head"); 34 36 35 - struct PathPen { 36 - path: BezPath, 37 + pub struct PathPen { 38 + pub path: BezPath, 37 39 } 38 40 39 41 impl PathPen { 40 - fn new() -> Self { 42 + pub fn new() -> Self { 41 43 Self { 42 44 path: BezPath::new(), 43 45 } ··· 105 107 process_single_font(font_ref, pinyin_font_ref) 106 108 } 107 109 108 - fn process_single_font(font: FontRef, pinyin_font: Option<FontRef>) -> Result<Vec<u8>> { 110 + fn process_single_font(font: FontRef, _pinyin_font: Option<FontRef>) -> Result<Vec<u8>> { 109 111 let font_file_data = font.table_directory.offset_data(); 110 112 let charmap = font.charmap(); 111 113 let hmtx = font.hmtx().context("Missing hmtx")?; ··· 114 116 let num_glyphs = maxp.num_glyphs(); 115 117 let upem = font.head()?.units_per_em() as f64; 116 118 117 - // Providers for pinyin (default to main font) 118 - let p_font_ref = pinyin_font.as_ref().unwrap_or(&font); 119 - let p_charmap = p_font_ref.charmap(); 120 - let p_outlines = p_font_ref.outline_glyphs(); 121 - let p_hmtx = p_font_ref.hmtx().context("Missing pinyin font hmtx")?; 122 - let p_upem = p_font_ref.head()?.units_per_em() as f64; 123 - 124 - // Normalizing scale factor: 125 - // We want pinyin to be 30% of main font's EM height. 126 - // Scale = (0.3 * MainUPEM) / PinyinUPEM 127 - let p_scale_factor = (0.3 * upem) / p_upem; 119 + #[cfg(feature = "pinyin")] 120 + let ruby_renderer: Option<Box<dyn crate::ruby::RubyRenderer>> = { 121 + if let Some(pf) = _pinyin_font.as_ref() { 122 + match crate::ruby::pinyin::PinyinRenderer::new(pf.clone(), 0.3, upem) { 123 + Ok(r) => Some(Box::new(r)), 124 + Err(err) => { 125 + eprintln!("Warning: failed to initialize pinyin renderer: {:?}", err); 126 + None 127 + } 128 + } 129 + } else { 130 + None 131 + } 132 + }; 128 133 129 134 let mut gid_to_char: HashMap<GlyphId, char> = HashMap::new(); 130 135 ··· 160 165 } 161 166 162 167 if let Some(&ch) = gid_to_char.get(&gid) { 163 - if let Some(p) = ch.to_pinyin() { 164 - let pinyin_text = p.with_tone(); 165 - let mut pinyin_paths: Vec<(skrifa::GlyphId, BezPath)> = Vec::new(); 166 - let mut all_found = true; 167 - 168 - for pc in pinyin_text.chars() { 169 - match p_charmap.map(pc) { 170 - Some(pgid) if pgid.to_u32() != 0 => { 171 - if let Some(pglyph) = p_outlines.get(pgid) { 172 - let mut ppen = PathPen::new(); 173 - 174 - if pglyph 175 - .draw(skrifa::instance::Size::unscaled(), &mut ppen) 176 - .is_ok() 177 - { 178 - pinyin_paths.push((pgid, ppen.path)); 179 - } else { 180 - all_found = false; 181 - 182 - break; 183 - } 184 - } else { 185 - all_found = false; 186 - 187 - break; 188 - } 189 - } 190 - _ => { 191 - all_found = false; 192 - 193 - break; 194 - } 195 - } 196 - } 197 - 198 - if all_found && !pinyin_paths.is_empty() { 199 - let orig_advance = hmtx 200 - .h_metrics() 201 - .get(gid.to_u32() as usize) 202 - .map(|m| m.advance.get()) 203 - .unwrap_or(upem as u16) as f64; 204 - 205 - let mut total_pinyin_width = 0.0; 206 - 207 - for (pgid, _) in &pinyin_paths { 208 - let adv = p_hmtx 209 - .h_metrics() 210 - .get(pgid.to_u32() as usize) 211 - .map(|m| m.advance.get()) 212 - .unwrap_or(p_upem as u16) as f64; 213 - 214 - total_pinyin_width += adv * p_scale_factor; 215 - } 216 - 217 - let bbox = final_path.bounding_box(); 218 - let target_y = bbox.y1 + (upem * 0.1); // 10% EM padding 219 - let mut current_x = (orig_advance - total_pinyin_width) / 2.0; 220 - 221 - for (pgid, mut p_path) in pinyin_paths { 222 - let adv = p_hmtx 223 - .h_metrics() 224 - .get(pgid.to_u32() as usize) 225 - .map(|m| m.advance.get()) 226 - .unwrap_or(p_upem as u16) as f64; 227 - let xform = Affine::translate((current_x, target_y)) 228 - * Affine::scale(p_scale_factor); 229 - 230 - p_path.apply_affine(xform); 231 - 232 - for el in p_path.elements() { 233 - match el { 234 - kurbo::PathEl::MoveTo(p) => final_path.move_to(*p), 235 - kurbo::PathEl::LineTo(p) => final_path.line_to(*p), 236 - kurbo::PathEl::QuadTo(p1, p2) => final_path.quad_to(*p1, *p2), 237 - kurbo::PathEl::CurveTo(p1, p2, p3) => { 238 - final_path.curve_to(*p1, *p2, *p3) 239 - } 240 - kurbo::PathEl::ClosePath => final_path.close_path(), 241 - } 242 - } 243 - 244 - current_x += adv * p_scale_factor; 245 - } 246 - } 168 + #[cfg(feature = "pinyin")] 169 + if let Some(renderer) = &ruby_renderer { 170 + let orig_advance = hmtx 171 + .h_metrics() 172 + .get(gid.to_u32() as usize) 173 + .map(|m| m.advance.get()) 174 + .unwrap_or(upem as u16) as f64; 175 + let _ = renderer.annotate(ch, &mut final_path, orig_advance, upem); 247 176 } 248 177 } 249 178
+7 -10
src/main.rs
··· 12 12 /// Output font file path 13 13 output: PathBuf, 14 14 15 - /// Optional font file to use for pinyin characters 15 + /// Optional font file to use for ruby characters 16 16 #[arg(long)] 17 - pinyin_font: Option<PathBuf>, 17 + ruby_font: Option<PathBuf>, 18 18 19 19 /// Subset the font to include only CJK and Pinyin characters 20 20 #[arg(long)] ··· 27 27 let font_data = fs::read(&cli.input) 28 28 .with_context(|| format!("Failed to read input file: {:?}", cli.input))?; 29 29 30 - let pinyin_font_data = if let Some(path) = &cli.pinyin_font { 31 - Some( 32 - fs::read(path) 33 - .with_context(|| format!("Failed to read pinyin font file: {:?}", path))?, 34 - ) 30 + let ruby_font_data = if let Some(path) = &cli.ruby_font { 31 + Some(fs::read(path).with_context(|| format!("Failed to read ruby font file: {:?}", path))?) 35 32 } else { 36 33 None 37 34 }; 38 35 39 36 println!("Processing font..."); 40 - let mut new_font_data = pinyinify::process_font_file(&font_data, pinyin_font_data.as_deref())?; 37 + let mut new_font_data = rubify::process_font_file(&font_data, ruby_font_data.as_deref())?; 41 38 42 39 if cli.subset { 43 40 println!("Subsetting font..."); 44 - new_font_data = pinyinify::subset_cjk(&new_font_data)?; 41 + new_font_data = rubify::subset_cjk(&new_font_data)?; 45 42 } 46 43 47 44 // Infer format from output extension ··· 53 50 54 51 if let Some("woff2") = extension.as_deref() { 55 52 println!("Converting to WOFF2..."); 56 - new_font_data = pinyinify::convert_to_woff2(&new_font_data)?; 53 + new_font_data = rubify::convert_to_woff2(&new_font_data)?; 57 54 } 58 55 59 56 fs::write(&cli.output, new_font_data)
+19
src/ruby/mod.rs
··· 1 + #[cfg(feature = "pinyin")] 2 + pub mod pinyin; 3 + 4 + use anyhow::Result; 5 + use kurbo::BezPath; 6 + 7 + /// A pluggable renderer that can add "ruby" annotations (small text above characters). 8 + /// Implementations (such as pinyin) will be provided behind features. 9 + pub trait RubyRenderer: Send + Sync { 10 + /// Given a base character `ch`, add annotation paths (if any) into `final_path`. 11 + /// `orig_advance` is the glyph advance in font units; `main_upem` is the main font UPEM. 12 + fn annotate( 13 + &self, 14 + ch: char, 15 + final_path: &mut BezPath, 16 + orig_advance: f64, 17 + main_upem: f64, 18 + ) -> Result<()>; 19 + }
+122
src/ruby/pinyin.rs
··· 1 + use ::pinyin::ToPinyin; 2 + use anyhow::{Context, Result}; 3 + use kurbo::{Affine, BezPath, Shape}; 4 + use read_fonts::{FontRef, TableProvider}; 5 + use skrifa::MetadataProvider; 6 + 7 + use crate::{PathPen, ruby::RubyRenderer}; 8 + 9 + pub struct PinyinRenderer<'a> { 10 + font: FontRef<'a>, 11 + upem: f64, 12 + scale_ratio: f64, 13 + } 14 + 15 + impl<'a> PinyinRenderer<'a> { 16 + pub fn new(font: FontRef<'a>, scale_ratio: f64, _main_upem: f64) -> Result<Self> { 17 + let upem = font.head()?.units_per_em() as f64; 18 + 19 + Ok(Self { 20 + font, 21 + upem, 22 + scale_ratio, 23 + }) 24 + } 25 + } 26 + 27 + impl<'a> RubyRenderer for PinyinRenderer<'a> { 28 + fn annotate( 29 + &self, 30 + ch: char, 31 + final_path: &mut BezPath, 32 + orig_advance: f64, 33 + main_upem: f64, 34 + ) -> Result<()> { 35 + if let Some(p) = ch.to_pinyin() { 36 + let pinyin_text = p.with_tone(); 37 + let mut pinyin_paths: Vec<(skrifa::GlyphId, BezPath)> = Vec::new(); 38 + let mut all_found = true; 39 + 40 + let cmap = self.font.charmap(); 41 + let outlines = self.font.outline_glyphs(); 42 + let hmtx = self.font.hmtx().context("Missing pinyin font hmtx")?; 43 + 44 + for pc in pinyin_text.chars() { 45 + match cmap.map(pc) { 46 + Some(pgid) if pgid.to_u32() != 0 => { 47 + if let Some(pglyph) = outlines.get(pgid) { 48 + let mut ppen = PathPen::new(); 49 + 50 + if pglyph 51 + .draw(skrifa::instance::Size::unscaled(), &mut ppen) 52 + .is_ok() 53 + { 54 + pinyin_paths.push((pgid, ppen.path)); 55 + } else { 56 + all_found = false; 57 + break; 58 + } 59 + } else { 60 + all_found = false; 61 + break; 62 + } 63 + } 64 + _ => { 65 + all_found = false; 66 + break; 67 + } 68 + } 69 + } 70 + 71 + if all_found && !pinyin_paths.is_empty() { 72 + // scale factor relative to the pinyin font's UPEM 73 + let p_scale_factor = (self.scale_ratio * main_upem) / self.upem; 74 + 75 + let mut total_pinyin_width = 0.0; 76 + 77 + for (pgid, _) in &pinyin_paths { 78 + let adv = hmtx 79 + .h_metrics() 80 + .get(pgid.to_u32() as usize) 81 + .map(|m| m.advance.get()) 82 + .unwrap_or(self.upem as u16) as f64; 83 + 84 + total_pinyin_width += adv * p_scale_factor; 85 + } 86 + 87 + let bbox = final_path.bounding_box(); 88 + let target_y = bbox.y1 + (main_upem * 0.1); // 10% EM padding 89 + let mut current_x = (orig_advance - total_pinyin_width) / 2.0; 90 + 91 + for (pgid, mut p_path) in pinyin_paths { 92 + let adv = hmtx 93 + .h_metrics() 94 + .get(pgid.to_u32() as usize) 95 + .map(|m| m.advance.get()) 96 + .unwrap_or(self.upem as u16) as f64; 97 + 98 + let xform = 99 + Affine::translate((current_x, target_y)) * Affine::scale(p_scale_factor); 100 + 101 + p_path.apply_affine(xform); 102 + 103 + for el in p_path.elements() { 104 + match el { 105 + kurbo::PathEl::MoveTo(p) => final_path.move_to(*p), 106 + kurbo::PathEl::LineTo(p) => final_path.line_to(*p), 107 + kurbo::PathEl::QuadTo(p1, p2) => final_path.quad_to(*p1, *p2), 108 + kurbo::PathEl::CurveTo(p1, p2, p3) => { 109 + final_path.curve_to(*p1, *p2, *p3) 110 + } 111 + kurbo::PathEl::ClosePath => final_path.close_path(), 112 + } 113 + } 114 + 115 + current_x += adv * p_scale_factor; 116 + } 117 + } 118 + } 119 + 120 + Ok(()) 121 + } 122 + }