Skip to main content

topcoat_font/
weight.rs

1//! Font weights and weight ranges for building CSS `font-weight` descriptors
2//! on `@font-face` rules.
3
4use topcoat_core::fnv1a::Fnv1a;
5
6/// A font weight: an integer in `100..=900`.
7///
8/// These are the standard CSS `font-weight` values, from `100` (Thin) to `900`
9/// (Black), in steps of `100`. Intermediate values are also permitted for
10/// variable fonts. [`FontWeight::default`] is `400` (Normal).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct FontWeight(u16);
13
14impl FontWeight {
15    /// Thin (Hairline), `100`.
16    pub const THIN: Self = Self(100);
17    /// Extra Light (Ultra Light), `200`.
18    pub const EXTRA_LIGHT: Self = Self(200);
19    /// Light, `300`.
20    pub const LIGHT: Self = Self(300);
21    /// Normal (Regular), `400`.
22    pub const NORMAL: Self = Self(400);
23    /// Medium, `500`.
24    pub const MEDIUM: Self = Self(500);
25    /// Semi Bold (Demi Bold), `600`.
26    pub const SEMI_BOLD: Self = Self(600);
27    /// Bold, `700`.
28    pub const BOLD: Self = Self(700);
29    /// Extra Bold (Ultra Bold), `800`.
30    pub const EXTRA_BOLD: Self = Self(800);
31    /// Black (Heavy), `900`.
32    pub const BLACK: Self = Self(900);
33
34    /// Create a font weight from a raw `u16`.
35    ///
36    /// # Panics
37    ///
38    /// Panics if `weight` is outside `100..=900`. Use
39    /// `FontWeight::try_from` for a non-panicking conversion.
40    #[must_use]
41    #[track_caller]
42    pub const fn new(weight: u16) -> Self {
43        assert!(
44            weight >= 100 && weight <= 900,
45            "font weight out of range 100..=900"
46        );
47        Self(weight)
48    }
49
50    /// Folds this weight into a running content hash.
51    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
52        h.write(&self.0.to_le_bytes())
53    }
54}
55
56impl Default for FontWeight {
57    fn default() -> Self {
58        Self::NORMAL
59    }
60}
61
62impl From<FontWeight> for u16 {
63    fn from(value: FontWeight) -> Self {
64        value.0
65    }
66}
67
68/// Error returned when converting a `u16` outside `100..=900` into a
69/// [`FontWeight`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct FontWeightOutOfRangeError;
72
73impl std::fmt::Display for FontWeightOutOfRangeError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str("font weight out of range 100..=900")
76    }
77}
78
79impl std::error::Error for FontWeightOutOfRangeError {}
80
81impl TryFrom<u16> for FontWeight {
82    type Error = FontWeightOutOfRangeError;
83
84    fn try_from(value: u16) -> Result<Self, Self::Error> {
85        if !(100..=900).contains(&value) {
86            return Err(FontWeightOutOfRangeError);
87        }
88        Ok(Self(value))
89    }
90}
91
92impl std::fmt::Display for FontWeight {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        self.0.fmt(f)
95    }
96}
97
98/// An inclusive range of [`FontWeight`]s, as carried by a variable font.
99///
100/// Displays as a single CSS `font-weight` descriptor value: `400` when it
101/// covers one weight, or `400 700` otherwise.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub struct FontWeightRange {
104    start: FontWeight,
105    end: FontWeight,
106}
107
108impl FontWeightRange {
109    /// Create an inclusive range from `start` to `end`.
110    ///
111    /// # Panics
112    ///
113    /// Panics if `end` is before `start`.
114    #[must_use]
115    #[track_caller]
116    pub const fn new(start: FontWeight, end: FontWeight) -> Self {
117        assert!(end.0 >= start.0, "font weight range must not be empty");
118        Self { start, end }
119    }
120
121    /// Create an inclusive range from two raw weight values.
122    ///
123    /// # Panics
124    ///
125    /// Panics if either value is outside `100..=900`, or if `end` is before
126    /// `start`.
127    #[must_use]
128    #[track_caller]
129    pub const fn from_u16(start: u16, end: u16) -> Self {
130        Self::new(FontWeight::new(start), FontWeight::new(end))
131    }
132
133    /// The lightest weight in the range.
134    #[must_use]
135    pub const fn start(&self) -> FontWeight {
136        self.start
137    }
138
139    /// The heaviest weight in the range, inclusive.
140    #[must_use]
141    pub const fn end(&self) -> FontWeight {
142        self.end
143    }
144
145    /// Folds this range into a running content hash.
146    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
147        self.end.hash(self.start.hash(h))
148    }
149}
150
151impl std::fmt::Display for FontWeightRange {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        if self.start == self.end {
154            self.start.fmt(f)
155        } else {
156            write!(f, "{} {}", self.start, self.end)
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn w(value: u16) -> FontWeight {
166        FontWeight::new(value)
167    }
168
169    #[test]
170    fn weight_displays_as_its_number() {
171        assert_eq!(w(100).to_string(), "100");
172        assert_eq!(w(900).to_string(), "900");
173    }
174
175    #[test]
176    fn weight_converts_to_u16() {
177        assert_eq!(u16::from(w(700)), 700);
178    }
179
180    #[test]
181    fn default_is_normal() {
182        assert_eq!(FontWeight::default(), FontWeight::NORMAL);
183    }
184
185    #[test]
186    fn named_constants_match_their_values() {
187        assert_eq!(FontWeight::THIN, w(100));
188        assert_eq!(FontWeight::EXTRA_LIGHT, w(200));
189        assert_eq!(FontWeight::LIGHT, w(300));
190        assert_eq!(FontWeight::NORMAL, w(400));
191        assert_eq!(FontWeight::MEDIUM, w(500));
192        assert_eq!(FontWeight::SEMI_BOLD, w(600));
193        assert_eq!(FontWeight::BOLD, w(700));
194        assert_eq!(FontWeight::EXTRA_BOLD, w(800));
195        assert_eq!(FontWeight::BLACK, w(900));
196    }
197
198    #[test]
199    fn try_from_accepts_the_bounds() {
200        assert_eq!(FontWeight::try_from(100), Ok(w(100)));
201        assert_eq!(FontWeight::try_from(900), Ok(w(900)));
202    }
203
204    #[test]
205    fn try_from_rejects_out_of_range() {
206        assert_eq!(FontWeight::try_from(99), Err(FontWeightOutOfRangeError));
207        assert_eq!(FontWeight::try_from(901), Err(FontWeightOutOfRangeError));
208    }
209
210    #[test]
211    #[should_panic = "out of range"]
212    fn new_panics_below_the_minimum() {
213        let _ = FontWeight::new(99);
214    }
215
216    #[test]
217    #[should_panic = "out of range"]
218    fn new_panics_above_the_maximum() {
219        let _ = FontWeight::new(901);
220    }
221
222    #[test]
223    fn single_weight_range_displays_one_number() {
224        assert_eq!(FontWeightRange::new(w(400), w(400)).to_string(), "400");
225    }
226
227    #[test]
228    fn multi_weight_range_displays_both_numbers() {
229        assert_eq!(FontWeightRange::new(w(400), w(700)).to_string(), "400 700");
230    }
231
232    #[test]
233    #[should_panic = "empty"]
234    fn range_panics_when_end_precedes_start() {
235        let _ = FontWeightRange::new(w(700), w(400));
236    }
237
238    #[test]
239    fn range_from_u16_matches_new() {
240        assert_eq!(
241            FontWeightRange::from_u16(400, 700),
242            FontWeightRange::new(w(400), w(700)),
243        );
244    }
245
246    #[test]
247    #[should_panic = "out of range"]
248    fn range_from_u16_panics_on_out_of_range() {
249        let _ = FontWeightRange::from_u16(100, 901);
250    }
251}