1#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2pub struct FontFile {
3 pub path: String,
4 pub weight: FontWeight,
5 pub style: FontStyle,
6}
7
8impl FontFile {
9 pub fn new(path: impl Into<String>) -> Self {
10 Self {
11 path: path.into(),
12 weight: FontWeight::NORMAL,
13 style: FontStyle::Normal,
14 }
15 }
16
17 pub fn with_weight(mut self, weight: FontWeight) -> Self {
18 self.weight = weight;
19 self
20 }
21
22 pub fn with_style(mut self, style: FontStyle) -> Self {
23 self.style = style;
24 self
25 }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
29pub struct FileBackedFontFamily {
30 pub fonts: Vec<FontFile>,
31}
32
33impl FileBackedFontFamily {
34 pub fn new(fonts: Vec<FontFile>) -> Self {
35 assert!(
36 !fonts.is_empty(),
37 "FileBackedFontFamily requires at least one font file"
38 );
39 Self { fonts }
40 }
41}
42
43#[derive(Clone, Debug, PartialEq, Eq, Hash)]
44pub struct LoadedTypefacePath {
45 pub path: String,
46}
47
48impl LoadedTypefacePath {
49 pub fn new(path: impl Into<String>) -> Self {
50 Self { path: path.into() }
51 }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
55pub enum FontFamily {
56 #[default]
57 Default,
58 SansSerif,
59 Serif,
60 Monospace,
61 Cursive,
62 Fantasy,
63 Named(String),
64 FileBacked(FileBackedFontFamily),
65 LoadedTypeface(LoadedTypefacePath),
66}
67
68impl FontFamily {
69 pub fn named(name: impl Into<String>) -> Self {
70 Self::from_name(&name.into())
71 }
72
73 pub fn from_name(name: &str) -> Self {
74 match name {
75 "" | "Default" | "default" => Self::Default,
76 "SansSerif" | "sans-serif" => Self::SansSerif,
77 "Serif" | "serif" => Self::Serif,
78 "Monospace" | "monospace" => Self::Monospace,
79 "Cursive" | "cursive" => Self::Cursive,
80 "Fantasy" | "fantasy" => Self::Fantasy,
81 value => Self::Named(value.to_string()),
82 }
83 }
84
85 pub fn file_backed(fonts: Vec<FontFile>) -> Self {
86 Self::FileBacked(FileBackedFontFamily::new(fonts))
87 }
88
89 pub fn loaded_typeface_path(path: impl Into<String>) -> Self {
90 Self::LoadedTypeface(LoadedTypefacePath::new(path))
91 }
92
93 pub fn family_name(&self) -> Option<&str> {
94 match self {
95 Self::Named(name) => Some(name.as_str()),
96 _ => None,
97 }
98 }
99}
100
101impl From<&str> for FontFamily {
102 fn from(value: &str) -> Self {
103 Self::from_name(value)
104 }
105}
106
107impl From<String> for FontFamily {
108 fn from(value: String) -> Self {
109 Self::from_name(value.as_str())
110 }
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
114pub struct FontWeight(pub u16);
115
116impl FontWeight {
117 pub fn new(weight: u16) -> Self {
118 match Self::try_new(weight) {
119 Some(value) => value,
120 None => panic!("Font weight must be in range [1, 1000], got {weight}"),
121 }
122 }
123
124 pub const fn try_new(weight: u16) -> Option<Self> {
125 if weight >= 1 && weight <= 1000 {
126 Some(Self(weight))
127 } else {
128 None
129 }
130 }
131
132 pub const fn value(self) -> u16 {
133 self.0
134 }
135
136 pub const THIN: Self = Self(100);
137 pub const EXTRA_LIGHT: Self = Self(200);
138 pub const LIGHT: Self = Self(300);
139 pub const NORMAL: Self = Self(400);
140 pub const MEDIUM: Self = Self(500);
141 pub const SEMI_BOLD: Self = Self(600);
142 pub const BOLD: Self = Self(700);
143 pub const EXTRA_BOLD: Self = Self(800);
144 pub const BLACK: Self = Self(900);
145 pub const W100: Self = Self(100);
146 pub const W200: Self = Self(200);
147 pub const W300: Self = Self(300);
148 pub const W400: Self = Self(400);
149 pub const W500: Self = Self(500);
150 pub const W600: Self = Self(600);
151 pub const W700: Self = Self(700);
152 pub const W800: Self = Self(800);
153 pub const W900: Self = Self(900);
154}
155
156impl Default for FontWeight {
157 fn default() -> Self {
158 Self::NORMAL
159 }
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
163pub enum FontStyle {
164 #[default]
165 Normal,
166 Italic,
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
170pub enum FontSynthesis {
171 #[default]
172 None,
173 All,
174 Weight,
175 Style,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn font_family_maps_compose_generic_names() {
184 assert_eq!(FontFamily::from_name("Default"), FontFamily::Default);
185 assert_eq!(FontFamily::from_name("sans-serif"), FontFamily::SansSerif);
186 assert_eq!(FontFamily::from_name("serif"), FontFamily::Serif);
187 assert_eq!(FontFamily::from_name("monospace"), FontFamily::Monospace);
188 assert_eq!(FontFamily::from_name("cursive"), FontFamily::Cursive);
189 assert_eq!(FontFamily::from_name("fantasy"), FontFamily::Fantasy);
190 }
191
192 #[test]
193 fn font_family_preserves_custom_names() {
194 let family = FontFamily::named("Fira Sans");
195 assert_eq!(family, FontFamily::Named("Fira Sans".to_string()));
196 assert_eq!(family.family_name(), Some("Fira Sans"));
197 }
198
199 #[test]
200 fn font_family_file_backed_preserves_font_entries() {
201 let family = FontFamily::file_backed(vec![
202 FontFile::new("/tmp/Roboto-Regular.ttf"),
203 FontFile::new("/tmp/Roboto-Bold.ttf").with_weight(FontWeight::BOLD),
204 ]);
205 let FontFamily::FileBacked(file_backed) = family else {
206 panic!("expected file-backed family");
207 };
208 assert_eq!(file_backed.fonts.len(), 2);
209 assert_eq!(file_backed.fonts[0].path, "/tmp/Roboto-Regular.ttf");
210 assert_eq!(file_backed.fonts[0].weight, FontWeight::NORMAL);
211 assert_eq!(file_backed.fonts[1].path, "/tmp/Roboto-Bold.ttf");
212 assert_eq!(file_backed.fonts[1].weight, FontWeight::BOLD);
213 }
214
215 #[test]
216 fn font_file_builder_applies_style_and_weight() {
217 let file = FontFile::new("/tmp/Roboto-Italic.ttf")
218 .with_weight(FontWeight::MEDIUM)
219 .with_style(FontStyle::Italic);
220 assert_eq!(file.path, "/tmp/Roboto-Italic.ttf");
221 assert_eq!(file.weight, FontWeight::MEDIUM);
222 assert_eq!(file.style, FontStyle::Italic);
223 }
224
225 #[test]
226 #[should_panic(expected = "requires at least one font file")]
227 fn file_backed_font_family_rejects_empty_font_list() {
228 let _ = FileBackedFontFamily::new(Vec::new());
229 }
230
231 #[test]
232 fn font_family_loaded_typeface_path_preserves_path() {
233 let family = FontFamily::loaded_typeface_path("/tmp/FiraSans-Regular.ttf");
234 let FontFamily::LoadedTypeface(typeface) = family else {
235 panic!("expected loaded typeface family");
236 };
237 assert_eq!(typeface.path, "/tmp/FiraSans-Regular.ttf");
238 }
239
240 #[test]
241 fn font_weight_default_is_normal() {
242 assert_eq!(FontWeight::default(), FontWeight::NORMAL);
243 }
244
245 #[test]
246 fn font_weight_try_new_validates_range() {
247 assert_eq!(FontWeight::try_new(0), None);
248 assert_eq!(FontWeight::try_new(1), Some(FontWeight(1)));
249 assert_eq!(FontWeight::try_new(1000), Some(FontWeight(1000)));
250 assert_eq!(FontWeight::try_new(1001), None);
251 }
252}