From 220994decff47b0d1275dac8bb41aea5e4ead6bd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:24:52 -0500 Subject: [PATCH] Fonts: cut names read from a font's name table to 128 characters (#5333) A name record can hold 64K characters, and every face copies its family into each localized name and every variable-font named instance (with their localized names again), so a small font could claim hundreds of megabytes of name strings before any later check. name() now keeps at most MAX_NAME_CHARS characters, the limit localized names already used, so every copy stays small. --- crates/fonts/src/fontdb.rs | 2 +- crates/fonts/src/fontdb/names.rs | 13 ++++++++--- crates/fonts/src/fontdb/tests.rs | 38 ++++++++++++++++++++++++++++---- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 44898b8711..c8c474500f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -35,7 +35,7 @@ pub(crate) use names::face_names; pub use names::{LocalName, base_style, name_language_matches, system_font_dirs}; use names::{MAX_FILE_FACES, enumerate_faces, make_face}; #[cfg(test)] -use names::{file_face_names, font_faces, mini_font, name}; +use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name}; #[cfg(test)] pub(crate) use names::{last_resort_face, legacy_family}; diff --git a/crates/fonts/src/fontdb/names.rs b/crates/fonts/src/fontdb/names.rs index 579c2a9e7d..0b01041d66 100644 --- a/crates/fonts/src/fontdb/names.rs +++ b/crates/fonts/src/fontdb/names.rs @@ -61,8 +61,16 @@ pub(super) struct CatalogEntry { /// The longest name a document's font table gives an embedded font that faces take on. pub(super) const MAX_FAMILY_NAME: usize = 128; +/// The longest name read from a font's `name` table, in characters. A record can hold 64K +/// characters, and each face copies its family into its localized names and into every named +/// instance, so a small font could claim hundreds of megabytes of names. Real names run to a +/// few dozen characters. +pub(super) const MAX_NAME_CHARS: usize = 128; + pub(super) 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())) + ids.iter().find_map(|id| { + font.localized_strings(*id).english_or_first().map(|s| s.chars().take(MAX_NAME_CHARS).collect::()).filter(|s| !s.is_empty()) + }) } /// The family's names in other languages: localized typographic family names (name ID 16) stand @@ -70,7 +78,6 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option, family: &str, id1: Option<&str>) -> Vec { const MAX_NAMES: usize = 64; - const MAX_CHARS: usize = 128; let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some(); let legacy = if typographic { id1 } else { Some(family) }; let mut out: Vec = Vec::new(); @@ -81,7 +88,7 @@ fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec< if lang == "en" || lang.starts_with("en-") { continue; } - let name: String = s.chars().take(MAX_CHARS).collect::().trim().to_string(); + let name: String = s.chars().take(MAX_NAME_CHARS).collect::().trim().to_string(); if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) { continue; } diff --git a/crates/fonts/src/fontdb/tests.rs b/crates/fonts/src/fontdb/tests.rs index a21221e416..4734f67bf3 100644 --- a/crates/fonts/src/fontdb/tests.rs +++ b/crates/fonts/src/fontdb/tests.rs @@ -245,15 +245,24 @@ fn postscript_names_select_faces() { /// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis /// and `instances` named instances, each with its own style name. fn variable_font(instances: u16) -> Vec { - let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())]; - names.extend((0..instances).map(|i| (256 + i, format!("Style {i}")))); + variable_font_named("Var Test", &[], instances) +} + +/// [`variable_font`] with family `family`, and family names in other languages `local` +/// (Windows language ids). +fn variable_font_named(family: &str, local: &[(u16, &str)], instances: u16) -> Vec { + let mut names: Vec<(u16, u16, String)> = vec![(0x409, 1, family.into()), (0x409, 2, "Regular".into())]; + names.extend(local.iter().map(|(lang, n)| (*lang, 1, n.to_string()))); + names.extend((0..instances).map(|i| (0x409, 256 + i, format!("Style {i}")))); + // Records sorted by language, then name id. + names.sort_by_key(|(lang, id, _)| (*lang, *id)); let mut name = vec![0, 0]; name.extend_from_slice(&(names.len() as u16).to_be_bytes()); name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes()); let mut strings = Vec::new(); - for (id, text) in &names { + for (lang, id, text) in &names { let utf16: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); - for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] { + for v in [3, 1, *lang, *id, utf16.len() as u16, strings.len() as u16] { name.extend_from_slice(&v.to_be_bytes()); } strings.extend(utf16); @@ -357,6 +366,27 @@ fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() { assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements()); } +#[test] +fn long_names_are_cut_before_faces_copy_them() { + // A name record can hold 64K characters, and every named instance copies the family and its + // localized names: the names are cut when read, so the copies stay small. + let long = "W".repeat(14_000); + let local = "Ж".repeat(14_000); + let font = variable_font_named(&long, &[(0x419, &local)], 32); + let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0); + assert_eq!(faces.len(), 32); + for f in &faces { + assert_eq!(f.family.chars().count(), MAX_NAME_CHARS); + assert!(f.legacy.chars().count() <= MAX_NAME_CHARS + " Style 31".len(), "{}", f.legacy.len()); + assert_eq!(f.local.len(), 1); + assert_eq!(f.local[0].name.chars().count(), MAX_NAME_CHARS); + assert_eq!(f.local[0].english, f.family); + } + // Short names are kept whole. + let faces = font_faces(&skrifa::FontRef::new(&variable_font_named("Var Test", &[(0x419, "Вар Тест")], 2)).unwrap(), 0); + assert_eq!((faces[0].family.as_str(), faces[0].local[0].name.as_str()), ("Var Test", "Вар Тест")); +} + #[test] fn named_instances_and_collection_members_are_capped() { // #3716: a file's faces are capped before they are built, however the file multiplies them.