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
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum FontFamilyError {
35 EmptyFileList,
36}
37
38impl std::fmt::Display for FontFamilyError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 FontFamilyError::EmptyFileList => {
42 write!(f, "file-backed font family requires at least one font file")
43 }
44 }
45 }
46}
47
48impl std::error::Error for FontFamilyError {}
49
50impl FileBackedFontFamily {
51 pub fn new(fonts: Vec<FontFile>) -> Result<Self, FontFamilyError> {
52 if fonts.is_empty() {
53 return Err(FontFamilyError::EmptyFileList);
54 }
55 Ok(Self { fonts })
56 }
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, Hash)]
60pub struct LoadedTypefacePath {
61 pub path: String,
62}
63
64impl LoadedTypefacePath {
65 pub fn new(path: impl Into<String>) -> Self {
66 Self { path: path.into() }
67 }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
71pub enum FontFamily {
72 #[default]
73 Default,
74 SansSerif,
75 Serif,
76 Monospace,
77 Cursive,
78 Fantasy,
79 Named(String),
80 FileBacked(FileBackedFontFamily),
81 LoadedTypeface(LoadedTypefacePath),
82}
83
84impl FontFamily {
85 pub fn named(name: impl Into<String>) -> Self {
86 Self::from_name(&name.into())
87 }
88
89 pub fn from_name(name: &str) -> Self {
90 match name {
91 "" | "Default" | "default" => Self::Default,
92 "SansSerif" | "sans-serif" => Self::SansSerif,
93 "Serif" | "serif" => Self::Serif,
94 "Monospace" | "monospace" => Self::Monospace,
95 "Cursive" | "cursive" => Self::Cursive,
96 "Fantasy" | "fantasy" => Self::Fantasy,
97 value => Self::Named(value.to_string()),
98 }
99 }
100
101 pub fn file_backed(fonts: Vec<FontFile>) -> Result<Self, FontFamilyError> {
102 FileBackedFontFamily::new(fonts).map(Self::FileBacked)
103 }
104
105 pub fn loaded_typeface_path(path: impl Into<String>) -> Self {
106 Self::LoadedTypeface(LoadedTypefacePath::new(path))
107 }
108
109 pub fn family_name(&self) -> Option<&str> {
110 match self {
111 Self::Named(name) => Some(name.as_str()),
112 _ => None,
113 }
114 }
115}
116
117impl From<&str> for FontFamily {
118 fn from(value: &str) -> Self {
119 Self::from_name(value)
120 }
121}
122
123impl From<String> for FontFamily {
124 fn from(value: String) -> Self {
125 Self::from_name(value.as_str())
126 }
127}
128
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
130pub struct FontWeight(pub u16);
131
132impl FontWeight {
133 pub const fn new(weight: u16) -> Self {
134 Self::clamped(weight)
135 }
136
137 pub const fn try_new(weight: u16) -> Option<Self> {
138 if weight >= 1 && weight <= 1000 {
139 Some(Self(weight))
140 } else {
141 None
142 }
143 }
144
145 pub const fn clamped(weight: u16) -> Self {
146 if weight < 1 {
147 Self(1)
148 } else if weight > 1000 {
149 Self(1000)
150 } else {
151 Self(weight)
152 }
153 }
154
155 pub const fn value(self) -> u16 {
156 self.0
157 }
158
159 pub const THIN: Self = Self(100);
160 pub const EXTRA_LIGHT: Self = Self(200);
161 pub const LIGHT: Self = Self(300);
162 pub const NORMAL: Self = Self(400);
163 pub const MEDIUM: Self = Self(500);
164 pub const SEMI_BOLD: Self = Self(600);
165 pub const BOLD: Self = Self(700);
166 pub const EXTRA_BOLD: Self = Self(800);
167 pub const BLACK: Self = Self(900);
168 pub const W100: Self = Self(100);
169 pub const W200: Self = Self(200);
170 pub const W300: Self = Self(300);
171 pub const W400: Self = Self(400);
172 pub const W500: Self = Self(500);
173 pub const W600: Self = Self(600);
174 pub const W700: Self = Self(700);
175 pub const W800: Self = Self(800);
176 pub const W900: Self = Self(900);
177}
178
179impl Default for FontWeight {
180 fn default() -> Self {
181 Self::NORMAL
182 }
183}
184
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
186pub enum FontStyle {
187 #[default]
188 Normal,
189 Italic,
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
193pub enum FontSynthesis {
194 #[default]
195 None,
196 All,
197 Weight,
198 Style,
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn font_family_maps_compose_generic_names() {
207 assert_eq!(FontFamily::from_name("Default"), FontFamily::Default);
208 assert_eq!(FontFamily::from_name("sans-serif"), FontFamily::SansSerif);
209 assert_eq!(FontFamily::from_name("serif"), FontFamily::Serif);
210 assert_eq!(FontFamily::from_name("monospace"), FontFamily::Monospace);
211 assert_eq!(FontFamily::from_name("cursive"), FontFamily::Cursive);
212 assert_eq!(FontFamily::from_name("fantasy"), FontFamily::Fantasy);
213 }
214
215 #[test]
216 fn font_family_preserves_custom_names() {
217 let family = FontFamily::named("Fira Sans");
218 assert_eq!(family, FontFamily::Named("Fira Sans".to_string()));
219 assert_eq!(family.family_name(), Some("Fira Sans"));
220 }
221
222 #[test]
223 fn font_family_file_backed_preserves_font_entries() {
224 let family = FontFamily::file_backed(vec![
225 FontFile::new("fixtures/fonts/Roboto-Regular.ttf"),
226 FontFile::new("fixtures/fonts/Roboto-Bold.ttf").with_weight(FontWeight::BOLD),
227 ])
228 .expect("valid file-backed font family");
229 let FontFamily::FileBacked(file_backed) = family else {
230 panic!("expected file-backed family");
231 };
232 assert_eq!(file_backed.fonts.len(), 2);
233 assert_eq!(
234 file_backed.fonts[0].path,
235 "fixtures/fonts/Roboto-Regular.ttf"
236 );
237 assert_eq!(file_backed.fonts[0].weight, FontWeight::NORMAL);
238 assert_eq!(file_backed.fonts[1].path, "fixtures/fonts/Roboto-Bold.ttf");
239 assert_eq!(file_backed.fonts[1].weight, FontWeight::BOLD);
240 }
241
242 #[test]
243 fn font_file_builder_applies_style_and_weight() {
244 let file = FontFile::new("fixtures/fonts/Roboto-Italic.ttf")
245 .with_weight(FontWeight::MEDIUM)
246 .with_style(FontStyle::Italic);
247 assert_eq!(file.path, "fixtures/fonts/Roboto-Italic.ttf");
248 assert_eq!(file.weight, FontWeight::MEDIUM);
249 assert_eq!(file.style, FontStyle::Italic);
250 }
251
252 #[test]
253 fn file_backed_font_family_rejects_empty_font_list() {
254 assert_eq!(
255 FileBackedFontFamily::new(Vec::new()),
256 Err(FontFamilyError::EmptyFileList)
257 );
258 }
259
260 #[test]
261 fn font_family_loaded_typeface_path_preserves_path() {
262 let family = FontFamily::loaded_typeface_path("fixtures/fonts/FiraSans-Regular.ttf");
263 let FontFamily::LoadedTypeface(typeface) = family else {
264 panic!("expected loaded typeface family");
265 };
266 assert_eq!(typeface.path, "fixtures/fonts/FiraSans-Regular.ttf");
267 }
268
269 #[test]
270 fn font_weight_default_is_normal() {
271 assert_eq!(FontWeight::default(), FontWeight::NORMAL);
272 }
273
274 #[test]
275 fn font_weight_try_new_validates_range() {
276 assert_eq!(FontWeight::try_new(0), None);
277 assert_eq!(FontWeight::try_new(1), Some(FontWeight(1)));
278 assert_eq!(FontWeight::try_new(1000), Some(FontWeight(1000)));
279 assert_eq!(FontWeight::try_new(1001), None);
280 }
281
282 #[test]
283 fn font_weight_new_clamps_invalid_input() {
284 assert_eq!(FontWeight::new(0), FontWeight(1));
285 assert_eq!(FontWeight::new(500), FontWeight(500));
286 assert_eq!(FontWeight::new(1001), FontWeight(1000));
287 }
288}