Skip to main content

devpage_design/
fonts.rs

1//! Fonts: a design's text is set in Inter, which the engine embeds, or in a
2//! **font file** from the served folder — a `.ttf` or `.otf` of one font,
3//! checked as it is read and known by its content, so a scene holds and
4//! sends it once however many texts use it.
5
6use std::sync::Arc;
7
8use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle};
9
10/// A font file's identity: the SHA-256 of its bytes.
11#[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    /// The family name the file is registered under for layout: its own,
21    /// so a text gets exactly this file — never another font that calls its
22    /// family by the same name, Inter included.
23    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
41/// The largest font file a design may use. Every font file a design uses
42/// goes whole to each browser that shows it, before the design draws: a
43/// whole Latin family, Greek and Cyrillic included, is well under 1 MB, and
44/// a larger file covers thousands of characters — Chinese or Japanese — and
45/// should be subset to the ones the design shows.
46pub const MAX_FONT_BYTES: usize = 4 * 1024 * 1024;
47
48/// The family name of the embedded font, and its weights.
49pub const INTER: &str = "Inter";
50pub const INTER_WEIGHTS: [u16; 4] = [400, 500, 600, 700];
51
52/// The weights a text may ask for: 100 to 900 in steps of 100.
53pub const WEIGHTS: [u16; 9] = [100, 200, 300, 400, 500, 600, 700, 800, 900];
54
55/// The weights one font file has.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum Weights {
58    /// A static font: its one weight.
59    Static(f32),
60    /// A variable font: its weight axis, from and to.
61    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
74/// A font file, checked: its bytes, its family's name as its own tables give
75/// it, its weights, and whether it is upright.
76pub struct FontFile {
77    pub id: FontId,
78    pub bytes: Arc<Vec<u8>>,
79    /// The bytes as parley and both backends take them: one blob for the
80    /// file's life, so every cache keyed by it keeps working.
81    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
98/// Font files compare by their identity, which is their bytes.
99impl PartialEq for FontFile {
100    fn eq(&self, other: &FontFile) -> bool {
101        self.id == other.id
102    }
103}
104
105/// A scene's font files by id.
106pub type FontFiles = std::collections::BTreeMap<FontId, Arc<FontFile>>;
107
108impl FontFile {
109    /// Checks `bytes` as a font file, or says why it isn't one — a sentence
110    /// that follows the file's path.
111    pub fn new(bytes: Vec<u8>) -> Result<FontFile, String> {
112        FontFile::with_id(FontId::of(&bytes), bytes)
113    }
114
115    /// Checks `bytes`, already known to be the file `id`.
116    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/// One family's font files, from a file or a folder: the faces a text's
181/// weight picks from.
182#[derive(Debug)]
183pub struct Family {
184    pub name: String,
185    pub faces: Vec<Arc<FontFile>>,
186}
187
188impl Family {
189    /// The face a text of `weight` is set in, when the family has it.
190    pub fn face(&self, weight: u16) -> Option<&Arc<FontFile>> {
191        self.faces.iter().find(|f| f.weights.has(weight))
192    }
193
194    /// Every weight a text may ask of the family.
195    pub fn weights(&self) -> Vec<u16> {
196        WEIGHTS
197            .into_iter()
198            .filter(|&w| self.face(w).is_some())
199            .collect()
200    }
201
202    /// A folder's font files, each with its name, as one family — or why
203    /// they aren't one. Italic and oblique faces are passed over.
204    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
249/// Whether a file in a font folder is one of its fonts, by its name.
250pub 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
255/// `weights` as a sentence's list: `300, 400 and 700`.
256pub 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    /// A face as a folder might hold it: Inter's regular, said to be
278    /// `weight` and upright or not.
279    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    /// A folder's italic faces are passed over; one with nothing else is a
308    /// problem.
309    #[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}