1use std::collections::BTreeSet;
5use std::path::Path;
6
7use allsorts::binary::read::ReadScope;
8use allsorts::font_data::FontData;
9use allsorts::tables::os2::Os2;
10use allsorts::tables::{FontTableProvider, NameTable, OpenTypeData};
11use allsorts::tag;
12
13use crate::parsers::metadata::ParserMetadata;
14
15pub(crate) const SUPPORTED_FONT_EXTENSIONS: &[&str] =
16 &["ttf", "otf", "woff", "woff2", "eot", "ttc", "otc"];
17pub(crate) const SUPPORTED_FONT_FILE_GLOBS: &[&str] = &[
18 "**/*.ttf",
19 "**/*.otf",
20 "**/*.woff",
21 "**/*.woff2",
22 "**/*.eot",
23 "**/*.ttc",
24 "**/*.otc",
25];
26const OFL_URL_CANONICALIZATIONS: &[(&str, &str)] = &[
27 ("https://scripts.sil.org/OFL/", "http://scripts.sil.org/OFL"),
28 ("https://scripts.sil.org/OFL", "http://scripts.sil.org/OFL"),
29 ("https://openfontlicense.org/", "http://scripts.sil.org/OFL"),
30 ("https://openfontlicense.org", "http://scripts.sil.org/OFL"),
31];
32const ALLSORTS_NAME_TABLE_TAG: u32 = u32::from_be_bytes(*b"name");
33const OS2_PERMISSIONS_MASK: u16 = 0xF;
34const OS2_RESTRICTED_LICENSE_EMBEDDING: u16 = 0x2;
35const OS2_PREVIEW_AND_PRINT_EMBEDDING: u16 = 0x4;
36const OS2_EDITABLE_EMBEDDING: u16 = 0x8;
37
38pub(crate) static FONT_METADATA: &[ParserMetadata] = &[ParserMetadata {
39 description: "Embedded font legal metadata (native fonts, webfonts, and collections)",
40 file_patterns: SUPPORTED_FONT_FILE_GLOBS,
41 package_type: "",
42 primary_language: "",
43 documentation_url: Some("https://learn.microsoft.com/en-us/typography/opentype/spec/name"),
44}];
45
46pub(crate) fn is_supported_font_extension(extension: &str) -> bool {
47 SUPPORTED_FONT_EXTENSIONS
48 .iter()
49 .any(|supported| supported.eq_ignore_ascii_case(extension))
50}
51
52pub(crate) fn is_supported_font_path(path: &Path) -> bool {
53 path.extension()
54 .and_then(|ext| ext.to_str())
55 .is_some_and(is_supported_font_extension)
56}
57
58pub(crate) fn extract_font_metadata_text(path: &Path, bytes: &[u8]) -> Option<String> {
59 let extension = path.extension().and_then(|ext| ext.to_str())?;
60 let extension = extension.to_ascii_lowercase();
61 if !is_supported_font_extension(&extension) {
62 return None;
63 }
64
65 match extension.as_str() {
66 "ttf" | "otf" | "woff" | "woff2" | "ttc" | "otc" => extract_sfnt_font_metadata_text(
67 bytes,
68 matches!(extension.as_str(), "ttf" | "otf" | "ttc" | "otc"),
69 ),
70 "eot" => extract_eot_metadata_text(bytes),
71 _ => None,
72 }
73}
74
75fn extract_sfnt_font_metadata_text(bytes: &[u8], include_permissions: bool) -> Option<String> {
76 let mut lines = Vec::new();
77 let mut seen = BTreeSet::new();
78
79 for line in extract_allsorts_name_table_lines(bytes) {
80 if seen.insert(line.clone()) {
81 lines.push(line);
82 }
83 }
84
85 if include_permissions {
86 for line in extract_font_embedding_permission_lines(bytes) {
87 if seen.insert(line.clone()) {
88 lines.push(line);
89 }
90 }
91 }
92
93 (!lines.is_empty()).then(|| lines.join("\n"))
94}
95
96pub(crate) fn extract_font_name_table_strings(bytes: &[u8]) -> String {
106 let mut lines = Vec::new();
107 let mut seen = BTreeSet::new();
108 for line in extract_allsorts_all_name_strings(bytes) {
109 let normalized = line.split_whitespace().collect::<Vec<_>>().join(" ");
110 if normalized.is_empty() {
111 continue;
112 }
113 if seen.insert(normalized.clone()) {
114 lines.push(normalized);
115 }
116 }
117 lines.join("\n")
118}
119
120fn for_each_name_table(bytes: &[u8], mut visit: impl FnMut(&NameTable<'_>)) {
125 let Some(font_data) = ReadScope::new(bytes).read::<FontData<'_>>().ok() else {
126 return;
127 };
128
129 for face_index in 0..allsorts_face_count(&font_data) {
130 let Ok(provider) = font_data.table_provider(face_index) else {
131 continue;
132 };
133 let Ok(name_table_data) = provider.read_table_data(ALLSORTS_NAME_TABLE_TAG) else {
134 continue;
135 };
136 let Ok(name_table) = ReadScope::new(name_table_data.as_ref()).read::<NameTable<'_>>()
137 else {
138 continue;
139 };
140 visit(&name_table);
141 }
142}
143
144fn extract_allsorts_all_name_strings(bytes: &[u8]) -> Vec<String> {
145 let mut strings = Vec::new();
146 for_each_name_table(bytes, |name_table| {
147 let mut name_ids = BTreeSet::new();
150 for record in name_table.name_records.iter() {
151 name_ids.insert(record.name_id);
152 }
153 for name_id in name_ids {
154 if let Some(value) = name_table.string_for_id(name_id) {
155 strings.push(value);
156 }
157 }
158 });
159 strings
160}
161
162fn extract_allsorts_name_table_lines(bytes: &[u8]) -> Vec<String> {
163 let mut lines = Vec::new();
164 let mut seen = BTreeSet::new();
165 for_each_name_table(bytes, |name_table| {
166 for name_id in [
167 NameTable::COPYRIGHT_NOTICE,
168 NameTable::LICENSE_DESCRIPTION,
169 NameTable::LICENSE_INFO_URL,
170 ] {
171 let Some(value) = name_table.string_for_id(name_id) else {
172 continue;
173 };
174 let Some(line) = build_font_metadata_line(name_id, value) else {
175 continue;
176 };
177 if seen.insert(line.clone()) {
178 lines.push(line);
179 }
180 }
181 });
182 lines
183}
184
185fn extract_font_embedding_permission_lines(bytes: &[u8]) -> Vec<String> {
186 let mut lines = Vec::new();
187 let Some(font_data) = ReadScope::new(bytes).read::<FontData<'_>>().ok() else {
188 return lines;
189 };
190
191 for face_index in 0..allsorts_face_count(&font_data) {
192 let Ok(provider) = font_data.table_provider(face_index) else {
193 continue;
194 };
195 let Ok(os2_data) = provider.read_table_data(tag::OS_2) else {
196 continue;
197 };
198 let os2_scope = ReadScope::new(os2_data.as_ref());
199 let Ok(os2) = os2_scope.read_dep::<Os2>(os2_scope.data().len()) else {
200 continue;
201 };
202 let Some(label) = font_permission_label(os2.version, os2.fs_type) else {
203 continue;
204 };
205 lines.push(format!("Embedding permissions: {label}"));
206 }
207
208 lines
209}
210
211fn allsorts_face_count(font_data: &FontData<'_>) -> usize {
212 match font_data {
213 FontData::OpenType(font) => match &font.data {
214 OpenTypeData::Single(_) => 1,
215 OpenTypeData::Collection(ttc) => ttc.offset_tables.len(),
216 },
217 FontData::Woff(_) => 1,
218 FontData::Woff2(font) => font
219 .collection_directory
220 .as_ref()
221 .map(|directory| directory.fonts().count())
222 .unwrap_or(1),
223 }
224}
225
226fn extract_eot_metadata_text(bytes: &[u8]) -> Option<String> {
227 let text = extract_eot_utf16le_marker_text(bytes).join("\n");
228 if text.is_empty() {
229 return None;
230 }
231
232 let mut lines = Vec::new();
233 let mut seen = BTreeSet::new();
234 for segment in split_eot_legal_metadata_segments(&text) {
235 let normalized = normalize_eot_metadata_segment(&segment);
236 if normalized.is_empty() {
237 continue;
238 }
239 if seen.insert(normalized.clone()) {
240 lines.push(normalized);
241 }
242 }
243
244 (!lines.is_empty()).then(|| lines.join("\n"))
245}
246
247fn extract_eot_utf16le_marker_text(bytes: &[u8]) -> Vec<String> {
248 let mut lines = Vec::new();
249 let mut seen = BTreeSet::new();
250 for marker in [
251 "Copyright",
252 "This Font Software is licensed under",
253 "http://",
254 "https://",
255 ] {
256 let encoded = marker.encode_utf16().collect::<Vec<_>>();
257 let marker_bytes = encoded
258 .iter()
259 .flat_map(|unit| unit.to_le_bytes())
260 .collect::<Vec<_>>();
261 let mut search_start = 0;
262 while let Some(relative_start) = bytes[search_start..]
263 .windows(marker_bytes.len())
264 .position(|window| window == marker_bytes.as_slice())
265 {
266 let start = search_start + relative_start;
267 let decoded = decode_utf16le_ascii_from_offset(bytes, start);
268 if !decoded.is_empty() && seen.insert(decoded.clone()) {
269 lines.push(decoded);
270 }
271 search_start = start + marker_bytes.len();
272 }
273 }
274 lines
275}
276
277fn decode_utf16le_ascii_from_offset(bytes: &[u8], start: usize) -> String {
278 let mut decoded = Vec::new();
279 let mut index = start;
280 while index + 1 < bytes.len() {
281 let lo = bytes[index];
282 let hi = bytes[index + 1];
283 if hi == 0 && (0x20..=0x7E).contains(&lo) {
284 decoded.push(lo);
285 index += 2;
286 continue;
287 }
288 break;
289 }
290 String::from_utf8_lossy(&decoded).into_owned()
291}
292
293fn split_eot_legal_metadata_segments(text: &str) -> Vec<String> {
294 let mut segments = Vec::new();
295
296 if let Some(segment) = extract_text_between_markers(
297 text,
298 "Copyright",
299 &["All Rights Reserved.", "All rights reserved."],
300 ) {
301 segments.push(segment);
302 }
303 if let Some(segment) = extract_text_between_markers(
304 text,
305 "This Font Software is licensed under",
306 &[
307 "governing your use of this Font Software.",
308 "This Font Software.",
309 ],
310 ) {
311 segments.push(segment);
312 }
313 segments.extend(extract_http_segments(text));
314
315 segments
316}
317
318fn extract_text_between_markers(
319 text: &str,
320 start_marker: &str,
321 end_markers: &[&str],
322) -> Option<String> {
323 let start = text.find(start_marker)?;
324 let tail = &text[start..];
325 let end = end_markers
326 .iter()
327 .filter_map(|marker| tail.find(marker).map(|idx| idx + marker.len()))
328 .min()
329 .unwrap_or(tail.len());
330 Some(tail[..end].to_string())
331}
332
333fn extract_http_segments(text: &str) -> Vec<String> {
334 let mut segments = Vec::new();
335 for marker in ["http://", "https://"] {
336 let mut search_start = 0;
337 while let Some(relative_start) = text[search_start..].find(marker) {
338 let start = search_start + relative_start;
339 let tail = &text[start + marker.len()..];
340 let mut end = text.len();
341 for boundary in [
342 "http://",
343 "https://",
344 "This Font Software",
345 "Copyright",
346 "Version ",
347 ] {
348 if let Some(relative_end) = tail.find(boundary) {
349 end = end.min(start + marker.len() + relative_end);
350 }
351 }
352 if let Some(relative_end) = tail.find(char::is_whitespace) {
353 end = end.min(start + marker.len() + relative_end);
354 }
355
356 let segment = text[start..end]
357 .trim_end_matches(&['.', ',', ';', ':'][..])
358 .to_string();
359 if !segment.is_empty() {
360 segments.push(segment);
361 }
362 search_start = end.max(start + marker.len());
363 }
364 }
365 segments
366}
367
368fn normalize_eot_metadata_segment(segment: &str) -> String {
369 let normalized = segment
370 .split_whitespace()
371 .collect::<Vec<_>>()
372 .join(" ")
373 .trim()
374 .to_string();
375
376 if normalized.is_empty() {
377 return normalized;
378 }
379
380 let lowered = normalized.to_ascii_lowercase();
381 if lowered.starts_with("http://") || lowered.starts_with("https://") {
382 return canonicalize_ofl_license_reference_urls(normalized);
383 }
384
385 if lowered.contains("font software") || lowered.contains("open font license") {
386 return canonicalize_ofl_license_reference_urls(normalized);
387 }
388
389 normalized
390}
391
392fn build_font_metadata_line(name_id_value: u16, value: String) -> Option<String> {
393 let value = normalize_font_value(name_id_value, value);
394 if value.is_empty() {
395 return None;
396 }
397
398 if name_id_value == NameTable::COPYRIGHT_NOTICE {
399 return Some(value);
400 }
401
402 let label = font_name_label(name_id_value)?;
403 Some(format!("{label}: {value}"))
404}
405
406fn font_name_label(name_id_value: u16) -> Option<&'static str> {
407 match name_id_value {
408 NameTable::LICENSE_DESCRIPTION => Some("License Description"),
409 NameTable::LICENSE_INFO_URL => Some("License Info URL"),
410 _ => None,
411 }
412}
413
414fn normalize_font_value(name_id_value: u16, value: String) -> String {
415 let normalized = value
416 .split_whitespace()
417 .collect::<Vec<_>>()
418 .join(" ")
419 .trim()
420 .to_string();
421
422 match name_id_value {
423 NameTable::COPYRIGHT_NOTICE => strip_reserved_font_name_clause(normalized),
424 NameTable::LICENSE_DESCRIPTION | NameTable::LICENSE_INFO_URL => {
425 canonicalize_ofl_license_reference_urls(normalized)
426 }
427 _ => normalized,
428 }
429}
430
431fn strip_reserved_font_name_clause(value: String) -> String {
432 let lower = value.to_ascii_lowercase();
433 for marker in [
434 ", with reserved font name",
435 ", with no reserved font name",
436 " with reserved font name",
437 " with no reserved font name",
438 ] {
439 if let Some(index) = lower.find(marker) {
440 return value[..index]
441 .trim_end_matches(&[',', ';', ':', ' ', '('][..])
442 .trim()
443 .to_string();
444 }
445 }
446
447 value
448}
449
450fn canonicalize_ofl_license_reference_urls(mut value: String) -> String {
451 for (from, to) in OFL_URL_CANONICALIZATIONS {
452 value = value.replace(from, to);
453 }
454 value
455}
456
457fn font_permission_label(os2_version: u16, fs_type: u16) -> Option<&'static str> {
458 let permission_bits = fs_type & OS2_PERMISSIONS_MASK;
459
460 if os2_version <= 2 {
461 return Some(if permission_bits == 0 {
462 "Installable"
463 } else if permission_bits & OS2_EDITABLE_EMBEDDING != 0 {
464 "Editable"
465 } else if permission_bits & OS2_PREVIEW_AND_PRINT_EMBEDDING != 0 {
466 "Preview and Print"
467 } else {
468 "Restricted"
469 });
470 }
471
472 match permission_bits {
473 0 => Some("Installable"),
474 OS2_RESTRICTED_LICENSE_EMBEDDING => Some("Restricted"),
475 OS2_PREVIEW_AND_PRINT_EMBEDDING => Some("Preview and Print"),
476 OS2_EDITABLE_EMBEDDING => Some("Editable"),
477 _ => None,
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use std::fs;
484 use std::path::Path;
485
486 use allsorts::tables::NameTable;
487
488 use crate::copyright::detect_copyrights;
489 use crate::license_detection::LicenseDetectionEngine;
490
491 use crate::finder::{DetectionConfig, find_urls};
492
493 use super::{
494 OS2_EDITABLE_EMBEDDING, OS2_PREVIEW_AND_PRINT_EMBEDDING, OS2_RESTRICTED_LICENSE_EMBEDDING,
495 build_font_metadata_line, canonicalize_ofl_license_reference_urls,
496 extract_font_metadata_text, extract_font_name_table_strings, font_permission_label,
497 };
498
499 #[test]
500 fn extracts_ofl_metadata_from_lato_font_fixture() {
501 let bytes =
502 fs::read("testdata/font-fixtures/Lato-Bold.ttf").expect("read lato font fixture");
503
504 let text = extract_font_metadata_text(Path::new("Lato-Bold.ttf"), &bytes)
505 .expect("font metadata text");
506
507 assert!(text.contains("License Description:"), "{text}");
508 assert!(
509 text.contains("Open Font License") || text.contains("OFL"),
510 "{text}"
511 );
512 }
513
514 #[test]
515 fn extracts_apache_metadata_from_underline_test_font_fixture() {
516 let bytes = fs::read("testdata/font-fixtures/UnderlineTest-Close.ttf")
517 .expect("read apache font fixture");
518
519 let text = extract_font_metadata_text(Path::new("UnderlineTest-Close.ttf"), &bytes)
520 .expect("font metadata text");
521
522 assert!(
523 text.contains("License Description:") || text.contains("Copyright"),
524 "{text}"
525 );
526 assert!(
527 text.contains("Apache") || text.contains("http://www.apache.org/licenses"),
528 "{text}"
529 );
530 }
531
532 #[test]
533 fn canonicalizes_ofl_url_variants_in_font_license_metadata() {
534 let canonical = canonicalize_ofl_license_reference_urls(
535 "This license is available with a FAQ at: https://openfontlicense.org/".to_string(),
536 );
537
538 assert_eq!(
539 canonical,
540 "This license is available with a FAQ at: http://scripts.sil.org/OFL"
541 );
542 }
543
544 #[test]
545 fn legacy_os2_embedding_permissions_use_least_restrictive_bit() {
546 assert_eq!(font_permission_label(2, 0), Some("Installable"));
547 assert_eq!(
548 font_permission_label(2, OS2_RESTRICTED_LICENSE_EMBEDDING),
549 Some("Restricted")
550 );
551 assert_eq!(
552 font_permission_label(
553 2,
554 OS2_RESTRICTED_LICENSE_EMBEDDING | OS2_PREVIEW_AND_PRINT_EMBEDDING
555 ),
556 Some("Preview and Print")
557 );
558 assert_eq!(
559 font_permission_label(2, OS2_RESTRICTED_LICENSE_EMBEDDING | OS2_EDITABLE_EMBEDDING),
560 Some("Editable")
561 );
562 }
563
564 #[test]
565 fn os2_version_3_embedding_permissions_require_exclusive_bits() {
566 assert_eq!(
567 font_permission_label(3, OS2_RESTRICTED_LICENSE_EMBEDDING),
568 Some("Restricted")
569 );
570 assert_eq!(
571 font_permission_label(3, OS2_PREVIEW_AND_PRINT_EMBEDDING),
572 Some("Preview and Print")
573 );
574 assert_eq!(
575 font_permission_label(3, OS2_EDITABLE_EMBEDDING),
576 Some("Editable")
577 );
578 assert_eq!(
579 font_permission_label(3, OS2_RESTRICTED_LICENSE_EMBEDDING | OS2_EDITABLE_EMBEDDING),
580 None
581 );
582 }
583
584 #[test]
585 fn font_metadata_lines_detect_noto_ofl_text_without_trademark_noise() {
586 let metadata_text = [
587 build_font_metadata_line(
588 NameTable::COPYRIGHT_NOTICE,
589 "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)".to_string(),
590 ),
591 build_font_metadata_line(
592 NameTable::TRADEMARK,
593 "Noto is a trademark of Google LLC.".to_string(),
594 ),
595 build_font_metadata_line(
596 NameTable::LICENSE_DESCRIPTION,
597 "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL".to_string(),
598 ),
599 build_font_metadata_line(
600 NameTable::LICENSE_INFO_URL,
601 "https://scripts.sil.org/OFL".to_string(),
602 ),
603 ]
604 .into_iter()
605 .flatten()
606 .collect::<Vec<_>>()
607 .join("\n");
608
609 assert!(!metadata_text.contains("Trademark:"), "{metadata_text}");
610 assert!(
611 metadata_text.contains("Copyright 2022 The Noto Project Authors"),
612 "{metadata_text}"
613 );
614 assert!(
615 metadata_text.contains("http://scripts.sil.org/OFL"),
616 "{metadata_text}"
617 );
618
619 let engine = LicenseDetectionEngine::from_embedded().expect("initialize license engine");
620 let detections = engine
621 .detect_with_kind_and_source_with_score(&metadata_text, false, false, "font.ttf", 0.0)
622 .expect("detect licenses from font metadata text");
623
624 assert!(
625 detections.iter().any(|detection| {
626 detection
627 .license_expression_spdx
628 .as_deref()
629 .is_some_and(|expression| expression.contains("OFL-1.1"))
630 }),
631 "detections: {detections:#?}"
632 );
633
634 let (copyrights, holders, _authors) = detect_copyrights(&metadata_text, None);
635 assert!(
636 copyrights.iter().any(|detection| {
637 detection.copyright
638 == "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)"
639 }),
640 "copyrights: {copyrights:#?}"
641 );
642 assert!(
643 holders
644 .iter()
645 .any(|detection| detection.holder == "The Noto Project Authors"),
646 "holders: {holders:#?}"
647 );
648 }
649
650 #[test]
651 fn extracts_metadata_from_sourcecodepro_woff_fixture() {
652 let bytes = fs::read("testdata/font-fixtures/SourceCodePro-Regular.otf.woff")
653 .expect("read woff font fixture");
654
655 let text = extract_font_metadata_text(Path::new("SourceCodePro-Regular.otf.woff"), &bytes)
656 .expect("woff font metadata text");
657
658 assert!(text.contains("Adobe"), "{text}");
659 assert!(
660 text.contains("Open Font License") || text.contains("OFL"),
661 "{text}"
662 );
663 assert!(text.contains("http://scripts.sil.org/OFL"), "{text}");
664 }
665
666 #[test]
667 fn extracts_metadata_from_sourcecodepro_woff2_fixture() {
668 let bytes = fs::read("testdata/font-fixtures/SourceCodePro-Regular.otf.woff2")
669 .expect("read woff2 font fixture");
670
671 let text = extract_font_metadata_text(Path::new("SourceCodePro-Regular.otf.woff2"), &bytes)
672 .expect("woff2 font metadata text");
673
674 assert!(text.contains("Adobe"), "{text}");
675 assert!(
676 text.contains("Open Font License") || text.contains("OFL"),
677 "{text}"
678 );
679 assert!(text.contains("http://scripts.sil.org/OFL"), "{text}");
680 }
681
682 #[test]
683 fn extracts_legal_strings_from_notosans_eot_fixture() {
684 let bytes =
685 fs::read("testdata/font-fixtures/NotoSans-Regular.eot").expect("read eot font fixture");
686
687 let text = extract_font_metadata_text(Path::new("NotoSans-Regular.eot"), &bytes)
688 .expect("eot font metadata text");
689
690 assert!(text.contains("Copyright 2015 Google Inc."), "{text}");
691 assert!(
692 text.contains("This Font Software is licensed under the SIL Open Font License"),
693 "{text}"
694 );
695 assert!(text.contains("http://scripts.sil.org/OFL"), "{text}");
696 }
697
698 #[test]
699 fn wrapped_font_metadata_detects_sourcecodepro_ofl_without_reserved_font_tail() {
700 let bytes = fs::read("testdata/font-fixtures/SourceCodePro-Regular.otf.woff")
701 .expect("read woff font fixture");
702 let metadata_text =
703 extract_font_metadata_text(Path::new("SourceCodePro-Regular.otf.woff"), &bytes)
704 .expect("wrapped font metadata text");
705
706 let engine = LicenseDetectionEngine::from_embedded().expect("initialize license engine");
707 let detections = engine
708 .detect_with_kind_and_source_with_score(&metadata_text, false, false, "font.woff", 0.0)
709 .expect("detect licenses from wrapped font metadata text");
710 assert!(
711 detections.iter().any(|detection| {
712 detection
713 .license_expression_spdx
714 .as_deref()
715 .is_some_and(|expression| expression.contains("OFL-1.1"))
716 }),
717 "detections: {detections:#?}"
718 );
719
720 let (copyrights, holders, _authors) = detect_copyrights(&metadata_text, None);
721 assert!(
722 copyrights.iter().any(|detection| {
723 detection.copyright == "(c) 2023 Adobe (http://www.adobe.com/)"
724 }),
725 "copyrights: {copyrights:#?}"
726 );
727 assert!(
728 holders.iter().any(|detection| detection.holder == "Adobe"),
729 "holders: {holders:#?}"
730 );
731 }
732
733 #[test]
734 fn extracts_metadata_from_ttc_fixture() {
735 let bytes = fs::read("testdata/font-fixtures/TTC.ttc").expect("read ttc font fixture");
736
737 let text = extract_font_metadata_text(Path::new("TTC.ttc"), &bytes)
738 .expect("ttc font metadata text");
739
740 assert!(
741 text.contains("Copyright") || text.contains("License"),
742 "{text}"
743 );
744 assert!(text.contains("No rights reserved"), "{text}");
745 }
746
747 #[test]
748 fn name_table_strings_do_not_run_records_together_into_malformed_urls() {
749 let bytes = fs::read("testdata/font-fixtures/SyntheticVariableNameRunon.ttf")
756 .expect("read synthetic variable font fixture");
757
758 let name_strings = extract_font_name_table_strings(&bytes);
759
760 assert!(
761 name_strings.contains("https://www.jetbrains.com"),
762 "{name_strings}"
763 );
764 assert!(
767 !name_strings.contains("bulenkovhttps://www.jetbrains.com"),
768 "{name_strings}"
769 );
770 assert!(
771 !name_strings.contains("www.jetbrains.comThis"),
772 "{name_strings}"
773 );
774 assert!(
775 !name_strings.contains("OFLhttps://scripts.sil.org/OFL"),
776 "{name_strings}"
777 );
778
779 let urls = find_urls(&name_strings, &DetectionConfig::default());
782 let detected: Vec<&str> = urls.iter().map(|url| url.url.as_str()).collect();
783 assert!(
784 detected
785 .iter()
786 .any(|url| url.starts_with("https://www.jetbrains.com")),
787 "detected URLs: {detected:?}"
788 );
789 for url in &detected {
790 assert!(
791 !url.contains("comThis") && !url.contains("OFLhttps"),
792 "malformed run-on URL detected: {url:?} (all: {detected:?})"
793 );
794 }
795 }
796}