Annotate fonts with ruby (pinyin/romaji) and produce modified TTF/WOFF2 outputs.
at main 266 lines 8.2 kB view raw
1use std::{fs, path::PathBuf, str::FromStr}; 2 3use anyhow::{Context, Error, Result, anyhow}; 4use facet::Facet; 5use figue::{self as args, FigueBuiltins}; 6use fontcull_read_fonts::FileRef; 7use glob::glob; 8use indicatif::ProgressStyle; 9use rubify::renderer::{self, RubyPosition, RubyRenderer}; 10use rustc_hash::FxHashSet; 11use tracing::{info, info_span}; 12use tracing_indicatif::{IndicatifLayer, span_ext::IndicatifSpanExt}; 13use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 14 15#[derive(Facet)] 16struct Cli { 17 /// Input paths or glob patterns (can be repeated), e.g. 'fonts/*.ttf' or 'file.ttf' 18 #[facet(args::positional)] 19 inputs: Vec<String>, 20 21 /// Output directory 22 #[facet(args::named, args::short = 'o')] 23 out: PathBuf, 24 25 /// Ruby characters. Can be repeated to enable multiple sets. 26 #[facet(args::named)] 27 ruby: String, 28 29 /// Separate font file to use for ruby characters 30 #[facet(args::named)] 31 font: Option<PathBuf>, 32 33 /// Subset the font to include only annotated characters. 34 #[facet(args::named, default = false)] 35 subset: bool, 36 37 /// Split font collection (TTC) into separate TTF files instead of rebuilding as TTC. 38 #[facet(args::named, default = false)] 39 split: bool, 40 41 /// Convert all outputs to WOFF2 42 #[cfg(feature = "woff2")] 43 #[facet(args::named, default = false)] 44 woff2: bool, 45 46 /// Where to place ruby characters relative to base glyph. 47 #[facet(args::named, default = "top")] 48 position: String, 49 50 /// Scale ratio for ruby characters (fraction of main font size). 51 #[facet(args::named, default = 0.4)] 52 scale: f64, 53 54 /// Gutter (in em) between base glyph and ruby characters. 55 #[facet(args::named, default = 0.0)] 56 gutter: f64, 57 58 /// When set, use tight per-character placement. By default we use a consistent baseline. 59 #[facet(args::named, default = false)] 60 tight: bool, 61 62 /// Fine-tune baseline offset (in em units). Positive moves annotation further away from base glyph. 63 #[facet(args::named, default = 0.0)] 64 offset: f64, 65 66 /// Standard CLI options (--help, --version, --completions) 67 #[facet(flatten)] 68 builtins: FigueBuiltins, 69} 70 71pub enum Ruby { 72 #[cfg(feature = "pinyin")] 73 Pinyin, 74 #[cfg(feature = "romaji")] 75 Romaji, 76} 77 78impl FromStr for Ruby { 79 type Err = Error; 80 81 fn from_str(s: &str) -> Result<Self, Self::Err> { 82 match s { 83 #[cfg(feature = "pinyin")] 84 "pinyin" => Ok(Ruby::Pinyin), 85 #[cfg(feature = "romaji")] 86 "romaji" => Ok(Ruby::Romaji), 87 other => { 88 return Err(anyhow!("Unknown ruby characters argument: {other}")); 89 } 90 } 91 } 92} 93 94fn position_from_str(s: &str) -> Result<RubyPosition> { 95 match s.to_lowercase().as_str() { 96 "top" => Ok(RubyPosition::Top), 97 "bottom" => Ok(RubyPosition::Bottom), 98 "leftdown" => Ok(RubyPosition::LeftDown), 99 "leftup" => Ok(RubyPosition::LeftUp), 100 "rightdown" => Ok(RubyPosition::RightDown), 101 "rightup" => Ok(RubyPosition::RightUp), 102 other => Err(anyhow!("Unknown ruby position argument: {other}")), 103 } 104} 105 106fn main() -> Result<()> { 107 let indicatif_layer = IndicatifLayer::new(); 108 109 tracing_subscriber::registry() 110 .with( 111 tracing_subscriber::EnvFilter::try_from_default_env() 112 .unwrap_or_else(|_| "rubify=info,tower_buffer=info".into()), 113 ) 114 .with(tracing_subscriber::fmt::layer().with_writer(indicatif_layer.get_stderr_writer())) 115 .with(indicatif_layer) 116 .init(); 117 118 let cli: Cli = args::from_std_args().unwrap(); 119 120 let ruby = Ruby::from_str(&cli.ruby) 121 .with_context(|| anyhow!("Failed to parse --ruby argument: {}", &cli.ruby))?; 122 123 let mut input_paths: FxHashSet<PathBuf> = FxHashSet::default(); 124 125 for pattern in &cli.inputs { 126 let entries = 127 glob(pattern).with_context(|| anyhow!("Failed to expand glob pattern: {pattern:?}"))?; 128 129 for entry in entries { 130 match entry { 131 Ok(path) if path.is_file() => { 132 input_paths.insert(path); 133 } 134 _ => {} 135 } 136 } 137 } 138 139 if input_paths.is_empty() { 140 return Err(anyhow!("No input files found")); 141 } 142 143 if !cli.out.exists() { 144 fs::create_dir_all(&cli.out) 145 .with_context(|| anyhow!("Failed to create out-dir: {:?}", cli.out))?; 146 } 147 148 #[cfg(feature = "woff2")] 149 if cli.woff2 && !cli.split { 150 anyhow::bail!( 151 "WOFF2 output is only supported when --split is enabled, because we don't currently support converting TTC collections to WOFF2. Please enable --split or disable --woff2." 152 ); 153 } 154 155 info!("Processing {} inputs -> {:?}", input_paths.len(), cli.out); 156 157 let inputs_span = info_span!("process_fonts_in_inputs"); 158 inputs_span.pb_set_style( 159 &ProgressStyle::with_template( 160 "{msg} [{wide_bar:.cyan/blue}] {pos}/{len} [{elapsed_precise}]", 161 ) 162 .unwrap(), 163 ); 164 inputs_span.pb_set_length(input_paths.len() as u64); 165 inputs_span.pb_set_message("Processing font inputs"); 166 167 let inputs_span_enter = inputs_span.enter(); 168 169 for in_path in &input_paths { 170 inputs_span.pb_inc(1); 171 inputs_span.pb_set_message(&format!("Processing {}", in_path.display())); 172 173 let file_name = in_path 174 .file_name() 175 .and_then(|s| s.to_str()) 176 .context("Invalid file name")? 177 .to_string(); 178 179 let out_path = cli.out.join(file_name); 180 181 process_file(&cli, &ruby, &in_path, &out_path)?; 182 } 183 184 drop(inputs_span_enter); 185 drop(inputs_span); 186 187 info!("Done processing inputs."); 188 189 Ok(()) 190} 191 192fn process_file(cli: &Cli, ruby: &Ruby, in_path: &PathBuf, out_path: &PathBuf) -> Result<()> { 193 let base_font_data = 194 fs::read(in_path).with_context(|| anyhow!("Failed to read input file: {in_path:?}"))?; 195 let base_file = FileRef::new(&base_font_data) 196 .map_err(|e| anyhow!("Failed to parse base font file: {:?}", e))?; 197 198 info!("Processing {:?} -> {:?}", in_path, out_path); 199 200 let ruby_font_data = if let Some(path) = &cli.font { 201 fs::read(path).with_context(|| anyhow!("Failed to read ruby font file: {path:?}"))? 202 } else { 203 base_font_data.clone() 204 }; 205 206 let ruby_font_data = Box::leak(ruby_font_data.into_boxed_slice()); 207 let ruby_file = FileRef::new(ruby_font_data).context("Failed to parse ruby font file")?; 208 let ruby_fonts: Vec<_> = ruby_file.fonts().collect(); 209 210 if ruby_fonts.is_empty() { 211 return Err(anyhow!("No fonts found in ruby font file")); 212 } 213 214 let ruby_font = ruby_fonts[0] 215 .clone() 216 .context("Failed to load font from ruby font file")?; 217 218 let renderer: Box<dyn RubyRenderer> = match ruby { 219 #[cfg(feature = "pinyin")] 220 Ruby::Pinyin => { 221 let renderer = renderer::pinyin::PinyinRenderer::new( 222 ruby_font, 223 cli.scale, 224 cli.gutter, 225 position_from_str(&cli.position)?, 226 cli.offset, 227 cli.tight, 228 )?; 229 230 Box::new(renderer) 231 } 232 #[cfg(feature = "romaji")] 233 Ruby::Romaji => { 234 let renderer = renderer::romaji::RomajiRenderer::new( 235 ruby_font, 236 cli.scale, 237 cli.gutter, 238 position_from_str(&cli.position)?, 239 cli.offset, 240 cli.tight, 241 )?; 242 243 Box::new(renderer) 244 } 245 }; 246 247 let fonts = rubify::process_font_file(base_file, &renderer, cli.subset, cli.split)?; 248 249 for font in fonts { 250 let mut data = font.data; 251 let mut path = out_path.to_owned(); 252 253 #[cfg(feature = "woff2")] 254 if cli.woff2 { 255 info!("Converting to WOFF2"); 256 data = rubify::convert_to_woff2(&data)?; 257 path = out_path.with_extension("woff2"); 258 } 259 260 fs::write(&path, data).with_context(|| anyhow!("Failed to write output file: {path:?}"))?; 261 262 info!("Wrote {path:?}"); 263 } 264 265 Ok(()) 266}