Skip to main content

polyfont_core/
font.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "kebab-case")]
7pub enum FontWeight {
8    Thin = 100,
9    ExtraLight = 200,
10    Light = 300,
11    #[default]
12    Regular = 400,
13    Medium = 500,
14    SemiBold = 600,
15    Bold = 700,
16    ExtraBold = 800,
17    Black = 900,
18}
19
20impl std::fmt::Display for FontWeight {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::Thin => write!(f, "thin"),
24            Self::ExtraLight => write!(f, "extra-light"),
25            Self::Light => write!(f, "light"),
26            Self::Regular => write!(f, "regular"),
27            Self::Medium => write!(f, "medium"),
28            Self::SemiBold => write!(f, "semi-bold"),
29            Self::Bold => write!(f, "bold"),
30            Self::ExtraBold => write!(f, "extra-bold"),
31            Self::Black => write!(f, "black"),
32        }
33    }
34}
35
36#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum FontStyle {
39    #[default]
40    Normal,
41    Italic,
42    Oblique,
43}
44
45impl std::fmt::Display for FontStyle {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Normal => write!(f, "normal"),
49            Self::Italic => write!(f, "italic"),
50            Self::Oblique => write!(f, "oblique"),
51        }
52    }
53}
54
55/// Named variable font axes for common controls.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum NamedAxis {
59    Weight,
60    Width,
61    Slant,
62    OpticalSize,
63    Italic,
64}
65
66impl NamedAxis {
67    /// Returns the 4-character OpenType axis tag.
68    #[must_use]
69    pub fn tag(&self) -> &'static str {
70        match self {
71            Self::Weight => "wght",
72            Self::Width => "wdth",
73            Self::Slant => "slnt",
74            Self::OpticalSize => "opsz",
75            Self::Italic => "ital",
76        }
77    }
78}
79
80impl std::fmt::Display for NamedAxis {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::Weight => write!(f, "weight"),
84            Self::Width => write!(f, "width"),
85            Self::Slant => write!(f, "slant"),
86            Self::OpticalSize => write!(f, "optical-size"),
87            Self::Italic => write!(f, "italic"),
88        }
89    }
90}
91
92/// A variable font axis value, either named or custom.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(untagged)]
95pub enum AxisValue {
96    Named { axis: NamedAxis, value: f32 },
97    Custom { tag: String, value: f32 },
98}
99
100impl AxisValue {
101    /// Returns the axis tag (4 chars for OpenType, or custom string).
102    #[must_use]
103    pub fn tag(&self) -> &str {
104        match self {
105            Self::Named { axis, .. } => axis.tag(),
106            Self::Custom { tag, .. } => tag,
107        }
108    }
109
110    /// Returns the axis value.
111    #[must_use]
112    pub fn value(&self) -> f32 {
113        match self {
114            Self::Named { value, .. } | Self::Custom { value, .. } => *value,
115        }
116    }
117
118    /// Formats as CSS `font-variation-settings` value (e.g., `"wght" 650`).
119    #[must_use]
120    pub fn to_css(&self) -> String {
121        format!("\"{}\" {}", self.tag(), self.value())
122    }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct FontSpec {
127    pub family: String,
128    #[serde(default)]
129    pub fallbacks: Vec<String>,
130    #[serde(default)]
131    pub weight: FontWeight,
132    #[serde(default)]
133    pub style: FontStyle,
134    #[serde(default)]
135    pub size: Option<f32>,
136    /// Variable font axis overrides. Keys are axis tags or named axes.
137    #[serde(default)]
138    pub axes: Vec<AxisValue>,
139}
140
141impl FontSpec {
142    #[must_use]
143    pub fn default_font(family: &str) -> Self {
144        Self {
145            family: family.to_string(),
146            fallbacks: vec![],
147            weight: FontWeight::default(),
148            style: FontStyle::default(),
149            size: None,
150            axes: vec![],
151        }
152    }
153
154    /// Returns CSS `font-variation-settings` string for all axes, or empty if none.
155    #[must_use]
156    pub fn css_variation_settings(&self) -> String {
157        if self.axes.is_empty() {
158            return String::new();
159        }
160        let parts: Vec<String> = self.axes.iter().map(AxisValue::to_css).collect();
161        parts.join(", ")
162    }
163
164    /// Returns a map from axis tag to value for programmatic access.
165    #[must_use]
166    pub fn axes_map(&self) -> BTreeMap<String, f32> {
167        self.axes
168            .iter()
169            .map(|a| (a.tag().to_string(), a.value()))
170            .collect()
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct FontRule {
176    pub scope: String,
177    pub font: FontSpec,
178}
179
180impl FontRule {
181    #[must_use]
182    pub fn specificity(&self) -> usize {
183        self.scope.split('.').count()
184    }
185}
186
187#[derive(Debug, Clone)]
188pub struct FontAssignment {
189    pub scope: String,
190    pub font: FontSpec,
191    pub specificity: usize,
192    pub is_active: bool,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_specificity_single() {
201        let rule = FontRule {
202            scope: "keyword".to_string(),
203            font: FontSpec::default_font("F"),
204        };
205        assert_eq!(rule.specificity(), 1);
206    }
207
208    #[test]
209    fn test_specificity_dotted() {
210        let rule = FontRule {
211            scope: "entity.name.function".to_string(),
212            font: FontSpec::default_font("F"),
213        };
214        assert_eq!(rule.specificity(), 3);
215    }
216
217    #[test]
218    fn test_specificity_wildcard() {
219        let rule = FontRule {
220            scope: "*".to_string(),
221            font: FontSpec::default_font("F"),
222        };
223        assert_eq!(rule.specificity(), 1);
224    }
225
226    #[test]
227    fn test_font_spec_default() {
228        let spec = FontSpec::default_font("Test");
229        assert_eq!(spec.family, "Test");
230        assert!(spec.fallbacks.is_empty());
231        assert_eq!(spec.weight, FontWeight::Regular);
232        assert_eq!(spec.style, FontStyle::Normal);
233        assert!(spec.size.is_none());
234        assert!(spec.axes.is_empty());
235    }
236
237    #[test]
238    fn test_named_axis_tags() {
239        assert_eq!(NamedAxis::Weight.tag(), "wght");
240        assert_eq!(NamedAxis::Width.tag(), "wdth");
241        assert_eq!(NamedAxis::Slant.tag(), "slnt");
242        assert_eq!(NamedAxis::OpticalSize.tag(), "opsz");
243        assert_eq!(NamedAxis::Italic.tag(), "ital");
244    }
245
246    #[test]
247    fn test_axis_value_named() {
248        let av = AxisValue::Named {
249            axis: NamedAxis::Weight,
250            value: 650.0,
251        };
252        assert_eq!(av.tag(), "wght");
253        assert!((av.value() - 650.0).abs() < f32::EPSILON);
254        assert_eq!(av.to_css(), "\"wght\" 650");
255    }
256
257    #[test]
258    fn test_axis_value_custom() {
259        let av = AxisValue::Custom {
260            tag: "CASL".to_string(),
261            value: 0.5,
262        };
263        assert_eq!(av.tag(), "CASL");
264        assert!((av.value() - 0.5).abs() < f32::EPSILON);
265    }
266
267    #[test]
268    fn test_css_variation_settings() {
269        let spec = FontSpec {
270            family: "Test".to_string(),
271            fallbacks: vec![],
272            weight: FontWeight::Regular,
273            style: FontStyle::Normal,
274            size: None,
275            axes: vec![
276                AxisValue::Named {
277                    axis: NamedAxis::Weight,
278                    value: 450.0,
279                },
280                AxisValue::Custom {
281                    tag: "CASL".to_string(),
282                    value: 1.0,
283                },
284            ],
285        };
286        let css = spec.css_variation_settings();
287        assert_eq!(css, "\"wght\" 450, \"CASL\" 1");
288    }
289
290    #[test]
291    fn test_axes_map() {
292        let spec = FontSpec {
293            family: "Test".to_string(),
294            fallbacks: vec![],
295            weight: FontWeight::Regular,
296            style: FontStyle::Normal,
297            size: None,
298            axes: vec![AxisValue::Named {
299                axis: NamedAxis::Width,
300                value: 75.0,
301            }],
302        };
303        let map = spec.axes_map();
304        assert_eq!(map.len(), 1);
305        assert!((map.get("wdth").copied().unwrap() - 75.0).abs() < f32::EPSILON);
306    }
307
308    #[test]
309    fn test_font_weight_display() {
310        assert_eq!(FontWeight::Thin.to_string(), "thin");
311        assert_eq!(FontWeight::ExtraLight.to_string(), "extra-light");
312        assert_eq!(FontWeight::Light.to_string(), "light");
313        assert_eq!(FontWeight::Regular.to_string(), "regular");
314        assert_eq!(FontWeight::Medium.to_string(), "medium");
315        assert_eq!(FontWeight::SemiBold.to_string(), "semi-bold");
316        assert_eq!(FontWeight::Bold.to_string(), "bold");
317        assert_eq!(FontWeight::ExtraBold.to_string(), "extra-bold");
318        assert_eq!(FontWeight::Black.to_string(), "black");
319    }
320
321    #[test]
322    fn test_font_style_display() {
323        assert_eq!(FontStyle::Normal.to_string(), "normal");
324        assert_eq!(FontStyle::Italic.to_string(), "italic");
325        assert_eq!(FontStyle::Oblique.to_string(), "oblique");
326    }
327}