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#[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 #[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#[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 #[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 #[must_use]
112 pub fn value(&self) -> f32 {
113 match self {
114 Self::Named { value, .. } | Self::Custom { value, .. } => *value,
115 }
116 }
117
118 #[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 #[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 #[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 #[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}