From f3498d36ae302c5a9ac1ce4f8fb33d0b90d65f5d Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Fri, 17 Jan 2025 12:12:46 +0000 Subject: [PATCH] Initial commit --- Cargo.toml | 33 ++++ src/lib.rs | 478 +++++++++++++++++++++++++++++++++++++++++++++++ src/nametable.rs | 78 ++++++++ 3 files changed, 589 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/lib.rs create mode 100644 src/nametable.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..f36018b8a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "google-fonts-axisregistry" +version = "0.7.1" +edition = "2021" +description = "Google Fonts font axis support data" +repository = "https://github.com/googlefonts/axisregistry" +license-file = "LICENSE.txt" + +[features] +default = ["fontations"] +fontations = ["dep:write-fonts", "dep:skrifa"] + +[dependencies] +bytes = "1.7.1" +prost = "0.13" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +write-fonts = { version = "0.33.1", optional = true, features = ["read"] } +skrifa = { version = "0.26.4", optional = true } +indexmap = "2.7.0" + +[build-dependencies] +prost-build = "0.13" +protobuf-support = "3.7.1" +protobuf = "3.7.1" +protobuf-parse = "3.7.1" +glob = "0" +prettyplease = "0.2" +quote = "1.0" +proc-macro2 = "1.0" +syn = "2.0" +itertools = "0.13" +serde_json = "1.0" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..d4c995edd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,478 @@ +use std::{collections::HashSet, ops::Index}; + +use indexmap::IndexMap; +#[cfg(feature = "fontations")] +use write_fonts::{ + read::FontRef, + read::{ReadError, TableProvider}, + FontBuilder, +}; + +include!(concat!(env!("OUT_DIR"), "/_.rs")); +include!(concat!(env!("OUT_DIR"), "/data.rs")); + +const LINKED_VALUES: [(&str, (f32, f32)); 2] = [("wght", (400.0, 700.0)), ("ital", (0.0, 1.0))]; +const GF_STATIC_STYLES: [(&str, u16); 18] = [ + ("Thin", 100), + ("ExtraLight", 200), + ("Light", 300), + ("Regular", 400), + ("Medium", 500), + ("SemiBold", 600), + ("Bold", 700), + ("ExtraBold", 800), + ("Black", 900), + ("Thin Italic", 100), + ("ExtraLight Italic", 200), + ("Light Italic", 300), + ("Italic", 400), + ("Medium Italic", 500), + ("SemiBold Italic", 600), + ("Bold Italic", 700), + ("ExtraBold Italic", 800), + ("Black Italic", 900), +]; + +pub struct AxisRegistry { + axes: BTreeMap>, +} + +pub struct FontAxis { + pub tag: String, + pub min: f32, + pub max: f32, + pub default: f32, +} + +pub struct NameParticle { + pub name: Option, + pub value: f32, + pub elided: bool, +} + +impl AxisRegistry { + pub fn new() -> Self { + Self { + axes: (*AXES).clone(), + } + } + + pub fn get(&self, tag: &str) -> Option<&AxisProto> { + self.axes.get(tag).map(|v| &**v) + } + + pub fn contains_key(&self, tag: &str) -> bool { + self.axes.contains_key(tag) + } + + pub fn iter(&self) -> impl Iterator { + self.axes.iter().map(|(k, v)| (k, &**v)) + } + + pub fn get_fallback<'a>(&'a self, name: &str) -> Option<(&'a str, &'a FallbackProto)> { + self.axes + .iter() + .flat_map(|(tag, axis)| { + let fallback = axis + .fallback + .iter() + .find(|f| f.name.as_deref() == Some(name)); + fallback.map(|f| (tag.as_str(), f)) + }) + .next() + } + + // This is fallbacks_in_fvar, but without assuming any particular font representation + pub fn fallbacks<'a>( + &'a self, + font_axes: &'a [FontAxis], + ) -> impl Iterator)> + 'a { + font_axes.iter().filter_map(|axis| { + self.get(&axis.tag).map(|registry_axis| { + ( + registry_axis.tag.clone().unwrap_or_default(), + registry_axis + .fallback + .iter() + .filter(|f| { + f.value.unwrap_or(-f32::INFINITY) <= axis.min + && f.value.unwrap_or(f32::INFINITY) >= axis.max + }) + .cloned() + .collect(), + ) + }) + }) + } + + // This is fallbacks_in_name_table, but without assuming any particular font representation + pub fn name_table_fallbacks<'a>( + &'a self, + family_name: &'a str, + subfamily_name: &'a str, + font_axes: &'a [FontAxis], + ) -> impl Iterator + 'a { + let axis_names: HashSet<&str> = font_axes.iter().map(|axis| axis.tag.as_ref()).collect(); + let tokens = family_name + .split_whitespace() + .skip(1) + .chain(subfamily_name.split_whitespace()); + tokens + .flat_map(|token| self.get_fallback(token)) + .filter(move |(tag, _)| axis_names.contains(tag)) + } + + pub fn fallback_for_value<'a>( + &'a self, + axis_tag: &str, + value: f32, + ) -> Option<&'a FallbackProto> { + self.get(axis_tag) + .and_then(|axis| axis.fallback.iter().find(|f| f.value == Some(value))) + } + + pub fn axis_order(&self) -> Vec<&str> { + let mut axis_tags: Vec<&str> = self.axes.keys().map(|k| k.as_str()).collect(); + axis_tags.sort(); + axis_tags.extend(vec!["opsz", "wdth", "wght", "ital", "slnt"]); + axis_tags + } + + pub fn name_particles<'a>(&self, font_axes: &'a [FontAxis]) -> IndexMap<&'a str, NameParticle> { + let mut particles = IndexMap::new(); + for axis in font_axes { + if axis.tag == "opsz" { + particles.insert( + "opsz", + NameParticle { + name: Some(format!("{}pt", axis.default)), + value: axis.default, + elided: false, + }, + ); + } else if let Some(fallback) = self.fallback_for_value(&axis.tag, axis.default) { + particles.insert( + &axis.tag, + NameParticle { + name: fallback.name.clone(), + value: axis.default, + elided: (fallback.value() == axis.default) + && !(["Regular", "Italic", "14pt"].contains(&fallback.name())), + }, + ); + } else { + particles.insert( + &axis.tag, + NameParticle { + name: None, + value: axis.default, + elided: true, + }, + ); + }; + } + particles + } +} + +impl Default for AxisRegistry { + fn default() -> Self { + Self::new() + } +} + +impl Index<&str> for AxisRegistry { + type Output = AxisProto; + + fn index(&self, tag: &str) -> &Self::Output { + self.get(tag).expect("No such axis") + } +} + +#[cfg(feature = "fontations")] +mod nametable; + +#[cfg(feature = "fontations")] +mod fontations { + use super::*; + use nametable::{best_familyname, best_subfamilyname, find_or_add_name, rewrite_or_insert}; + use skrifa::{string::StringId, Tag}; + use std::{cmp::Reverse, collections::HashMap}; + use write_fonts::{ + from_obj::ToOwnedTable, + tables::{ + name::{Name, NameRecord}, + os2::Os2, + stat::{AxisValue, Stat}, + }, + types::Fixed, + }; + + pub fn build_name_table( + font: FontRef, + family_name: Option<&str>, + style_name: Option<&str>, + siblings: &[FontRef], + ) -> Result, Box> { + let mut new_font = FontBuilder::new(); + let family_name = family_name + .map(|x| x.to_string()) + .unwrap_or_else(|| best_familyname(&font).unwrap_or("Unknown".to_string())); + let style_name = style_name + .map(|x| x.to_string()) + .unwrap_or_else(|| best_subfamilyname(&font).unwrap_or("Regular".to_string())); + + if font.table_data(Tag::new(b"fvar")).is_some() { + build_vf_name_table(&mut new_font, &font, &family_name, siblings)?; + } else { + // build_static_name_table_v1(&mut new_font, &font, &family_name, &style_name) + } + + let mut styles: Vec<_> = GF_STATIC_STYLES.iter().collect(); + styles.sort_by_key(|(name, _weight)| Reverse(name.len())); + for (name, weight) in styles.iter() { + if style_name.contains(name) { + let mut new_os2: Os2 = font.os2()?.to_owned_table(); + new_os2.us_weight_class = *weight; + new_font.add_table(&new_os2)?; + break; + } + } + // Set RIBBI bits + Ok(new_font.copy_missing_tables(font).build()) + } + + fn fvar_instance_collisions(font: &FontRef, siblings: &[FontRef]) -> bool { + let fonts = siblings.iter().chain(std::iter::once(font)); + let is_italic = fonts + .map(|f| { + f.post() + .map(|post| post.italic_angle().abs() != Fixed::from_f64(0.0)) + .unwrap_or(false) + }) + .collect::>(); + // Any duplicates? + is_italic.iter().any(|&x| x) && is_italic.iter().all(|&x| x) + } + + fn build_vf_name_table( + newfont: &mut FontBuilder, + font: &FontRef, + family_name: &str, + siblings: &[FontRef], + ) -> Result<(), Box> { + let style_name = vf_style_name(font, family_name)?; + let mut new_name: Name = (if fvar_instance_collisions(font, siblings) { + build_static_name_table_v1(newfont, font, family_name, style_name) + } else { + build_static_name_table(newfont, font, family_name, style_name) + })?; + build_variations_ps_name(&mut new_name, font, Some(family_name)); + newfont.add_table(&new_name)?; + Ok(()) + } + + fn build_variations_ps_name(newname: &mut Name, font: &FontRef, family_name: Option<&str>) { + let fallback = best_familyname(font); + let family_name = family_name.or(fallback.as_deref()).unwrap_or("New Font"); + let subfamily_name = best_subfamilyname(font).unwrap_or("Regular".to_string()); + let font_axes = font_axes(font).unwrap_or_default(); + let registry = AxisRegistry::new(); + let font_styles = registry.name_table_fallbacks(family_name, &subfamily_name, &font_axes); + let mut var_ps = family_name.replace(" ", ""); + for (_, fallback) in font_styles { + let fallback_name = fallback.name(); + if !var_ps.contains(fallback_name) { + var_ps.push_str(fallback_name); + } + } + rewrite_or_insert(&mut newname.name_record, StringId::POSTSCRIPT_NAME, var_ps); + } + + fn build_static_name_table_v1( + _newfont: &mut FontBuilder, + _font: &FontRef, + _family_name: &str, + _style_name: String, + ) -> Result> { + unimplemented!() + } + + fn build_static_name_table( + newfont: &mut FontBuilder, + font: &FontRef, + family_name: &str, + style_name: String, + ) -> Result> { + let mut name: Name = font.name()?.to_owned_table(); + let mut records = name.name_record.into_iter().collect::>(); + records.retain(|record| record.platform_id != 1); + // let existing_name = best_familyname(font).unwrap_or("New Font".to_string()); + let mut removed_names: HashMap = HashMap::new(); + let (new_family_name, new_style_name, removeable_name_ids) = + if ["Italic", "Bold Italic", "Bold", "Regular"].contains(&style_name.as_str()) { + ( + family_name.to_string(), + style_name, + vec![ + StringId::TYPOGRAPHIC_FAMILY_NAME, + StringId::TYPOGRAPHIC_SUBFAMILY_NAME, + StringId::new(21), + StringId::new(22), + ], + ) + } else { + let style_tokens = style_name.split_whitespace().collect::>(); + let mut new_family_name_tokens = family_name.split_whitespace().collect::>(); + let is_italic = style_tokens.contains(&"Italic"); + let additional_tokens = (style_tokens.into_iter().filter(|token| { + !(*token == "Regular" + || *token == "Italic" + || new_family_name_tokens.contains(token)) + })) + .collect::>(); + new_family_name_tokens.extend(additional_tokens); + let new_family_name = new_family_name_tokens.join(" "); + let new_style_name = if is_italic { "Italic" } else { "Regular" }; + rewrite_or_insert( + &mut records, + StringId::TYPOGRAPHIC_FAMILY_NAME, + family_name.to_string(), + ); + rewrite_or_insert( + &mut records, + StringId::TYPOGRAPHIC_SUBFAMILY_NAME, + style_name, + ); + ( + new_family_name, + new_style_name.to_string(), + vec![StringId::new(21), StringId::new(22)], + ) + }; + let full_name = new_family_name.to_string() + " " + &new_style_name; + let ps_name = (new_family_name.to_string() + "-" + &new_style_name).replace(" ", ""); + rewrite_or_insert(&mut records, StringId::FAMILY_NAME, family_name.to_string()); + rewrite_or_insert(&mut records, StringId::SUBFAMILY_NAME, new_style_name); + rewrite_or_insert(&mut records, StringId::FULL_NAME, full_name); + rewrite_or_insert(&mut records, StringId::POSTSCRIPT_NAME, ps_name); + let mut to_delete = vec![]; + for name_id in removeable_name_ids.into_iter() { + if let Some(existing) = records.iter_mut().position(|r| { + r.name_id == name_id + && r.platform_id == 3 + && r.encoding_id == 1 + && r.language_id == 0x409 + }) { + removed_names.insert(name_id, records[existing].string.to_string()); + to_delete.push(existing); + } + } + for i in to_delete.into_iter().rev() { + records.remove(i); + } + + // If STAT table was using any removed names, add then back with a new ID + if !removed_names.is_empty() && font.table_data(Tag::from_be_bytes(*b"STAT")).is_some() { + let mut stat: Stat = font.stat()?.to_owned_table(); + for axis in stat.design_axes.iter_mut() { + let id = axis.axis_name_id; + if let Some(old_name) = removed_names.get(&id) { + axis.axis_name_id = find_or_add_name(&mut records, old_name); + } + } + // Also do the axis value array + if let Some(axis_values) = stat.offset_to_axis_values.as_deref_mut() { + for axis_value in axis_values.iter_mut() { + match &mut **axis_value { + AxisValue::Format1(ref mut axis_value) => { + if let Some(old_name) = removed_names.get(&axis_value.value_name_id) { + axis_value.value_name_id = find_or_add_name(&mut records, old_name); + } + } + AxisValue::Format2(ref mut axis_value) => { + if let Some(old_name) = removed_names.get(&axis_value.value_name_id) { + axis_value.value_name_id = find_or_add_name(&mut records, old_name); + } + } + AxisValue::Format3(ref mut axis_value) => { + if let Some(old_name) = removed_names.get(&axis_value.value_name_id) { + axis_value.value_name_id = find_or_add_name(&mut records, old_name); + } + } + AxisValue::Format4(_axis_value_format4) => { + // We don't do any magic here in Python; maybe we should... + } + } + } + } + newfont.add_table(&stat)?; + } + name.name_record = records; + Ok(name) + } + + fn font_axes(font: &FontRef) -> Result, ReadError> { + let fvar = font.fvar().unwrap(); + let mut axes = vec![]; + for axis in fvar.axes()? { + let tag = axis.axis_tag().to_string(); + let min = axis.min_value().to_f32(); + let max = axis.max_value().to_f32(); + let default = axis.default_value().to_f32(); + axes.push(FontAxis { + tag, + min, + max, + default, + }); + } + Ok(axes) + } + + fn vf_style_name(font: &FontRef, family_name: &str) -> Result { + let axisregistry = AxisRegistry::new(); + let axes: Vec<_> = font_axes(font)?; + let fvar_dflts = axisregistry.name_particles(&axes); + let mut relevant_particles: Vec = axisregistry + .axis_order() + .iter() + .flat_map(|tag| fvar_dflts.get(tag)) + .filter(|particle| !particle.elided) + .flat_map(|particle| particle.name.clone()) + .collect::>(); + let family_name_tokens = family_name.split_whitespace().collect::>(); + let subfamily_name = best_subfamilyname(&font); + let font_styles = axisregistry + .name_table_fallbacks( + family_name, + subfamily_name.as_deref().unwrap_or("Regular"), + &axes, + ) + .map(|(_tag, proto)| proto.name()) + .filter(|name| !family_name_tokens.contains(name)) + .map(|name| name.to_string()) + .filter(|name| !relevant_particles.contains(&name)) + .collect::>(); + relevant_particles.extend(font_styles); + let name = relevant_particles + .join(" ") + .replace("Regular Italic", "Italic"); + Ok(name) + } +} + +#[cfg(feature = "fontations")] +pub use fontations::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opsz() { + let ar = AxisRegistry::new(); + assert!(ar.contains_key("opsz")); + assert_eq!(ar["opsz"].display_name.as_deref(), Some("Optical Size")); + } +} diff --git a/src/nametable.rs b/src/nametable.rs new file mode 100644 index 000000000..eb268f5db --- /dev/null +++ b/src/nametable.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use skrifa::{string::StringId, FontRef, MetadataProvider}; +use write_fonts::tables::name::NameRecord; + +/// Utility functions for name table handling. + +pub(crate) fn get_best_name(font: &FontRef, ids: &[StringId]) -> Option { + for id in ids { + if let Some(name) = font.localized_strings(*id).english_or_first() { + return Some(name.chars().collect()); + } + } + None +} + +pub(crate) fn best_familyname(font: &FontRef) -> Option { + get_best_name( + font, + &[ + StringId::WWS_FAMILY_NAME, + StringId::TYPOGRAPHIC_FAMILY_NAME, + StringId::FAMILY_NAME, + ], + ) +} + +pub(crate) fn best_subfamilyname(font: &FontRef) -> Option { + get_best_name( + font, + &[ + StringId::WWS_SUBFAMILY_NAME, + StringId::TYPOGRAPHIC_SUBFAMILY_NAME, + StringId::SUBFAMILY_NAME, + ], + ) +} + +pub(crate) fn rewrite_or_insert(records: &mut Vec, name_id: StringId, string: String) { + if let Some(existing) = records.iter_mut().find(|r| { + r.name_id == name_id && r.platform_id == 3 && r.encoding_id == 1 && r.language_id == 0x409 + }) { + *existing.string = string.into(); + } else { + records.push(NameRecord { + platform_id: 3, + encoding_id: 1, + language_id: 0x409, + name_id, + string: string.into(), + }); + } +} + +pub(crate) fn find_or_add_name(records: &mut Vec, string: &str) -> StringId { + if let Some(existing) = records.iter().find(|r| { + r.string.to_string() == string + && r.platform_id == 3 + && r.encoding_id == 1 + && r.language_id == 0x409 + }) { + return existing.name_id; + } + let existing_ids: HashSet = records.iter().map(|r| r.name_id.to_u16()).collect(); + let mut id: u16 = 256; + while existing_ids.contains(&id) { + id += 1; + } + let name_id = StringId::new(id); + records.push(NameRecord { + platform_id: 3, + encoding_id: 1, + language_id: 0x409, + name_id, + string: string.to_string().into(), + }); + name_id +} -- 2.47.3