1use std::collections::BTreeSet;
2
3pub fn font_dirs() -> Vec<String> {
4 let mut dirs = Vec::new();
5 if let Ok(extra) = std::env::var("BLIT_FONT_DIRS") {
6 let sep = if cfg!(windows) { ';' } else { ':' };
7 for d in extra.split(sep) {
8 let d = d.trim();
9 if !d.is_empty() {
10 dirs.push(d.to_owned());
11 }
12 }
13 }
14 #[cfg(unix)]
15 {
16 if let Some(home) = std::env::var_os("HOME") {
17 let home = home.to_string_lossy();
18 dirs.push(format!("{home}/Library/Fonts"));
19 dirs.push(format!("{home}/.local/share/fonts"));
20 dirs.push(format!("{home}/.fonts"));
21 }
22 dirs.push("/Library/Fonts".into());
23 dirs.push("/System/Library/Fonts".into());
24 dirs.push("/usr/share/fonts".into());
25 dirs.push("/usr/local/share/fonts".into());
26 }
27 #[cfg(windows)]
28 {
29 if let Ok(windir) = std::env::var("SYSTEMROOT") {
30 dirs.push(format!("{windir}\\Fonts"));
31 } else {
32 dirs.push(r"C:\Windows\Fonts".into());
33 }
34 if let Ok(local) = std::env::var("LOCALAPPDATA") {
35 dirs.push(format!(r"{local}\Microsoft\Windows\Fonts"));
36 }
37 }
38 dirs
39}
40
41#[derive(Debug, Clone)]
42pub struct FontInfo {
43 pub family: String,
44 pub subfamily: String,
45 pub is_monospace: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct FontVariant {
50 pub path: String,
51 pub weight: String,
52 pub style: String,
53}
54
55fn sfnt_offset(data: &[u8]) -> Option<usize> {
56 if data.len() < 12 {
57 return None;
58 }
59 if &data[0..4] == b"ttcf" {
60 if data.len() < 16 {
61 return None;
62 }
63 Some(u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize)
64 } else {
65 Some(0)
66 }
67}
68
69fn table_slice<'a>(data: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> {
70 let offset = sfnt_offset(data)?;
71 if offset + 12 > data.len() {
72 return None;
73 }
74 let num_tables = u16::from_be_bytes([data[offset + 4], data[offset + 5]]) as usize;
75 if offset + 12 + num_tables * 16 > data.len() {
76 return None;
77 }
78 for i in 0..num_tables {
79 let rec = offset + 12 + i * 16;
80 if &data[rec..rec + 4] == tag {
81 let table_offset =
82 u32::from_be_bytes([data[rec + 8], data[rec + 9], data[rec + 10], data[rec + 11]])
83 as usize;
84 let table_length = u32::from_be_bytes([
85 data[rec + 12],
86 data[rec + 13],
87 data[rec + 14],
88 data[rec + 15],
89 ]) as usize;
90 let table_end = table_offset.checked_add(table_length)?;
91 if table_end > data.len() {
92 return None;
93 }
94 return Some(&data[table_offset..table_end]);
95 }
96 }
97 None
98}
99
100fn read_is_monospace(data: &[u8]) -> bool {
101 if let Some(post) = table_slice(data, b"post")
102 && post.len() >= 16
103 {
104 let is_fixed_pitch = u32::from_be_bytes([post[12], post[13], post[14], post[15]]);
105 if is_fixed_pitch != 0 {
106 return true;
107 }
108 }
109
110 let Some(hhea) = table_slice(data, b"hhea") else {
111 return false;
112 };
113 let Some(hmtx) = table_slice(data, b"hmtx") else {
114 return false;
115 };
116 if hhea.len() < 36 {
117 return false;
118 }
119 let num_long_metrics = u16::from_be_bytes([hhea[34], hhea[35]]) as usize;
120 if num_long_metrics == 0 {
121 return false;
122 }
123 let Some(metrics_len) = num_long_metrics.checked_mul(4) else {
124 return false;
125 };
126 if hmtx.len() < metrics_len {
127 return false;
128 }
129
130 let mut reference_width: Option<u16> = None;
131 for i in 0..num_long_metrics {
132 let idx = i * 4;
133 let advance = u16::from_be_bytes([hmtx[idx], hmtx[idx + 1]]);
134 if advance == 0 {
135 continue;
136 }
137 match reference_width {
138 Some(width) if width != advance => return false,
139 Some(_) => {}
140 None => reference_width = Some(advance),
141 }
142 }
143
144 reference_width.is_some()
145}
146
147fn read_advance_ratio(data: &[u8]) -> Option<f64> {
151 let head = table_slice(data, b"head")?;
152 if head.len() < 20 {
153 return None;
154 }
155 let units_per_em = u16::from_be_bytes([head[18], head[19]]) as f64;
156 if units_per_em == 0.0 {
157 return None;
158 }
159
160 let hhea = table_slice(data, b"hhea")?;
161 let hmtx = table_slice(data, b"hmtx")?;
162 if hhea.len() < 36 {
163 return None;
164 }
165 let num_long_metrics = u16::from_be_bytes([hhea[34], hhea[35]]) as usize;
166 if num_long_metrics == 0 || hmtx.len() < num_long_metrics * 4 {
167 return None;
168 }
169
170 for i in 0..num_long_metrics {
171 let idx = i * 4;
172 let advance = u16::from_be_bytes([hmtx[idx], hmtx[idx + 1]]);
173 if advance > 0 {
174 return Some(advance as f64 / units_per_em);
175 }
176 }
177 None
178}
179
180fn read_font_info(data: &[u8]) -> Option<FontInfo> {
182 let tbl = table_slice(data, b"name")?;
183 if tbl.len() < 6 {
184 return None;
185 }
186 let count = u16::from_be_bytes([tbl[2], tbl[3]]) as usize;
187 let string_offset = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
188 if tbl.len() < 6 + count * 12 {
189 return None;
190 }
191
192 let mut family: Option<String> = None;
196 let mut family_pri = 0u8;
197 let mut subfamily: Option<String> = None;
198 let mut subfamily_pri = 0u8;
199
200 for i in 0..count {
201 let rec = 6 + i * 12;
202 let platform = u16::from_be_bytes([tbl[rec], tbl[rec + 1]]);
203 let name_id = u16::from_be_bytes([tbl[rec + 6], tbl[rec + 7]]);
204 let length = u16::from_be_bytes([tbl[rec + 8], tbl[rec + 9]]) as usize;
205 let str_off = u16::from_be_bytes([tbl[rec + 10], tbl[rec + 11]]) as usize;
206
207 let is_family = name_id == 1 || name_id == 16;
208 let is_subfamily = name_id == 2 || name_id == 17;
209 if !is_family && !is_subfamily {
210 continue;
211 }
212
213 let plat_bonus: u8 = if platform == 3 {
214 2
215 } else if platform == 1 {
216 1
217 } else {
218 0
219 };
220 if plat_bonus == 0 {
221 continue;
222 }
223 let typo_bonus: u8 = if name_id >= 16 { 4 } else { 0 };
224 let priority = plat_bonus + typo_bonus;
225
226 let start = string_offset + str_off;
227 if start + length > tbl.len() {
228 continue;
229 }
230 let raw = &tbl[start..start + length];
231
232 let decoded = if platform == 3 {
233 let chars: Vec<u16> = raw
234 .chunks_exact(2)
235 .map(|c| u16::from_be_bytes([c[0], c[1]]))
236 .collect();
237 String::from_utf16_lossy(&chars)
238 } else {
239 String::from_utf8_lossy(raw).into_owned()
240 };
241 let decoded = decoded.trim().to_owned();
242 if decoded.is_empty() {
243 continue;
244 }
245
246 if is_family && priority > family_pri {
247 family = Some(decoded);
248 family_pri = priority;
249 } else if is_subfamily && priority > subfamily_pri {
250 subfamily = Some(decoded);
251 subfamily_pri = priority;
252 }
253 }
254
255 Some(FontInfo {
256 family: family?,
257 subfamily: subfamily.unwrap_or_else(|| "Regular".to_owned()),
258 is_monospace: read_is_monospace(data),
259 })
260}
261
262fn subfamily_to_weight_style(subfamily: &str) -> (&'static str, &'static str) {
263 let s = subfamily.to_lowercase();
264 let bold = s.contains("bold") || s.contains("heavy") || s.contains("black");
265 let italic = s.contains("italic") || s.contains("oblique");
266 match (bold, italic) {
267 (true, true) => ("bold", "italic"),
268 (true, false) => ("bold", "normal"),
269 (false, true) => ("normal", "italic"),
270 (false, false) => ("normal", "normal"),
271 }
272}
273
274pub fn find_font_files(family: &str) -> Vec<FontVariant> {
275 #[cfg(unix)]
276 if let Some(results) = find_via_fc_match(family)
277 && !results.is_empty()
278 {
279 return results;
280 }
281 let dirs = font_dirs();
282 let family_lower = family.to_lowercase();
283 let family_nospace = family_lower.replace(' ', "");
284 let mut results = Vec::new();
285 for dir in &dirs {
286 find_in_dir_recursive(dir, &family_lower, &family_nospace, &mut results);
287 }
288 results
289}
290
291#[cfg(unix)]
292fn find_via_fc_match(family: &str) -> Option<Vec<FontVariant>> {
293 let output = std::process::Command::new("fc-match")
294 .args(["--format", "%{file}\n%{style}\n", "-a", family])
295 .output()
296 .ok()?;
297 if !output.status.success() {
298 return None;
299 }
300 let text = String::from_utf8_lossy(&output.stdout);
301 let lines: Vec<&str> = text.lines().collect();
302 let mut results = Vec::with_capacity(lines.len() / 2);
303 let mut seen = BTreeSet::new();
304 for pair in lines.chunks(2) {
305 if pair.len() < 2 {
306 break;
307 }
308 let path = pair[0].trim();
309 let style_str = pair[1].trim();
310 if path.is_empty() || !seen.insert(path.to_owned()) {
311 continue;
312 }
313 if let Ok(data) = std::fs::read(path)
314 && let Some(info) = read_font_info(&data)
315 {
316 if !info.family.eq_ignore_ascii_case(family) {
317 continue;
318 }
319 let (weight, style) = subfamily_to_weight_style(style_str);
320 results.push(FontVariant {
321 path: path.to_owned(),
322 weight: weight.to_owned(),
323 style: style.to_owned(),
324 });
325 }
326 }
327 if results.is_empty() {
328 None
329 } else {
330 Some(results)
331 }
332}
333
334fn find_in_dir_recursive(
335 dir: &str,
336 family_lower: &str,
337 family_nospace: &str,
338 results: &mut Vec<FontVariant>,
339) {
340 let Ok(entries) = std::fs::read_dir(dir) else {
341 return;
342 };
343 for entry in entries.flatten() {
344 let path = entry.path();
345 if path.is_dir() {
346 find_in_dir_recursive(
347 &path.to_string_lossy(),
348 family_lower,
349 family_nospace,
350 results,
351 );
352 continue;
353 }
354 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
355 if !matches!(ext, "ttf" | "otf" | "woff" | "woff2" | "ttc") {
356 continue;
357 }
358
359 if let Ok(data) = std::fs::read(&path)
360 && let Some(info) = read_font_info(&data)
361 {
362 let parsed_lower = info.family.to_lowercase();
363 if parsed_lower != family_lower && parsed_lower.replace(' ', "") != family_nospace {
364 continue;
365 }
366 let (weight, style) = subfamily_to_weight_style(&info.subfamily);
367 results.push(FontVariant {
368 path: path.to_string_lossy().into_owned(),
369 weight: weight.to_owned(),
370 style: style.to_owned(),
371 });
372 }
373 }
374}
375
376pub fn list_font_families() -> Vec<String> {
377 #[cfg(unix)]
378 if let Some(families) = list_via_fc_list() {
379 return families;
380 }
381 list_via_name_tables()
382}
383
384pub fn list_monospace_font_families() -> Vec<String> {
385 #[cfg(unix)]
386 if let Some(families) = list_monospace_via_fc_list() {
387 return families;
388 }
389 list_monospace_via_name_tables()
390}
391
392#[cfg(unix)]
393fn list_via_fc_list() -> Option<Vec<String>> {
394 let output = std::process::Command::new("fc-list")
395 .args(["--format", "%{family}\n"])
396 .output()
397 .ok()?;
398 if !output.status.success() {
399 return None;
400 }
401 let text = String::from_utf8_lossy(&output.stdout);
402 let mut families = BTreeSet::new();
403 for line in text.lines() {
404 for name in line.split(',') {
405 let name = name.trim();
406 if !name.is_empty() {
407 families.insert(name.to_owned());
408 }
409 }
410 }
411 if families.is_empty() {
412 return None;
413 }
414 Some(families.into_iter().collect())
415}
416
417fn list_via_name_tables() -> Vec<String> {
418 let dirs = font_dirs();
419 let mut families = BTreeSet::new();
420 for dir in &dirs {
421 scan_dir_recursive(dir, &mut families);
422 }
423 families.into_iter().collect()
424}
425
426#[cfg(unix)]
427fn list_monospace_via_fc_list() -> Option<Vec<String>> {
428 let output = std::process::Command::new("fc-list")
429 .args(["--format", "%{file}\n"])
430 .output()
431 .ok()?;
432 if !output.status.success() {
433 return None;
434 }
435 let text = String::from_utf8_lossy(&output.stdout);
436 let mut families = BTreeSet::new();
437 let mut seen_paths = BTreeSet::new();
438 for line in text.lines() {
439 let path = line.trim();
440 if path.is_empty() || !seen_paths.insert(path.to_owned()) {
441 continue;
442 }
443 let Ok(data) = std::fs::read(path) else {
444 continue;
445 };
446 let Some(info) = read_font_info(&data) else {
447 continue;
448 };
449 if !info.is_monospace {
450 continue;
451 }
452 families.insert(info.family);
454 }
455 if families.is_empty() {
456 return None;
457 }
458 Some(families.into_iter().collect())
459}
460
461fn list_monospace_via_name_tables() -> Vec<String> {
462 let dirs = font_dirs();
463 let mut families = BTreeSet::new();
464 for dir in &dirs {
465 scan_monospace_dir_recursive(dir, &mut families);
466 }
467 families.into_iter().collect()
468}
469
470fn scan_dir_recursive(dir: &str, families: &mut BTreeSet<String>) {
471 let Ok(entries) = std::fs::read_dir(dir) else {
472 return;
473 };
474 for entry in entries.flatten() {
475 let path = entry.path();
476 if path.is_dir() {
477 scan_dir_recursive(&path.to_string_lossy(), families);
478 continue;
479 }
480 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
481 if !matches!(ext, "ttf" | "otf" | "woff" | "woff2" | "ttc") {
482 continue;
483 }
484 if let Ok(data) = std::fs::read(&path)
485 && let Some(info) = read_font_info(&data)
486 {
487 families.insert(info.family);
488 }
489 }
490}
491
492fn scan_monospace_dir_recursive(dir: &str, families: &mut BTreeSet<String>) {
493 let Ok(entries) = std::fs::read_dir(dir) else {
494 return;
495 };
496 for entry in entries.flatten() {
497 let path = entry.path();
498 if path.is_dir() {
499 scan_monospace_dir_recursive(&path.to_string_lossy(), families);
500 continue;
501 }
502 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
503 if !matches!(ext, "ttf" | "otf" | "woff" | "woff2" | "ttc") {
504 continue;
505 }
506 if let Ok(data) = std::fs::read(&path)
507 && let Some(info) = read_font_info(&data)
508 && info.is_monospace
509 {
510 families.insert(info.family);
511 }
512 }
513}
514
515pub fn base64_encode(data: &[u8]) -> String {
516 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
517 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
518 for chunk in data.chunks(3) {
519 let b0 = chunk[0] as u32;
520 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
521 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
522 let n = (b0 << 16) | (b1 << 8) | b2;
523 out.push(CHARS[(n >> 18 & 63) as usize] as char);
524 out.push(CHARS[(n >> 12 & 63) as usize] as char);
525 if chunk.len() > 1 {
526 out.push(CHARS[(n >> 6 & 63) as usize] as char);
527 } else {
528 out.push('=');
529 }
530 if chunk.len() > 2 {
531 out.push(CHARS[(n & 63) as usize] as char);
532 } else {
533 out.push('=');
534 }
535 }
536 out
537}
538
539pub fn font_face_css(family: &str) -> Option<String> {
540 let files = find_font_files_with_data(family);
541 if files.is_empty() {
542 return None;
543 }
544 let mut css = String::new();
545 for (variant, data) in &files {
546 let ext = variant.path.rsplit('.').next().unwrap_or("ttf");
547 let mime = match ext {
548 "otf" => "font/otf",
549 "woff" => "font/woff",
550 "woff2" => "font/woff2",
551 _ => "font/ttf",
552 };
553 let b64 = base64_encode(data);
554 let safe_family = family.replace('\\', "\\\\").replace('\'', "\\'");
556 css.push_str(&format!(
557 "@font-face {{ font-family: '{}'; font-weight: {}; font-style: {}; src: url('data:{};base64,{}'); }}\n",
558 safe_family, variant.weight, variant.style, mime, b64,
559 ));
560 }
561 if css.is_empty() { None } else { Some(css) }
562}
563
564pub fn font_advance_ratio(family: &str) -> Option<f64> {
567 let files = find_font_files_with_data(family);
568 for (variant, data) in &files {
570 if variant.style == "normal"
571 && (variant.weight == "400" || variant.weight == "normal")
572 && let Some(ratio) = read_advance_ratio(data)
573 {
574 return Some(ratio);
575 }
576 }
577 for (_variant, data) in &files {
579 if let Some(ratio) = read_advance_ratio(data) {
580 return Some(ratio);
581 }
582 }
583 None
584}
585
586fn find_font_files_with_data(family: &str) -> Vec<(FontVariant, Vec<u8>)> {
589 let variants = find_font_files(family);
590 variants
591 .into_iter()
592 .filter_map(|v| {
593 let data = std::fs::read(&v.path).ok()?;
594 Some((v, data))
595 })
596 .collect()
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 fn build_test_font(tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
604 let header_len = 12 + tables.len() * 16;
605 let mut data = vec![0u8; header_len];
606 data[0..4].copy_from_slice(&[0, 1, 0, 0]);
607 data[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
608
609 let mut offset = header_len;
610 for (i, (tag, table)) in tables.iter().enumerate() {
611 let rec = 12 + i * 16;
612 data[rec..rec + 4].copy_from_slice(*tag);
613 data[rec + 8..rec + 12].copy_from_slice(&(offset as u32).to_be_bytes());
614 data[rec + 12..rec + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
615 data.extend_from_slice(table);
616 offset += table.len();
617 }
618
619 data
620 }
621
622 #[test]
623 fn parse_font_info_from_system_fonts() {
624 let families = list_font_families();
625 assert!(!families.is_empty(), "no fonts found on system");
626 for f in &families {
627 assert!(!f.is_empty());
628 assert!(!f.contains('\0'));
629 }
630 }
631
632 #[test]
633 fn subfamily_parsing() {
634 assert_eq!(subfamily_to_weight_style("Regular"), ("normal", "normal"));
635 assert_eq!(subfamily_to_weight_style("Bold"), ("bold", "normal"));
636 assert_eq!(subfamily_to_weight_style("Italic"), ("normal", "italic"));
637 assert_eq!(subfamily_to_weight_style("Bold Italic"), ("bold", "italic"));
638 assert_eq!(
639 subfamily_to_weight_style("Bold Oblique"),
640 ("bold", "italic")
641 );
642 }
643
644 #[test]
645 fn detects_monospace_from_post_table() {
646 let mut post = vec![0u8; 32];
647 post[12..16].copy_from_slice(&1u32.to_be_bytes());
648 let font = build_test_font(&[(b"post", post)]);
649 assert!(read_is_monospace(&font));
650 }
651
652 #[test]
653 fn detects_monospace_from_uniform_hmtx_widths() {
654 let mut hhea = vec![0u8; 36];
655 hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
656
657 let mut hmtx = vec![0u8; 8];
658 hmtx[0..2].copy_from_slice(&600u16.to_be_bytes());
659 hmtx[4..6].copy_from_slice(&600u16.to_be_bytes());
660
661 let font = build_test_font(&[(b"hhea", hhea), (b"hmtx", hmtx)]);
662 assert!(read_is_monospace(&font));
663 }
664
665 #[test]
666 fn rejects_variable_width_fonts() {
667 let mut hhea = vec![0u8; 36];
668 hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
669
670 let mut hmtx = vec![0u8; 8];
671 hmtx[0..2].copy_from_slice(&500u16.to_be_bytes());
672 hmtx[4..6].copy_from_slice(&700u16.to_be_bytes());
673
674 let font = build_test_font(&[(b"hhea", hhea), (b"hmtx", hmtx)]);
675 assert!(!read_is_monospace(&font));
676 }
677
678 #[test]
681 fn base64_empty() {
682 assert_eq!(base64_encode(b""), "");
683 }
684
685 #[test]
686 fn base64_one_byte() {
687 assert_eq!(base64_encode(b"M"), "TQ==");
688 }
689
690 #[test]
691 fn base64_two_bytes() {
692 assert_eq!(base64_encode(b"Ma"), "TWE=");
693 }
694
695 #[test]
696 fn base64_three_bytes() {
697 assert_eq!(base64_encode(b"Man"), "TWFu");
698 }
699
700 #[test]
701 fn base64_rfc4648_vectors() {
702 assert_eq!(base64_encode(b"f"), "Zg==");
703 assert_eq!(base64_encode(b"fo"), "Zm8=");
704 assert_eq!(base64_encode(b"foo"), "Zm9v");
705 assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
706 assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
707 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
708 }
709
710 #[test]
713 fn sfnt_offset_too_short() {
714 assert_eq!(sfnt_offset(b"abc"), None);
715 }
716
717 #[test]
718 fn sfnt_offset_non_ttc() {
719 let font = build_test_font(&[]);
720 assert_eq!(sfnt_offset(&font), Some(0));
721 }
722
723 #[test]
724 fn sfnt_offset_ttc_header() {
725 let mut data = vec![0u8; 20];
726 data[0..4].copy_from_slice(b"ttcf");
727 data[12..16].copy_from_slice(&100u32.to_be_bytes());
728 assert_eq!(sfnt_offset(&data), Some(100));
729 }
730
731 #[test]
732 fn sfnt_offset_ttc_too_short() {
733 let mut data = vec![0u8; 14];
734 data[0..4].copy_from_slice(b"ttcf");
735 assert_eq!(sfnt_offset(&data), None);
736 }
737
738 #[test]
741 fn table_slice_found() {
742 let table_data = vec![1, 2, 3, 4];
743 let font = build_test_font(&[(b"test", table_data.clone())]);
744 let slice = table_slice(&font, b"test");
745 assert_eq!(slice, Some(table_data.as_slice()));
746 }
747
748 #[test]
749 fn table_slice_not_found() {
750 let font = build_test_font(&[(b"aaaa", vec![0])]);
751 assert_eq!(table_slice(&font, b"zzzz"), None);
752 }
753
754 #[test]
755 fn table_slice_empty_font() {
756 let font = build_test_font(&[]);
757 assert_eq!(table_slice(&font, b"test"), None);
758 }
759
760 #[test]
763 fn advance_ratio_basic() {
764 let mut head = vec![0u8; 20];
765 head[18..20].copy_from_slice(&1000u16.to_be_bytes());
766
767 let mut hhea = vec![0u8; 36];
768 hhea[34..36].copy_from_slice(&1u16.to_be_bytes());
769
770 let mut hmtx = vec![0u8; 4];
771 hmtx[0..2].copy_from_slice(&600u16.to_be_bytes());
772
773 let font = build_test_font(&[(b"head", head), (b"hhea", hhea), (b"hmtx", hmtx)]);
774 let ratio = read_advance_ratio(&font).unwrap();
775 assert!((ratio - 0.6).abs() < 1e-10);
776 }
777
778 #[test]
779 fn advance_ratio_skips_zero_advances() {
780 let mut head = vec![0u8; 20];
781 head[18..20].copy_from_slice(&1000u16.to_be_bytes());
782
783 let mut hhea = vec![0u8; 36];
784 hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
785
786 let mut hmtx = vec![0u8; 8];
787 hmtx[0..2].copy_from_slice(&0u16.to_be_bytes());
788 hmtx[4..6].copy_from_slice(&500u16.to_be_bytes());
789
790 let font = build_test_font(&[(b"head", head), (b"hhea", hhea), (b"hmtx", hmtx)]);
791 let ratio = read_advance_ratio(&font).unwrap();
792 assert!((ratio - 0.5).abs() < 1e-10);
793 }
794
795 #[test]
796 fn advance_ratio_no_head_table() {
797 let hhea = vec![0u8; 36];
798 let hmtx = vec![0u8; 4];
799 let font = build_test_font(&[(b"hhea", hhea), (b"hmtx", hmtx)]);
800 assert!(read_advance_ratio(&font).is_none());
801 }
802
803 #[test]
804 fn advance_ratio_zero_units_per_em() {
805 let head = vec![0u8; 20];
806 let hhea = vec![0u8; 36];
807 let hmtx = vec![0u8; 4];
808 let font = build_test_font(&[(b"head", head), (b"hhea", hhea), (b"hmtx", hmtx)]);
809 assert!(read_advance_ratio(&font).is_none());
810 }
811
812 #[test]
815 fn subfamily_heavy() {
816 assert_eq!(subfamily_to_weight_style("Heavy"), ("bold", "normal"));
817 }
818
819 #[test]
820 fn subfamily_black() {
821 assert_eq!(subfamily_to_weight_style("Black"), ("bold", "normal"));
822 }
823
824 #[test]
825 fn subfamily_oblique() {
826 assert_eq!(subfamily_to_weight_style("Oblique"), ("normal", "italic"));
827 }
828
829 #[test]
830 fn subfamily_case_insensitive() {
831 assert_eq!(subfamily_to_weight_style("BOLD ITALIC"), ("bold", "italic"));
832 assert_eq!(subfamily_to_weight_style("bold italic"), ("bold", "italic"));
833 }
834
835 #[test]
836 fn subfamily_unrecognized() {
837 assert_eq!(subfamily_to_weight_style("Light"), ("normal", "normal"));
838 assert_eq!(subfamily_to_weight_style("Thin"), ("normal", "normal"));
839 }
840}