Skip to main content

topcoat_font/
style.rs

1//! Font styles for building CSS `font-style` descriptors on `@font-face`
2//! rules.
3
4use topcoat_core::fnv1a::Fnv1a;
5
6/// An oblique slant angle in degrees, in `-90.0..=90.0`.
7///
8/// This is the angle a glyph is slanted from upright, as used by
9/// [`FontStyle::Oblique`]. CSS uses [`ObliqueAngle::DEFAULT`] (`14deg`) when an
10/// oblique style omits its angle.
11#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
12pub struct ObliqueAngle(f32);
13
14impl ObliqueAngle {
15    /// The angle CSS assumes for an oblique style with no explicit angle,
16    /// `14deg`.
17    pub const DEFAULT: Self = Self(14.0);
18
19    /// Create an oblique angle from a value in degrees.
20    ///
21    /// # Panics
22    ///
23    /// Panics if `degrees` is outside `-90.0..=90.0`. Use
24    /// `ObliqueAngle::try_from` for a non-panicking conversion.
25    #[must_use]
26    #[track_caller]
27    pub const fn new(degrees: f32) -> Self {
28        assert!(
29            degrees >= -90.0 && degrees <= 90.0,
30            "oblique angle out of range -90deg..=90deg"
31        );
32        Self(degrees)
33    }
34
35    /// Folds this angle into a running content hash.
36    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
37        h.write(&self.0.to_bits().to_le_bytes())
38    }
39}
40
41impl Default for ObliqueAngle {
42    fn default() -> Self {
43        Self::DEFAULT
44    }
45}
46
47impl From<ObliqueAngle> for f32 {
48    fn from(value: ObliqueAngle) -> Self {
49        value.0
50    }
51}
52
53/// Error returned when converting an angle outside `-90.0..=90.0` degrees into
54/// an [`ObliqueAngle`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct ObliqueAngleOutOfRangeError;
57
58impl std::fmt::Display for ObliqueAngleOutOfRangeError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str("oblique angle out of range -90deg..=90deg")
61    }
62}
63
64impl std::error::Error for ObliqueAngleOutOfRangeError {}
65
66impl TryFrom<f32> for ObliqueAngle {
67    type Error = ObliqueAngleOutOfRangeError;
68
69    fn try_from(value: f32) -> Result<Self, Self::Error> {
70        if !(-90.0..=90.0).contains(&value) {
71            return Err(ObliqueAngleOutOfRangeError);
72        }
73        Ok(Self(value))
74    }
75}
76
77impl std::fmt::Display for ObliqueAngle {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "{}deg", self.0)
80    }
81}
82
83/// An inclusive range of [`ObliqueAngle`]s, as carried by a variable font.
84///
85/// Displays as a single angle (`14deg`) when it covers one angle, or as the
86/// space-separated pair CSS expects otherwise (`20deg 40deg`).
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct ObliqueAngleRange {
89    start: ObliqueAngle,
90    end: ObliqueAngle,
91}
92
93impl ObliqueAngleRange {
94    /// Create an inclusive range from `start` to `end`.
95    ///
96    /// # Panics
97    ///
98    /// Panics if `end` is before `start`.
99    #[must_use]
100    #[track_caller]
101    pub const fn new(start: ObliqueAngle, end: ObliqueAngle) -> Self {
102        assert!(end.0 >= start.0, "oblique angle range must not be empty");
103        Self { start, end }
104    }
105
106    /// Create an inclusive range from two values in degrees.
107    ///
108    /// # Panics
109    ///
110    /// Panics if either value is outside `-90.0..=90.0`, or if `end` is before
111    /// `start`.
112    #[must_use]
113    #[track_caller]
114    pub const fn from_degrees(start: f32, end: f32) -> Self {
115        Self::new(ObliqueAngle::new(start), ObliqueAngle::new(end))
116    }
117
118    /// The smallest angle in the range.
119    #[must_use]
120    pub const fn start(&self) -> ObliqueAngle {
121        self.start
122    }
123
124    /// The largest angle in the range, inclusive.
125    #[must_use]
126    pub const fn end(&self) -> ObliqueAngle {
127        self.end
128    }
129
130    /// Folds this range into a running content hash.
131    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
132        self.end.hash(self.start.hash(h))
133    }
134}
135
136impl std::fmt::Display for ObliqueAngleRange {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        if self.start == self.end {
139            self.start.fmt(f)
140        } else {
141            write!(f, "{} {}", self.start, self.end)
142        }
143    }
144}
145
146/// The style axis of a font face: upright, italic, or oblique.
147///
148/// Displays as a CSS `font-style` value: `normal`, `italic`, `oblique`, or
149/// `oblique` followed by an angle or angle range (`oblique 14deg`,
150/// `oblique 20deg 40deg`).
151#[derive(Debug, Clone, Copy, PartialEq, Default)]
152pub enum FontStyle {
153    /// Upright (`normal`) face.
154    #[default]
155    Normal,
156    /// Italic (`italic`) face.
157    Italic,
158    /// Slanted (`oblique`) face, with an optional slant angle or angle range.
159    ///
160    /// `None` renders the bare `oblique` keyword, which CSS treats as
161    /// [`ObliqueAngle::DEFAULT`].
162    Oblique(Option<ObliqueAngleRange>),
163}
164
165impl FontStyle {
166    /// An oblique face with no explicit angle (CSS `oblique`).
167    #[must_use]
168    pub const fn oblique() -> Self {
169        Self::Oblique(None)
170    }
171
172    /// An oblique face slanted by a single angle in degrees
173    /// (CSS `oblique 14deg`).
174    ///
175    /// # Panics
176    ///
177    /// Panics if `degrees` is outside `-90.0..=90.0`.
178    #[must_use]
179    #[track_caller]
180    pub const fn oblique_angle(degrees: f32) -> Self {
181        let angle = ObliqueAngle::new(degrees);
182        Self::Oblique(Some(ObliqueAngleRange::new(angle, angle)))
183    }
184
185    /// An oblique face spanning a range of angles in degrees, as carried by a
186    /// variable font (CSS `oblique 20deg 40deg`).
187    ///
188    /// # Panics
189    ///
190    /// Panics if either value is outside `-90.0..=90.0`, or if `end` is before
191    /// `start`.
192    #[must_use]
193    #[track_caller]
194    pub const fn oblique_range(start: f32, end: f32) -> Self {
195        Self::Oblique(Some(ObliqueAngleRange::from_degrees(start, end)))
196    }
197
198    /// Folds this style into a running content hash.
199    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
200        match self {
201            Self::Normal => h.write(b"n"),
202            Self::Italic => h.write(b"i"),
203            Self::Oblique(None) => h.write(b"o"),
204            Self::Oblique(Some(range)) => range.hash(h.write(b"oa")),
205        }
206    }
207}
208
209impl std::fmt::Display for FontStyle {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        match self {
212            Self::Normal => f.write_str("normal"),
213            Self::Italic => f.write_str("italic"),
214            Self::Oblique(None) => f.write_str("oblique"),
215            Self::Oblique(Some(angle)) => write!(f, "oblique {angle}"),
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn normal_and_italic_display_as_keywords() {
226        assert_eq!(FontStyle::Normal.to_string(), "normal");
227        assert_eq!(FontStyle::Italic.to_string(), "italic");
228    }
229
230    #[test]
231    fn default_is_normal() {
232        assert_eq!(FontStyle::default(), FontStyle::Normal);
233    }
234
235    #[test]
236    fn bare_oblique_displays_without_an_angle() {
237        assert_eq!(FontStyle::oblique().to_string(), "oblique");
238    }
239
240    #[test]
241    fn oblique_with_an_angle_displays_the_angle() {
242        assert_eq!(FontStyle::oblique_angle(14.0).to_string(), "oblique 14deg");
243    }
244
245    #[test]
246    fn oblique_with_a_negative_angle_displays_the_sign() {
247        assert_eq!(
248            FontStyle::oblique_angle(-12.5).to_string(),
249            "oblique -12.5deg",
250        );
251    }
252
253    #[test]
254    fn oblique_with_a_range_displays_both_angles() {
255        assert_eq!(
256            FontStyle::oblique_range(20.0, 40.0).to_string(),
257            "oblique 20deg 40deg",
258        );
259    }
260
261    #[test]
262    fn oblique_range_collapses_when_start_equals_end() {
263        assert_eq!(
264            FontStyle::oblique_range(14.0, 14.0).to_string(),
265            "oblique 14deg",
266        );
267    }
268
269    #[test]
270    fn default_oblique_angle_is_14_degrees() {
271        assert_eq!(ObliqueAngle::DEFAULT.to_string(), "14deg");
272        assert_eq!(ObliqueAngle::default(), ObliqueAngle::DEFAULT);
273    }
274
275    #[test]
276    fn angle_converts_to_f32() {
277        assert!((f32::from(ObliqueAngle::new(30.0)) - 30.0).abs() < 0.001);
278    }
279
280    #[test]
281    fn try_from_accepts_the_bounds() {
282        assert_eq!(ObliqueAngle::try_from(-90.0), Ok(ObliqueAngle::new(-90.0)));
283        assert_eq!(ObliqueAngle::try_from(90.0), Ok(ObliqueAngle::new(90.0)));
284    }
285
286    #[test]
287    fn try_from_rejects_out_of_range() {
288        assert_eq!(
289            ObliqueAngle::try_from(90.1),
290            Err(ObliqueAngleOutOfRangeError),
291        );
292        assert_eq!(
293            ObliqueAngle::try_from(-90.1),
294            Err(ObliqueAngleOutOfRangeError),
295        );
296    }
297
298    #[test]
299    #[should_panic = "out of range"]
300    fn new_panics_above_the_maximum() {
301        let _ = ObliqueAngle::new(90.1);
302    }
303
304    #[test]
305    #[should_panic = "out of range"]
306    fn new_panics_below_the_minimum() {
307        let _ = ObliqueAngle::new(-90.1);
308    }
309
310    #[test]
311    #[should_panic = "empty"]
312    fn range_panics_when_end_precedes_start() {
313        let _ = ObliqueAngleRange::from_degrees(40.0, 20.0);
314    }
315
316    #[test]
317    fn range_exposes_its_bounds() {
318        let range = ObliqueAngleRange::from_degrees(20.0, 40.0);
319        assert_eq!(range.start(), ObliqueAngle::new(20.0));
320        assert_eq!(range.end(), ObliqueAngle::new(40.0));
321    }
322}