use std::collections::HashMap;
use google_fonts_axisregistry::build_stat;
use serde::{Deserialize, Deserializer, Serialize, de};
use skrifa::{FontRef, raw::TableProvider as _};
use write_fonts::{
FontBuilder,
from_obj::ToOwnedTable,
tables::{
name::{Name, NameRecord},
stat as write_stat,
},
types::{Fixed, NameId, Tag},
};
use crate::GftoolsError;
use crate::names::find_or_add_name;
use crate::utils::font_is_italic;
#[derive(Clone, Debug)]
pub struct StatBuilder {
pub records: Vec<AxisRecord>,
pub values: Vec<AxisValue>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AxisRecord {
pub tag: Tag,
pub name: String,
pub ordering: u16,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AxisValue {
pub flags: u16,
pub name: String,
pub location: AxisLocation,
}
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub enum AxisLocation {
One {
tag: Tag,
value: Fixed,
},
Two {
tag: Tag,
nominal: Fixed,
min: Fixed,
max: Fixed,
},
Three {
tag: Tag,
value: Fixed,
linked: Fixed,
},
Four(Vec<(Tag, Fixed)>),
}
impl StatBuilder {
pub fn build(&self, name_records: &mut Vec<NameRecord>) -> write_stat::Stat {
let mut sorted_values = HashMap::<Tag, Vec<_>>::new();
let mut sorted_records = self.records.iter().collect::<Vec<_>>();
sorted_records.sort_by_key(|x| x.ordering);
for axis_value in &self.values {
match axis_value.location {
AxisLocation::One { tag, .. }
| AxisLocation::Two { tag, .. }
| AxisLocation::Three { tag, .. } => {
sorted_values.entry(tag).or_default().push(axis_value)
}
AxisLocation::Four(_) => sorted_values
.entry(Tag::default())
.or_default()
.push(axis_value),
}
}
let mut design_axes = Vec::with_capacity(self.records.len());
let mut axis_values = Vec::with_capacity(self.values.len());
for (i, record) in self.records.iter().enumerate() {
let name_id = find_or_add_name(name_records, &record.name);
let record = write_stat::AxisRecord {
axis_tag: record.tag,
axis_name_id: name_id,
axis_ordering: record.ordering,
};
for axis_value in sorted_values
.get(&record.axis_tag)
.iter()
.flat_map(|x| x.iter())
{
let flags = write_stat::AxisValueTableFlags::from_bits(axis_value.flags).unwrap();
let name_id = find_or_add_name(name_records, &axis_value.name);
let value = match &axis_value.location {
AxisLocation::One { value, .. } => write_stat::AxisValue::format_1(
i as u16, flags, name_id, *value,
),
AxisLocation::Two {
nominal, min, max, ..
} => write_stat::AxisValue::format_2(
i as _, flags, name_id, *nominal, *min, *max,
),
AxisLocation::Three { value, linked, .. } => {
write_stat::AxisValue::format_3(i as _, flags, name_id, *value, *linked)
}
AxisLocation::Four(_) => panic!("assigned to separate group"),
};
axis_values.push(value);
}
design_axes.push(record);
}
let format4 = sorted_values
.remove(&Tag::default())
.unwrap_or_default()
.into_iter()
.map(|format4| {
let flags = write_stat::AxisValueTableFlags::from_bits(format4.flags).unwrap();
let name_id = find_or_add_name(name_records, &format4.name);
let AxisLocation::Four(values) = &format4.location else {
panic!("only format 4 in this group")
};
let mapping = values
.iter()
.map(|(tag, value)| {
let axis_index = design_axes
.iter()
.position(|rec| rec.axis_tag == *tag)
.expect("validated");
write_stat::AxisValueRecord::new(axis_index as _, *value)
})
.collect();
write_stat::AxisValue::format_4(flags, name_id, mapping)
});
let axis_values = format4.chain(axis_values).collect();
write_stat::Stat::new(design_axes, axis_values, NameId::from(2))
}
pub fn from_config(axes: &[AxisConfig]) -> Result<Self, GftoolsError> {
let mut records = Vec::with_capacity(axes.len());
let mut values = Vec::new();
for (index, axis) in axes.iter().enumerate() {
let tag = Tag::new_checked(axis.tag.as_bytes()).map_err(|_| {
GftoolsError::Misc(format!("Invalid axis tag in config: {:?}", axis.tag))
})?;
records.push(AxisRecord {
tag,
name: axis.name.to_axis_name()?,
ordering: axis.ordering.unwrap_or(index as u16),
});
for value in &axis.values {
values.push(value.to_axis_value(tag)?);
}
}
Ok(StatBuilder { records, values })
}
}
#[derive(Debug, Clone)]
pub struct VarFont {
pub filename: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub enum StatConfig {
Family(Vec<AxisConfig>),
PerFile(HashMap<String, Vec<AxisConfig>>),
}
impl StatConfig {
pub fn axes_for(&self, filename: &str) -> Result<&[AxisConfig], GftoolsError> {
match self {
StatConfig::Family(axes) => Ok(axes),
StatConfig::PerFile(configs) => {
configs.get(filename).map(Vec::as_slice).ok_or_else(|| {
GftoolsError::Misc(format!("Filename {filename} not found in stat dictionary"))
})
}
}
}
}
impl<'de> Deserialize<'de> for StatConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct StatConfigVisitor;
impl<'de> de::Visitor<'de> for StatConfigVisitor {
type Value = StatConfig;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter
.write_str("a list of axes, or a mapping of font file name to a list of axes")
}
fn visit_seq<A: de::SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
Vec::<AxisConfig>::deserialize(de::value::SeqAccessDeserializer::new(seq))
.map(StatConfig::Family)
}
fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
HashMap::<String, Vec<AxisConfig>>::deserialize(
de::value::MapAccessDeserializer::new(map),
)
.map(StatConfig::PerFile)
}
}
deserializer.deserialize_any(StatConfigVisitor)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AxisConfig {
pub tag: String,
pub name: NameSpec,
#[serde(default)]
pub ordering: Option<u16>,
#[serde(default)]
pub values: Vec<AxisValueConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AxisValueConfig {
pub name: NameSpec,
#[serde(default)]
pub flags: u16,
#[serde(default)]
pub value: Option<f64>,
#[serde(default)]
pub linked_value: Option<f64>,
#[serde(default)]
pub nominal_value: Option<f64>,
#[serde(default)]
pub range_min_value: Option<f64>,
#[serde(default)]
pub range_max_value: Option<f64>,
}
impl AxisValueConfig {
fn to_axis_value(&self, tag: Tag) -> Result<AxisValue, GftoolsError> {
let location = if let Some(value) = self.value {
let value = Fixed::from_f64(value);
match self.linked_value {
Some(linked) => AxisLocation::Three {
tag,
value,
linked: Fixed::from_f64(linked),
},
None => AxisLocation::One { tag, value },
}
} else if let Some(nominal) = self.nominal_value {
AxisLocation::Two {
tag,
nominal: Fixed::from_f64(nominal),
min: self
.range_min_value
.map_or_else(negative_infinity, Fixed::from_f64),
max: self
.range_max_value
.map_or_else(positive_infinity, Fixed::from_f64),
}
} else {
return Err(GftoolsError::Misc(
"Can't determine format for AxisValue".to_string(),
));
};
Ok(AxisValue {
flags: self.flags,
name: self.name.to_name(),
location,
})
}
}
impl NameSpec {
pub fn to_name(&self) -> String {
match self {
NameSpec::Name(name) => name.clone(),
NameSpec::Number(number) => number.to_string(),
}
}
fn to_axis_name(&self) -> Result<String, GftoolsError> {
match self {
NameSpec::Name(name) => Ok(name.clone()),
NameSpec::Number(number) => Err(GftoolsError::Misc(format!(
"Axis name {number} looks like a name ID; the Rust STAT builder only supports string axis names"
))),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub enum NameSpec {
Name(String),
Number(i64),
}
fn negative_infinity() -> Fixed {
Fixed::from_bits(i32::MIN)
}
fn positive_infinity() -> Fixed {
Fixed::from_bits(i32::MAX)
}
pub fn gen_stat_tables(varfonts: &[VarFont]) -> Result<Vec<Vec<u8>>, GftoolsError> {
varfonts
.iter()
.enumerate()
.map(|(index, font)| {
let siblings = varfonts
.iter()
.enumerate()
.filter(|(other, _)| *other != index)
.map(|(_, sibling)| sibling.data.as_slice())
.collect::<Vec<_>>();
build_stat(&font.data, &siblings).map_err(|e| GftoolsError::Misc(e.to_string()))
})
.collect()
}
pub fn gen_stat_tables_from_config(
config: &StatConfig,
varfonts: &[VarFont],
has_italic: Option<bool>,
) -> Result<Vec<Vec<u8>>, GftoolsError> {
for font in varfonts {
let fontref = FontRef::new(&font.data)?;
if fontref.fvar().is_err() {
return Err(GftoolsError::Misc(format!(
"{} does not have an fvar table",
font.filename
)));
}
}
let has_italic = match has_italic {
Some(has_italic) => has_italic,
None => varfonts
.iter()
.any(|font| FontRef::new(&font.data).is_ok_and(|fontref| font_is_italic(&fontref))),
};
let generated_italic_axis = match config {
StatConfig::Family(axes) if has_italic => {
if axes.iter().any(|axis| axis.tag == "ital") {
return Err(GftoolsError::Misc(
"ital axis should not appear in stat config".to_string(),
));
}
true
}
_ => false,
};
varfonts
.iter()
.map(|font| {
let fontref = FontRef::new(&font.data)?;
let mut axes = config.axes_for(&font.filename)?.to_vec();
if generated_italic_axis {
axes.push(italic_axis(font_is_italic(&fontref)));
}
let mut name_table: Name = fontref.name()?.to_owned_table();
let stat = StatBuilder::from_config(&axes)?.build(&mut name_table.name_record);
name_table.name_record.sort();
let mut builder = FontBuilder::new();
builder.add_table(&name_table)?;
builder.add_table(&stat)?;
builder.copy_missing_tables(fontref);
Ok(builder.build())
})
.collect()
}
fn italic_axis(italic: bool) -> AxisConfig {
let values = if italic {
vec![AxisValueConfig {
name: NameSpec::Name("Italic".to_string()),
flags: 0,
value: Some(1.0),
linked_value: None,
nominal_value: None,
range_min_value: None,
range_max_value: None,
}]
} else {
vec![AxisValueConfig {
name: NameSpec::Name("Roman".to_string()),
flags: 0x2,
value: Some(0.0),
linked_value: Some(1.0),
nominal_value: None,
range_min_value: None,
range_max_value: None,
}]
};
AxisConfig {
tag: "ital".to_string(),
name: NameSpec::Name("Italic".to_string()),
ordering: None,
values,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use skrifa::MetadataProvider as _;
use skrifa::raw::tables::stat::AxisValue as ReadAxisValue;
const ROBOTO: &str = "resources/test/Roboto[wdth,wght].ttf";
fn roboto() -> VarFont {
VarFont {
filename: "Roboto[wdth,wght].ttf".to_string(),
data: std::fs::read(ROBOTO).unwrap(),
}
}
fn parse_config(yaml: &str) -> StatConfig {
serde_yaml_ng::from_str(yaml).unwrap()
}
fn family_config(config: &StatConfig) -> &[AxisConfig] {
match config {
StatConfig::Family(axes) => axes,
StatConfig::PerFile(_) => panic!("expected a family-wide config"),
}
}
fn fmt(value: Fixed) -> String {
format!("{:?}", value.to_f64())
}
fn localized_name(font: &FontRef, name_id: NameId) -> String {
font.localized_strings(name_id)
.english_or_first()
.map(|name| name.to_string())
.unwrap_or_default()
}
fn axis_tags(font: &FontRef) -> Vec<String> {
font.stat()
.unwrap()
.design_axes()
.unwrap()
.iter()
.map(|axis| axis.axis_tag().to_string())
.collect()
}
fn axis_rows(font: &FontRef) -> Vec<(String, String, String)> {
let stat = font.stat().unwrap();
let axes = stat
.design_axes()
.unwrap()
.iter()
.map(|axis| axis.axis_tag().to_string())
.collect::<Vec<_>>();
let Some(Ok(values)) = stat.offset_to_axis_values() else {
return Vec::new();
};
values
.axis_values()
.iter()
.flatten()
.map(|value| {
let axis = axes[value.axis_index().unwrap_or_default() as usize].clone();
let name = localized_name(font, value.value_name_id());
let description = match &value {
ReadAxisValue::Format1(value) => format!("= {}", fmt(value.value())),
ReadAxisValue::Format2(value) => format!(
"= {} in {}-{}",
fmt(value.nominal_value()),
fmt(value.range_min_value()),
fmt(value.range_max_value())
),
ReadAxisValue::Format3(value) => format!(
"= {} linked to {}",
fmt(value.value()),
fmt(value.linked_value())
),
ReadAxisValue::Format4(_) => "format 4".to_string(),
};
(axis, name, description)
})
.collect()
}
fn axis_value_flags(font: &FontRef) -> Vec<u16> {
let stat = font.stat().unwrap();
let Some(Ok(values)) = stat.offset_to_axis_values() else {
return Vec::new();
};
values
.axis_values()
.iter()
.flatten()
.map(|value| match &value {
ReadAxisValue::Format1(value) => value.flags().bits(),
ReadAxisValue::Format2(value) => value.flags().bits(),
ReadAxisValue::Format3(value) => value.flags().bits(),
ReadAxisValue::Format4(value) => value.flags().bits(),
})
.collect()
}
#[test]
fn test_config_reordering() {
let config = parse_config(
r#"
- name: Weight
tag: wght
values:
- name: Regular
value: 400
linkedValue: 700
flags: 2
- name: Bold
value: 700
- name: Year
tag: YEAR
ordering: 3
values:
- name: 2020
value: 2020
"#,
);
let builder = StatBuilder::from_config(family_config(&config)).unwrap();
assert_eq!(
builder.records,
vec![
AxisRecord {
tag: Tag::new(b"wght"),
name: "Weight".to_string(),
ordering: 0,
},
AxisRecord {
tag: Tag::new(b"YEAR"),
name: "Year".to_string(),
ordering: 3,
},
]
);
assert_eq!(
builder.values,
vec![
AxisValue {
flags: 2,
name: "Regular".to_string(),
location: AxisLocation::Three {
tag: Tag::new(b"wght"),
value: Fixed::from_f64(400.0),
linked: Fixed::from_f64(700.0),
},
},
AxisValue {
flags: 0,
name: "Bold".to_string(),
location: AxisLocation::One {
tag: Tag::new(b"wght"),
value: Fixed::from_f64(700.0),
},
},
AxisValue {
flags: 0,
name: "2020".to_string(),
location: AxisLocation::One {
tag: Tag::new(b"YEAR"),
value: Fixed::from_f64(2020.0),
},
},
]
);
}
#[test]
fn test_config_ranges() {
let config = parse_config(
r#"
- name: Weight
tag: wght
values:
- name: Nominal
nominalValue: 100
- name: Ranged
nominalValue: 200
rangeMinValue: 150
rangeMaxValue: 250
"#,
);
let builder = StatBuilder::from_config(family_config(&config)).unwrap();
assert_eq!(
builder.values[0].location,
AxisLocation::Two {
tag: Tag::new(b"wght"),
nominal: Fixed::from_f64(100.0),
min: Fixed::from_bits(i32::MIN),
max: Fixed::from_bits(i32::MAX),
}
);
assert_eq!(
builder.values[1].location,
AxisLocation::Two {
tag: Tag::new(b"wght"),
nominal: Fixed::from_f64(200.0),
min: Fixed::from_f64(150.0),
max: Fixed::from_f64(250.0),
}
);
}
#[test]
fn test_config_without_a_value() {
let config = parse_config("- name: Weight\n tag: wght\n values:\n - name: Nonsense\n");
let error = StatBuilder::from_config(family_config(&config)).unwrap_err();
assert!(
error
.to_string()
.contains("Can't determine format for AxisValue")
);
}
#[test]
fn test_per_file_config() {
let config = parse_config(
r#"
Font[wght].ttf:
- name: Weight
tag: wght
values:
- name: Regular
value: 400
Font-Italic[wght].ttf:
- name: Weight
tag: wght
values:
- name: Italic
value: 400
"#,
);
let StatConfig::PerFile(configs) = &config else {
panic!("expected a per-file config")
};
assert_eq!(configs.len(), 2);
assert_eq!(config.axes_for("Font[wght].ttf").unwrap()[0].tag, "wght");
assert_eq!(
config.axes_for("Font-Italic[wght].ttf").unwrap()[0].values[0]
.name
.to_name(),
"Italic"
);
let error = config.axes_for("Nope.ttf").unwrap_err();
assert!(
error
.to_string()
.contains("Filename Nope.ttf not found in stat dictionary")
);
}
#[test]
fn test_gen_stat_tables_from_config() {
let config = parse_config(
r#"
- name: Weight
tag: wght
values:
- name: Regular
value: 400
linkedValue: 700
flags: 2
- name: Bold
value: 700
"#,
);
let fonts = vec![roboto()];
let out = gen_stat_tables_from_config(&config, &fonts, None).unwrap();
let font = FontRef::new(&out[0]).unwrap();
assert_eq!(axis_tags(&font), vec!["wght".to_string()]);
assert_eq!(
axis_rows(&font),
vec![
(
"wght".to_string(),
"Regular".to_string(),
"= 400.0 linked to 700.0".to_string()
),
(
"wght".to_string(),
"Bold".to_string(),
"= 700.0".to_string()
),
]
);
assert_eq!(axis_value_flags(&font), vec![2, 0]);
let stat = font.stat().unwrap();
assert_eq!(
localized_name(&font, stat.design_axes().unwrap()[0].axis_name_id()),
"Weight"
);
assert_eq!(
font.axes()
.iter()
.map(|axis| axis.tag().to_string())
.collect::<Vec<_>>(),
vec!["wght", "wdth"]
);
}
#[test]
fn test_generated_italic_axis() {
let config = parse_config(
"- name: Weight\n tag: wght\n values:\n - name: Regular\n value: 400\n",
);
let out = gen_stat_tables_from_config(&config, &[roboto()], Some(false)).unwrap();
assert_eq!(axis_tags(&FontRef::new(&out[0]).unwrap()), vec!["wght"]);
let out = gen_stat_tables_from_config(&config, &[roboto()], Some(true)).unwrap();
let font = FontRef::new(&out[0]).unwrap();
assert_eq!(axis_tags(&font), vec!["wght", "ital"]);
assert_eq!(
axis_rows(&font).last().unwrap(),
&(
"ital".to_string(),
"Roman".to_string(),
"= 0.0 linked to 1.0".to_string()
)
);
assert_eq!(axis_value_flags(&font), vec![0, 2]);
let with_ital =
parse_config("- name: Italic\n tag: ital\n values:\n - name: Roman\n value: 0\n");
let error = gen_stat_tables_from_config(&with_ital, &[roboto()], Some(true)).unwrap_err();
assert!(
error
.to_string()
.contains("ital axis should not appear in stat config")
);
}
#[test]
fn test_gen_stat_tables() {
let fonts = vec![roboto()];
let out = gen_stat_tables(&fonts).unwrap();
let font = FontRef::new(&out[0]).unwrap();
let tags = axis_tags(&font);
assert!(tags.contains(&"wght".to_string()), "{tags:?}");
assert!(tags.contains(&"wdth".to_string()), "{tags:?}");
assert!(!axis_rows(&font).is_empty());
}
}