Skip to main content

topcoat_font/
unicode.rs

1//! Unicode code points and ranges for building CSS `unicode-range`
2//! descriptors on subsetted `@font-face` rules.
3
4use std::ops::Deref;
5
6use topcoat_core::fnv1a::Fnv1a;
7
8/// A Unicode code point: an integer in `U+0000..=U+10FFFF`.
9///
10/// The upper bound is the Unicode code space, which is intentionally broader
11/// than [`char`]: surrogate code points (`U+D800..=U+DFFF`) are not valid
12/// [`char`]s, but are valid in a CSS `unicode-range`, which addresses code
13/// points rather than scalar values.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct UnicodeCodePoint(u32);
16
17impl UnicodeCodePoint {
18    /// Create a code point from a raw `u32`.
19    ///
20    /// # Panics
21    ///
22    /// Panics if `code_point` is greater than `U+10FFFF`. Use
23    /// `UnicodeCodePoint::try_from` for a non-panicking conversion.
24    #[must_use]
25    #[track_caller]
26    pub const fn new(code_point: u32) -> Self {
27        assert!(
28            code_point <= 0x10_FFFF,
29            "unicode code point exceeds U+10FFFF"
30        );
31        Self(code_point)
32    }
33
34    /// Folds this code point into a running content hash.
35    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
36        h.write(&self.0.to_le_bytes())
37    }
38}
39
40impl From<UnicodeCodePoint> for u32 {
41    fn from(value: UnicodeCodePoint) -> Self {
42        value.0
43    }
44}
45
46/// Error returned when converting a `u32` greater than `U+10FFFF` into a
47/// [`UnicodeCodePoint`].
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct CodePointOutOfRangeError;
50
51impl std::fmt::Display for CodePointOutOfRangeError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str("code point exceeds U+10FFFF")
54    }
55}
56
57impl std::error::Error for CodePointOutOfRangeError {}
58
59impl TryFrom<u32> for UnicodeCodePoint {
60    type Error = CodePointOutOfRangeError;
61
62    fn try_from(value: u32) -> Result<Self, Self::Error> {
63        if value > 0x10_FFFF {
64            return Err(CodePointOutOfRangeError);
65        }
66        Ok(Self(value))
67    }
68}
69
70impl std::fmt::Display for UnicodeCodePoint {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        let code_point = self.0;
73        write!(f, "U+{code_point:04X}")
74    }
75}
76
77/// An inclusive range of [`UnicodeCodePoint`]s.
78///
79/// Displays as a single CSS `unicode-range` interval: `U+0041` when it covers
80/// one code point, or `U+0041-005A` otherwise.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub struct UnicodeRange {
83    start: UnicodeCodePoint,
84    end: UnicodeCodePoint,
85}
86
87impl UnicodeRange {
88    /// Create an inclusive range from `start` to `end`.
89    ///
90    /// # Panics
91    ///
92    /// Panics if `end` is before `start`.
93    #[must_use]
94    #[track_caller]
95    pub const fn new(start: UnicodeCodePoint, end: UnicodeCodePoint) -> Self {
96        assert!(end.0 >= start.0, "unicode range must not be empty");
97        Self { start, end }
98    }
99
100    /// Create an inclusive range from two raw code point values.
101    ///
102    /// # Panics
103    ///
104    /// Panics if either value is greater than `U+10FFFF`, or if `end` is
105    /// before `start`.
106    #[must_use]
107    #[track_caller]
108    pub const fn from_u32(start: u32, end: u32) -> Self {
109        Self::new(UnicodeCodePoint::new(start), UnicodeCodePoint::new(end))
110    }
111
112    /// The first code point in the range.
113    #[must_use]
114    pub const fn start(&self) -> UnicodeCodePoint {
115        self.start
116    }
117
118    /// The last code point in the range, inclusive.
119    #[must_use]
120    pub const fn end(&self) -> UnicodeCodePoint {
121        self.end
122    }
123
124    /// Folds this range into a running content hash.
125    pub(crate) const fn hash(self, h: Fnv1a<u64>) -> Fnv1a<u64> {
126        self.end.hash(self.start.hash(h))
127    }
128}
129
130impl std::fmt::Display for UnicodeRange {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        if self.start == self.end {
133            self.start.fmt(f)
134        } else {
135            let start = self.start.0;
136            let end = self.end.0;
137            write!(f, "U+{start:04X}-{end:04X}")
138        }
139    }
140}
141
142/// A set of [`UnicodeRange`]s, the value of a CSS `unicode-range` descriptor.
143///
144/// Displays as the comma-separated list CSS expects, e.g.
145/// `U+0000-00FF, U+0131, U+0152-0153`.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
147pub struct UnicodeRanges(&'static [UnicodeRange]);
148
149impl UnicodeRanges {
150    /// Wrap a slice of ranges.
151    #[must_use]
152    pub const fn new(ranges: &'static [UnicodeRange]) -> Self {
153        Self(ranges)
154    }
155
156    /// Folds these ranges into a running content hash.
157    pub(crate) const fn hash(self, mut h: Fnv1a<u64>) -> Fnv1a<u64> {
158        let mut i = 0;
159        while i < self.0.len() {
160            h = self.0[i].hash(h);
161            i += 1;
162        }
163        h
164    }
165}
166
167impl std::fmt::Display for UnicodeRanges {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        for (i, range) in self.0.iter().enumerate() {
170            if i > 0 {
171                f.write_str(", ")?;
172            }
173            range.fmt(f)?;
174        }
175        Ok(())
176    }
177}
178
179impl Deref for UnicodeRanges {
180    type Target = [UnicodeRange];
181
182    fn deref(&self) -> &Self::Target {
183        self.0
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn cp(value: u32) -> UnicodeCodePoint {
192        UnicodeCodePoint::new(value)
193    }
194
195    #[test]
196    fn code_point_displays_as_padded_hex() {
197        assert_eq!(cp(0x41).to_string(), "U+0041");
198        assert_eq!(cp(0x10_FFFF).to_string(), "U+10FFFF");
199    }
200
201    #[test]
202    fn code_point_converts_to_u32() {
203        assert_eq!(u32::from(cp(0x1F600)), 0x1F600);
204    }
205
206    #[test]
207    fn try_from_accepts_surrogates_and_the_maximum() {
208        assert_eq!(UnicodeCodePoint::try_from(0xD800), Ok(cp(0xD800)));
209        assert_eq!(UnicodeCodePoint::try_from(0x10_FFFF), Ok(cp(0x10_FFFF)));
210    }
211
212    #[test]
213    fn try_from_rejects_out_of_range() {
214        assert_eq!(
215            UnicodeCodePoint::try_from(0x11_0000),
216            Err(CodePointOutOfRangeError),
217        );
218    }
219
220    #[test]
221    #[should_panic = "exceeds"]
222    fn new_panics_on_out_of_range_code_point() {
223        let _ = UnicodeCodePoint::new(0x11_0000);
224    }
225
226    #[test]
227    fn single_code_point_range_omits_the_dash() {
228        assert_eq!(UnicodeRange::new(cp(0x41), cp(0x41)).to_string(), "U+0041");
229    }
230
231    #[test]
232    fn multi_code_point_range_includes_the_dash() {
233        assert_eq!(
234            UnicodeRange::new(cp(0x41), cp(0x5A)).to_string(),
235            "U+0041-005A",
236        );
237    }
238
239    #[test]
240    #[should_panic = "empty"]
241    fn range_panics_when_end_precedes_start() {
242        let _ = UnicodeRange::new(cp(0x5A), cp(0x41));
243    }
244
245    #[test]
246    fn range_from_u32_matches_new() {
247        assert_eq!(
248            UnicodeRange::from_u32(0x41, 0x5A),
249            UnicodeRange::new(cp(0x41), cp(0x5A)),
250        );
251    }
252
253    #[test]
254    #[should_panic = "exceeds"]
255    fn range_from_u32_panics_on_out_of_range() {
256        let _ = UnicodeRange::from_u32(0x00, 0x11_0000);
257    }
258
259    #[test]
260    fn ranges_display_comma_separated() {
261        const RANGES: UnicodeRanges = UnicodeRanges::new(&[
262            UnicodeRange::new(UnicodeCodePoint::new(0x00), UnicodeCodePoint::new(0xFF)),
263            UnicodeRange::new(UnicodeCodePoint::new(0x131), UnicodeCodePoint::new(0x131)),
264            UnicodeRange::new(UnicodeCodePoint::new(0x152), UnicodeCodePoint::new(0x153)),
265        ]);
266        assert_eq!(RANGES.to_string(), "U+0000-00FF, U+0131, U+0152-0153");
267    }
268
269    #[test]
270    fn empty_ranges_display_as_empty_string() {
271        const RANGES: UnicodeRanges = UnicodeRanges::new(&[]);
272        assert_eq!(RANGES.to_string(), "");
273    }
274
275    #[test]
276    fn ranges_deref_to_their_slice() {
277        const RANGES: UnicodeRanges = UnicodeRanges::new(&[UnicodeRange::new(
278            UnicodeCodePoint::new(0x00),
279            UnicodeCodePoint::new(0xFF),
280        )]);
281        assert_eq!(RANGES.len(), 1);
282        assert_eq!(RANGES[0].start(), cp(0x00));
283    }
284}