Skip to main content

provenant/utils/
font.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
96/// Decode every `name` table record into its own newline-separated line.
97///
98/// Each record is decoded from its own `offset`/`length` slice of the name
99/// table's string storage, so adjacent records never run together. This is the
100/// safe alternative to scraping raw printable strings from the font binary,
101/// where packed UTF-16 name-table storage glues consecutive records (e.g.
102/// designer, vendor URL, description) into run-on tokens such as
103/// `bulenkovhttps://www.jetbrains.comThis…`, corrupting downstream URL and
104/// copyright extraction.
105pub(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
120/// Read each face's `name` table from an SFNT/WOFF/collection and hand it to
121/// `visit`. Centralizes the `ReadScope` → `FontData` → face loop → provider →
122/// `NameTable` setup shared by the name-table extractors so the reading logic
123/// (tag constant, error handling, allsorts API) lives in one place.
124fn 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        // Decode each distinct name id via `string_for_id`, which reads each
148        // record from its own offset/length rather than concatenating storage.
149        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        // Reproduces the JetBrains variable-font name-table packing where
750        // designer, vendor URL, and description records are stored contiguously
751        // in UTF-16 storage with no separators. A raw whole-binary
752        // printable-strings scrape glued them into run-on URLs such as
753        // `bulenkovhttps://www.jetbrains.comThis`; per-record decoding keeps each
754        // value on its own line.
755        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        // The vendor URL must stand alone, never fused with the adjacent
765        // designer or description records.
766        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        // The name-table strings feed URL detection downstream; assert no
780        // malformed run-on URL survives end-of-URL cleaning.
781        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}