Fix #813: type can name an installed version of its font

A font library can hold two versions of one family and style (a new
Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept
only the first one loaded, so type set in the other couldn't be matched
when a PDF or .ai was opened: it came in as outlines. Now every version
loads (the first stays the one family and style resolve to), PDF import
tries the other versions when the glyphs differ, and type set in a version
the family and style alone wouldn't pick names it in CharStyle::font_version
(left out when None, so documents without it are unchanged). Layout,
shaping, the substituted-glyph highlight, Find Font, Package and Document
Info follow it; changing the font clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Juan Manuel Verges Solanas
2026-10-09 19:26:51 +00:00
co-authored by Claude Opus 5.5
parent b43e633329
commit 562e09707a
18 changed files with 279 additions and 38 deletions
+71 -3
View File
@@ -52,6 +52,9 @@ pub struct FontFace {
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two
/// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]).
pub version: String,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
@@ -324,6 +327,10 @@ pub struct FontDb {
/// scan, so what they find never depends on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock<()>,
/// Families (lowercased) whose installed files were all loaded to find other versions of their
/// faces (see [`FontDb::versions`]); a rescan forgets them.
#[cfg(not(target_arch = "wasm32"))]
version_scanned: RwLock<std::collections::HashSet<String>>,
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
family_cache: Mutex<Option<Arc<[String]>>>,
menu_cache: Mutex<Option<MenuFamilies>>,
@@ -1032,6 +1039,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
let version = name(&f, &[StringId::VERSION_STRING]).unwrap_or_default();
// A named instance: outlines, metrics and advances at its axis settings.
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
let m = f.metrics(Size::unscaled(), &location);
@@ -1046,6 +1054,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
version,
weight: weight.unwrap_or(a.weight.value()),
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
variations,
@@ -1150,6 +1159,8 @@ impl FontDb {
aliases: RwLock::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
#[cfg(not(target_arch = "wasm32"))]
version_scanned: RwLock::new(std::collections::HashSet::new()),
family_cache: Mutex::new(None),
menu_cache: Mutex::new(None),
generation: AtomicU64::new(0),
@@ -1242,8 +1253,13 @@ impl FontDb {
pub fn styles(&self, family: &str) -> Vec<String> {
let (family, _) = self.canonical(family, "");
let family = family.as_str();
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
let mut v: Vec<(bool, f32, String)> = Vec::new();
// Each style once, as its first loaded version describes it (versions can disagree on weight).
for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) {
v.push((f.italic, f.weight, f.style.clone()));
}
}
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
for cf in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
@@ -1267,7 +1283,14 @@ impl FontDb {
let data = Arc::new(bytes);
let mut added = 0;
for (i, spec) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
// Another version of a family and style is kept: a document may name it (the first one
// loaded stays the one the family and style alone resolve to).
let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default();
if self
.read_faces()
.iter()
.any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version)
{
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
@@ -1329,6 +1352,8 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize {
// Files may have been installed since: other versions are looked for again.
self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear();
let mut catalog = Catalog::new();
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
let mut postscript = PostScriptNames::new();
@@ -1520,6 +1545,49 @@ impl FontDb {
.or_else(|| self.read_faces().first().cloned())
}
/// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when
/// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives.
pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option<Arc<FontFace>> {
let face = self.face(family, style)?;
let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) };
let same =
|f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version;
if let Some(f) = self.read_faces().iter().find(same) {
return Some(f.clone());
}
// Not loaded: the family's installed files may hold it (the catalog doesn't list versions).
self.load_family_files(&face.family);
let found = self.read_faces().iter().find(same).cloned();
Some(found.unwrap_or(face))
}
/// Every installed version of `face`'s family and style, `face` itself first.
pub fn versions(&self, face: &Arc<FontFace>) -> Vec<Arc<FontFace>> {
self.load_family_files(&face.family);
let mut out = vec![face.clone()];
out.extend(
self.read_faces()
.iter()
.filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id)
.cloned(),
);
out
}
/// Load the installed files of `family` once (other versions of faces already loaded from
/// elsewhere); a no-op on wasm, where no fonts are installed.
fn load_family_files(&self, family: &str) {
#[cfg(not(target_arch = "wasm32"))]
{
let key = family.to_ascii_lowercase();
if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) {
self.load_cataloged(family);
}
}
#[cfg(target_arch = "wasm32")]
let _ = family;
}
/// Is `style` of `family` among the installed fonts?
#[cfg(not(target_arch = "wasm32"))]
fn is_cataloged(&self, family: &str, style: &str) -> bool {
+4 -2
View File
@@ -263,7 +263,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => {
let first = cx.style_at(0);
let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre =
db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
}
_ => QUARTER_TURN,
@@ -1392,7 +1393,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let st = cx.style_at(pr.start);
let (cap, xh) = cap_x_heights(cx.db, st);
let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre =
cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 }
};
if pi > 0 {
+2
View File
@@ -522,6 +522,8 @@ mod tests_typo;
#[cfg(test)]
mod tests_variable;
#[cfg(test)]
mod tests_versions;
#[cfg(test)]
mod tests_vertical;
#[cfg(test)]
#[path = "../build/web_fonts.rs"]
+8 -4
View File
@@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool {
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) };
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
};
let k = st.size / face.upem;
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
}
@@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
/// Cap height and x height (points) of a style's resolved face.
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) };
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
};
let k = st.size / face.upem * vs;
(face.cap_height * k, face.x_height * k)
}
@@ -291,12 +295,12 @@ pub(crate) fn shape_range(
let a = bytes.start;
let b = bytes.end;
if let Some(art) = art {
if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) {
if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a)));
}
continue;
}
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
let pmap = primary.skrifa().map(|f| f.charmap());
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
let small_caps = st.small_caps.is_some() && !st.all_caps;
+27
View File
@@ -174,3 +174,30 @@ pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
}
Some(builder.build())
}
/// The family of [`twin_font`]: two versions of one family and style.
pub const TWIN_FAMILY: &str = "Twintest Sans";
/// The version strings of [`twin_font`]'s older and newer face.
pub const TWIN_VERSIONS: [&str; 2] = ["Version 1.000", "Version 2.000"];
/// A face of family [`TWIN_FAMILY`], style Regular, in version [`TWIN_VERSIONS`]`[0]` (the bundled
/// Source Sans 3 Regular) or, `newer`, `[1]` (the bundled Inter Regular): one name, two sets of
/// glyphs, as a font library that keeps an old version beside a new one has. `None` when it can't
/// be built.
pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
const OLDER: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
const NEWER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let font = skrifa::FontRef::new(if newer { NEWER } else { OLDER }).ok()?;
let version = TWIN_VERSIONS[usize::from(newer)];
let name = name_table(&[(1, TWIN_FAMILY), (2, "Regular"), (4, "Twintest Sans Regular"), (5, version), (6, "TwintestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
+43
View File
@@ -0,0 +1,43 @@
//! Two installed versions of one family and style (a font library keeps an old version beside a
//! new one): both load, the family and style alone keep resolving to the first, and type that names
//! the other version is set in it.
use kurbo::Point;
use vectorcraft_doc::CharStyle;
use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font};
use super::*;
/// A database of the bundled fonts, the newer version of the test family loaded first.
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(twin_font(true).expect("the newer test font builds")), 1);
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 1, "another version is kept");
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 0, "the same version isn't added twice");
db
}
fn width(db: &FontDb, version: Option<&str>) -> f64 {
let st = CharStyle { font_family: FAMILY.into(), font_version: version.map(Into::into), size: 100.0, ..CharStyle::default() };
layout(db, &TextObject::point(Point::ZERO, "Hamburgefonstiv", st)).glyphs.iter().map(|g| g.advance).sum()
}
#[test]
fn a_version_is_found_by_its_version_string() {
let db = db();
let face = |v: Option<&str>| db.face_version(FAMILY, "Regular", v).expect("a face");
assert_eq!(db.face(FAMILY, "Regular").expect("a face").version, VERSIONS[1], "the first loaded stays the default");
assert_eq!(face(None).version, VERSIONS[1]);
assert_eq!(face(Some(VERSIONS[0])).version, VERSIONS[0]);
assert_eq!(face(Some("Version 9.000")).version, VERSIONS[1], "a version not installed: the default");
let all = db.versions(&face(None));
assert_eq!(all.iter().map(|f| f.version.as_str()).collect::<Vec<_>>(), [VERSIONS[1], VERSIONS[0]]);
}
#[test]
fn type_naming_a_version_is_set_in_it() {
let db = db();
let (newer, older) = (width(&db, None), width(&db, Some(VERSIONS[0])));
assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}");
assert_eq!(width(&db, Some(VERSIONS[1])), newer);
}