collective

Browse / github.com/storytold/craft-libs / patch

feat(font-info): read a font's axes, instances and OpenType features

feat(font-info): read a font's axes, instances and OpenType features This adds a small crate, `craft-font-info`. It reads what a font offers for detailed typography settings: - its variable axes, with the font's own names - its named instances ("Bold Condensed", …) - its OpenType features, including the names the font gives its stylistic sets and character variants ("Open digits", "Single-story a"), and which characters each feature changes (up to 24 of them sampled, with the total count) - what's needed to name a variable instance (Adobe Tech Note #5902): each named instance's PostScript name, the name ID 25 prefix and the `STAT` axes It comes from PhotoCraf

Status
open
Score
0.00
Adopters
0 other operators
Author
Arecsu (imported from pull request #154, open)
Base
9ff719499d8f4730b93071abed993e5f12fe1be8
Size
+1088 −0 in 8 files
Review
pending
Shared
2026-10-28
Id
31ce1f31b8225ddc2a102e6b

How it went

No other operator has reported using it yet.

Diff

raw · .github/workflows/font-info.yml Cargo.lock Cargo.toml README.md crates/font-info/Cargo.toml crates/font-info/README.md crates/font-info/src/lib.rs crates/font-info/tests/read.rs

diff --git a/.github/workflows/font-info.yml b/.github/workflows/font-info.ymlnew file mode 100644--- /dev/null+++ b/.github/workflows/font-info.yml@@ -0,0 +1,31 @@+name: Font info++on:+  workflow_dispatch:+  push:+    branches: [main]+    paths: ['crates/font-info/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/font-info.yml']+  pull_request:+    paths: ['crates/font-info/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/font-info.yml']++permissions:+  contents: read++jobs:+  font-info:+    timeout-minutes: 15+    strategy:+      fail-fast: false+      matrix:+        os: [ubuntu-latest, windows-latest, macos-latest]+    runs-on: ${{ matrix.os }}+    steps:+      - uses: actions/checkout@v4+      - uses: dtolnay/rust-toolchain@stable+        with:+          components: rustfmt, clippy+      - run: cargo fmt --check+      - run: cargo clippy -p craft-font-info --all-targets --locked -- -D warnings+      - run: cargo test -p craft-font-info --locked+      - if: runner.os == 'Linux'+        run: rustup target add wasm32-unknown-unknown && cargo check -p craft-font-info --target wasm32-unknown-unknown --lockeddiff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -790,6 +790,14 @@ dependencies = [  "write-fonts 0.54.1", ] +[[package]]+name = "craft-font-info"+version = "0.1.0"+dependencies = [+ "skrifa 0.44.0",+ "write-fonts 0.54.1",+]+ [[package]] name = "craft-font-manager" version = "0.1.0"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -27,6 +27,7 @@ craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } craft-font-catalog = { path = "crates/font-catalog" }+craft-font-info = { path = "crates/font-info" } craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } craft-fonts-build = { path = "crates/fonts-build" }diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -41,6 +41,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit   (font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),   lookup by family, style, localized, legacy, full and PostScript names, family loads shared   between threads, and rescans when fonts are installed or removed.+- [`craft-font-info`](crates/font-info/README.md): what a font face offers for fine typography:+  variation axes and named instances by the font's own names, and every OpenType feature with+  its stylistic-set or character-variant name and the characters it changes. - [`craft-font-manager`](crates/font-manager/README.md): Craft Font Manager as a library: search   and filters over the Google Fonts index, installed fonts by family, the font store interface,   and (`egui` feature) `FontsPanel`, the whole interface as one panel for the launcher's Fonts tabdiff --git a/crates/font-info/Cargo.toml b/crates/font-info/Cargo.tomlnew file mode 100644--- /dev/null+++ b/crates/font-info/Cargo.toml@@ -0,0 +1,17 @@+[package]+name = "craft-font-info"+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true+description = "What a font face offers for fine typography: variation axes, named instances and OpenType features with the font's own names"++[lints]+workspace = true++[dependencies]+skrifa = "0.44"++[dev-dependencies]+write-fonts = "0.54"diff --git a/crates/font-info/README.md b/crates/font-info/README.mdnew file mode 100644--- /dev/null+++ b/crates/font-info/README.md@@ -0,0 +1,43 @@+# Craft Font Info++`craft-font-info` reads what one font face offers for fine typography, for type panels: variable+font controls, OpenType feature toggles and their previews:++- **Variation axes** (`fvar`): tag, the font's own name ("Parametric Thick Stroke"), range,+  default, and whether the font asks to hide it.+- **Named instances**: name ("Bold Condensed"), PostScript name and coordinates.+- **What naming variable instances takes** (PostScript names for instances, Adobe Tech Note+  #5902): the Variations PostScript Name Prefix (name ID 25) and the `STAT` design axes with the+  face's value on each, including axes `fvar` lacks (Inter Variable's `ital`).+- **OpenType layout features** (`GSUB` and `GPOS`): every tag once, sorted; the font's UI name for+  stylistic sets (`ss01`–`ss20`) and character variants (`cv01`–`cv99`); and the characters each+  substitution changes, for a preview that shows exactly those: how many in all, and a sample of+  up to 24 taking a few of each kind in turn (`012ABCabc345…`), so even a short preview shows+  that a set changes letters as well as figures. The read is bounded, so a feature too large to+  read whole is marked `partial`: its count is then a lower bound.++Extracted from PhotoCraft (`photocraft-text`, storytold/photocraft#218).++```rust+let bytes = std::fs::read("Inter.ttf")?;+let details = craft_font_info::read(&bytes, 0)?; // face 0; the face index in a .ttc+if let Some(f) = details.feature("cv11") {+    println!("{:?} changes {}", f.name, f.sample); // Some("Single-story a") changes a+}+```++## What stays in each app++Finding the face for a text style (family, weight, italic), caching, and localization.+`FEATURE_NAMES` (`feature_name`) gives the OpenType registry's English names of feature tags as+source strings: each app translates them in its own catalogs, as it does the names the font gives+(`FeatureDetail::name`).++## Guarantees++Fonts are untrusted input. Data that isn't a font (or has no face at the index) is an error; any+malformed table is left out of the result, never a panic. Features, lookups per feature and+glyphs per coverage table are capped. Takes bytes, so callers don't share a `skrifa` version with+it. Pure Rust, no `unsafe`, builds for `wasm32-unknown-unknown`.++Tests build their font with `write-fonts` in code; no font file is committed.diff --git a/crates/font-info/src/lib.rs b/crates/font-info/src/lib.rsnew file mode 100644--- /dev/null+++ b/crates/font-info/src/lib.rs@@ -0,0 +1,535 @@+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]++//! What one font face offers for fine typography: its variation axes with the font's own names,+//! its named instances, and its OpenType layout features with the names the font gives them+//! (`ss01` "Open digits", `cv11` "Single-story a") and the characters each feature changes, so a+//! preview can show exactly those. Also what naming its variable instances takes (Adobe Tech Note+//! #5902): the Variations PostScript Name Prefix, each named instance's PostScript name and the+//! `STAT` design axes; and [`FEATURE_NAMES`], the registered names of feature tags.+//!+//! [`read`] takes the font file's bytes and the face's index in it (0 for a single font; the+//! face in a `.ttc` collection). Fonts are untrusted input: a malformed table is left out, never a+//! panic, and every list read from the file is capped.+//!+//! ```no_run+//! let bytes = std::fs::read("Inter.ttf")?;+//! let details = craft_font_info::read(&bytes, 0)?;+//! for axis in &details.axes {+//!     println!("{} {:?} {}..{}", axis.tag, axis.name, axis.min, axis.max);+//! }+//! if let Some(ss01) = details.feature("ss01") {+//!     println!("ss01 is {:?} and changes {}", ss01.name, ss01.sample);+//! }+//! # Ok::<(), Box<dyn std::error::Error>>(())+//! ```++use std::collections::{BTreeMap, BTreeSet};++use skrifa::raw::tables::gsub::{SingleSubst, SubstitutionSubtables};+use skrifa::raw::tables::layout::{CoverageTable, FeatureParams};+use skrifa::raw::{ReadError, TableProvider};+use skrifa::string::{LocalizedString, StringId};+use skrifa::{Axis, FontRef, MetadataProvider};++/// Most characters kept as a feature's sample.+const SAMPLE_CHARS: usize = 24;+/// A sample takes this many characters of one kind before moving to the next kind.+const RUN: usize = 3;+/// Most features read from one table, most lookups followed per feature, and most glyphs read+/// from one coverage table: fonts are input, and a malformed one can claim anything.+const MAX_FEATURES: usize = 512;+const MAX_LOOKUPS: usize = 64;+const MAX_COVERAGE: usize = 4096;+/// Most subtables entered and coverage glyphs read for one face's features, all together. The caps+/// above multiply: a font can name the same lookup from every feature, and that one lookup can+/// hold thousands of subtables, so per-list limits alone don't bound the work a font can ask for.+const MAX_SUBTABLES: usize = 8192;+const MAX_GLYPHS: usize = 1 << 20;+/// Most `STAT` design axes and axis value records read.+const MAX_STAT: usize = 256;++/// A variation axis of the face, from its `fvar` table.+#[derive(Clone, Debug, PartialEq)]+pub struct AxisDetail {+    /// Four-character tag (`wght`, `GRAD`).+    pub tag: String,+    /// The font's own (English, else first) name for the axis ("Parametric Thick Stroke"), if it+    /// has one.+    pub name: Option<String>,+    pub min: f32,+    pub default: f32,+    pub max: f32,+    /// The font asks for the axis to stay out of user interfaces (`fvar` flag 0x0001).+    pub hidden: bool,+}++/// A named instance ("Bold Condensed"): a full set of axis coordinates.+#[derive(Clone, Debug, PartialEq)]+pub struct InstanceDetail {+    pub name: String,+    /// The instance's own PostScript name (`InterVariable-Bold`), if the font gives one.+    pub postscript_name: Option<String>,+    /// User-space coordinates, one per axis in [`FaceDetails::axes`] order: (tag, value).+    pub coords: Vec<(String, f32)>,+}++/// An OpenType layout feature the face has (in `GSUB` or `GPOS`).+#[derive(Clone, Debug, PartialEq)]+pub struct FeatureDetail {+    pub tag: String,+    /// The font's UI name for a stylistic set or character variant (`ss01`–`ss20`,+    /// `cv01`–`cv99` feature parameters), if it gives one.+    pub name: Option<String>,+    /// Up to 24 of the characters the feature substitutes, a few of each kind in turn (figures,+    /// capitals, lower case, other letters, the rest: `012ABCabc345DEF…`), so any prefix of it+    /// shows every kind the feature changes. Empty for positioning features and for substitutions+    /// only reachable through context.+    pub sample: String,+    /// How many characters the feature substitutes in all (the sample shows at most 24), or at+    /// least, when [`partial`](Self::partial).+    pub changed: usize,+    /// The read stopped before the end of the feature's substitutions (a cap on lookups or+    /// coverage, or the face's budget, ran out), so `changed` and `sample` cover only what it read.+    pub partial: bool,+}++/// A design axis of the face's style attributes (`STAT`), which may be one `fvar` lacks (Inter+/// Variable's `ital`: its upright and italic are two files).+#[derive(Clone, Debug, PartialEq)]+pub struct StatAxisDetail {+    pub tag: String,+    /// This face's value on the axis: its first single-axis value record (formats 1–3), if any.+    pub value: Option<f32>,+}++/// Everything [`read`] reads from one face.+#[derive(Clone, Debug, Default, PartialEq)]+pub struct FaceDetails {+    pub axes: Vec<AxisDetail>,+    pub instances: Vec<InstanceDetail>,+    /// Variations PostScript Name Prefix (name ID 25), if the font has one.+    pub variations_prefix: Option<String>,+    /// `STAT` design axes, in the table's order.+    pub stat_axes: Vec<StatAxisDetail>,+    /// Sorted by tag, each tag once.+    pub features: Vec<FeatureDetail>,+}++impl FaceDetails {+    /// Whether the face has the layout feature `tag`.+    pub fn has_feature(&self, tag: &str) -> bool {+        self.features.binary_search_by(|f| f.tag.as_str().cmp(tag)).is_ok()+    }++    /// The face's feature `tag`, if it has it.+    pub fn feature(&self, tag: &str) -> Option<&FeatureDetail> {+        self.features.binary_search_by(|f| f.tag.as_str().cmp(tag)).ok().and_then(|i| self.features.get(i))+    }++    /// The face's axis `tag`, if it has it.+    pub fn axis(&self, tag: &str) -> Option<&AxisDetail> {+        self.axes.iter().find(|a| a.tag == tag)+    }+}++/// The data isn't a font, or has no face at the index asked for.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct NotAFont(String);++impl std::fmt::Display for NotAFont {+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+        write!(f, "not a readable font: {}", self.0)+    }+}++impl std::error::Error for NotAFont {}++/// Reads [`FaceDetails`] from a font file's bytes: face `index` of a collection (`.ttc`), or 0.+/// Only data that isn't a font is an error; unreadable tables are left out (a font with a broken+/// `GSUB` still has its axes).+pub fn read(data: &[u8], index: u32) -> Result<FaceDetails, NotAFont> {+    let font = FontRef::from_index(data, index).map_err(|e| NotAFont(e.to_string()))?;+    Ok(read_face(&font))+}++/// Every name the face gives, by ID, chosen as `skrifa`'s `english_or_first` chooses it.+///+/// Asking skrifa for a name by ID scans the whole `name` table, and a face asks for one per axis,+/// per instance and per feature: a font with many of each, and a long `name` table, multiplies+/// that into seconds. One pass over the table answers all of them.+fn read_names(font: &FontRef) -> BTreeMap<u16, String> {+    let Ok(table) = font.name() else { return BTreeMap::new() };+    // The ranking of `english_or_first` (skrifa 0.44, `string.rs`), kept in step with it: en-US,+    // then en, then a string with no language, then whatever comes first.+    let mut out: BTreeMap<u16, (u8, String)> = BTreeMap::new();+    for record in table.name_record() {+        let string = LocalizedString::new(&table, record);+        let rank = match string.language() {+            Some("en-US") => 3,+            Some("en") => 2,+            None => 1,+            _ => 0,+        };+        let id = record.name_id().to_u16();+        match out.get_mut(&id) {+            Some(best) if rank > best.0 => *best = (rank, string.to_string()),+            Some(_) => {}+            None => {+                out.insert(id, (rank, string.to_string()));+            }+        }+    }+    out.into_iter().map(|(id, (_, string))| (id, string)).collect()+}++/// Whether an `fvar` axis has a range worth reporting: a malformed one is left out rather than+/// shown with nonsensical bounds.+fn has_usable_range(a: &Axis) -> bool {+    a.min_value().is_finite() && a.default_value().is_finite() && a.max_value().is_finite() && a.min_value() <= a.max_value()+}++fn read_face(font: &FontRef) -> FaceDetails {+    let names = read_names(font);+    let name = |id: StringId| names.get(&id.to_u16()).filter(|s| !s.trim().is_empty()).cloned();+    // The axes that are kept, and where each sits in an instance's coordinates.+    let fvar: Vec<Axis> = font.axes().iter().collect();+    let kept: Vec<usize> = fvar.iter().enumerate().filter(|(_, a)| has_usable_range(a)).map(|(i, _)| i).collect();+    let axes: Vec<AxisDetail> = kept+        .iter()+        .map(|&i| {+            let a = &fvar[i];+            AxisDetail {+                tag: a.tag().to_string(),+                name: name(a.name_id()),+                min: a.min_value(),+                default: a.default_value().clamp(a.min_value(), a.max_value()),+                max: a.max_value(),+                hidden: a.is_hidden(),+            }+        })+        .collect();+    let instances = font+        .named_instances()+        .iter()+        .filter_map(|inst| {+            // One coordinate per kept axis, in `axes` order: a malformed axis left out above is+            // left out here too, so pairing the two lists by index stays right. (`fvar` stores+            // coordinates as `Fixed`, so none of them can be non-finite.)+            let user: Vec<f32> = inst.user_coords().collect();+            let coords: Vec<(String, f32)> = axes.iter().zip(&kept).filter_map(|(axis, &i)| user.get(i).map(|v| (axis.tag.clone(), *v))).collect();+            Some(InstanceDetail { name: name(inst.subfamily_name_id())?, postscript_name: inst.postscript_name_id().and_then(name), coords })+        })+        .collect();+    let mut features: BTreeMap<String, FeatureDetail> = BTreeMap::new();+    if let Ok(gsub) = font.gsub() {+        let _ = gsub_features(font, &gsub, &name, &mut features);+    }+    if let Ok(gpos) = font.gpos()+        && let Ok(list) = gpos.feature_list()+    {+        for record in list.feature_records().iter().take(MAX_FEATURES) {+            let tag = record.feature_tag().to_string();+            features.entry(tag.clone()).or_insert(FeatureDetail { tag, name: None, sample: String::new(), changed: 0, partial: false });+        }+    }+    FaceDetails {+        axes,+        instances,+        variations_prefix: name(StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX),+        stat_axes: stat_axes(font),+        features: features.into_values().collect(),+    }+}++/// The `STAT` design axes with this face's value on each.+fn stat_axes(font: &FontRef) -> Vec<StatAxisDetail> {+    use skrifa::raw::tables::stat::AxisValue;+    let Ok(stat) = font.stat() else { return Vec::new() };+    let Ok(design) = stat.design_axes() else { return Vec::new() };+    let mut values: BTreeMap<u16, f32> = BTreeMap::new();+    if let Some(Ok(list)) = stat.offset_to_axis_values() {+        for v in list.axis_values().iter().take(MAX_STAT).filter_map(Result::ok) {+            let (axis, value) = match v {+                AxisValue::Format1(v) => (v.axis_index(), v.value()),+                AxisValue::Format2(v) => (v.axis_index(), v.nominal_value()),+                AxisValue::Format3(v) => (v.axis_index(), v.value()),+                AxisValue::Format4(_) => continue,+            };+            values.entry(axis).or_insert(value.to_f32());+        }+    }+    design+        .iter()+        .take(MAX_STAT)+        .enumerate()+        .map(|(i, a)| StatAxisDetail {+            tag: a.axis_tag().to_string(),+            value: u16::try_from(i).ok().and_then(|i| values.get(&i)).copied().filter(|v| v.is_finite()),+        })+        .collect()+}++/// Registered OpenType layout features with their English names from the OpenType feature+/// registry (`tnum` "Tabular Figures", `salt` "Stylistic Alternates"), for an interface to show+/// and translate. Tags and names are copied from the registry+/// (<https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist>, OpenType 1.9.1)+/// as it writes them, capitalisation included; an app that translates them keeps its own words.+/// The numbered `ssNN` and `cvNN` aren't here: [`FeatureDetail::name`] names them from the font+/// itself.+pub const FEATURE_NAMES: &[(&str, &str)] = &[+    ("liga", "Standard Ligatures"),+    ("dlig", "Discretionary Ligatures"),+    ("calt", "Contextual Alternates"),+    ("swsh", "Swash"),+    ("salt", "Stylistic Alternates"),+    ("titl", "Titling"),+    ("ordn", "Ordinals"),+    ("frac", "Fractions"),+    ("case", "Case-sensitive Forms"),+    ("cpsp", "Capital Spacing"),+    ("kern", "Kerning"),+    ("zero", "Slashed Zero"),+    ("subs", "Subscript"),+    ("sups", "Superscript"),+    ("sinf", "Scientific Inferiors"),+    ("smcp", "Small Capitals"),+    ("c2sc", "Small Capitals From Capitals"),+    ("pcap", "Petite Capitals"),+    ("c2pc", "Petite Capitals From Capitals"),+    ("unic", "Unicase"),+    ("dnom", "Denominators"),+    ("numr", "Numerators"),+    ("hist", "Historical Forms"),+    ("hlig", "Historical Ligatures"),+    ("ornm", "Ornaments"),+    ("nalt", "Alternate Annotation Forms"),+    ("mgrk", "Mathematical Greek"),+    ("lnum", "Lining Figures"),+    ("onum", "Oldstyle Figures"),+    ("pnum", "Proportional Figures"),+    ("tnum", "Tabular Figures"),+    ("afrc", "Alternative Fractions"),+    ("cswh", "Contextual Swash"),+    ("jalt", "Justification Alternates"),+    ("ital", "Italics"),+    ("rtbd", "Right Bounds"),+    ("lfbd", "Left Bounds"),+    ("opbd", "Optical Bounds"),+    ("palt", "Proportional Alternate Widths"),+    ("halt", "Alternate Half Widths"),+    ("pwid", "Proportional Widths"),+    ("fwid", "Full Widths"),+    ("hwid", "Half Widths"),+    ("ruby", "Ruby Notation Forms"),+    ("expt", "Expert Forms"),+    ("jp78", "JIS78 Forms"),+    ("jp83", "JIS83 Forms"),+    ("jp90", "JIS90 Forms"),+    ("jp04", "JIS2004 Forms"),+    ("smpl", "Simplified Forms"),+    ("trad", "Traditional Forms"),+    ("tnam", "Traditional Name Forms"),+];++/// The English name of the registered feature `tag` ([`FEATURE_NAMES`]), if it has one.+pub fn feature_name(tag: &str) -> Option<&'static str> {+    FEATURE_NAMES.iter().find(|(t, _)| *t == tag).map(|(_, name)| *name)+}++/// Adds the `GSUB` features, their UI names and their substituted characters to `out`.+fn gsub_features(+    font: &FontRef,+    gsub: &skrifa::raw::tables::gsub::Gsub,+    name: &dyn Fn(StringId) -> Option<String>,+    out: &mut BTreeMap<String, FeatureDetail>,+) -> Result<(), ReadError> {+    let list = gsub.feature_list()?;+    let lookups = gsub.lookup_list().ok();+    let reverse = reverse_cmap(font);+    let data = list.offset_data();+    // Glyphs per tag; one tag can have several records (one per script/language).+    let mut glyphs: BTreeMap<String, BTreeSet<u16>> = BTreeMap::new();+    // Lookups already read for a tag: records repeat a tag across scripts and languages, and+    // re-reading a lookup would only spend the read's budget twice on the same glyphs.+    let mut seen: BTreeMap<String, BTreeSet<u16>> = BTreeMap::new();+    let mut budget = Budget { subtables: MAX_SUBTABLES, glyphs: MAX_GLYPHS };+    for record in list.feature_records().iter().take(MAX_FEATURES) {+        let tag = record.feature_tag().to_string();+        let entry =+            out.entry(tag.clone()).or_insert(FeatureDetail { tag: tag.clone(), name: None, sample: String::new(), changed: 0, partial: false });+        let Ok(feature) = record.feature(data) else { continue };+        if entry.name.is_none() {+            entry.name = match feature.feature_params() {+                Some(Ok(FeatureParams::StylisticSet(p))) => name(p.ui_name_id()),+                Some(Ok(FeatureParams::CharacterVariant(p))) => name(p.feat_ui_label_name_id()),+                _ => None,+            };+        }+        let Some(lookups) = &lookups else { continue };+        let set = glyphs.entry(tag.clone()).or_default();+        let seen = seen.entry(tag).or_default();+        let indices = feature.lookup_list_indices();+        if indices.len() > MAX_LOOKUPS {+            entry.partial = true;+        }+        for index in indices.iter().take(MAX_LOOKUPS) {+            if !seen.insert(index.get()) {+                continue;+            }+            let Ok(lookup) = lookups.lookups().get(usize::from(index.get())) else { continue };+            let Ok(subtables) = lookup.subtables() else { continue };+            if !collect_coverage(&subtables, set, &mut budget) {+                entry.partial = true;+            }+        }+    }+    for (tag, set) in glyphs {+        if let Some(entry) = out.get_mut(&tag) {+            let chars: BTreeSet<char> = set.iter().filter_map(|g| reverse.get(g).copied()).collect();+            entry.changed = chars.len();+            entry.sample = sample(chars);+        }+    }+    Ok(())+}++/// What one read may still spend on `GSUB`: subtable offsets resolved and coverage glyphs read,+/// over the whole face. Caps on each list don't bound the total, since any number of features can+/// name the same lookup, and that lookup can hold many subtables.+struct Budget {+    subtables: usize,+    glyphs: usize,+}++impl Budget {+    /// Takes one subtable from the budget; false once it's spent.+    fn subtable(&mut self) -> bool {+        if self.subtables == 0 {+            return false;+        }+        self.subtables -= 1;+        true+    }++    /// Takes one glyph from the budget; false once it's spent.+    fn glyph(&mut self) -> bool {+        if self.glyphs == 0 {+            return false;+        }+        self.glyphs -= 1;+        true+    }+}++/// The glyphs each directly substituting subtable acts on. Contextual subtables are skipped: what+/// they change depends on the surrounding text, so their coverage would mislead. Stops when the+/// read's budget runs out, leaving what it has read; false if it stopped short.+fn collect_coverage(subtables: &SubstitutionSubtables, set: &mut BTreeSet<u16>, budget: &mut Budget) -> bool {+    match subtables {+        SubstitutionSubtables::Single(s) => {+            let coverage = |t| match t {+                SingleSubst::Format1(t) => t.coverage(),+                SingleSubst::Format2(t) => t.coverage(),+            };+            add_coverage(s.iter(), coverage, set, budget)+        }+        SubstitutionSubtables::Multiple(s) => add_coverage(s.iter(), |t| t.coverage(), set, budget),+        SubstitutionSubtables::Alternate(s) => add_coverage(s.iter(), |t| t.coverage(), set, budget),+        SubstitutionSubtables::Ligature(s) => add_coverage(s.iter(), |t| t.coverage(), set, budget),+        _ => true,+    }+}++/// Adds the coverage of each subtable to `set` until the read's budget runs out. An offset that+/// fails to resolve costs the budget too: a malformed table can hold thousands of them, and+/// dropping them before the budget is charged would make resolving them free. False if the budget+/// or [`MAX_COVERAGE`] cut it short, so the glyphs in `set` may not be all of them.+fn add_coverage<'a, T, F>(subtables: impl Iterator<Item = Result<T, ReadError>>, coverage: F, set: &mut BTreeSet<u16>, budget: &mut Budget) -> bool+where+    F: Fn(T) -> Result<CoverageTable<'a>, ReadError>,+{+    let mut complete = true;+    for subtable in subtables {+        if !budget.subtable() {+            return false;+        }+        let Ok(subtable) = subtable else { continue };+        let Ok(table) = coverage(subtable) else { continue };+        let mut glyphs = table.iter();+        for glyph in glyphs.by_ref().take(MAX_COVERAGE) {+            if !budget.glyph() {+                return false;+            }+            set.insert(glyph.to_u16());+        }+        if glyphs.next().is_some() {+            complete = false;+        }+    }+    complete+}++/// Nominal glyph → its lowest character, for the Basic Multilingual Plane (enough for samples).+fn reverse_cmap(font: &FontRef) -> BTreeMap<u16, char> {+    let mut out = BTreeMap::new();+    for (cp, gid) in font.charmap().mappings().take(1 << 17) {+        let (Some(c), Ok(g)) = (char::from_u32(cp), u16::try_from(gid.to_u32())) else { continue };+        if !c.is_control() && !c.is_whitespace() {+            out.entry(g).or_insert(c);+        }+    }+    out+}++/// A readable sample from substituted characters: runs of [`RUN`] of each kind in turn (ASCII+/// figures, ASCII capitals, ASCII lower case, other letters and figures, the rest), each kind in+/// code point order, at most [`SAMPLE_CHARS`]. Taking kinds in turn rather than one after the+/// other keeps a set of circled figures *and* letters from reading as figures only when a+/// preview shows just the start.+fn sample(chars: BTreeSet<char>) -> String {+    let kind = |c: char| match c {+        '0'..='9' => 0,+        'A'..='Z' => 1,+        'a'..='z' => 2,+        c if c.is_alphanumeric() => 3,+        _ => 4,+    };+    let mut kinds: [Vec<char>; 5] = Default::default();+    for c in chars {+        if let Some(k) = kinds.get_mut(kind(c)) {+            k.push(c);+        }+    }+    let mut queues: Vec<std::vec::IntoIter<char>> = kinds.into_iter().filter(|k| !k.is_empty()).map(Vec::into_iter).collect();+    let mut out = String::new();+    let mut n = 0;+    while n < SAMPLE_CHARS && !queues.is_empty() {+        queues.retain_mut(|q| {+            for c in q.by_ref().take(RUN.min(SAMPLE_CHARS - n)) {+                out.push(c);+                n += 1;+            }+            q.len() > 0+        });+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn sample_takes_each_kind_in_turn() {+        let set = |s: &str| s.chars().collect::<BTreeSet<char>>();+        assert_eq!(sample(set("é!a1Z")), "1Zaé!");+        assert_eq!(sample(set(&"x".repeat(100))), "x");+        // Squared figures and capitals: a short prefix already has both.+        let squared = sample(('0'..='9').chain('A'..='Z').collect());+        assert_eq!(squared, "012ABC345DEF678GHI9JKLMN");+        assert_eq!(squared.chars().count(), SAMPLE_CHARS);+        // One kind only keeps code point order.+        assert_eq!(sample(('a'..='z').collect()), "abcdefghijklmnopqrstuvwx");+        assert_eq!(sample(BTreeSet::new()), "");+    }+}diff --git a/crates/font-info/tests/read.rs b/crates/font-info/tests/read.rsnew file mode 100644--- /dev/null+++ b/crates/font-info/tests/read.rs@@ -0,0 +1,450 @@+//! Reads a font built here with `write-fonts` (no font file in the repository): two axes, one+//! hidden, a named instance, a stylistic set and a character variant with their own names, and a+//! positioning feature.++use write_fonts::FontBuilder;+use write_fonts::tables::cmap::Cmap;+use write_fonts::tables::fvar::InstanceRecord;+use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, VariationAxisRecord};+use write_fonts::tables::gpos::{Gpos, PositionLookupList};+use write_fonts::tables::gsub::{Gsub, SingleSubst, SubstitutionLookupList};+use write_fonts::tables::layout::{+    CharacterVariantParams, CoverageTable, Feature, FeatureList, FeatureParams, FeatureRecord, LangSys, Lookup, LookupFlag, Script, ScriptList,+    ScriptRecord, StylisticSetParams,+};+use write_fonts::tables::name::{Name, NameRecord};+use write_fonts::tables::stat::{AxisRecord, AxisValue, AxisValueTableFlags, Stat};+use write_fonts::types::{Fixed, GlyphId, GlyphId16, NameId, Tag};++fn name(id: u16, s: &str) -> NameRecord {+    NameRecord::new(3, 1, 0x409, NameId::new(id), s.to_string().into())+}++fn single(from: u16, delta: i16) -> SingleSubst {+    SingleSubst::format_1([GlyphId16::new(from)].into_iter().collect::<CoverageTable>(), delta)+}++/// Glyphs: 1 `a`, 2 `b`, 3 `1`, 4 `1.ss01`, 5 `a.cv11`.+fn font() -> Vec<u8> {+    let cmap = Cmap::from_mappings([('a', GlyphId::new(1)), ('b', GlyphId::new(2)), ('1', GlyphId::new(3))]).unwrap();+    let mut names = vec![+        name(256, "Weight"),+        name(257, "Parametric Thick Stroke"),+        name(258, "Heavy"),+        name(259, "Open digits"),+        name(260, "Single-story a"),+        name(261, "   "),+        name(262, "TestVF-Heavy"),+        name(263, "Roman"),+        name(25, "TestVF"),+    ];+    names.sort_by_key(|r| r.name_id);+    let axes = vec![+        VariationAxisRecord::new(Tag::new(b"wght"), Fixed::from_f64(100.0), Fixed::from_f64(400.0), Fixed::from_f64(900.0), 0, NameId::new(256)),+        VariationAxisRecord::new(Tag::new(b"XOPQ"), Fixed::from_f64(27.0), Fixed::from_f64(96.0), Fixed::from_f64(175.0), 1, NameId::new(257)),+    ];+    let instances = vec![+        InstanceRecord {+            subfamily_name_id: NameId::new(258),+            flags: 0,+            coordinates: vec![Fixed::from_f64(800.0), Fixed::from_f64(96.0)],+            post_script_name_id: Some(NameId::new(262)),+        },+        // A blank name is no instance.+        InstanceRecord {+            subfamily_name_id: NameId::new(261),+            flags: 0,+            coordinates: vec![Fixed::from_f64(100.0), Fixed::from_f64(96.0)],+            // 0xFFFF: the instance has no PostScript name.+            post_script_name_id: Some(NameId::new(0xFFFF)),+        },+    ];+    let fvar = Fvar::new(AxisInstanceArrays::new(axes, instances));+    let lookups = SubstitutionLookupList::new(vec![+        Lookup::new(LookupFlag::empty(), vec![single(3, 1)]).into(),+        Lookup::new(LookupFlag::empty(), vec![single(1, 4)]).into(),+    ]);+    let ss01 = FeatureParams::StylisticSet(StylisticSetParams::new(NameId::new(259)));+    let cv11 =+        FeatureParams::CharacterVariant(CharacterVariantParams::new(NameId::new(260), NameId::new(0), NameId::new(0), 0, NameId::new(0), vec![]));+    let features = FeatureList::new(vec![+        FeatureRecord::new(Tag::new(b"ss01"), Feature::new(Some(ss01), vec![0])),+        FeatureRecord::new(Tag::new(b"cv11"), Feature::new(Some(cv11), vec![1])),+        // A second record of a tag (another script) is merged, and its out-of-range lookup skipped.+        FeatureRecord::new(Tag::new(b"ss01"), Feature::new(None, vec![99])),+    ]);+    let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]))]);+    let gsub = Gsub::new(scripts.clone(), features, lookups);+    let gpos = Gpos::new(+        scripts,+        FeatureList::new(vec![FeatureRecord::new(Tag::new(b"kern"), Feature::new(None, vec![]))]),+        PositionLookupList::new(vec![]),+    );+    // STAT: wght, and an `ital` fvar lacks with this face's value 0 ("Roman"); a second value record+    // for it doesn't override the first.+    let stat = Stat {+        design_axes: vec![AxisRecord::new(Tag::new(b"wght"), NameId::new(256), 0), AxisRecord::new(Tag::new(b"ital"), NameId::new(263), 1)].into(),+        offset_to_axis_values: Some(vec![+            AxisValue::format_3(1, AxisValueTableFlags::ELIDABLE_AXIS_VALUE_NAME, NameId::new(263), Fixed::from_f64(0.0), Fixed::from_f64(1.0))+                .into(),+            AxisValue::format_1(1, AxisValueTableFlags::empty(), NameId::new(263), Fixed::from_f64(1.0)).into(),+        ])+        .into(),+        elided_fallback_name_id: Some(NameId::new(2)),+    };+    let mut b = FontBuilder::new();+    b.add_table(&cmap).unwrap().add_table(&Name::new(names)).unwrap().add_table(&fvar).unwrap().add_table(&stat).unwrap();+    b.add_table(&gsub).unwrap().add_table(&gpos).unwrap();+    b.build()+}++#[test]+fn reads_axes_instances_and_named_features() {+    let d = craft_font_info::read(&font(), 0).unwrap();+    let wght = d.axis("wght").unwrap();+    assert_eq!((wght.name.as_deref(), wght.min, wght.default, wght.max, wght.hidden), (Some("Weight"), 100.0, 400.0, 900.0, false));+    let xopq = d.axis("XOPQ").unwrap();+    assert_eq!((xopq.name.as_deref(), xopq.hidden), (Some("Parametric Thick Stroke"), true));+    assert_eq!(d.instances.len(), 1, "{:?}", d.instances);+    assert_eq!(d.instances[0].name, "Heavy");+    assert_eq!(d.instances[0].coords, vec![("wght".to_string(), 800.0), ("XOPQ".to_string(), 96.0)]);+    assert_eq!(d.instances[0].postscript_name.as_deref(), Some("TestVF-Heavy"));+    assert_eq!(d.variations_prefix.as_deref(), Some("TestVF"));+    let stat: Vec<(&str, Option<f32>)> = d.stat_axes.iter().map(|a| (a.tag.as_str(), a.value)).collect();+    assert_eq!(stat, [("wght", None), ("ital", Some(0.0))]);++    let tags: Vec<&str> = d.features.iter().map(|f| f.tag.as_str()).collect();+    assert_eq!(tags, ["cv11", "kern", "ss01"], "sorted, each tag once");+    let ss01 = d.feature("ss01").unwrap();+    assert_eq!((ss01.name.as_deref(), ss01.sample.as_str()), (Some("Open digits"), "1"));+    let cv11 = d.feature("cv11").unwrap();+    assert_eq!((cv11.name.as_deref(), cv11.sample.as_str()), (Some("Single-story a"), "a"));+    assert_eq!((ss01.changed, cv11.changed, d.feature("kern").map(|f| f.changed)), (1, 1, Some(0)));+    assert!(d.features.iter().all(|f| !f.partial), "everything was read");+    assert_eq!(d.feature("kern").map(|f| (f.name.clone(), f.sample.clone())), Some((None, String::new())), "positioning has no sample");+    assert!(d.has_feature("kern") && !d.has_feature("liga") && d.feature("zzzz").is_none());+}++#[test]+fn not_a_font_is_an_error() {+    assert!(craft_font_info::read(b"not a font", 0).is_err());+    assert!(craft_font_info::read(&[], 0).is_err());+    assert!(craft_font_info::read(&font(), 5).is_err(), "no face 5");+    assert!(craft_font_info::read(b"not a font", 0).unwrap_err().to_string().starts_with("not a readable font"));+}++/// Fonts are input: every truncation and a sweep of corrupted bytes reads or fails, never panics.+#[test]+fn damaged_fonts_never_panic() {+    let f = font();+    for n in 0..f.len() {+        let _ = craft_font_info::read(&f[..n], 0);+    }+    for i in 0..f.len() {+        for v in [0x00, 0xFF, 0x7F] {+            let mut g = f.clone();+            g[i] = v;+            let _ = craft_font_info::read(&g, 0);+        }+    }+}++/// A font with three `fvar` axes, the middle one malformed (`min > max`), and one instance.+fn font_with_a_bad_axis() -> Vec<u8> {+    let cmap = Cmap::from_mappings([('a', GlyphId::new(1))]).unwrap();+    let axes = vec![+        VariationAxisRecord::new(Tag::new(b"wght"), Fixed::from_f64(100.0), Fixed::from_f64(400.0), Fixed::from_f64(900.0), 0, NameId::new(256)),+        VariationAxisRecord::new(Tag::new(b"XXXX"), Fixed::from_f64(900.0), Fixed::from_f64(400.0), Fixed::from_f64(100.0), 0, NameId::new(256)),+        VariationAxisRecord::new(Tag::new(b"wdth"), Fixed::from_f64(50.0), Fixed::from_f64(100.0), Fixed::from_f64(200.0), 0, NameId::new(256)),+    ];+    let instances = vec![InstanceRecord {+        subfamily_name_id: NameId::new(258),+        flags: 0,+        coordinates: vec![Fixed::from_f64(800.0), Fixed::from_f64(50.0), Fixed::from_f64(75.0)],+        post_script_name_id: None,+    }];+    let fvar = Fvar::new(AxisInstanceArrays::new(axes, instances));+    let names = Name::new(vec![name(256, "Weight"), name(258, "Heavy")]);+    let mut b = FontBuilder::new();+    b.add_table(&cmap).unwrap().add_table(&names).unwrap().add_table(&fvar).unwrap();+    b.build()+}++/// A malformed axis is left out of `axes`, so its coordinate must be left out of every instance+/// too: `coords` promises one entry per axis, in `axes` order, and a consumer pairs them by index.+#[test]+fn instance_coordinates_pair_with_the_axes_kept() {+    let d = craft_font_info::read(&font_with_a_bad_axis(), 0).unwrap();+    let tags: Vec<&str> = d.axes.iter().map(|a| a.tag.as_str()).collect();+    assert_eq!(tags, ["wght", "wdth"], "the malformed axis is left out");+    let coords = &d.instances[0].coords;+    assert_eq!(coords, &[("wght".to_string(), 800.0), ("wdth".to_string(), 75.0)]);+    for (axis, (tag, value)) in d.axes.iter().zip(coords) {+        assert_eq!(&axis.tag, tag, "coordinate and axis line up");+        assert!((axis.min..=axis.max).contains(value), "{tag} {value} is outside its axis");+    }+}++#[test]+fn registered_feature_names() {+    assert_eq!(craft_font_info::feature_name("tnum"), Some("Tabular Figures"));+    assert_eq!(craft_font_info::feature_name("salt"), Some("Stylistic Alternates"));+    // Numbered sets and variants are named by the font; unknown tags have no name.+    for tag in ["ss01", "cv11", "zzzz", ""] {+        assert_eq!(craft_font_info::feature_name(tag), None, "{tag}");+    }+}++#[test]+fn feature_names_are_one_per_tag() {+    let mut tags: Vec<&str> = craft_font_info::FEATURE_NAMES.iter().map(|(t, _)| *t).collect();+    let n = tags.len();+    tags.sort_unstable();+    tags.dedup();+    assert_eq!(tags.len(), n, "a tag listed twice");+    assert!(tags.iter().all(|t| t.len() == 4 && t.is_ascii()));+}++/// The names are the registry's, so they follow how it capitalises: every word, small ones+/// included, starts with a capital ("Small Capitals From Capitals", "JIS78 Forms"), and none is+/// padding. Whether a name is the *right* word for its tag can't be checked without a second copy+/// of the registry, so that stays a review question; what a test can hold is that no name drifts+/// into house style, repeats another's, or goes missing.+#[test]+fn feature_names_are_capitalised_as_the_registry_writes_them() {+    for (tag, name) in craft_font_info::FEATURE_NAMES {+        assert!(!name.is_empty() && *name == name.trim() && name.is_ascii(), "{tag}: {name:?}");+        for word in name.split(' ') {+            assert!(word.starts_with(|c: char| c.is_ascii_uppercase()), "{tag}: {name:?} has {word:?}");+        }+    }+    // Seven CJK tags once shared one label, so a UI showed seven identical toggles.+    let mut names: Vec<&str> = craft_font_info::FEATURE_NAMES.iter().map(|(_, n)| *n).collect();+    let n = names.len();+    names.sort_unstable();+    names.dedup();+    assert_eq!(names.len(), n, "two tags share a label");+}++/// A `GSUB` shaped like an attack, which no font tool would build and `write-fonts` refuses to:+/// one lookup holding `subtables` offsets that all point at the same 12-byte substitution, listed+/// `indices` times in the lookup list, and named by `records` feature records with distinct tags.+fn hostile_gsub(subtables: usize, indices: usize, records: usize, resolvable: bool) -> Vec<u8> {+    let mut script = Vec::new();+    script.extend(1u16.to_be_bytes()); // ScriptList: one script,+    script.extend(b"DFLT");+    script.extend(8u16.to_be_bytes()); // after the count and this record+    script.extend(4u16.to_be_bytes()); // Script: a default LangSys, no others+    script.extend(0u16.to_be_bytes());+    script.extend([0u16.to_be_bytes(), u16::MAX.to_be_bytes(), 0u16.to_be_bytes()].concat()); // no features++    let mut features = Vec::new();+    features.extend((records as u16).to_be_bytes());+    let tables = 2 + 6 * records;+    for i in 0..records {+        features.extend(format!("{:04x}", i).as_bytes());+        features.extend(((tables + i * (4 + 2 * indices)) as u16).to_be_bytes());+    }+    for _ in 0..records {+        features.extend(0u16.to_be_bytes()); // no feature params+        features.extend((indices as u16).to_be_bytes());+        for index in 0..indices as u16 {+            features.extend(index.to_be_bytes());+        }+    }++    let mut lookups = Vec::new();+    lookups.extend((indices as u16).to_be_bytes());+    for _ in 0..indices {+        // Every index names the one lookup, which follows the offsets.+        lookups.extend(((2 + 2 * indices) as u16).to_be_bytes());+    }+    lookups.extend([1u16.to_be_bytes(), 0u16.to_be_bytes(), (subtables as u16).to_be_bytes()].concat());+    for _ in 0..subtables {+        // Just past what the table holds, so every one of them fails to resolve.+        let offset = if resolvable { (6 + 2 * subtables) as u16 } else { 0xFFFE };+        lookups.extend(offset.to_be_bytes());+    }+    if resolvable {+        lookups.extend([1u16.to_be_bytes(), 6u16.to_be_bytes(), 1i16.to_be_bytes()].concat()); // single: 3 → 4+        lookups.extend([1u16.to_be_bytes(), 1u16.to_be_bytes(), 3u16.to_be_bytes()].concat()); // coverage: glyph 3+    }++    let mut gsub = vec![0, 1, 0, 0];+    for offset in [10, 10 + script.len(), 10 + script.len() + features.len()] {+        gsub.extend((offset as u16).to_be_bytes());+    }+    gsub.extend(script);+    gsub.extend(features);+    gsub.extend(lookups);+    gsub+}++/// A `name` table with `count` UTF-16 records, the last of them carrying ID 30000; one `x` each.+/// `stringOffset` is a `uint16`, so about 5,460 records is as long as a valid table gets.+fn long_name_table(count: usize) -> Vec<u8> {+    let mut out = Vec::new();+    out.extend(0u16.to_be_bytes());+    out.extend((count as u16).to_be_bytes());+    out.extend(((6 + 12 * count) as u16).to_be_bytes());+    for i in 0..count {+        let id: u16 = if i + 1 == count { 30_000 } else { 1 };+        out.extend(+            [3u16.to_be_bytes(), 1u16.to_be_bytes(), 0x409u16.to_be_bytes(), id.to_be_bytes(), 2u16.to_be_bytes(), (2 * i as u16).to_be_bytes()]+                .concat(),+        );+    }+    for _ in 0..count {+        out.extend([0u8, b'x']); // UTF-16BE "x"+    }+    out+}++/// A font whose `instances` named instances all take their name from ID 30000.+fn many_instances(instances: u16) -> Vec<u8> {+    let cmap = Cmap::from_mappings([('a', GlyphId::new(1))]).unwrap();+    let axes = vec![VariationAxisRecord::new(+        Tag::new(b"wght"),+        Fixed::from_f64(100.0),+        Fixed::from_f64(400.0),+        Fixed::from_f64(900.0),+        0,+        NameId::new(30000),+    )];+    let inst: Vec<InstanceRecord> = (0..instances)+        .map(|_| InstanceRecord {+            subfamily_name_id: NameId::new(30000),+            flags: 0,+            coordinates: vec![Fixed::from_f64(400.0)],+            post_script_name_id: None,+        })+        .collect();+    let fvar = Fvar::new(AxisInstanceArrays::new(axes, inst));+    let mut b = FontBuilder::new();+    b.add_table(&cmap).unwrap().add_table(&Name::new(vec![name(30000, "Heavy")])).unwrap().add_table(&fvar).unwrap();+    b.build()+}++/// `font` with `tag`'s table replaced (or added), recomputing the table directory.+fn with_table(font: &[u8], tag: &[u8; 4], table: &[u8]) -> Vec<u8> {+    let count = usize::from(u16::from_be_bytes([font[4], font[5]]));+    let mut tables: Vec<([u8; 4], &[u8])> = Vec::new();+    for i in 0..count {+        let record = 12 + i * 16;+        let existing: [u8; 4] = font[record..record + 4].try_into().unwrap();+        let offset = u32::from_be_bytes(font[record + 8..record + 12].try_into().unwrap()) as usize;+        let length = u32::from_be_bytes(font[record + 12..record + 16].try_into().unwrap()) as usize;+        if &existing != tag {+            tables.push((existing, &font[offset..offset + length]));+        }+    }+    tables.push((*tag, table));+    tables.sort_by_key(|(tag, _)| *tag);+    let n = tables.len() as u16;+    let log: u16 = (tables.len() as f32).log2().floor() as u16;+    let range: u16 = 16 << log;+    let mut out = Vec::new();+    out.extend(0x0001_0000u32.to_be_bytes());+    out.extend([n.to_be_bytes(), range.to_be_bytes(), log.to_be_bytes(), (n * 16 - range).to_be_bytes()].concat());+    let mut offset = 12 + 16 * tables.len();+    for (tag, table) in &tables {+        out.extend(*tag);+        out.extend(0u32.to_be_bytes()); // checksum: skrifa doesn't check it+        out.extend((offset as u32).to_be_bytes());+        out.extend((table.len() as u32).to_be_bytes());+        offset += table.len() + (4 - table.len() % 4) % 4;+    }+    for (_, table) in &tables {+        out.extend(*table);+        out.resize(out.len() + (4 - table.len() % 4) % 4, 0);+    }+    out+}++/// A font with one glyph, `1`, for the hostile `GSUB` to substitute.+fn one_glyph_font() -> Vec<u8> {+    let cmap = Cmap::from_mappings([('a', GlyphId::new(1)), ('1', GlyphId::new(3))]).unwrap();+    let mut b = FontBuilder::new();+    b.add_table(&cmap).unwrap();+    b.build()+}++/// The per-list caps multiply: 512 records × 48 lookups each × every subtable of those lookups is+/// 805 million coverage visits, which unbudgeted takes ~37 s optimized and minutes in a test+/// build, from a font of 120 KB. No per-list cap sees that, so the read has a budget.+#[test]+fn repeated_lookup_references_stay_within_budget() {+    let bytes = with_table(&one_glyph_font(), b"GSUB", &hostile_gsub(32_759, 48, 512, true));+    let started = std::time::Instant::now();+    let d = craft_font_info::read(&bytes, 0).unwrap();+    let took = started.elapsed();+    assert!(took < std::time::Duration::from_secs(2), "{took:?} for {} bytes", bytes.len());+    // What the budget reads is still the right answer, but the read can't know it saw it all.+    let first = d.feature("0000").unwrap();+    assert_eq!((first.sample.as_str(), first.changed, first.partial), ("1", 1, true));+}++/// Skipping a lookup the face has already read is per tag: two tags naming one lookup both see+/// what it changes.+#[test]+fn a_lookup_shared_by_two_tags_still_counts_for_both() {+    let d = craft_font_info::read(&with_table(&one_glyph_font(), b"GSUB", &hostile_gsub(1, 1, 2, true)), 0).unwrap();+    let tags: Vec<&str> = d.features.iter().map(|f| f.tag.as_str()).collect();+    assert_eq!(tags, ["0000", "0001"]);+    for tag in tags {+        let feature = d.feature(tag).unwrap();+        assert_eq!((feature.sample.as_str(), feature.changed, feature.partial), ("1", 1, false), "{tag}");+    }+}++/// A coverage table longer than the read takes from one is cut short, and the count says so+/// rather than passing the glyphs it read off as all the feature changes.+#[test]+fn a_cut_coverage_table_marks_the_count_partial() {+    let glyphs = 5_000u16;+    let cmap = Cmap::from_mappings((1..=glyphs).map(|g| (char::from_u32(0x4E00 + u32::from(g)).unwrap(), GlyphId::new(u32::from(g))))).unwrap();+    let coverage: CoverageTable = (1..=glyphs).map(GlyphId16::new).collect();+    let lookups = SubstitutionLookupList::new(vec![Lookup::new(LookupFlag::empty(), vec![SingleSubst::format_1(coverage, 1)]).into()]);+    let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![0]))]);+    let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0])), vec![]))]);+    let mut b = FontBuilder::new();+    b.add_table(&cmap).unwrap().add_table(&Gsub::new(scripts, features, lookups)).unwrap();+    let d = craft_font_info::read(&b.build(), 0).unwrap();+    let salt = d.feature("salt").unwrap();+    assert!(salt.partial);+    assert!(salt.changed > 0 && salt.changed < usize::from(glyphs), "{}", salt.changed);+}++/// Asking for a name by ID scans the whole `name` table, so a face that names 60 000 instances+/// from a long table spends a scan per instance. Naming the same instances must not get slower+/// when the table it reads them from gets 54 times longer.+#[test]+fn a_long_name_table_does_not_slow_every_instance() {+    let base = many_instances(60_000);+    let short = with_table(&base, b"name", &long_name_table(100));+    let long = with_table(&base, b"name", &long_name_table(5_400));+    let time = |bytes: &[u8]| {+        let started = std::time::Instant::now();+        let d = craft_font_info::read(bytes, 0).unwrap();+        assert_eq!(d.instances.len(), 60_000, "every instance keeps its name");+        assert_eq!(d.instances[0].name, "x");+        started.elapsed().as_secs_f64()+    };+    time(&short); // warm the allocator up before comparing+    let ratio = time(&long) / time(&short);+    assert!(ratio < 5.0, "a 54× longer name table took {ratio:.1}× as long");+}++/// A malformed table whose subtable offsets all fail to resolve must not walk them for free: the+/// budget pays for each offset it resolves, successful or not.+#[test]+fn unresolvable_subtables_stay_within_budget() {+    let bytes = with_table(&one_glyph_font(), b"GSUB", &hostile_gsub(16_000, 48, 512, false));+    let started = std::time::Instant::now();+    let d = craft_font_info::read(&bytes, 0).unwrap();+    let took = started.elapsed();+    // Uncharged, resolving 393 million of them took 2 s optimized and ~20 s in a test build.+    assert!(took < std::time::Duration::from_secs(1), "{took:?} for {} bytes", bytes.len());+    // Nothing resolved, so nothing changed; the tags are still listed.+    assert!(d.features.is_empty() || d.features.iter().all(|f| f.changed == 0));+}