mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
font-catalog: bound name records before pairing legacy names (#536)
* font-catalog: bound name records before pairing legacy names The scanner paired every legacy family record with every legacy style record of the same language before removing duplicates, so a small name table repeating those records allocated millions of strings (a million pairs from 4,000 records). Name records are now read per ID up to a cap, deduplicated as they are read and kept up to a cap, and the legacy pairs have a budget of their own. * font-catalog: count distinct legacy pairs against the pair budget Collect the legacy (family, style) pairs into a sorted set and stop at MAX_LEGACY_PAIRS distinct pairs, so the same pair repeated across languages cannot use up the budget. Output order is unchanged (sorted, deduplicated), and the test now expects exactly the full budget. * address review: typographic family decided by name(), distinct-text cap for all-language names, document the legacy pair cap * address review: keep selected full and PostScript names in lookup keys past the name cap
This commit is contained in:
1 parent
60ede99985
commit
5279ecdc0e
2 files changed
+99
-14
No files matched your search
@@ -170,13 +170,46 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
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>, 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>, 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<String> {
|
||||
localized(font, id).into_iter().map(|(_, s)| s).collect()
|
||||
let mut out: Vec<String> = 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<String>) {
|
||||
@@ -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<LocalizedName> = 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<String> = if typo_style.is_empty() { legacy_style.iter().map(|(_, s)| s.clone()).collect() } else { typo_style };
|
||||
let mut styles: Vec<String> = 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<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
|
||||
let mut full_names: Vec<String> = 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<String> =
|
||||
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 {
|
||||
|
||||
@@ -515,3 +515,44 @@ fn fork_of(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
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<u8> = 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");
|
||||
}
|
||||
Reference in new issue
Block a user