Skip to main content

max / quasi-type

11.2 KB · 319 lines History Blame Raw
1 //! `quasi-type` — cut a house face from a pinned base.
2
3 use std::path::{Path, PathBuf};
4 use std::process::ExitCode;
5
6 use quasi_type::base::{self, BaseParams};
7 use quasi_type::compose::{self, Identity};
8 use quasi_type::manifest::{Manifest, format_codepoint};
9 use quasi_type::pins::Pins;
10 use quasi_type::proof;
11 use quasi_type::{Error, HOUSE_SET, PINS};
12
13 const USAGE: &str = "\
14 quasi-type — a pinned base plus the house glyph set in, a Quasi <Slot> face out
15
16 quasi-type build <slot> cut every face of a slot, verify, write to out/
17 quasi-type verify <slot> cut without writing; assert coverage only
18 quasi-type params <slot> print what each base face measures
19 quasi-type list print the house glyph set and the pinned slots
20 quasi-type proof <slot> rasterise the cut face to out/, so it can be seen
21
22 Options
23 --out <dir> where faces are written (default: out/)
24 --offline fail rather than fetch a base that is not cached
25 --base <id> measure a pinned base directly, for a base no slot names yet
26 --px <n> proof sheet type size in pixels (default: 24)
27 ";
28
29 fn main() -> ExitCode {
30 let args: Vec<String> = std::env::args().skip(1).collect();
31 match run(&args) {
32 Ok(()) => ExitCode::SUCCESS,
33 Err(e) => {
34 eprintln!("quasi-type: {e}");
35 ExitCode::FAILURE
36 }
37 }
38 }
39
40 fn run(args: &[String]) -> Result<(), Error> {
41 let mut positional: Vec<&str> = Vec::new();
42 let mut out = PathBuf::from("out");
43 let mut offline = false;
44 let mut base_id: Option<&str> = None;
45 let mut px = 24.0_f32;
46 let mut rest = args.iter();
47 while let Some(arg) = rest.next() {
48 match arg.as_str() {
49 "--offline" => offline = true,
50 "--px" => {
51 px = rest
52 .next()
53 .and_then(|n| n.parse().ok())
54 .ok_or_else(|| Error::Pins("--px needs a size in pixels".into()))?;
55 }
56 "--base" => {
57 base_id = Some(
58 rest.next()
59 .map(String::as_str)
60 .ok_or_else(|| Error::Pins("--base needs a base id".into()))?,
61 );
62 }
63 "--out" => {
64 out = rest
65 .next()
66 .map(PathBuf::from)
67 .ok_or_else(|| Error::Pins("--out needs a directory".into()))?;
68 }
69 "-h" | "--help" => {
70 print!("{USAGE}");
71 return Ok(());
72 }
73 other => positional.push(other),
74 }
75 }
76
77 let root = repo_root();
78 let pins = Pins::parse(PINS)?;
79 let manifest = Manifest::parse(HOUSE_SET)?;
80
81 match positional.split_first() {
82 None => {
83 print!("{USAGE}");
84 Ok(())
85 }
86 Some((&"list", _)) => {
87 println!("{} v{}", manifest.set.name, manifest.set.version);
88 for glyph in &manifest.glyphs {
89 println!(
90 " {} {:<10} {:<13} {}",
91 format_codepoint(glyph.codepoint),
92 glyph.name,
93 glyph.shape.kind(),
94 glyph.role
95 );
96 }
97 println!("\nslots");
98 for slot in &pins.slots {
99 let base = pins.base(&slot.base)?;
100 println!(
101 " {:<12} {:<12} from {} {}",
102 slot.id, slot.family, base.family, base.version
103 );
104 }
105 Ok(())
106 }
107 Some((&"params", tail)) => {
108 // A base no slot names yet is still measurable, which is how a base
109 // gets read before anything is cut from it.
110 let base_pin = match base_id {
111 Some(id) => pins.base(id)?,
112 None => pins.base(
113 &pins
114 .slot(tail.first().copied().unwrap_or("quasi-mono"))?
115 .base,
116 )?,
117 };
118 let faces = base::load(base_pin, &base::cache_dir(&root), offline)?;
119 for face in &faces {
120 let name = format!("{} {}", base_pin.family, face.style);
121 match base::variation(&face.bytes)? {
122 None => print_params(&name, &base::measure(&face.bytes)?),
123 Some(axis) => {
124 println!(
125 "{name} variable: {} {}-{}, default {} ({})",
126 axis.tag, axis.min, axis.max, axis.default, axis.default_style
127 );
128 let mut at = vec![axis.default_master()];
129 at.extend(axis.delta_masters());
130 for master in at {
131 let params = base::measure_at(&face.bytes, &axis, master)?;
132 print_params(&format!(" {} {}", axis.tag, master.user), &params);
133 }
134 }
135 }
136 }
137 Ok(())
138 }
139 Some((&"build", tail)) => {
140 let slot_id = tail.first().copied().unwrap_or("quasi-mono");
141 cut(&root, &out, slot_id, offline, true)
142 }
143 Some((&"verify", tail)) => {
144 let slot_id = tail.first().copied().unwrap_or("quasi-mono");
145 cut(&root, &out, slot_id, offline, false)
146 }
147 Some((&"proof", tail)) => {
148 let slot_id = tail.first().copied().unwrap_or("quasi-mono");
149 proof(&root, &out, &pins, &manifest, slot_id, offline, px)
150 }
151 Some((other, _)) => {
152 print!("{USAGE}");
153 Err(Error::Pins(format!("no command `{other}`")))
154 }
155 }
156 }
157
158 fn cut(root: &Path, out: &Path, slot_id: &str, offline: bool, write: bool) -> Result<(), Error> {
159 let cut = quasi_type::cut(slot_id, &base::cache_dir(root), offline)?;
160
161 println!(
162 "{} from {} {} ({} marks)",
163 cut.family, cut.base_family, cut.base_version, cut.selected
164 );
165 if write {
166 std::fs::create_dir_all(out).map_err(|e| Error::Io(out.to_path_buf(), e))?;
167 }
168
169 for face in &cut.faces {
170 println!(
171 " {:<22} ttf {:>7} woff2 {:>7} +{} marks, {}",
172 format!("{} {}", cut.family, face.style),
173 human(face.ttf.len()),
174 human(face.woff2.len()),
175 face.added.len(),
176 face.verdict,
177 );
178 if let Some(axis) = &face.variation {
179 println!(
180 " {:<22} {} {}-{}, and the marks vary with it. At rest this face is {} {} \
181 ({}), so a consumer names the weight it wants: `font-weight: {} {}`.",
182 "",
183 axis.tag,
184 axis.min,
185 axis.max,
186 axis.tag,
187 axis.default,
188 axis.default_style,
189 axis.min,
190 axis.max,
191 );
192 }
193 if write {
194 write_file(&out.join(format!("{}.ttf", face.stem)), &face.ttf)?;
195 write_file(&out.join(format!("{}.woff2", face.stem)), &face.woff2)?;
196 }
197 }
198
199 if write {
200 // OFL 1.1 requires the licence to travel with a modified build, and the
201 // gap this closes is live: MNW serves three families with no licence
202 // beside them today.
203 // Named for the family rather than `OFL.txt`, because two slots write
204 // into the same directory and their bases are two different licence
205 // files. goingson already does it this way for Reglo.
206 let license = format!("OFL-{}.txt", cut.family.replace(' ', ""));
207 write_file(&out.join(&license), &cut.license)?;
208 println!(" {license:<22} the base's licence, as OFL requires");
209 println!("\nwritten to {}", out.display());
210 }
211 Ok(())
212 }
213
214 /// Cut the slot and rasterise it, one sheet per end of the axis.
215 ///
216 /// A variable cut gets a sheet at each master rather than at its default
217 /// alone, because the defects the axis introduces — a mark whose stroke closes
218 /// its own counter at the heavy end — are invisible at rest, which is exactly
219 /// where every other check reads it.
220 fn proof(
221 root: &Path,
222 out: &Path,
223 pins: &Pins,
224 manifest: &Manifest,
225 slot_id: &str,
226 offline: bool,
227 px: f32,
228 ) -> Result<(), Error> {
229 let slot = pins.slot(slot_id)?;
230 let base_pin = pins.base(&slot.base)?;
231 let selected: Vec<&quasi_type::manifest::GlyphSpec> = manifest
232 .glyphs
233 .iter()
234 .filter(|g| slot.glyphs.includes(g))
235 .collect();
236 let faces = base::load(base_pin, &base::cache_dir(root), offline)?;
237 let version = format!("{}.{}", manifest.set.version, base_pin.version);
238 std::fs::create_dir_all(out).map_err(|e| Error::Io(out.to_path_buf(), e))?;
239
240 println!("{} at {}px", slot.family, px);
241 for face in &faces {
242 let id = Identity {
243 family: &slot.family,
244 style: &face.style,
245 version: &version,
246 set_version: manifest.set.version,
247 base: base_pin,
248 };
249 let built = compose::build(&face.bytes, &selected, &id)?;
250 let coverage = compose::coverage(&built.bytes)?;
251 let rows = proof::house_rows(&coverage);
252
253 // The axis default is one of its ends often enough to be worth saying
254 // once: Atkinson Mono's is its minimum, so a sheet per master is two
255 // sheets rather than three.
256 let mut locations: Vec<Option<f32>> = match &built.variation {
257 Some(axis) => vec![Some(axis.min), Some(axis.default), Some(axis.max)],
258 None => vec![None],
259 };
260 locations.dedup();
261 for wght in locations {
262 let page = proof::Page {
263 rows: rows.clone(),
264 px,
265 wght,
266 };
267 let sheet = proof::render(&built.bytes, &page)?;
268 let name = match wght {
269 Some(w) => format!("proof-{slot_id}-wght{w:.0}.png"),
270 None => format!("proof-{slot_id}-{}.png", face.style.replace(' ', "")),
271 };
272 let path = out.join(&name);
273 write_file(&path, &proof::png(&sheet))?;
274 println!(" {name:<28} {} x {}", sheet.width, sheet.height);
275 }
276 }
277 println!("\nwritten to {}", out.display());
278 Ok(())
279 }
280
281 fn print_params(name: &str, p: &BaseParams) {
282 println!("{name}");
283 println!(" upem {}", p.upem);
284 println!(" advance {}", p.advance);
285 println!(" cap height {}", p.cap_height);
286 println!(" x height {}", p.x_height);
287 println!(" stem (|) {}", p.stem);
288 println!(" stroke (-) {}", p.stroke);
289 println!(
290 " band (+) x[{}, {}] y[{}, {}]",
291 p.band_x0, p.band_x1, p.band_y0, p.band_y1
292 );
293 println!(
294 " cell {} x {} (asc {}, desc {}, rule at {})",
295 p.advance,
296 p.cell_height(),
297 p.ascent,
298 p.descent,
299 p.cell_center_y()
300 );
301 }
302
303 fn write_file(path: &Path, bytes: &[u8]) -> Result<(), Error> {
304 std::fs::write(path, bytes).map_err(|e| Error::Io(path.to_path_buf(), e))
305 }
306
307 fn human(bytes: usize) -> String {
308 if bytes >= 1024 {
309 format!("{:.1}K", bytes as f64 / 1024.0)
310 } else {
311 format!("{bytes}B")
312 }
313 }
314
315 /// The checkout, so `quasi-type` works from any working directory.
316 fn repo_root() -> PathBuf {
317 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
318 }
319