Annotate fonts with ruby (pinyin/romaji) and produce modified TTF/WOFF2 outputs.
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}