Skip to main content

gpui/text_system/
font_variations.rs

1use std::{
2    collections::BTreeMap,
3    hash::{Hash, Hasher},
4    str::FromStr,
5    sync::Arc,
6};
7
8/// A validated four-byte OpenType tag (printable ASCII, including spaces).
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
10pub struct FontTag([u8; 4]);
11
12impl FontTag {
13    /// Registered weight axis (`wght`), conventionally 400 for regular weight.
14    pub const WEIGHT: Self = Self(*b"wght");
15    /// Registered width axis (`wdth`), expressed as a percentage of normal width.
16    pub const WIDTH: Self = Self(*b"wdth");
17    /// Registered slant axis (`slnt`), expressed in degrees; negative values lean right.
18    pub const SLANT: Self = Self(*b"slnt");
19    /// Registered optical-size axis (`opsz`), expressed in typographic points.
20    /// Setting this explicitly does not enable automatic optical sizing.
21    pub const OPTICAL_SIZE: Self = Self(*b"opsz");
22    /// Registered italic axis (`ital`), conventionally 0 for roman and 1 for italic.
23    /// This selects a font design independently of the slant angle.
24    pub const ITALIC: Self = Self(*b"ital");
25
26    /// Returns the tag's bytes in OpenType order.
27    pub fn as_bytes(&self) -> &[u8; 4] {
28        &self.0
29    }
30}
31
32impl FromStr for FontTag {
33    type Err = anyhow::Error;
34    fn from_str(value: &str) -> Result<Self, Self::Err> {
35        anyhow::ensure!(
36            value.len() == 4 && value.bytes().all(|b| (0x20..=0x7e).contains(&b)),
37            "OpenType tags must contain four printable ASCII bytes"
38        );
39        Ok(Self(value.as_bytes().try_into().unwrap()))
40    }
41}
42
43impl std::fmt::Display for FontTag {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str(std::str::from_utf8(&self.0).unwrap())
46    }
47}
48
49/// Precise author coordinates, independent of any interactive preview policy.
50///
51/// Settings are sorted by tag; the last duplicate wins. Values are finite signed
52/// f32 coordinates with negative zero normalized. No quantization is performed.
53/// Explicit settings override implicit weight/style coordinates after face selection.
54/// Unsupported axes are ignored per face; supported values are clamped per face.
55#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, schemars::JsonSchema)]
56#[serde(into = "BTreeMap<String, f32>")]
57#[schemars(with = "BTreeMap<String, f32>")]
58pub struct FontVariations(Arc<Vec<(FontTag, f32)>>);
59
60impl Eq for FontVariations {}
61impl Hash for FontVariations {
62    fn hash<H: Hasher>(&self, state: &mut H) {
63        self.0.len().hash(state);
64        for (tag, value) in self.0.iter() {
65            tag.hash(state);
66            value.to_bits().hash(state);
67        }
68    }
69}
70
71impl FontVariations {
72    /// Validates and canonicalizes settings. Invalid values, even in overwritten
73    /// entries, are rejected. Empty settings preserve backend defaults.
74    pub fn new(settings: impl IntoIterator<Item = (FontTag, f32)>) -> anyhow::Result<Self> {
75        let mut axes = BTreeMap::new();
76        for (tag, value) in settings {
77            anyhow::ensure!(value.is_finite(), "axis {tag} must be finite");
78            axes.insert(tag, if value == 0.0 { 0.0 } else { value });
79        }
80        Ok(Self(Arc::new(axes.into_iter().collect())))
81    }
82
83    /// Returns canonical settings in tag order, in the font's design units.
84    pub fn settings(&self) -> &[(FontTag, f32)] {
85        &self.0
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::collections::hash_map::DefaultHasher;
93    fn hash(value: &FontVariations) -> u64 {
94        let mut h = DefaultHasher::new();
95        value.hash(&mut h);
96        h.finish()
97    }
98    #[test]
99    fn canonical_coordinates_preserve_signed_precision() {
100        let slnt = FontTag::SLANT;
101        let wght = FontTag::WEIGHT;
102        let a = FontVariations::new([(wght, 300.25), (slnt, -12.5), (wght, 400.125)]).unwrap();
103        let b = FontVariations::new([(slnt, -12.5), (wght, 400.125)]).unwrap();
104        assert_eq!(a, b);
105        assert_eq!(hash(&a), hash(&b));
106        assert_eq!(a.settings()[0], (slnt, -12.5));
107        assert_ne!(
108            a,
109            FontVariations::new([(slnt, -12.5), (wght, 400.126)]).unwrap()
110        );
111        let zero = FontVariations::new([(slnt, 0.0)]).unwrap();
112        let negative_zero = FontVariations::new([(slnt, -0.0)]).unwrap();
113        assert_eq!(zero, negative_zero);
114        assert_eq!(hash(&zero), hash(&negative_zero));
115    }
116    #[test]
117    fn serialization_preserves_signed_settings_and_rejects_invalid_duplicates() {
118        let values = FontVariations::new([("slnt".parse().unwrap(), -10.125)]).unwrap();
119        assert_eq!(
120            serde_json::to_string(&values).unwrap(),
121            r#"{"slnt":-10.125}"#
122        );
123        assert_eq!(
124            serde_json::from_str::<FontVariations>(r#"{"wght":400,"slnt":-10.125,"wght":500}"#)
125                .unwrap()
126                .settings(),
127            &[
128                ("slnt".parse().unwrap(), -10.125),
129                ("wght".parse().unwrap(), 500.0)
130            ]
131        );
132        assert!(serde_json::from_str::<FontVariations>(r#"{"wght":1e100,"wght":400}"#).is_err());
133        assert!(serde_json::from_str::<FontVariations>(r#"{"bad":400}"#).is_err());
134    }
135
136    #[test]
137    fn rejects_invalid_coordinates_and_tags() {
138        for (tag, name) in [
139            (FontTag::WEIGHT, "wght"),
140            (FontTag::WIDTH, "wdth"),
141            (FontTag::SLANT, "slnt"),
142            (FontTag::OPTICAL_SIZE, "opsz"),
143            (FontTag::ITALIC, "ital"),
144        ] {
145            assert_eq!(name.parse::<FontTag>().unwrap(), tag);
146            assert_eq!(tag.to_string(), name);
147        }
148        for tag in ["", "abc", "abcde", "éab", "a\nbc"] {
149            assert!(tag.parse::<FontTag>().is_err());
150        }
151        assert!("CFF ".parse::<FontTag>().is_ok());
152        for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
153            assert!(FontVariations::new([("slnt".parse().unwrap(), value)]).is_err());
154        }
155        assert!(FontVariations::default().settings().is_empty());
156    }
157}
158
159impl TryFrom<BTreeMap<String, f32>> for FontVariations {
160    type Error = anyhow::Error;
161    fn try_from(settings: BTreeMap<String, f32>) -> Result<Self, Self::Error> {
162        Self::new(
163            settings
164                .into_iter()
165                .map(|(tag, value)| Ok((tag.parse()?, value)))
166                .collect::<anyhow::Result<Vec<_>>>()?,
167        )
168    }
169}
170
171impl From<FontVariations> for BTreeMap<String, f32> {
172    fn from(settings: FontVariations) -> Self {
173        settings
174            .settings()
175            .iter()
176            .map(|(tag, value)| (tag.to_string(), *value))
177            .collect()
178    }
179}
180
181impl<'de> serde::Deserialize<'de> for FontVariations {
182    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
183        struct Visitor;
184        impl<'de> serde::de::Visitor<'de> for Visitor {
185            type Value = FontVariations;
186            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
187                formatter.write_str("a map of four-byte axis tags to finite coordinates")
188            }
189            fn visit_map<M: serde::de::MapAccess<'de>>(
190                self,
191                mut map: M,
192            ) -> Result<Self::Value, M::Error> {
193                let mut settings = Vec::new();
194                while let Some((tag, value)) = map.next_entry::<String, f32>()? {
195                    let tag = tag.parse().map_err(serde::de::Error::custom)?;
196                    if !value.is_finite() {
197                        return Err(serde::de::Error::custom("axis coordinate must be finite"));
198                    }
199                    settings.push((tag, value));
200                }
201                FontVariations::new(settings).map_err(serde::de::Error::custom)
202            }
203        }
204        deserializer.deserialize_map(Visitor)
205    }
206}