Skip to main content

max / quasi-type

Add cut_native, the ttf counterpart of cut_web The house tier reaches native renderers without going through makeover, which names the web copies. What it reached them through was copying about thirty lines out of shop's build.rs: a second caller doing its own file naming and its own weight defaulting, which is how two builds of one slot stop being the same face. cut_native writes the ttf and the OFL text the way cut_web writes the woff2, and returns family, style and default weight per slot so a build script has something to export. The weight is the variable axis default where there is one, which is the trap: Atkinson Mono opens at ExtraLight, not Regular. cut already returned both encodings, so this is packaging rather than pipeline.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 14:52 UTC
Signed with PGP, not checked
Commit: e57a57f35563252b5ed00a8caf1262517f3d51f4
Parent: ad2122f
3 files changed, +129 insertions, -51 deletions
M Cargo.lock +46 -50
@@ -290,7 +290,7 @@
290 290
291 291 [[package]]
292 292 name = "quasi-type"
293 - version = "0.1.0"
293 + version = "0.1.1"
294 294 dependencies = [
295 295 "brotli",
296 296 "font-types",
@@ -526,6 +526,50 @@
526 526 "simd-adler32",
527 527 ]
528 528
529 + [[patch.unused]]
530 + name = "quasi-axum"
531 + version = "0.71.0"
532 +
533 + [[patch.unused]]
534 + name = "quasi-basics"
535 + version = "0.71.0"
536 +
537 + [[patch.unused]]
538 + name = "quasi-http"
539 + version = "0.71.0"
540 +
541 + [[patch.unused]]
542 + name = "quasi-immediate"
543 + version = "0.71.0"
544 +
545 + [[patch.unused]]
546 + name = "quasi-notifs"
547 + version = "0.71.0"
548 +
549 + [[patch.unused]]
550 + name = "quasi-router"
551 + version = "0.71.0"
552 +
553 + [[patch.unused]]
554 + name = "quasi-store"
555 + version = "0.1.0"
556 +
557 + [[patch.unused]]
558 + name = "quasi-tauri"
559 + version = "0.71.0"
560 +
561 + [[patch.unused]]
562 + name = "quasi-webview"
563 + version = "0.71.0"
564 +
565 + [[patch.unused]]
566 + name = "synckit-client"
567 + version = "0.9.1"
568 +
569 + [[patch.unused]]
570 + name = "synckit-config"
571 + version = "0.2.0"
572 +
529 573 [[patch.unused]]
530 574 name = "kberg"
531 575 version = "0.1.0"
@@ -540,56 +584,8 @@
540 584
541 585 [[patch.unused]]
542 586 name = "tagtree"
543 - version = "0.4.0"
544 -
545 - [[patch.unused]]
546 - name = "makeover"
547 - version = "2.7.0"
548 -
549 - [[patch.unused]]
550 - name = "makeover-build"
551 - version = "0.40.0"
587 + version = "0.4.1"
552 588
553 589 [[patch.unused]]
554 590 name = "docengine"
555 591 version = "0.7.0"
556 -
557 - [[patch.unused]]
558 - name = "quasi-axum"
559 - version = "0.18.0"
560 -
561 - [[patch.unused]]
562 - name = "quasi-basics"
563 - version = "0.18.0"
564 -
565 - [[patch.unused]]
566 - name = "quasi-http"
567 - version = "0.18.0"
568 -
569 - [[patch.unused]]
570 - name = "quasi-immediate"
571 - version = "0.18.0"
572 -
573 - [[patch.unused]]
574 - name = "quasi-router"
575 - version = "0.18.0"
576 -
577 - [[patch.unused]]
578 - name = "quasi-store"
579 - version = "0.1.0"
580 -
581 - [[patch.unused]]
582 - name = "quasi-tauri"
583 - version = "0.18.0"
584 -
585 - [[patch.unused]]
586 - name = "quasi-webview"
587 - version = "0.18.0"
588 -
589 - [[patch.unused]]
590 - name = "synckit-client"
591 - version = "0.8.0"
592 -
593 - [[patch.unused]]
594 - name = "synckit-config"
595 - version = "0.2.0"
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-type"
3 - version = "0.1.0"
3 + version = "0.1.1"
4 4 edition = "2024"
5 5 description = "The house font pipeline: a pinned base face plus the house glyph set in, a Quasi <Slot> face out."
6 6 license = "MIT"
M src/lib.rs +82
@@ -331,6 +331,88 @@
331 331 Ok(())
332 332 }
333 333
334 + /// One slot, cut and written for a native consumer.
335 + ///
336 + /// Carries what a `build.rs` has to export beside the file itself. A native
337 + /// renderer registers a face by family and style and picks a weight to draw at,
338 + /// and none of the three can be assumed from the filename: the style is the
339 + /// base's default instance, and Atkinson Mono's is `ExtraLight` rather than
340 + /// `Regular`.
341 + pub struct NativeFace {
342 + /// The slot id that was cut, as it appears in the pins.
343 + pub slot: String,
344 + /// The output family, e.g. `Quasi Mono`.
345 + pub family: String,
346 + /// The style of the face that was written.
347 + pub style: String,
348 + /// The weight to draw at unless the consumer says otherwise: the variable
349 + /// axis default where there is an axis, and 400 where there is not.
350 + pub default_weight: f32,
351 + /// The ttf that was written.
352 + pub path: std::path::PathBuf,
353 + /// The licence text that was written beside it.
354 + pub license_path: std::path::PathBuf,
355 + }
356 +
357 + /// Cut slots into a directory a native build points at, as ttf.
358 + ///
359 + /// The native counterpart of [`cut_web`], and the same agreement in a different
360 + /// shape: `cut_web` writes woff2 for a browser to fetch, this writes ttf for a
361 + /// font loader to register. `slots` pairs a slot id with the filename it lands
362 + /// under, the filename being the caller's for the same reason as there — it is
363 + /// half of an agreement this crate cannot see, and the other half lives in
364 + /// whatever the consumer does with `OUT_DIR`.
365 + ///
366 + /// ttf rather than woff2: nothing native decodes woff2 without being told how,
367 + /// and `cut` already returns both, so this is packaging rather than a second
368 + /// pipeline.
369 + ///
370 + /// This exists because the house tier has to reach native renderers without
371 + /// going through `makeover`. `makeover::FontSlot::house_face` names the *web*
372 + /// copies, and a native consumer cuts its own; before this it copied about
373 + /// thirty lines out of `shop/crates/shop-font/build.rs` to do it, which is a
374 + /// second caller doing its own file naming and its own weight defaulting.
375 + ///
376 + /// The licence text travels as `OFL-<Family>.txt` beside each face, which is
377 + /// what OFL requires of a build that ships the face.
378 + ///
379 + /// Nothing is rewritten if it is already byte-identical, for the reason
380 + /// [`cut_web`] gives: a build script that rewrites its outputs every run bumps
381 + /// their mtimes, and cargo then rebuilds everything watching them.
382 + ///
383 + /// # Errors
384 + ///
385 + /// If a slot cannot be cut, or a file cannot be written.
386 + pub fn cut_native(
387 + dir: &std::path::Path,
388 + cache: &std::path::Path,
389 + offline: bool,
390 + slots: &[(&str, &str)],
391 + ) -> Result<Vec<NativeFace>, Error> {
392 + std::fs::create_dir_all(dir).map_err(|e| Error::Io(dir.to_path_buf(), e))?;
393 + let mut written = Vec::with_capacity(slots.len());
394 + for (slot_id, filename) in slots {
395 + let cut = cut(slot_id, cache, offline)?;
396 + let face = cut
397 + .faces
398 + .first()
399 + .ok_or_else(|| Error::Pins(format!("slot `{slot_id}` cut no faces")))?;
400 + let path = dir.join(filename);
401 + let license_path = dir.join(format!("OFL-{}.txt", cut.family.replace(' ', "")));
402 + write_if_changed(&path, &face.ttf)?;
403 + write_if_changed(&license_path, &cut.license)?;
404 + written.push(NativeFace {
405 + slot: (*slot_id).to_string(),
406 + family: cut.family.clone(),
407 + style: face.style.clone(),
408 + default_weight: face.variation.as_ref().map_or(400.0, |axis| axis.default),
409 + path,
410 + license_path,
411 + });
412 + }
413 + Ok(written)
414 + }
415 +
334 416 fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> Result<(), Error> {
335 417 if std::fs::read(path).is_ok_and(|existing| existing == bytes) {
336 418 return Ok(());