max / quasi-type
4 files changed,
+180 insertions,
-79 deletions
| @@ -50,6 +50,25 @@ | |||
| 50 | 50 | - `out/` — built faces. Not committed: they rebuild from the checkout, and a | |
| 51 | 51 | committed binary is a second source of truth. | |
| 52 | 52 | ||
| 53 | + | ## Using it from another crate | |
| 54 | + | ||
| 55 | + | A consumer cuts the face rather than vendoring it, for the same reason `out/` is | |
| 56 | + | not committed. `quasi_type::cut` is the whole pipeline in one call: | |
| 57 | + | ||
| 58 | + | ```rust | |
| 59 | + | // build.rs | |
| 60 | + | let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); | |
| 61 | + | let cut = quasi_type::cut("quasi-mono", &out.join("bases"), false).unwrap(); | |
| 62 | + | std::fs::write(out.join("QuasiMono.ttf"), &cut.faces[0].ttf).unwrap(); | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | Take it as a git dependency, and give it a cache directory: bases are downloaded | |
| 66 | + | and checksummed there, so a build with a warm cache needs no network. | |
| 67 | + | ||
| 68 | + | Do not reassemble the steps `build` walks. A second caller writing its own | |
| 69 | + | version string or skipping the coverage assertion is how two builds of the same | |
| 70 | + | slot stop being the same face. | |
| 71 | + | ||
| 53 | 72 | ``` | |
| 54 | 73 | quasi-type build <slot> cut every face of a slot, verify, write to out/ | |
| 55 | 74 | quasi-type verify <slot> cut without writing; assert coverage only |
| @@ -172,3 +172,115 @@ | |||
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | 174 | impl std::error::Error for Error {} | |
| 175 | + | ||
| 176 | + | /// One cut face, ready to write or to embed. | |
| 177 | + | pub struct Face { | |
| 178 | + | /// The style the pin declares, which is the base's default instance. | |
| 179 | + | pub style: String, | |
| 180 | + | /// The file name without an extension: `QuasiMono[wght]` for a variable | |
| 181 | + | /// face, `QuasiMono-Regular` for a static one. A face is named for its axis | |
| 182 | + | /// when it has one, the way upstream names the file it came from. | |
| 183 | + | pub stem: String, | |
| 184 | + | pub ttf: Vec<u8>, | |
| 185 | + | pub woff2: Vec<u8>, | |
| 186 | + | /// What the cut added to the base, as `(codepoint, glyph name)`. | |
| 187 | + | pub added: Vec<(u32, String)>, | |
| 188 | + | pub variation: Option<base::Variation>, | |
| 189 | + | /// The coverage verdict, already asserted. Carried so a caller can print it | |
| 190 | + | /// rather than re-derive it. | |
| 191 | + | pub verdict: String, | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// A slot, cut. | |
| 195 | + | pub struct Cut { | |
| 196 | + | pub family: String, | |
| 197 | + | pub base_family: String, | |
| 198 | + | pub base_version: String, | |
| 199 | + | /// How many glyphs of the house set this slot selected. | |
| 200 | + | pub selected: usize, | |
| 201 | + | pub faces: Vec<Face>, | |
| 202 | + | /// The base's licence text, which OFL requires to travel with the build. | |
| 203 | + | pub license: Vec<u8>, | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Cut a slot from its pinned base: the whole pipeline, in one call. | |
| 207 | + | /// | |
| 208 | + | /// This is what a consumer runs. `shop` cuts its bundled face from a `build.rs` | |
| 209 | + | /// and Alloy's image cuts both faces into `/usr/share/fonts`, and neither should | |
| 210 | + | /// have to reassemble the steps `main.rs` walks — a second caller doing its own | |
| 211 | + | /// version string or skipping the coverage assertion is how two builds of the | |
| 212 | + | /// same slot stop being the same face. | |
| 213 | + | /// | |
| 214 | + | /// `cache` is where pinned bases are downloaded to and verified in. It is a | |
| 215 | + | /// parameter rather than a constant because a consumer's is not this repo's: | |
| 216 | + | /// a `build.rs` wants `OUT_DIR`, and a checkout wants `bases/cache`. | |
| 217 | + | pub fn cut(slot_id: &str, cache: &std::path::Path, offline: bool) -> Result<Cut, Error> { | |
| 218 | + | let pins = pins::Pins::parse(PINS)?; | |
| 219 | + | let manifest = manifest::Manifest::parse(HOUSE_SET)?; | |
| 220 | + | let slot = pins.slot(slot_id)?; | |
| 221 | + | let base_pin = pins.base(&slot.base)?; | |
| 222 | + | ||
| 223 | + | // The licence gate, before anything is built: an output name may not carry | |
| 224 | + | // the base's Reserved Font Name. | |
| 225 | + | base_pin.check_output_name(&slot.family)?; | |
| 226 | + | ||
| 227 | + | let selected: Vec<&manifest::GlyphSpec> = manifest | |
| 228 | + | .glyphs | |
| 229 | + | .iter() | |
| 230 | + | .filter(|g| slot.glyphs.includes(g)) | |
| 231 | + | .collect(); | |
| 232 | + | let house: Vec<u32> = selected.iter().map(|g| g.codepoint).collect(); | |
| 233 | + | // The floor is the role's, and the slot's own selection is what says which | |
| 234 | + | // role it fills: a slot that takes no cell furniture is not the face that | |
| 235 | + | // will answer `monospace`, so asking it for `│ █ ▏ ░` asks it to carry | |
| 236 | + | // glyphs that cannot tile in it. | |
| 237 | + | let floor: &[u32] = if selected.iter().any(|g| g.shape.is_cell_furniture()) { | |
| 238 | + | &assert::ALLOY_SURFACE | |
| 239 | + | } else { | |
| 240 | + | &assert::BODY_SURFACE | |
| 241 | + | }; | |
| 242 | + | ||
| 243 | + | let version = format!("{}.{}", manifest.set.version, base_pin.version); | |
| 244 | + | let mut faces = Vec::new(); | |
| 245 | + | for face in base::load(base_pin, cache, offline)? { | |
| 246 | + | let id = compose::Identity { | |
| 247 | + | family: &slot.family, | |
| 248 | + | style: &face.style, | |
| 249 | + | version: &version, | |
| 250 | + | set_version: manifest.set.version, | |
| 251 | + | base: base_pin, | |
| 252 | + | }; | |
| 253 | + | let built = compose::build(&face.bytes, &selected, &id)?; | |
| 254 | + | let verdict = assert::describe(&assert::check( | |
| 255 | + | floor, | |
| 256 | + | &compose::coverage(&built.bytes)?, | |
| 257 | + | &house, | |
| 258 | + | ))?; | |
| 259 | + | let stem = match &built.variation { | |
| 260 | + | Some(axis) => format!("{}[{}]", slot.family.replace(' ', ""), axis.tag), | |
| 261 | + | None => format!( | |
| 262 | + | "{}-{}", | |
| 263 | + | slot.family.replace(' ', ""), | |
| 264 | + | face.style.replace(' ', "") | |
| 265 | + | ), | |
| 266 | + | }; | |
| 267 | + | faces.push(Face { | |
| 268 | + | style: face.style, | |
| 269 | + | stem, | |
| 270 | + | woff2: woff2::encode(&built.bytes)?, | |
| 271 | + | ttf: built.bytes, | |
| 272 | + | added: built.added, | |
| 273 | + | variation: built.variation, | |
| 274 | + | verdict, | |
| 275 | + | }); | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | Ok(Cut { | |
| 279 | + | family: slot.family.clone(), | |
| 280 | + | base_family: base_pin.family.clone(), | |
| 281 | + | base_version: base_pin.version.clone(), | |
| 282 | + | selected: selected.len(), | |
| 283 | + | license: base::license_text(base_pin, cache, offline)?, | |
| 284 | + | faces, | |
| 285 | + | }) | |
| 286 | + | } |
| @@ -8,7 +8,7 @@ | |||
| 8 | 8 | use quasi_type::manifest::{Manifest, format_codepoint}; | |
| 9 | 9 | use quasi_type::pins::Pins; | |
| 10 | 10 | use quasi_type::proof; | |
| 11 | - | use quasi_type::{Error, HOUSE_SET, PINS, assert, woff2}; | |
| 11 | + | use quasi_type::{Error, HOUSE_SET, PINS}; | |
| 12 | 12 | ||
| 13 | 13 | const USAGE: &str = "\ | |
| 14 | 14 | quasi-type — a pinned base plus the house glyph set in, a Quasi <Slot> face out | |
| @@ -138,11 +138,11 @@ | |||
| 138 | 138 | } | |
| 139 | 139 | Some((&"build", tail)) => { | |
| 140 | 140 | let slot_id = tail.first().copied().unwrap_or("quasi-mono"); | |
| 141 | - | cut(&root, &out, &pins, &manifest, slot_id, offline, true) | |
| 141 | + | cut(&root, &out, slot_id, offline, true) | |
| 142 | 142 | } | |
| 143 | 143 | Some((&"verify", tail)) => { | |
| 144 | 144 | let slot_id = tail.first().copied().unwrap_or("quasi-mono"); | |
| 145 | - | cut(&root, &out, &pins, &manifest, slot_id, offline, false) | |
| 145 | + | cut(&root, &out, slot_id, offline, false) | |
| 146 | 146 | } | |
| 147 | 147 | Some((&"proof", tail)) => { | |
| 148 | 148 | let slot_id = tail.first().copied().unwrap_or("quasi-mono"); | |
| @@ -155,86 +155,27 @@ | |||
| 155 | 155 | } | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | - | fn cut( | |
| 159 | - | root: &Path, | |
| 160 | - | out: &Path, | |
| 161 | - | pins: &Pins, | |
| 162 | - | manifest: &Manifest, | |
| 163 | - | slot_id: &str, | |
| 164 | - | offline: bool, | |
| 165 | - | write: bool, | |
| 166 | - | ) -> Result<(), Error> { | |
| 167 | - | let slot = pins.slot(slot_id)?; | |
| 168 | - | let base_pin = pins.base(&slot.base)?; | |
| 169 | - | ||
| 170 | - | // The licence gate, before anything is built: an output name may not carry | |
| 171 | - | // the base's Reserved Font Name. | |
| 172 | - | base_pin.check_output_name(&slot.family)?; | |
| 173 | - | ||
| 174 | - | let selected: Vec<&quasi_type::manifest::GlyphSpec> = manifest | |
| 175 | - | .glyphs | |
| 176 | - | .iter() | |
| 177 | - | .filter(|g| slot.glyphs.includes(g)) | |
| 178 | - | .collect(); | |
| 179 | - | let house: Vec<u32> = selected.iter().map(|g| g.codepoint).collect(); | |
| 180 | - | // The floor is the role's, and the slot's own selection is what says which | |
| 181 | - | // role it fills: a slot that takes no cell furniture is not the face that | |
| 182 | - | // will answer `monospace`, so asking it for `│ █ ▏ ░` asks it to carry | |
| 183 | - | // glyphs that cannot tile in it. | |
| 184 | - | let floor: &[u32] = if selected.iter().any(|g| g.shape.is_cell_furniture()) { | |
| 185 | - | &assert::ALLOY_SURFACE | |
| 186 | - | } else { | |
| 187 | - | &assert::BODY_SURFACE | |
| 188 | - | }; | |
| 189 | - | ||
| 190 | - | let cache = base::cache_dir(root); | |
| 191 | - | let faces = base::load(base_pin, &cache, offline)?; | |
| 192 | - | let version = format!("{}.{}", manifest.set.version, base_pin.version); | |
| 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)?; | |
| 193 | 160 | ||
| 194 | 161 | println!( | |
| 195 | 162 | "{} from {} {} ({} marks)", | |
| 196 | - | slot.family, | |
| 197 | - | base_pin.family, | |
| 198 | - | base_pin.version, | |
| 199 | - | selected.len() | |
| 163 | + | cut.family, cut.base_family, cut.base_version, cut.selected | |
| 200 | 164 | ); | |
| 201 | - | ||
| 202 | 165 | if write { | |
| 203 | 166 | std::fs::create_dir_all(out).map_err(|e| Error::Io(out.to_path_buf(), e))?; | |
| 204 | 167 | } | |
| 205 | 168 | ||
| 206 | - | for face in &faces { | |
| 207 | - | let id = Identity { | |
| 208 | - | family: &slot.family, | |
| 209 | - | style: &face.style, | |
| 210 | - | version: &version, | |
| 211 | - | set_version: manifest.set.version, | |
| 212 | - | base: base_pin, | |
| 213 | - | }; | |
| 214 | - | let built = compose::build(&face.bytes, &selected, &id)?; | |
| 215 | - | let coverage = assert::check(floor, &compose::coverage(&built.bytes)?, &house); | |
| 216 | - | let verdict = assert::describe(&coverage)?; | |
| 217 | - | let web = woff2::encode(&built.bytes)?; | |
| 218 | - | ||
| 219 | - | // A variable face is named for its axis rather than for a style, the way | |
| 220 | - | // upstream names the file it came from. One face covers the range, and | |
| 221 | - | // the name says so before anyone opens it. | |
| 222 | - | let stem = match &built.variation { | |
| 223 | - | Some(axis) => format!("{}[{}]", slot.family.replace(' ', ""), axis.tag), | |
| 224 | - | None => format!( | |
| 225 | - | "{}-{}", | |
| 226 | - | slot.family.replace(' ', ""), | |
| 227 | - | face.style.replace(' ', "") | |
| 228 | - | ), | |
| 229 | - | }; | |
| 169 | + | for face in &cut.faces { | |
| 230 | 170 | println!( | |
| 231 | - | " {:<22} ttf {:>7} woff2 {:>7} +{} marks, {verdict}", | |
| 232 | - | format!("{} {}", slot.family, face.style), | |
| 233 | - | human(built.bytes.len()), | |
| 234 | - | human(web.len()), | |
| 235 | - | built.added.len(), | |
| 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, | |
| 236 | 177 | ); | |
| 237 | - | if let Some(axis) = &built.variation { | |
| 178 | + | if let Some(axis) = &face.variation { | |
| 238 | 179 | println!( | |
| 239 | 180 | " {:<22} {} {}-{}, and the marks vary with it. At rest this face is {} {} \ | |
| 240 | 181 | ({}), so a consumer names the weight it wants: `font-weight: {} {}`.", | |
| @@ -249,10 +190,9 @@ | |||
| 249 | 190 | axis.max, | |
| 250 | 191 | ); | |
| 251 | 192 | } | |
| 252 | - | ||
| 253 | 193 | if write { | |
| 254 | - | write_file(&out.join(format!("{stem}.ttf")), &built.bytes)?; | |
| 255 | - | write_file(&out.join(format!("{stem}.woff2")), &web)?; | |
| 194 | + | write_file(&out.join(format!("{}.ttf", face.stem)), &face.ttf)?; | |
| 195 | + | write_file(&out.join(format!("{}.woff2", face.stem)), &face.woff2)?; | |
| 256 | 196 | } | |
| 257 | 197 | } | |
| 258 | 198 | ||
| @@ -260,9 +200,12 @@ | |||
| 260 | 200 | // OFL 1.1 requires the licence to travel with a modified build, and the | |
| 261 | 201 | // gap this closes is live: MNW serves three families with no licence | |
| 262 | 202 | // beside them today. | |
| 263 | - | let license = base::license_text(base_pin, &cache, offline)?; | |
| 264 | - | write_file(&out.join("OFL.txt"), &license)?; | |
| 265 | - | println!(" {:<22} the base's licence, as OFL requires", "OFL.txt"); | |
| 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"); | |
| 266 | 209 | println!("\nwritten to {}", out.display()); | |
| 267 | 210 | } | |
| 268 | 211 | Ok(()) |
| @@ -149,3 +149,30 @@ | |||
| 149 | 149 | "the base's advances do not vary, so this test is asserting nothing" | |
| 150 | 150 | ); | |
| 151 | 151 | } | |
| 152 | + | ||
| 153 | + | /// The consumer entry point, which is the only path a consumer should take. | |
| 154 | + | /// | |
| 155 | + | /// `shop` cuts its bundled face from a `build.rs` and Alloy's image cuts both | |
| 156 | + | /// faces into `/usr/share/fonts`; both call `quasi_type::cut`. What is asserted | |
| 157 | + | /// here is that it produces the same face `build` writes, licence and all, so | |
| 158 | + | /// there is one pipeline rather than one per caller. | |
| 159 | + | #[test] | |
| 160 | + | fn the_consumer_entry_point_cuts_the_same_face() { | |
| 161 | + | let cache = base::cache_dir(&root()); | |
| 162 | + | let Ok(whole) = quasi_type::cut(SLOT, &cache, true) else { | |
| 163 | + | eprintln!("skipped: {SLOT}'s base is not cached"); | |
| 164 | + | return; | |
| 165 | + | }; | |
| 166 | + | let Some((direct, _)) = cut() else { return }; | |
| 167 | + | ||
| 168 | + | assert_eq!(whole.family, "Quasi Body"); | |
| 169 | + | assert_eq!(whole.faces.len(), 1); | |
| 170 | + | let face = &whole.faces[0]; | |
| 171 | + | assert_eq!(face.ttf, direct, "the entry point cuts a different face"); | |
| 172 | + | assert_eq!(face.stem, "QuasiBody[wght]"); | |
| 173 | + | assert!(!face.woff2.is_empty() && &face.woff2[0..4] == b"wOF2"); | |
| 174 | + | assert!( | |
| 175 | + | String::from_utf8_lossy(&whole.license).contains("Open Font License"), | |
| 176 | + | "the licence has to travel with the build" | |
| 177 | + | ); | |
| 178 | + | } |