Skip to main content

ferroui_base/media/
font_variation_settings.rs

1use std::fmt;
2use std::hash::{Hash, Hasher};
3use std::rc::Rc;
4use std::str::FromStr;
5
6use crate::media::fonts::OpenTypeTag;
7use crate::utilities::FormatError;
8
9/// A single variable-font axis setting in user-space coordinates, e.g.
10/// `wght=650`.
11#[derive(Clone, Copy, Debug)]
12pub struct FontVariation {
13    /// The four-character axis tag (e.g. `wght`).
14    pub tag: OpenTypeTag,
15    /// The axis value in user-space units (e.g. `650` for `wght`).
16    pub value: f64,
17}
18
19impl FontVariation {
20    /// Creates an axis setting.
21    pub const fn new(tag: OpenTypeTag, value: f64) -> Self {
22        Self { tag, value }
23    }
24}
25
26// Values compare like C# `double.Equals`: a NaN equals a NaN.
27impl PartialEq for FontVariation {
28    fn eq(&self, other: &Self) -> bool {
29        self.tag == other.tag && (self.value == other.value || (self.value.is_nan() && other.value.is_nan()))
30    }
31}
32
33impl Eq for FontVariation {}
34
35impl Hash for FontVariation {
36    fn hash<H: Hasher>(&self, state: &mut H) {
37        self.tag.hash(state);
38        state.write_u64(normalized_bits(self.value));
39    }
40}
41
42/// The bits of a value such that equal values have equal bits.
43fn normalized_bits(value: f64) -> u64 {
44    if value == 0.0 {
45        0
46    } else if value.is_nan() {
47        f64::NAN.to_bits()
48    } else {
49        value.to_bits()
50    }
51}
52
53/// Returns the `tag=value` form of the variation (e.g. `wght=650`), using the
54/// invariant culture.
55impl fmt::Display for FontVariation {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}=", self.tag)?;
58        write_invariant(f, self.value)
59    }
60}
61
62/// Writes a value as the shortest text that parses back to it, in the
63/// notation of the invariant culture (`Infinity`, `1E+21`, `1E-05`).
64fn write_invariant(f: &mut fmt::Formatter<'_>, value: f64) -> fmt::Result {
65    if value.is_nan() {
66        return f.write_str("NaN");
67    }
68
69    if value.is_infinite() {
70        return f.write_str(if value > 0.0 { "Infinity" } else { "-Infinity" });
71    }
72
73    if value == 0.0 {
74        return f.write_str(if value.is_sign_negative() { "-0" } else { "0" });
75    }
76
77    let scientific = format!("{value:e}");
78    let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
79    let exponent: i32 = exponent.parse().unwrap_or(0);
80
81    if (-4..15).contains(&exponent) {
82        return write!(f, "{value}");
83    }
84
85    write!(f, "{mantissa}E{}{:02}", if exponent < 0 { '-' } else { '+' }, exponent.abs())
86}
87
88/// An immutable, value-equal set of variable-font axis settings in user-space
89/// coordinates (e.g. `wght=650,wdth=85`). This is the public input form;
90/// values are normalized against a specific font's `fvar`/`avar` when applied.
91///
92/// Cloning is cheap (one reference count). Variations are stored sorted by
93/// axis tag so that equality and hashing are independent of the order they
94/// were specified in.
95#[derive(Clone, Debug)]
96pub struct FontVariationSettings {
97    variations: Rc<[FontVariation]>,
98    hash_code: u64,
99}
100
101impl FontVariationSettings {
102    fn from_sorted(sorted_variations: Vec<FontVariation>) -> Self {
103        let hash_code = Self::compute_hash_code(&sorted_variations);
104
105        Self { variations: Rc::from(sorted_variations), hash_code }
106    }
107
108    /// The empty settings: no axis is varied.
109    pub fn empty() -> FontVariationSettings {
110        crate::ferro_thread_local! {
111            static EMPTY: FontVariationSettings = FontVariationSettings::from_sorted(Vec::new());
112        }
113
114        EMPTY.with(Clone::clone)
115    }
116
117    /// Initializes a new instance of the `FontVariationSettings` class.
118    ///
119    /// When a tag appears more than once, the last occurrence wins.
120    ///
121    /// Panics when a variation's value is NaN.
122    pub fn new(variations: impl IntoIterator<Item = FontVariation>) -> Self {
123        let mut builder: Vec<FontVariation> = Vec::new();
124
125        for variation in variations {
126            // Infinite values are usable - they clamp to the axis range like any other
127            // out-of-range value when the settings are applied. NaN has no such meaning.
128            if variation.value.is_nan() {
129                panic!("Value for axis '{}' must not be NaN.", variation.tag);
130            }
131
132            // Last-wins for duplicate tags, matching CSS font-variation-settings.
133            match builder.iter_mut().find(|existing| existing.tag == variation.tag) {
134                Some(existing) => *existing = variation,
135                None => builder.push(variation),
136            }
137        }
138
139        builder.sort_by_key(|variation| variation.tag.value());
140
141        Self::from_sorted(builder)
142    }
143
144    /// The axis settings, sorted by axis tag.
145    pub fn variations(&self) -> &[FontVariation] {
146        &self.variations
147    }
148
149    /// Gets whether no axis is varied.
150    pub fn is_empty(&self) -> bool {
151        self.variations.is_empty()
152    }
153
154    /// Tries to get the user-space value for an axis.
155    pub fn try_get_value(&self, tag: OpenTypeTag) -> Option<f64> {
156        self.variations.iter().find(|variation| variation.tag == tag).map(|variation| variation.value)
157    }
158
159    /// Parses a comma-separated list of `tag=value` pairs, e.g.
160    /// `"wght=650,wdth=85"`. Values use the invariant culture.
161    pub fn parse(s: &str) -> Result<FontVariationSettings, FormatError> {
162        if s.trim().is_empty() {
163            return Ok(Self::empty());
164        }
165
166        let mut variations = Vec::new();
167
168        for part in s.split(',') {
169            let pair = part.trim();
170
171            if pair.is_empty() {
172                continue;
173            }
174
175            let (tag_text, value_text) = match pair.split_once('=') {
176                Some((tag_text, value_text)) if !tag_text.is_empty() && !value_text.is_empty() => {
177                    (tag_text.trim(), value_text.trim())
178                }
179                _ => {
180                    return Err(FormatError::from_string(format!(
181                        "Invalid font variation '{pair}': expected tag=value."
182                    )));
183                }
184            };
185
186            if tag_text.is_empty() || tag_text.encode_utf16().count() > 4 {
187                return Err(FormatError::from_string(format!("Invalid font variation axis tag '{tag_text}'.")));
188            }
189
190            // The value text is already trimmed, and no whitespace is allowed inside the number itself.
191            let value = match parse_invariant(value_text) {
192                Some(value) if !value.is_nan() => value,
193                _ => {
194                    return Err(FormatError::from_string(format!(
195                        "Invalid font variation value '{value_text}' for axis '{tag_text}'."
196                    )));
197                }
198            };
199
200            variations.push(FontVariation::new(OpenTypeTag::parse(tag_text), value));
201        }
202
203        Ok(if variations.is_empty() { Self::empty() } else { Self::new(variations) })
204    }
205
206    fn compute_hash_code(variations: &[FontVariation]) -> u64 {
207        let mut hasher = std::collections::hash_map::DefaultHasher::new();
208
209        for variation in variations {
210            variation.hash(&mut hasher);
211        }
212
213        hasher.finish()
214    }
215}
216
217/// Parses a number written with an optional leading sign, a decimal point and
218/// an exponent (invariant culture), or one of the symbols `Infinity`,
219/// `-Infinity` and `NaN`.
220fn parse_invariant(text: &str) -> Option<f64> {
221    let bytes = text.as_bytes();
222    let mut index = 0;
223
224    if matches!(bytes.first(), Some(b'+' | b'-')) {
225        index += 1;
226    }
227
228    let integer_start = index;
229
230    while bytes.get(index).is_some_and(u8::is_ascii_digit) {
231        index += 1;
232    }
233
234    let mut digits = index - integer_start;
235
236    if bytes.get(index) == Some(&b'.') {
237        index += 1;
238
239        let fraction_start = index;
240
241        while bytes.get(index).is_some_and(u8::is_ascii_digit) {
242            index += 1;
243        }
244
245        digits += index - fraction_start;
246    }
247
248    let mut is_number = digits > 0;
249
250    if is_number && matches!(bytes.get(index), Some(b'e' | b'E')) {
251        index += 1;
252
253        if matches!(bytes.get(index), Some(b'+' | b'-')) {
254            index += 1;
255        }
256
257        let exponent_start = index;
258
259        while bytes.get(index).is_some_and(u8::is_ascii_digit) {
260            index += 1;
261        }
262
263        is_number = index > exponent_start;
264    }
265
266    if is_number && index == bytes.len() {
267        return text.parse().ok();
268    }
269
270    let (negative, symbol) = match bytes.first() {
271        Some(b'-') => (true, &text[1..]),
272        Some(b'+') => (false, &text[1..]),
273        _ => (false, text),
274    };
275
276    if symbol.eq_ignore_ascii_case("Infinity") {
277        Some(if negative { f64::NEG_INFINITY } else { f64::INFINITY })
278    } else if symbol.eq_ignore_ascii_case("NaN") {
279        Some(f64::NAN)
280    } else {
281        None
282    }
283}
284
285impl Default for FontVariationSettings {
286    fn default() -> Self {
287        Self::empty()
288    }
289}
290
291impl FromStr for FontVariationSettings {
292    type Err = FormatError;
293
294    fn from_str(s: &str) -> Result<Self, Self::Err> {
295        Self::parse(s)
296    }
297}
298
299/// Returns the settings as a string that round-trips through
300/// [`FontVariationSettings::parse`].
301impl fmt::Display for FontVariationSettings {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        for (index, variation) in self.variations.iter().enumerate() {
304            if index > 0 {
305                f.write_str(",")?;
306            }
307
308            fmt::Display::fmt(variation, f)?;
309        }
310
311        Ok(())
312    }
313}
314
315impl PartialEq for FontVariationSettings {
316    fn eq(&self, other: &Self) -> bool {
317        if Rc::ptr_eq(&self.variations, &other.variations) {
318            return true;
319        }
320
321        if self.hash_code != other.hash_code {
322            return false;
323        }
324
325        self.variations == other.variations
326    }
327}
328
329impl Eq for FontVariationSettings {}
330
331impl Hash for FontVariationSettings {
332    fn hash<H: Hasher>(&self, state: &mut H) {
333        state.write_u64(self.hash_code);
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use std::collections::hash_map::DefaultHasher;
340
341    use super::*;
342
343    fn wght() -> OpenTypeTag {
344        OpenTypeTag::parse("wght")
345    }
346
347    fn wdth() -> OpenTypeTag {
348        OpenTypeTag::parse("wdth")
349    }
350
351    fn opsz() -> OpenTypeTag {
352        OpenTypeTag::parse("opsz")
353    }
354
355    fn hash_of(settings: &FontVariationSettings) -> u64 {
356        let mut hasher = DefaultHasher::new();
357        settings.hash(&mut hasher);
358        hasher.finish()
359    }
360
361    #[test]
362    fn empty_is_empty_and_round_trips() {
363        assert!(FontVariationSettings::empty().is_empty());
364        assert!(FontVariationSettings::empty().variations().is_empty());
365        assert_eq!(FontVariationSettings::empty().to_string(), "");
366        assert_eq!(FontVariationSettings::parse("").unwrap(), FontVariationSettings::empty());
367        assert_eq!(FontVariationSettings::parse("   ").unwrap(), FontVariationSettings::empty());
368    }
369
370    #[test]
371    fn parse_reads_comma_separated_tag_value_pairs() {
372        let settings = FontVariationSettings::parse(" wght = 700 , wdth=85.5 ").unwrap();
373
374        assert_eq!(settings.variations().len(), 2);
375        assert_eq!(settings.try_get_value(wght()), Some(700.0));
376        assert_eq!(settings.try_get_value(wdth()), Some(85.5));
377    }
378
379    #[test]
380    fn parse_rejects_malformed_input() {
381        for input in ["wght", "wght=", "=700", "weight=700", "wght=seven", "wght=NaN", "wght=7 0", "wght=inf", "wght=1e"]
382        {
383            assert!(FontVariationSettings::parse(input).is_err(), "{input}");
384        }
385    }
386
387    #[test]
388    fn to_string_round_trips_through_parse() {
389        let settings = FontVariationSettings::parse("opsz=14.25,wght=650").unwrap();
390
391        let round_tripped = FontVariationSettings::parse(&settings.to_string()).unwrap();
392
393        assert_eq!(settings, round_tripped);
394        assert_eq!(settings.to_string(), "opsz=14.25,wght=650");
395    }
396
397    #[test]
398    fn equality_is_order_independent_with_matching_hashes() {
399        let a = FontVariationSettings::new([FontVariation::new(wght(), 700.0), FontVariation::new(opsz(), 36.0)]);
400        let b = FontVariationSettings::new([FontVariation::new(opsz(), 36.0), FontVariation::new(wght(), 700.0)]);
401
402        assert_eq!(a, b);
403        assert_eq!(hash_of(&a), hash_of(&b));
404        assert_ne!(a, FontVariationSettings::parse("wght=700").unwrap());
405    }
406
407    #[test]
408    fn duplicate_tags_collapse_to_the_last_value() {
409        // CSS font-variation-settings behavior: the last occurrence wins.
410        let settings = FontVariationSettings::parse("wght=400,wght=700").unwrap();
411
412        assert_eq!(settings.variations().len(), 1);
413        assert_eq!(settings.try_get_value(wght()), Some(700.0));
414    }
415
416    #[test]
417    fn variations_are_sorted_by_tag() {
418        let settings = FontVariationSettings::parse("wght=700,opsz=14,wdth=85").unwrap();
419
420        assert_eq!(settings.variations()[0].tag, opsz());
421        assert_eq!(settings.variations()[1].tag, wdth());
422        assert_eq!(settings.variations()[2].tag, wght());
423    }
424
425    #[test]
426    #[should_panic(expected = "must not be NaN")]
427    // The null half of the reference test has no counterpart: the variations
428    // cannot be null.
429    fn constructor_rejects_nan_and_null() {
430        FontVariationSettings::new([FontVariation::new(wght(), f64::NAN)]);
431    }
432
433    #[test]
434    fn infinite_values_are_accepted_and_round_trip() {
435        // Infinities clamp to the axis range when applied, like any out-of-range value.
436        let settings = FontVariationSettings::new([
437            FontVariation::new(wght(), f64::INFINITY),
438            FontVariation::new(opsz(), f64::NEG_INFINITY),
439        ]);
440
441        assert_eq!(settings.try_get_value(wght()), Some(f64::INFINITY));
442
443        let round_tripped = FontVariationSettings::parse(&settings.to_string()).unwrap();
444
445        assert_eq!(settings, round_tripped);
446        assert_eq!(round_tripped.try_get_value(opsz()), Some(f64::NEG_INFINITY));
447    }
448
449    #[test]
450    fn try_get_value_misses_report_false_and_zero() {
451        let settings = FontVariationSettings::parse("wght=700").unwrap();
452
453        assert_eq!(settings.try_get_value(opsz()), None);
454    }
455
456    #[test]
457    fn variation_to_string_is_the_pair_form() {
458        assert_eq!(FontVariation::new(wght(), 700.0).to_string(), "wght=700");
459        assert_eq!(FontVariation::new(opsz(), 14.25).to_string(), "opsz=14.25");
460    }
461
462    #[test]
463    fn values_use_the_invariant_number_format() {
464        for (value, text) in [
465            (1e15, "wght=1E+15"),
466            (1.5e21, "wght=1.5E+21"),
467            (123456789012345.0, "wght=123456789012345"),
468            (0.0001, "wght=0.0001"),
469            (0.00001, "wght=1E-05"),
470            (-0.5, "wght=-0.5"),
471            (0.0, "wght=0"),
472        ] {
473            let variation = FontVariation::new(wght(), value);
474
475            assert_eq!(variation.to_string(), text);
476            assert_eq!(FontVariationSettings::parse(text).unwrap().try_get_value(wght()), Some(value));
477        }
478
479        assert_eq!(FontVariationSettings::parse("wght=+.5e1").unwrap().try_get_value(wght()), Some(5.0));
480        assert_eq!(FontVariationSettings::parse("wght=5.").unwrap().try_get_value(wght()), Some(5.0));
481    }
482}