diff --git a/crates/font-catalog/src/scan.rs b/crates/font-catalog/src/scan.rs index ae321daca..29770d4d7 100644 --- a/crates/font-catalog/src/scan.rs +++ b/crates/font-catalog/src/scan.rs @@ -170,13 +170,46 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } -/// Every string of `id`, with its language. +/// Name records read per name ID. A font has one per platform and language, a few dozen at most; +/// a damaged or hostile `name` table can repeat a record tens of thousands of times. +const MAX_NAME_RECORDS: usize = 512; +/// Distinct strings, with their language, kept per name ID. +const MAX_NAMES: usize = 128; +/// Distinct legacy family and style pairs kept per face. The pairs are a product of two name +/// lists, so they get a budget of their own. Pairs past the cap are dropped in record order, so a +/// legacy family name that appears only in them gets no alias (intended: bounded work on hostile fonts). +const MAX_LEGACY_PAIRS: usize = 256; + +/// Every distinct string of `id`, with its language, in the order the font lists them (at most +/// [`MAX_NAMES`], from the first [`MAX_NAME_RECORDS`] records). fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option, String)> { - font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() + let mut out: Vec<(Option, String)> = Vec::new(); + for s in font.localized_strings(id).take(MAX_NAME_RECORDS) { + let entry = (s.language().map(str::to_owned), s.to_string()); + if !entry.1.is_empty() && !out.contains(&entry) { + out.push(entry); + if out.len() == MAX_NAMES { + break; + } + } + } + out } +/// Every distinct non-empty string of `id`, ignoring language (at most [`MAX_NAMES`], from the +/// first [`MAX_NAME_RECORDS`] records), so many language tags sharing one text cannot crowd out another. fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec { - localized(font, id).into_iter().map(|(_, s)| s).collect() + let mut out: Vec = Vec::new(); + for s in font.localized_strings(id).take(MAX_NAME_RECORDS) { + let text = s.to_string(); + if !text.is_empty() && !out.contains(&text) { + out.push(text); + if out.len() == MAX_NAMES { + break; + } + } + } + out } fn sort_dedup(v: &mut Vec) { @@ -195,7 +228,9 @@ pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&P let legacy_family = localized(font, StringId::FAMILY_NAME); let legacy_style = localized(font, StringId::SUBFAMILY_NAME); - let family_records = if typo_family.is_empty() { &legacy_family } else { &typo_family }; + // `name` read past the record cap `localized` keeps, so it decides whether a typographic family exists. + let has_typo_family = name(font, &[StringId::TYPOGRAPHIC_FAMILY_NAME]).is_some(); + let family_records = if has_typo_family { &typo_family } else { &legacy_family }; let mut localized_families: Vec = Vec::new(); for (language, n) in family_records { if *n != family && !localized_families.iter().any(|l| l.name == *n) { @@ -207,19 +242,27 @@ pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&P // typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family. let style_names: Vec = if typo_style.is_empty() { legacy_style.iter().map(|(_, s)| s.clone()).collect() } else { typo_style }; let mut styles: Vec = std::iter::once(style.as_str()).chain(style_names.iter().map(String::as_str)).map(norm).collect(); - let mut legacy: Vec<(String, String)> = if typo_family.is_empty() { - vec![] - } else { - legacy_family + // Sorted and distinct; the budget counts distinct pairs, so repeats across languages do not + // crowd out real ones. + let mut legacy_pairs: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new(); + if has_typo_family { + let pairs = legacy_family .iter() .flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s)))) - .filter(|(f, s)| !f.is_empty() && !s.is_empty()) - .collect() - }; - legacy.sort(); - legacy.dedup(); + .filter(|(f, s)| !f.is_empty() && !s.is_empty()); + for pair in pairs { + legacy_pairs.insert(pair); + if legacy_pairs.len() == MAX_LEGACY_PAIRS { + break; + } + } + } + let legacy: Vec<(String, String)> = legacy_pairs.into_iter().collect(); let mut postscript: Vec = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect(); let mut full_names: Vec = all_names(font, StringId::FULL_NAME).iter().map(|n| norm(n)).collect(); + // `all_names` is capped; the names `FaceInfo` reports stay findable even when the cap cut them. + postscript.extend(name(font, &[StringId::POSTSCRIPT_NAME]).map(|n| norm(&n))); + full_names.extend(name(font, &[StringId::FULL_NAME]).map(|n| norm(&n))); for v in [&mut families, &mut styles, &mut full_names, &mut postscript] { sort_dedup(v); } @@ -239,7 +282,7 @@ pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&P let fs_type = os2.as_ref().map_or(0, |t| t.fs_type()); let code_pages = os2.as_ref().map_or([0, 0], |t| [t.ul_code_page_range_1().unwrap_or(0), t.ul_code_page_range_2().unwrap_or(0)]); let axes = font.axes(); - let legacy_pair = (!typo_family.is_empty()) + let legacy_pair = has_typo_family .then(|| name(font, &[StringId::FAMILY_NAME])) .flatten() .filter(|f| *f != family) @@ -312,6 +355,7 @@ fn named_instances(font: &skrifa::FontRef<'_>, face: &FaceInfo, keys: &mut Keys) let mut postscript: Vec = inst.postscript_name_id().map(|id| all_names(font, id)).unwrap_or_default().iter().map(|n| norm(n)).collect(); postscript.push(norm(&ps_default)); + postscript.extend(own_ps.as_deref().map(norm)); sort_dedup(&mut styles); sort_dedup(&mut postscript); let info = FaceInfo { diff --git a/crates/font-catalog/src/tests.rs b/crates/font-catalog/src/tests.rs index 9c53d1301..c061ca2aa 100644 --- a/crates/font-catalog/src/tests.rs +++ b/crates/font-catalog/src/tests.rs @@ -515,3 +515,44 @@ fn fork_of(fonts: &[Vec]) -> Vec { fork.extend(map); fork } + +#[test] +fn repeated_name_records_are_bounded_before_pairing() { + // Thousands of legacy family and style records in one language: pairing them all would + // allocate millions of strings. write-fonts refuses unsorted or repeated records, so the + // `name` table is written by hand. + let mut names: Vec<(u16, String)> = Vec::new(); + for i in 0..2000 { + names.push((1, format!("Family {}", i % 1000))); + names.push((2, format!("Style {}", i % 1000))); + } + names.push((16, "Typo Family".into())); + names.push((17, "Regular".into())); + let mut records = Vec::new(); + let mut strings = Vec::new(); + let mut stored: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for (id, text) in &names { + let utf16: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + let offset = *stored.entry(text.as_str()).or_insert_with(|| { + strings.extend_from_slice(&utf16); + strings.len() - utf16.len() + }); + for v in [3u16, 1, 0x409, *id, utf16.len() as u16, offset as u16] { + records.extend_from_slice(&v.to_be_bytes()); + } + } + let mut name = Vec::new(); + for v in [0u16, names.len() as u16, (6 + records.len()) as u16] { + name.extend_from_slice(&v.to_be_bytes()); + } + name.extend(records); + name.extend(strings); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_raw(Tag::new(b"glyf"), vec![0u8; 4]); + let faces = crate::scan::data_faces(&builder.build(), 1, None, Source::User); + let face = faces.first().expect("a face"); + assert_eq!(face.info.family, "Typo Family"); + assert!(!face.keys.legacy.is_empty()); + assert_eq!(face.keys.legacy.len(), 256, "the pair budget counts distinct pairs"); +}