Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build MVAR table from variable GlobalMetrics #648

Merged
merged 5 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions fontbe/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub enum Error {
OutOfBounds { what: String, value: String },
#[error("Unable to compute deltas for {0}: {1}")]
GlyphDeltaError(GlyphName, DeltaError),
#[error("Unable to compute deltas for MVAR {0}: {1}")]
MvarDeltaError(Tag, DeltaError),
#[error("Unable to assemble gvar")]
GvarError(#[from] GvarInputError),
#[error("Unable to read")]
Expand Down
6 changes: 5 additions & 1 deletion fontbe/src/font.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use write_fonts::{
tables::{
avar::Avar, cmap::Cmap, fvar::Fvar, gdef::Gdef, glyf::Glyf, gpos::Gpos, gsub::Gsub,
gvar::Gvar, head::Head, hhea::Hhea, hmtx::Hmtx, hvar::Hvar, loca::Loca, maxp::Maxp,
name::Name, os2::Os2, post::Post, stat::Stat,
mvar::Mvar, name::Name, os2::Os2, post::Post, stat::Stat,
},
types::Tag,
FontBuilder,
Expand Down Expand Up @@ -50,6 +50,7 @@ const TABLES_TO_MERGE: &[(WorkId, Tag, TableType)] = &[
(WorkId::Post, Post::TAG, TableType::Static),
(WorkId::Stat, Stat::TAG, TableType::Variable),
(WorkId::Hvar, Hvar::TAG, TableType::Variable),
(WorkId::Mvar, Mvar::TAG, TableType::Variable),
];

fn has(context: &Context, id: WorkId) -> bool {
Expand All @@ -72,6 +73,7 @@ fn has(context: &Context, id: WorkId) -> bool {
WorkId::Post => context.post.try_get().is_some(),
WorkId::Stat => context.stat.try_get().is_some(),
WorkId::Hvar => context.hvar.try_get().is_some(),
WorkId::Mvar => context.mvar.try_get().is_some(),
_ => false,
}
}
Expand All @@ -97,6 +99,7 @@ fn bytes_for(context: &Context, id: WorkId) -> Result<Option<Vec<u8>>, Error> {
WorkId::Post => to_bytes(context.post.get().as_ref()),
WorkId::Stat => to_bytes(context.stat.get().as_ref()),
WorkId::Hvar => to_bytes(context.hvar.get().as_ref()),
WorkId::Mvar => to_bytes(context.mvar.get().as_ref()),
_ => panic!("Missing a match for {id:?}"),
};
Ok(bytes)
Expand Down Expand Up @@ -127,6 +130,7 @@ impl Work<Context, AnyWorkId, Error> for FontWork {
.variant(WorkId::Post)
.variant(WorkId::Stat)
.variant(WorkId::Hvar)
.variant(WorkId::Mvar)
.variant(WorkId::LocaFormat)
.variant(FeWorkId::StaticMetadata)
.build()
Expand Down
1 change: 1 addition & 0 deletions fontbe/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod hvar;
pub mod kern;
pub mod marks;
pub mod metrics_and_limits;
pub mod mvar;
pub mod name;
pub mod orchestration;
pub mod os2;
Expand Down
329 changes: 329 additions & 0 deletions fontbe/src/mvar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
//! Generates an [MVAR](https://learn.microsoft.com/en-us/typography/opentype/spec/MVAR) table.

use std::collections::{BTreeMap, BTreeSet, HashMap};

use fontdrasil::orchestration::AccessBuilder;

use fontdrasil::{
coords::NormalizedLocation,
orchestration::{Access, Work},
types::Axis,
};
use fontir::{
ir::GlobalMetricValues, orchestration::WorkId as FeWorkId, variations::VariationModel,
};
use write_fonts::types::MajorMinor;
use write_fonts::{
tables::{
mvar::{Mvar, ValueRecord},
variations::{ivs_builder::VariationStoreBuilder, VariationRegion},
},
types::Tag,
OtRound,
};

use crate::{
error::Error,
orchestration::{AnyWorkId, BeWork, Context, WorkId},
};

#[derive(Debug)]
struct MvarWork {}

pub fn create_mvar_work() -> Box<BeWork> {
Box::new(MvarWork {})
}

/// Helper to build MVAR table from global metrics sources.
struct MvarBuilder {
/// Variation axes
axes: Vec<Axis>,
/// Sparse variation models, keyed by the set of locations they define
models: HashMap<BTreeSet<NormalizedLocation>, VariationModel>,
anthrotype marked this conversation as resolved.
Show resolved Hide resolved
/// Metrics deltas keyed by MVAR tag
deltas: BTreeMap<Tag, Vec<(VariationRegion, i16)>>,
}

impl MvarBuilder {
fn new(global_model: VariationModel) -> Self {
let axes = global_model.axes().cloned().collect::<Vec<_>>();
let global_locations = global_model.locations().cloned().collect::<BTreeSet<_>>();
let mut models = HashMap::new();
models.insert(global_locations, global_model);
MvarBuilder {
axes,
models,
deltas: BTreeMap::new(),
}
}

fn add_sources(&mut self, mvar_tag: Tag, sources: &GlobalMetricValues) -> Result<(), Error> {
let sources: HashMap<_, _> = sources
.iter()
.map(|(loc, src)| (loc.clone(), vec![src.into_inner() as f64]))
.collect();
if sources.len() == 1 {
assert!(sources.keys().next().unwrap().is_default());
// spare the model the work of computing no-op deltas
return Ok(());
}
let locations = sources.keys().cloned().collect::<BTreeSet<_>>();
let model = self.models.entry(locations).or_insert_with(|| {
// this glyph defines its own set of locations, a new sparse model is needed
VariationModel::new(sources.keys().cloned().collect(), self.axes.clone()).unwrap()
});
let deltas: Vec<_> = model
.deltas(&sources)
.map_err(|e| Error::MvarDeltaError(mvar_tag, e))?
.into_iter()
.filter_map(|(region, values)| {
if region.is_default() {
return None;
}
// Only 1 value per region for our input
assert!(values.len() == 1, "{} values?!", values.len());
Some((
region.to_write_fonts_variation_region(&self.axes),
values[0].ot_round(),
))
})
.collect();
// don't encode no-op deltas
if deltas.iter().all(|(_, delta)| *delta == 0) {
return Ok(());
}
self.deltas.insert(mvar_tag, deltas);
Ok(())
}

fn build(self) -> Option<Mvar> {
let mut builder = VariationStoreBuilder::new(self.axes.len() as u16);
let delta_ids = self
.deltas
.into_iter()
.map(|(tag, deltas)| (tag, builder.add_deltas(deltas)))
.collect::<Vec<_>>();

let (varstore, index_map) = builder.build();

let records = delta_ids
.into_iter()
.map(|(tag, temp_id)| {
let varidx = index_map.get(temp_id).unwrap();
ValueRecord::new(
tag,
varidx.delta_set_outer_index,
varidx.delta_set_inner_index,
)
})
.collect::<Vec<_>>();

(!records.is_empty()).then(|| Mvar::new(MajorMinor::VERSION_1_0, Some(varstore), records))
}
}

impl Work<Context, AnyWorkId, Error> for MvarWork {
fn id(&self) -> AnyWorkId {
WorkId::Mvar.into()
}

fn read_access(&self) -> Access<AnyWorkId> {
AccessBuilder::new()
.variant(FeWorkId::StaticMetadata)
.variant(FeWorkId::GlobalMetrics)
.build()
}

/// Generate [MVAR](https://learn.microsoft.com/en-us/typography/opentype/spec/MVAR)
fn exec(&self, context: &Context) -> Result<(), Error> {
anthrotype marked this conversation as resolved.
Show resolved Hide resolved
let static_metadata = context.ir.static_metadata.get();
let metrics = context.ir.global_metrics.get();
let var_model = &static_metadata.variation_model;

let mut mvar_builder = MvarBuilder::new(var_model.clone());
for (metric, values) in metrics.iter() {
// maybe we should get rid of GlobalMetric variants that aren't MVAR-relevant?
anthrotype marked this conversation as resolved.
Show resolved Hide resolved
if let Some(mvar_tag) = metric.mvar_tag() {
mvar_builder.add_sources(mvar_tag, values)?;
}
}
let mvar = mvar_builder.build();

context.mvar.set_unconditionally(mvar.into());

Ok(())
}
}

#[cfg(test)]
mod tests {
use std::str::FromStr;

use fontdrasil::{
coords::{CoordConverter, UserCoord},
types::Axis,
};
use write_fonts::{
dump_table,
read::{
tables::{mvar as read_mvar, variations::ItemVariationData},
FontData, FontRead,
},
types::F2Dot14,
};

use super::*;

fn axis(tag: &str, min: f32, default: f32, max: f32) -> Axis {
let min = UserCoord::new(min);
let default = UserCoord::new(default);
let max = UserCoord::new(max);
Axis {
name: tag.to_string(),
tag: Tag::from_str(tag).unwrap(),
min,
default,
max,
hidden: false,
converter: CoordConverter::unmapped(min, default, max),
}
}

fn new_mvar_builder(locations: Vec<&NormalizedLocation>, axes: Vec<Axis>) -> MvarBuilder {
let locations = locations.into_iter().cloned().collect();
let model = VariationModel::new(locations, axes).unwrap();
MvarBuilder::new(model)
}

fn add_sources(
builder: &mut MvarBuilder,
mvar_tag: &str,
sources: &[(&NormalizedLocation, f32)],
) {
let sources = sources
.iter()
.map(|(loc, value)| ((*loc).clone(), (*value).into()))
.collect::<HashMap<_, _>>();
builder
.add_sources(Tag::from_str(mvar_tag).unwrap(), &sources)
.unwrap();
}

fn delta_sets(var_data: &ItemVariationData) -> Vec<Vec<i32>> {
(0..var_data.item_count())
.map(|i| var_data.delta_set(i).collect::<Vec<_>>())
.collect()
}

#[test]
fn smoke_test() {
let regular = NormalizedLocation::for_pos(&[("wght", 0.0)]);
let bold = NormalizedLocation::for_pos(&[("wght", 1.0)]);
let mut builder = new_mvar_builder(
vec![&regular, &bold],
vec![axis("wght", 400.0, 400.0, 700.0)],
);

add_sources(&mut builder, "xhgt", &[(&regular, 500.0), (&bold, 550.0)]);

let Some(mvar) = builder.build() else {
panic!("no MVAR?!");
};

let bytes = dump_table(&mvar).unwrap();
let mvar = read_mvar::Mvar::read(FontData::new(&bytes)).unwrap();

assert_eq!(mvar.version(), MajorMinor::VERSION_1_0);
assert_eq!(mvar.value_records().len(), 1);

let rec = &mvar.value_records()[0];
assert_eq!(rec.value_tag(), Tag::new(b"xhgt"));
assert_eq!(rec.delta_set_outer_index(), 0);
assert_eq!(rec.delta_set_inner_index(), 0);
Comment on lines +239 to +245
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, I continue to greatly prefer the test output of this sort of thing done as one assert, e.g.

assert_eq!(vec![(Tag::new(b"xhgt"), 0, 0),], mvar.value_records().iter().map(...)]

I've always found it much easier to troubleshoot and harder to forget to assert the length


let Some(Ok(varstore)) = mvar.item_variation_store() else {
panic!("MVAR has no ItemVariationStore?!");
};
anthrotype marked this conversation as resolved.
Show resolved Hide resolved

assert_eq!(varstore.variation_region_list().unwrap().region_count(), 1);
assert_eq!(varstore.item_variation_data_count(), 1);

let vardata = varstore.item_variation_data().get(0).unwrap().unwrap();
assert_eq!(vardata.region_indexes(), &[0]);
assert_eq!(delta_sets(&vardata), vec![vec![50]]);
}

#[test]
fn no_variations_no_mvar() {
let regular = NormalizedLocation::for_pos(&[("wght", 0.0)]);
let bold = NormalizedLocation::for_pos(&[("wght", 1.0)]);
let mut builder = new_mvar_builder(
vec![&regular, &bold],
vec![axis("wght", 400.0, 400.0, 700.0)],
);

add_sources(&mut builder, "xhgt", &[(&regular, 500.0), (&bold, 500.0)]);
add_sources(&mut builder, "cpht", &[(&regular, 800.0), (&bold, 800.0)]);

// hence no MVAR needed
assert!(builder.build().is_none());
}

struct MvarReader<'a> {
mvar: read_mvar::Mvar<'a>,
}

impl<'a> MvarReader<'a> {
fn new(mvar: read_mvar::Mvar<'a>) -> Self {
Self { mvar }
}

fn metric_delta(&self, mvar_tag: &str, coords: &[f32]) -> f64 {
let mvar_tag = Tag::from_str(mvar_tag).unwrap();
let coords: Vec<F2Dot14> = coords
.iter()
.map(|coord| F2Dot14::from_f32(*coord))
.collect();
self.mvar.metric_delta(mvar_tag, &coords).unwrap().to_f64()
}
}

#[test]
fn sparse_global_metrics() {
let regular = NormalizedLocation::for_pos(&[("wght", 0.0)]);
let medium = NormalizedLocation::for_pos(&[("wght", 0.5)]);
let bold = NormalizedLocation::for_pos(&[("wght", 1.0)]);
let mut builder = new_mvar_builder(
vec![&regular, &medium, &bold],
vec![axis("wght", 400.0, 400.0, 700.0)],
);
// 'xhgt' defines a value for all three locations
add_sources(
&mut builder,
"xhgt",
&[(&regular, 500.0), (&medium, 530.0), (&bold, 550.0)],
);
// 'strs' is sparse: defines a value for regular and bold, not medium
add_sources(&mut builder, "strs", &[(&regular, 50.0), (&bold, 100.0)]);

let Some(mvar) = builder.build() else {
panic!("no MVAR?!");
};

let bytes = dump_table(&mvar).unwrap();
let mvar = read_mvar::Mvar::read(FontData::new(&bytes)).unwrap();

assert_eq!(mvar.value_records().len(), 2);
assert_eq!(mvar.value_records()[0].value_tag(), Tag::new(b"strs"));
assert_eq!(mvar.value_records()[1].value_tag(), Tag::new(b"xhgt"));
anthrotype marked this conversation as resolved.
Show resolved Hide resolved

let mvar = MvarReader::new(mvar);
assert_eq!(mvar.metric_delta("xhgt", &[0.0]), 0.0);
assert_eq!(mvar.metric_delta("xhgt", &[0.5]), 30.0); // not 25.0
assert_eq!(mvar.metric_delta("xhgt", &[1.0]), 50.0);

assert_eq!(mvar.metric_delta("strs", &[0.0]), 0.0);
assert_eq!(mvar.metric_delta("strs", &[0.5]), 25.0); // interpolated
assert_eq!(mvar.metric_delta("strs", &[1.0]), 50.0);
}
}
Loading