Skip to main content

waterui_cli/esp32/
fonts.rs

1//! Build-time font subsetting for flash-bundled faces.
2//!
3//! A whole TTF embeds hundreds of kilobytes of glyphs a firmware image will
4//! never draw — a full Latin face is ~770 KB, of which an ASCII UI uses a few
5//! dozen kilobytes. `[backends.esp32] font_ranges` opts a project into
6//! subsetting: every configured font is reduced to the requested Unicode
7//! ranges before it is embedded, the way LVGL and Slint prepare their
8//! offline fonts, while the runtime keeps consuming ordinary TTF bytes.
9//!
10//! Subsetting is explicit rather than a default because it trades away
11//! glyphs silently: a codepoint outside the configured ranges simply does
12//! not render on the device. The ranges are part of the subset file's name,
13//! so changing them regenerates the harness instead of reusing stale bytes.
14//!
15//! The subsetter is `fontcull-klippa`, a Rust port of `HarfBuzz`'s
16//! `hb-subset`: it computes the composite-glyph and GSUB closures and keeps
17//! a working `cmap` for the retained codepoints, which is exactly what
18//! dew's parley/fontique/skrifa stack needs at runtime. Layout scripts,
19//! features, and name records are all retained so kerning and ligatures
20//! inside the kept ranges behave identically to the full font.
21
22use std::hash::{Hash, Hasher};
23use std::path::{Path, PathBuf};
24
25use eyre::{WrapErr, eyre};
26use fontcull_klippa::{Plan, SubsetFlags, parse_unicodes, subset_font};
27use fontcull_write_fonts::read::FontRef;
28use fontcull_write_fonts::read::collections::IntSet;
29use fontcull_write_fonts::types::{GlyphId, NameId, Tag};
30
31/// Subsets `source` to `ranges`, writing the result under `output_dir`.
32///
33/// Returns the subset file's path. The output name carries a hash of the
34/// ranges, so a changed configuration produces a new file (and therefore a
35/// harness regeneration) instead of silently reusing the old subset. An
36/// up-to-date output — newer than the source font — is reused as is.
37///
38/// # Errors
39///
40/// Returns an error when the source font cannot be read or parsed, when the
41/// ranges are invalid, or when the subset cannot be written.
42pub fn subset_into(source: &Path, ranges: &str, output_dir: &Path) -> eyre::Result<PathBuf> {
43    let stem = source
44        .file_stem()
45        .and_then(|stem| stem.to_str())
46        .ok_or_else(|| eyre!("font path {} has no UTF-8 file stem", source.display()))?;
47    let output = output_dir.join(format!("{stem}-{:08x}.subset.ttf", ranges_key(ranges)));
48
49    if is_fresh(source, &output) {
50        return Ok(output);
51    }
52
53    let data = std::fs::read(source)
54        .wrap_err_with(|| format!("failed to read font {}", source.display()))?;
55    let subset = subset_bytes(&data, ranges)
56        .wrap_err_with(|| format!("failed to subset font {}", source.display()))?;
57
58    std::fs::create_dir_all(output_dir)
59        .wrap_err_with(|| format!("failed to create {}", output_dir.display()))?;
60    std::fs::write(&output, &subset)
61        .wrap_err_with(|| format!("failed to write {}", output.display()))?;
62    tracing::info!(
63        "subset {} ({} KiB) to {} ({} KiB) for ranges {ranges}",
64        source.display(),
65        data.len() / 1024,
66        output.display(),
67        subset.len() / 1024,
68    );
69    Ok(output)
70}
71
72/// Reduces raw font bytes to the glyphs reachable from `ranges`.
73///
74/// # Errors
75///
76/// Returns an error when the font or the ranges fail to parse, or when the
77/// subsetter rejects the font.
78pub fn subset_bytes(data: &[u8], ranges: &str) -> eyre::Result<Vec<u8>> {
79    let font = FontRef::new(data).map_err(|error| eyre!("font does not parse: {error}"))?;
80    let unicodes = parse_unicodes(ranges)
81        .map_err(|error| eyre!("invalid font_ranges {ranges:?}: {error:?}"))?;
82    if unicodes.is_empty() {
83        return Err(eyre!("font_ranges {ranges:?} selects no codepoints"));
84    }
85    // Everything except the glyph set is retained: all layout scripts and
86    // features (kerning/ligatures within the kept ranges stay intact), all
87    // name records, no extra table drops. The savings come from the glyph
88    // outlines, which dominate the file.
89    let plan = Plan::new(
90        &IntSet::<GlyphId>::empty(),
91        &unicodes,
92        &font,
93        SubsetFlags::default(),
94        &IntSet::<Tag>::empty(),
95        &IntSet::<Tag>::all(),
96        &IntSet::<Tag>::all(),
97        &IntSet::<NameId>::all(),
98        &IntSet::<u16>::all(),
99    );
100    subset_font(&font, &plan).map_err(|error| eyre!("subsetting failed: {error:?}"))
101}
102
103/// Whether `output` exists and is at least as new as `source`.
104fn is_fresh(source: &Path, output: &Path) -> bool {
105    let (Ok(source_meta), Ok(output_meta)) = (source.metadata(), output.metadata()) else {
106        return false;
107    };
108    match (source_meta.modified(), output_meta.modified()) {
109        (Ok(source_time), Ok(output_time)) => output_time >= source_time,
110        _ => false,
111    }
112}
113
114/// A stable key for the ranges string, used in the subset file name.
115fn ranges_key(ranges: &str) -> u64 {
116    let mut hasher = std::hash::DefaultHasher::new();
117    ranges.hash(&mut hasher);
118    hasher.finish()
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    /// The font these tests subset.
126    ///
127    /// It is the one this repository ships for testing rather than whatever
128    /// the host has installed: a list of absolute paths only ever covers the
129    /// platforms someone remembered, and Windows was not one of them, so both
130    /// tests here failed on it outright (part of #152). A committed font also
131    /// makes the size assertion below mean something — it compares against a
132    /// known font rather than whichever one the machine happened to offer.
133    const TEST_FONT: &[u8] = include_bytes!(concat!(
134        env!("CARGO_MANIFEST_DIR"),
135        "/tests/fixtures/fonts/Roboto-Regular.ttf"
136    ));
137
138    fn host_font() -> Vec<u8> {
139        TEST_FONT.to_vec()
140    }
141
142    /// The subset must shrink dramatically while keeping a working cmap for
143    /// the retained range — the property dew's text stack depends on.
144    #[test]
145    fn ascii_subset_shrinks_and_keeps_cmap() {
146        use fontcull_skrifa::MetadataProvider as _;
147
148        let full = host_font();
149        let subset = subset_bytes(&full, "20-7E").expect("ASCII subset must succeed");
150        assert!(
151            subset.len() * 4 < full.len(),
152            "an ASCII subset should be under a quarter of the full font \
153             ({} vs {} bytes)",
154            subset.len(),
155            full.len()
156        );
157
158        let font = fontcull_skrifa::FontRef::new(&subset).expect("subset must parse as a font");
159        let charmap = font.charmap();
160        for ch in ['A', 'z', '0', ' ', '~'] {
161            let glyph = charmap
162                .map(ch)
163                .unwrap_or_else(|| panic!("subset cmap must map {ch:?}"));
164            assert_ne!(glyph.to_u32(), 0, "{ch:?} must not map to .notdef");
165        }
166        assert!(
167            charmap.map('中').is_none() || charmap.map('中').unwrap().to_u32() == 0,
168            "codepoints outside the ranges must not survive"
169        );
170    }
171
172    #[test]
173    fn empty_ranges_fail_fast() {
174        let full = host_font();
175        assert!(subset_bytes(&full, "").is_err());
176    }
177}