waterui_cli/esp32/
fonts.rs1use 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
31pub 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
72pub 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 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
103fn 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
114fn 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 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 #[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}