1use std::sync::Arc;
7
8use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle};
9
10#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct FontId(pub [u8; 32]);
13
14impl FontId {
15 pub fn of(bytes: &[u8]) -> FontId {
16 use sha2::Digest;
17 FontId(sha2::Sha256::digest(bytes).into())
18 }
19
20 pub fn family(&self) -> String {
24 let mut name = String::from("devpage-font-");
25 for b in self.0 {
26 name.push_str(&format!("{b:02x}"));
27 }
28 name
29 }
30}
31
32impl std::fmt::Debug for FontId {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 for b in &self.0[..6] {
35 write!(f, "{b:02x}")?;
36 }
37 Ok(())
38 }
39}
40
41pub const MAX_FONT_BYTES: usize = 4 * 1024 * 1024;
47
48pub const INTER: &str = "Inter";
50pub const INTER_WEIGHTS: [u16; 4] = [400, 500, 600, 700];
51
52pub const WEIGHTS: [u16; 9] = [100, 200, 300, 400, 500, 600, 700, 800, 900];
54
55#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum Weights {
58 Static(f32),
60 Axis(f32, f32),
62}
63
64impl Weights {
65 pub fn has(&self, weight: u16) -> bool {
66 let w = weight as f32;
67 match *self {
68 Weights::Static(own) => own == w,
69 Weights::Axis(from, to) => (from..=to).contains(&w),
70 }
71 }
72}
73
74pub struct FontFile {
77 pub id: FontId,
78 pub bytes: Arc<Vec<u8>>,
79 pub blob: Blob<u8>,
82 pub family: String,
83 pub weights: Weights,
84 pub upright: bool,
85}
86
87impl std::fmt::Debug for FontFile {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("FontFile")
90 .field("id", &self.id)
91 .field("family", &self.family)
92 .field("weights", &self.weights)
93 .field("upright", &self.upright)
94 .finish()
95 }
96}
97
98impl PartialEq for FontFile {
100 fn eq(&self, other: &FontFile) -> bool {
101 self.id == other.id
102 }
103}
104
105pub type FontFiles = std::collections::BTreeMap<FontId, Arc<FontFile>>;
107
108impl FontFile {
109 pub fn new(bytes: Vec<u8>) -> Result<FontFile, String> {
112 FontFile::with_id(FontId::of(&bytes), bytes)
113 }
114
115 pub fn with_id(id: FontId, bytes: Vec<u8>) -> Result<FontFile, String> {
117 if bytes.len() > MAX_FONT_BYTES {
118 return Err(format!(
119 "is {:.1} MB; a font file is at most {} MB — subset it to the characters the design shows",
120 bytes.len() as f64 / (1024.0 * 1024.0),
121 MAX_FONT_BYTES / (1024 * 1024)
122 ));
123 }
124 match bytes.get(..4) {
125 Some(b"wOFF") => {
126 return Err(
127 "is a WOFF file, which devpage doesn't read — decompress it to a .ttf or .otf"
128 .into(),
129 );
130 }
131 Some(b"wOF2") => {
132 return Err(
133 "is a WOFF2 file, which devpage doesn't read — decompress it to a .ttf or .otf"
134 .into(),
135 );
136 }
137 Some(b"ttcf") => {
138 return Err(
139 "is a collection of fonts; name one font's own .ttf or .otf file".into(),
140 );
141 }
142 Some([0, 1, 0, 0] | b"OTTO" | b"true") => {}
143 _ => return Err("is not a font: devpage reads .ttf and .otf files".into()),
144 }
145 let bytes = Arc::new(bytes);
146 let blob = Blob::new(bytes.clone());
147 let mut collection = Collection::new(CollectionOptions {
148 shared: false,
149 system_fonts: false,
150 });
151 let mut found = collection.register_fonts(blob.clone(), None);
152 let (family, info) = match (found.len(), found.first_mut()) {
153 (1, Some((family, infos))) if infos.len() == 1 => (*family, infos.remove(0)),
154 (0, _) => return Err("is a .ttf or .otf file that doesn't read as a font".into()),
155 _ => {
156 return Err(
157 "holds more than one font; name one font's own .ttf or .otf file".into(),
158 );
159 }
160 };
161 let family = collection
162 .family_name(family)
163 .unwrap_or_default()
164 .to_string();
165 let weights = match info.axes().iter().find(|a| a.tag.to_be_bytes() == *b"wght") {
166 Some(axis) => Weights::Axis(axis.min, axis.max),
167 None => Weights::Static(info.weight().value()),
168 };
169 Ok(FontFile {
170 id,
171 bytes,
172 blob,
173 family,
174 weights,
175 upright: info.style() == FontStyle::Normal,
176 })
177 }
178}
179
180#[derive(Debug)]
183pub struct Family {
184 pub name: String,
185 pub faces: Vec<Arc<FontFile>>,
186}
187
188impl Family {
189 pub fn face(&self, weight: u16) -> Option<&Arc<FontFile>> {
191 self.faces.iter().find(|f| f.weights.has(weight))
192 }
193
194 pub fn weights(&self) -> Vec<u16> {
196 WEIGHTS
197 .into_iter()
198 .filter(|&w| self.face(w).is_some())
199 .collect()
200 }
201
202 pub fn of_folder(files: Vec<(String, Arc<FontFile>)>) -> Result<Family, String> {
205 if files.is_empty() {
206 return Err("holds no .ttf or .otf file".into());
207 }
208 let upright: Vec<_> = files.into_iter().filter(|(_, f)| f.upright).collect();
209 let Some((_, first)) = upright.first() else {
210 return Err("holds italic faces only; a text is set upright".into());
211 };
212 let name = first.family.clone();
213 if upright.iter().any(|(_, f)| f.family != name) {
214 let mut families: Vec<String> = Vec::new();
215 for (file, f) in &upright {
216 let entry = format!("{} ({file})", f.family);
217 if !families
218 .iter()
219 .any(|e| e.starts_with(&format!("{} (", f.family)))
220 {
221 families.push(entry);
222 }
223 }
224 return Err(format!(
225 "holds more than one family: {}",
226 families.join(", ")
227 ));
228 }
229 for w in WEIGHTS {
230 let of: Vec<&str> = upright
231 .iter()
232 .filter(|(_, f)| f.weights.has(w))
233 .map(|(file, _)| file.as_str())
234 .collect();
235 if of.len() > 1 {
236 return Err(format!(
237 "holds two files of weight {w}: {} and {}",
238 of[0], of[1]
239 ));
240 }
241 }
242 Ok(Family {
243 name,
244 faces: upright.into_iter().map(|(_, f)| f).collect(),
245 })
246 }
247}
248
249pub fn is_font_name(name: &str) -> bool {
251 let lower = name.to_ascii_lowercase();
252 lower.ends_with(".ttf") || lower.ends_with(".otf")
253}
254
255pub fn list(weights: &[u16]) -> String {
257 let words: Vec<String> = weights.iter().map(|w| w.to_string()).collect();
258 match words.split_last() {
259 Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
260 Some((last, _)) => last.clone(),
261 None => "none".into(),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn inter() -> Vec<u8> {
270 std::fs::read(concat!(
271 env!("CARGO_MANIFEST_DIR"),
272 "/fonts/Inter-Regular.ttf"
273 ))
274 .unwrap()
275 }
276
277 fn face(weight: f32, upright: bool) -> Arc<FontFile> {
280 let mut file = FontFile::new(inter()).unwrap();
281 file.weights = Weights::Static(weight);
282 file.upright = upright;
283 Arc::new(file)
284 }
285
286 #[test]
287 fn a_font_file_knows_its_family_and_weights() {
288 let file = FontFile::new(inter()).unwrap();
289 assert_eq!(file.family, "Inter");
290 assert_eq!(file.weights, Weights::Static(400.0));
291 assert!(file.upright);
292 let figtree = std::fs::read(concat!(
293 env!("CARGO_MANIFEST_DIR"),
294 "/../docs/designs/checkout/fonts/Figtree/Figtree-Variable.ttf"
295 ))
296 .unwrap();
297 let file = FontFile::new(figtree).unwrap();
298 assert_eq!(file.family, "Figtree");
299 assert_eq!(file.weights, Weights::Axis(300.0, 900.0));
300 let family = Family {
301 name: file.family.clone(),
302 faces: vec![Arc::new(file)],
303 };
304 assert_eq!(family.weights(), [300, 400, 500, 600, 700, 800, 900]);
305 }
306
307 #[test]
310 fn a_folder_is_its_upright_faces() {
311 let family = Family::of_folder(vec![
312 ("Regular.ttf".into(), face(400.0, true)),
313 ("Italic.ttf".into(), face(400.0, false)),
314 ("Bold.ttf".into(), face(700.0, true)),
315 ])
316 .unwrap();
317 assert_eq!(family.weights(), [400, 700]);
318 assert_eq!(
319 Family::of_folder(vec![("Italic.ttf".into(), face(400.0, false))]).unwrap_err(),
320 "holds italic faces only; a text is set upright"
321 );
322 assert_eq!(list(&[400]), "400");
323 assert_eq!(list(&[400, 700]), "400 and 700");
324 }
325}