perf(render): look CFF glyph names up in a map (vendored hayro-interpret patch)

CffFontBlob::glyph_index_by_name searched the whole charset, with a string lookup per glyph, for every character code of a font (there was a TODO noting it). Build a name to glyph map on first use and share it between clones. The first glyph with a name still wins, so results are unchanged.
This commit is contained in:
narain
2026-10-21 17:13:39 -07:00
parent 6898094e77
commit 9c2f4e3529
2 changed files with 122 additions and 9 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+121 -8
View File
@@ -13,7 +13,7 @@ use skrifa::raw::{FontData as ReadFontData, FontRead};
use skrifa::{FontRef, GlyphId, MetadataProvider, OutlineGlyphCollection};
use std::collections::HashMap;
use std::fmt::{Debug, Formatter};
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use yoke::{Yoke, Yokeable};
type FontData = Arc<dyn AsRef<[u8]> + Send + Sync>;
@@ -50,7 +50,11 @@ impl Type1FontBlob {
/// A font blob for CFF-based fonts.
#[derive(Clone)]
pub(crate) struct CffFontBlob(Arc<CffFontYoke>);
pub(crate) struct CffFontBlob(Arc<CffFontYoke>, Arc<OnceLock<GlyphNames>>);
/// PdfCraft patch: (see vendor/README.md hayro-interpret (44)) glyph name to glyph id, built on
/// first use.
type GlyphNames = HashMap<Box<[u8]>, GlyphId>;
impl Debug for CffFontBlob {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
@@ -84,7 +88,7 @@ impl CffFontBlob {
});
let _ = (cff, charset, subfonts);
Some(Self(Arc::new(yoke)))
Some(Self(Arc::new(yoke), Arc::new(OnceLock::new())))
}
pub(crate) fn font_data(&self) -> FontData {
@@ -134,11 +138,22 @@ impl CffFontBlob {
}
pub(crate) fn glyph_index_by_name(&self, name: &str) -> Option<GlyphId> {
// TODO: This is probably slow to do repeatedly?
self.charset()?.iter().find_map(|(gid, sid)| {
let bytes = self.0.as_ref().get().cff.string(sid)?;
(bytes == name.as_bytes()).then_some(gid)
})
// Called for every character code of a font, so the charset is read once. The first
// glyph of a name wins, as it did when the charset was searched in order.
self.1
.get_or_init(|| {
let mut names = GlyphNames::new();
if let Some(charset) = self.charset() {
for (gid, sid) in charset.iter() {
if let Some(bytes) = self.0.as_ref().get().cff.string(sid) {
names.entry(bytes.into()).or_insert(gid);
}
}
}
names
})
.get(name.as_bytes())
.copied()
}
pub(crate) fn glyph_index_by_cid(&self, cid: u16) -> Option<GlyphId> {
@@ -333,3 +348,101 @@ struct CFFYoke<'a> {
charset: Option<Charset<'a>>,
subfonts: Vec<Subfont>,
}
#[cfg(test)]
mod tests {
use super::*;
/// A CFF font with glyphs `.notdef`, `Alpha`, `Alpha` (a duplicate name) and `Beta`.
fn cff_with_names() -> Vec<u8> {
fn index(items: &[&[u8]]) -> Vec<u8> {
let mut out = (items.len() as u16).to_be_bytes().to_vec();
if items.is_empty() {
return out;
}
out.push(1);
let mut offset = 1u8;
out.push(offset);
for item in items {
offset += item.len() as u8;
out.push(offset);
}
for item in items {
out.extend_from_slice(item);
}
out
}
// Operands use the 5-byte form so the Top DICT has a fixed size.
fn int(value: usize) -> Vec<u8> {
let mut out = vec![29];
out.extend_from_slice(&(value as u32).to_be_bytes());
out
}
fn build_top(charset: usize, char_strings: usize, private: usize) -> Vec<u8> {
let mut dict = Vec::new();
dict.extend(int(charset));
dict.push(15);
dict.extend(int(char_strings));
dict.push(17);
dict.extend(int(0));
dict.extend(int(private));
dict.push(18);
dict
}
let header = vec![1, 0, 4, 1];
let names = index(&[b"T"]);
let strings = index(&[b"Alpha", b"Beta"]);
let subrs = index(&[]);
// Standard strings take SIDs 0..=390, so the first custom string is 391.
let charset = vec![0, 0x01, 0x87, 0x01, 0x87, 0x01, 0x88];
let char_strings = index(&[&[14], &[14], &[14], &[14]]);
// The Top DICT has a fixed size because every operand takes the 5-byte form.
let top_len = index(&[&build_top(0, 0, 0)]).len();
let charset_at = header.len() + names.len() + top_len + strings.len() + subrs.len();
let char_strings_at = charset_at + charset.len();
let private_at = char_strings_at + char_strings.len();
let dict = build_top(charset_at, char_strings_at, private_at);
let top = index(&[&dict]);
let mut out = header;
for part in [names, top, strings, subrs, charset, char_strings] {
out.extend(part);
}
out
}
/// The charset search `glyph_index_by_name` replaced.
fn linear(blob: &CffFontBlob, name: &str) -> Option<GlyphId> {
blob.charset()?.iter().find_map(|(gid, sid)| {
let bytes = blob.0.as_ref().get().cff.string(sid)?;
(bytes == name.as_bytes()).then_some(gid)
})
}
#[test]
fn glyph_names_resolve_to_the_first_glyph_with_that_name() {
let data: FontData = Arc::new(cff_with_names());
let blob = CffFontBlob::new(data).expect("the test font parses");
assert!(blob.1.get().is_none(), "the name map is built on first use");
assert_eq!(blob.glyph_index_by_name("Alpha"), Some(GlyphId::new(1)));
assert_eq!(blob.glyph_index_by_name("Beta"), Some(GlyphId::new(3)));
assert_eq!(blob.glyph_index_by_name("Missing"), None);
assert_eq!(blob.glyph_index_by_name(""), None);
for name in ["Alpha", "Beta", "Missing", ".notdef", ""] {
assert_eq!(
blob.glyph_index_by_name(name),
linear(&blob, name),
"{name:?}"
);
// The second lookup is answered from the stored map.
assert_eq!(
blob.glyph_index_by_name(name),
linear(&blob, name),
"{name:?}"
);
}
assert!(blob.1.get().is_some(), "the lookups built the name map");
let shared = blob.clone();
assert!(Arc::ptr_eq(&blob.1, &shared.1), "clones share one name map");
assert_eq!(shared.glyph_index_by_name("Alpha"), Some(GlyphId::new(1)));
}
}