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