use std::sync::Arc;
use kurbo::{BezPath, Point};
use write_fonts::read::tables::glyf::Glyph;
use write_fonts::read::tables::gsub::{
SingleSubst as ReadSingleSubst, SubstitutionLookup as ReadSubstitutionLookup,
};
use write_fonts::read::tables::layout::Condition as ReadCondition;
use write_fonts::read::tables::stat::AxisValue as ReadAxisValue;
use write_fonts::read::{FontRef, TableProvider};
use write_fonts::types::{F2Dot14, MajorMinor};
use crate::input::resolve_generate_webfonts_options;
use crate::svg::types::{PreparedVariantFamily, ProcessedGlyph, ProcessedVariantGlyph};
use crate::types::color::{ProcessedColorLayer, ResolvedLayerPaint};
use crate::{FontType, FontVariant, GenerateWebfontsOptions};
use super::*;
fn resolved_variants() -> ResolvedVariants {
resolve_generate_webfonts_options(GenerateWebfontsOptions {
dest: "artifacts".to_owned(),
files: vec![],
types: Some(vec![FontType::Ttf]),
variants: Some(vec![
FontVariant {
name: "Light".to_owned(),
files: vec!["light.svg".to_owned()],
weight: Some(300),
default: None,
},
FontVariant {
name: "Regular".to_owned(),
files: vec!["regular.svg".to_owned()],
weight: Some(400),
default: Some(true),
},
FontVariant {
name: "Bold".to_owned(),
files: vec!["bold.svg".to_owned()],
weight: Some(700),
default: None,
},
]),
write_files: Some(false),
..Default::default()
})
.unwrap()
.variants
.unwrap()
}
#[test]
fn color_presentations_do_not_alias_identical_fallback_outlines() {
let mut source = outline("a", 0xe001, 30.0, 20.0);
let red = ProcessedColorLayer {
outline: source.ttf_path.as_ref().unwrap().clone(),
outline_hash: 0,
paint: ResolvedLayerPaint::Solid {
red: 255,
green: 0,
blue: 0,
alpha: 1.0,
},
};
source.color_layers = Some(Arc::from([red]));
let mut blue = source.clone();
blue.color_layers = Some(Arc::from([ProcessedColorLayer {
outline: source.ttf_path.as_ref().unwrap().clone(),
outline_hash: 0,
paint: ResolvedLayerPaint::Solid {
red: 0,
green: 0,
blue: 255,
alpha: 1.0,
},
}]));
let mut family = PreparedVariantFamily {
ascent: 10.0,
descent: 0.0,
font_height: 1000.0,
glyphs: vec![ProcessedVariantGlyph {
name: "a".into(),
codepoint: 0xe001,
advance_width: 30.0,
outlines: vec![Some(source.clone()), Some(source), Some(blue)].into_boxed_slice(),
}],
};
let variants = resolved_variants();
let built = build_variant(options(), &family, &variants).unwrap();
assert_eq!(
built.presentation_gids[0].as_ref(),
&[GlyphId16::new(1), GlyphId16::new(1), GlyphId16::new(2)]
);
let font = FontRef::new(built.tables.ttf()).unwrap();
assert_eq!(
font.colr()
.unwrap()
.base_glyph_list()
.unwrap()
.unwrap()
.base_glyph_paint_records()
.len(),
2
);
family.glyphs[0].outlines[0].as_mut().unwrap().color_layers = None;
family.glyphs[0].outlines[2] = None;
let built = build_variant(options(), &family, &variants).unwrap();
assert_eq!(
built.presentation_gids[0].as_ref(),
&[GlyphId16::new(2), GlyphId16::new(1), GlyphId16::new(3)]
);
let font = FontRef::new(built.tables.ttf()).unwrap();
assert_eq!(
font.colr()
.unwrap()
.base_glyph_list()
.unwrap()
.unwrap()
.base_glyph_paint_records()
.len(),
1
);
}
fn outline(name: &str, codepoint: u32, width: f64, shape_width: f64) -> ProcessedGlyph {
let mut path = BezPath::new();
path.move_to(Point::new(0.0, 0.0));
path.line_to(Point::new(shape_width, 0.0));
path.line_to(Point::new(shape_width, 10.0));
path.line_to(Point::new(0.0, 10.0));
path.close_path();
ProcessedGlyph {
color_layers: None,
codepoint,
height: 10.0,
index: 0,
name: name.to_owned(),
path_data: Arc::from(""),
ttf_path: Some(Arc::new(path)),
ttf_path_hash: None,
width,
}
}
fn family() -> PreparedVariantFamily {
PreparedVariantFamily {
ascent: 10.0,
descent: 0.0,
font_height: 1000.0,
glyphs: vec![
ProcessedVariantGlyph {
name: "a".to_owned(),
codepoint: 0xe001,
advance_width: 30.0,
outlines: vec![
Some(outline("a", 0xe001, 30.0, 10.0)),
Some(outline("a", 0xe001, 30.0, 20.0)),
Some(outline("a", 0xe001, 30.0, 30.0)),
]
.into_boxed_slice(),
},
ProcessedVariantGlyph {
name: "blank-default".to_owned(),
codepoint: 0xe002,
advance_width: 40.0,
outlines: vec![
None,
None,
Some(outline("blank-default", 0xe002, 40.0, 10.0)),
]
.into_boxed_slice(),
},
],
}
}
fn options() -> TtfOptions<'static> {
TtfOptions {
color_slots: None,
ascent: None,
copyright: None,
descent: None,
description: None,
font_height: None,
font_name: "Variant Test",
font_style: None,
font_weight: None,
ligature: false,
manufacturer_url: None,
ts: Some(0),
version: None,
}
}
#[test]
fn builds_deterministic_ordered_variant_glyph_store_and_default_cmap() {
let variants = resolved_variants();
let first = build_variant(options(), &family(), &variants).unwrap();
let second = build_variant(options(), &family(), &variants).unwrap();
assert_eq!(first.tables.ttf(), second.tables.ttf());
assert_eq!(
first.presentation_gids[0]
.iter()
.map(|gid| gid.to_u16())
.collect::<Vec<_>>(),
vec![3, 1, 4]
);
assert_eq!(
first.presentation_gids[1]
.iter()
.map(|gid| gid.to_u16())
.collect::<Vec<_>>(),
vec![2, 2, 5]
);
let font = FontRef::new(first.tables.ttf()).expect("variant TTF should parse");
assert_eq!(font.maxp().unwrap().num_glyphs(), 6);
assert_eq!(
font.cmap()
.unwrap()
.map_codepoint(0xe001_u32)
.unwrap()
.to_u32(),
1
);
assert_eq!(
font.cmap()
.unwrap()
.map_codepoint(0xe002_u32)
.unwrap()
.to_u32(),
2
);
assert_eq!(font.cmap().unwrap().map_codepoint(0xe003_u32), None);
let notdef = font
.loca(None)
.unwrap()
.get_glyf(GlyphId::new(0), &font.glyf().unwrap())
.unwrap();
assert!(notdef.is_none());
assert!(
font.loca(None)
.unwrap()
.get_glyf(GlyphId::new(2), &font.glyf().unwrap())
.unwrap()
.is_none(),
"blank default must target an explicit empty glyph"
);
let widths = (1..=5)
.map(|gid| font.hmtx().unwrap().h_metrics()[gid].advance())
.collect::<Vec<_>>();
assert_eq!(widths, vec![30, 40, 30, 30, 40]);
assert!(matches!(
font.loca(None)
.unwrap()
.get_glyf(GlyphId::new(3), &font.glyf().unwrap())
.unwrap(),
Some(Glyph::Simple(_))
));
assert!(font.gsub().is_ok());
assert!(font.table_data(Tag::new(b"gvar")).is_none());
}
#[test]
fn writes_weight_conditioned_direct_and_ligature_substitutions() {
let variants = resolved_variants();
let mut options = options();
options.ligature = true;
let built = build_variant(options, &family(), &variants).unwrap();
let font = FontRef::new(built.tables.ttf()).unwrap();
let gsub = font.gsub().unwrap();
assert_eq!(gsub.version(), MajorMinor::VERSION_1_1);
assert_eq!(
gsub.feature_list().unwrap().get(0).unwrap().tag,
Tag::new(b"liga")
);
assert_eq!(
gsub.feature_list().unwrap().get(1).unwrap().tag,
Tag::new(b"rvrn")
);
assert!(font.cmap().unwrap().map_codepoint(u32::from('b')).is_some());
assert_eq!(font.os2().unwrap().us_first_char_index(), u16::from(b'-'));
assert_eq!(font.os2().unwrap().us_last_char_index(), 0xe002);
let default_liga = gsub.feature_list().unwrap().get(0).unwrap();
assert_eq!(
ligature_target(&gsub, default_liga.lookup_list_indices()[0].get()),
2
);
let variations = gsub.feature_variations().unwrap().unwrap();
let expected = [
(F2Dot14::NEG_ONE, F2Dot14::from_bits(-8193), [3, 2]),
(F2Dot14::from_f64(0.5), F2Dot14::ONE, [4, 5]),
];
assert_eq!(
variations.feature_variation_record_count(),
expected.len() as u32
);
for (record, (minimum, maximum, targets)) in
variations.feature_variation_records().iter().zip(expected)
{
let condition_set = record
.condition_set(variations.offset_data())
.unwrap()
.unwrap();
let ReadCondition::Format1AxisRange(condition) = condition_set.conditions().get(0).unwrap()
else {
panic!("expected an axis-range condition")
};
assert_eq!(condition.filter_range_min_value(), minimum);
assert_eq!(condition.filter_range_max_value(), maximum);
let substitutions = record
.feature_table_substitution(variations.offset_data())
.unwrap()
.unwrap();
assert_eq!(substitutions.substitution_count(), 2);
let liga = substitutions.substitutions()[0]
.alternate_feature(substitutions.offset_data())
.unwrap();
assert_eq!(substitutions.substitutions()[0].feature_index(), 0);
assert_eq!(
ligature_target(&gsub, liga.lookup_list_indices()[0].get()),
targets[1]
);
let rvrn = substitutions.substitutions()[1]
.alternate_feature(substitutions.offset_data())
.unwrap();
assert_eq!(substitutions.substitutions()[1].feature_index(), 1);
let lookup = gsub
.lookup_list()
.unwrap()
.lookups()
.get(usize::from(rvrn.lookup_list_indices()[0].get()))
.unwrap();
let ReadSubstitutionLookup::Single(lookup) = lookup else {
panic!("expected rvrn to use single substitution")
};
let ReadSingleSubst::Format2(single) = lookup.subtables().get(0).unwrap() else {
panic!("expected explicit variant glyph substitutions")
};
assert_eq!(
single
.coverage()
.unwrap()
.iter()
.map(|gid| gid.to_u32())
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(
single
.substitute_glyph_ids()
.iter()
.map(|gid| gid.get().to_u16())
.collect::<Vec<_>>(),
targets
);
}
}
#[test]
fn large_variant_families_do_not_collide_with_ligature_characters() {
let variants = resolved_variants();
let mut family = family();
family.glyphs = (0..100)
.map(|index| {
let name = format!("icon-{index}");
let codepoint = 0xf101 + index;
let glyph = outline(&name, codepoint, 30.0, 20.0);
ProcessedVariantGlyph {
name,
codepoint,
advance_width: 30.0,
outlines: vec![Some(glyph.clone()); 3].into_boxed_slice(),
}
})
.collect();
let mut options = options();
options.ligature = true;
let built = build_variant(options, &family, &variants).unwrap();
let font = FontRef::new(built.tables.ttf()).unwrap();
let cmap = font.cmap().unwrap();
for (index, glyph) in family.glyphs.iter().enumerate() {
assert_eq!(
cmap.map_codepoint(glyph.codepoint).unwrap().to_u32(),
u32::from(built.presentation_gids[index][variants.default_index].to_u16())
);
}
for character in "icon-0123456789".chars() {
assert!(cmap.map_codepoint(u32::from(character)).unwrap().to_u32() > 100);
}
assert!(cmap.map_codepoint(0_u32).is_none());
let gsub = font.gsub().unwrap();
let lookup_index = gsub
.feature_list()
.unwrap()
.get(0)
.unwrap()
.lookup_list_indices()[0]
.get();
let ReadSubstitutionLookup::Ligature(lookup) = gsub
.lookup_list()
.unwrap()
.lookups()
.get(usize::from(lookup_index))
.unwrap()
else {
panic!("expected ligatures");
};
let write_fonts::read::tables::gsub::LigatureSubst::Format1(subtable) =
lookup.subtables().get(0).unwrap()
else {
panic!("expected conventional ligature format")
};
let ligatures = subtable.ligature_sets().get(0).unwrap();
let mut targets = (0..ligatures.ligature_count())
.map(|index| {
ligatures
.ligatures()
.get(usize::from(index))
.unwrap()
.ligature_glyph()
.to_u16()
})
.collect::<Vec<_>>();
targets.sort_unstable();
assert_eq!(targets, (1..=100).collect::<Vec<_>>());
}
#[test]
fn quantizes_asymmetric_ranges_and_handles_a_maximum_default() {
let mut variants = resolved_variants();
variants.variants[0].weight = 100;
variants.variants[1].weight = 200;
variants.variants[2].weight = 400;
variants.variants.push(variants.variants[2].clone());
variants.variants[3].weight = 1000;
variants.default_index = 2;
let first_boundary = F2Dot14::from_f64(-5.0 / 6.0);
let second_boundary = F2Dot14::from_f64(-1.0 / 3.0);
assert_eq!(
variant_range(&variants, 0),
(
F2Dot14::NEG_ONE,
F2Dot14::from_bits(first_boundary.to_bits() - 1)
)
);
assert_eq!(
variant_range(&variants, 1),
(
first_boundary,
F2Dot14::from_bits(second_boundary.to_bits() - 1)
)
);
assert_eq!(
variant_range(&variants, 3),
(F2Dot14::from_f64(0.5), F2Dot14::ONE)
);
variants.default_index = 3;
assert_eq!(
variant_range(&variants, 2),
(
F2Dot14::from_f64(-7.0 / 9.0),
F2Dot14::from_bits(F2Dot14::from_f64(-1.0 / 3.0).to_bits() - 1)
)
);
}
fn ligature_target(gsub: &write_fonts::read::tables::gsub::Gsub<'_>, lookup_index: u16) -> u16 {
let lookup = gsub
.lookup_list()
.unwrap()
.lookups()
.get(usize::from(lookup_index))
.unwrap();
let ReadSubstitutionLookup::Ligature(lookup) = lookup else {
panic!("expected liga to use ligature substitution")
};
let write_fonts::read::tables::gsub::LigatureSubst::Format1(subtable) =
lookup.subtables().get(0).unwrap()
else {
panic!("expected conventional ligature format")
};
subtable
.ligature_sets()
.get(0)
.unwrap()
.ligatures()
.get(0)
.unwrap()
.ligature_glyph()
.to_u16()
}
#[test]
fn deduplicates_only_equal_compiled_outlines_and_clamped_advances() {
let variants = resolved_variants();
let same = outline("same", 0xe010, 20.0, 10.0);
let different = outline("different", 0xe012, 20.0, 11.0);
let family = PreparedVariantFamily {
ascent: 10.0,
descent: 0.0,
font_height: 1000.0,
glyphs: vec![
ProcessedVariantGlyph {
name: "same".to_owned(),
codepoint: 0xe010,
advance_width: 20.4,
outlines: vec![Some(same.clone()), Some(same.clone()), Some(same.clone())]
.into_boxed_slice(),
},
ProcessedVariantGlyph {
name: "same-outline-new-advance".to_owned(),
codepoint: 0xe011,
advance_width: 20.6,
outlines: vec![Some(same.clone()), Some(same.clone()), Some(same.clone())]
.into_boxed_slice(),
},
ProcessedVariantGlyph {
name: "different-outline".to_owned(),
codepoint: 0xe012,
advance_width: 20.4,
outlines: vec![
Some(different.clone()),
Some(different.clone()),
Some(different),
]
.into_boxed_slice(),
},
ProcessedVariantGlyph {
name: "blank".to_owned(),
codepoint: 0xe013,
advance_width: 20.4,
outlines: vec![None, None, None].into_boxed_slice(),
},
],
};
let built = build_variant(options(), &family, &variants).unwrap();
assert!(!built.tables.ttf().is_empty());
assert_eq!(built.presentation_gids[0].as_ref(), &[GlyphId16::new(1); 3]);
assert_eq!(built.presentation_gids[1].as_ref(), &[GlyphId16::new(2); 3]);
assert_eq!(built.presentation_gids[2].as_ref(), &[GlyphId16::new(3); 3]);
assert_eq!(built.presentation_gids[3].as_ref(), &[GlyphId16::new(4); 3]);
assert_eq!(
FontRef::new(built.tables.ttf())
.unwrap()
.maxp()
.unwrap()
.num_glyphs(),
5
);
}
#[test]
fn keeps_default_gids_distinct_when_logical_variant_rows_diverge() {
let variants = resolved_variants();
let shared_default = outline("shared", 0xe020, 20.0, 20.0);
let family = PreparedVariantFamily {
ascent: 10.0,
descent: 0.0,
font_height: 1000.0,
glyphs: vec![
ProcessedVariantGlyph {
name: "first".to_owned(),
codepoint: 0xe020,
advance_width: 20.0,
outlines: vec![
Some(outline("first", 0xe020, 20.0, 10.0)),
Some(shared_default.clone()),
Some(outline("first", 0xe020, 20.0, 30.0)),
]
.into_boxed_slice(),
},
ProcessedVariantGlyph {
name: "second".to_owned(),
codepoint: 0xe021,
advance_width: 20.0,
outlines: vec![
Some(outline("second", 0xe021, 20.0, 11.0)),
Some(shared_default),
Some(outline("second", 0xe021, 20.0, 31.0)),
]
.into_boxed_slice(),
},
],
};
let built = build_variant(options(), &family, &variants).unwrap();
assert_eq!(built.presentation_gids[0][1], GlyphId16::new(1));
assert_eq!(built.presentation_gids[1][1], GlyphId16::new(2));
}
#[test]
fn writes_fvar_stat_names_and_default_weight_metadata() {
let variants = resolved_variants();
let mut options = options();
options.version = Some("1.2");
let built = build_variant(options, &family(), &variants).unwrap();
let font = FontRef::new(built.tables.ttf()).unwrap();
let name = font.name().unwrap();
let string_data = name.string_data();
let read_name = |id| {
name.name_record()
.iter()
.find(|record| record.name_id().to_u16() == id)
.unwrap()
.string(string_data)
.unwrap()
.to_string()
};
assert_eq!(read_name(256), "Weight");
assert_eq!(read_name(5), "Version 1.2");
assert_eq!(
read_name(6),
variant_postscript_name("Variant Test", "Regular")
);
assert_eq!(
(read_name(257), read_name(258), read_name(259)),
("Light".to_owned(), "Regular".to_owned(), "Bold".to_owned())
);
let fvar = font.fvar().unwrap();
let arrays = fvar.axis_instance_arrays().unwrap();
let axis = arrays.axes()[0];
assert_eq!(axis.axis_tag(), Tag::new(b"wght"));
assert_eq!(axis.axis_name_id(), NameId::new(256));
assert_eq!(
(
axis.min_value().to_i32(),
axis.default_value().to_i32(),
axis.max_value().to_i32()
),
(300, 400, 700)
);
let instances = arrays
.instances()
.iter()
.map(Result::unwrap)
.collect::<Vec<_>>();
assert_eq!(
instances
.iter()
.map(|instance| instance.subfamily_name_id.to_u16())
.collect::<Vec<_>>(),
vec![257, 258, 259]
);
assert_eq!(
instances
.iter()
.map(|instance| instance.coordinates[0].get().to_i32())
.collect::<Vec<_>>(),
vec![300, 400, 700]
);
let stat = font.stat().unwrap();
let design_axis = stat.design_axes().unwrap()[0];
assert_eq!(
(
design_axis.axis_tag(),
design_axis.axis_name_id(),
design_axis.axis_ordering()
),
(Tag::new(b"wght"), NameId::new(256), 0)
);
assert_eq!(stat.elided_fallback_name_id(), Some(NameId::new(258)));
let values = stat.offset_to_axis_values().unwrap().unwrap().axis_values();
assert_eq!(values.len(), 3);
for index in 0..values.len() {
let value = values.get(index).unwrap();
let ReadAxisValue::Format1(value) = value else {
panic!("STAT values must use Format 1")
};
assert_eq!(value.value_name_id(), NameId::new(257 + index as u16));
assert_eq!(value.value().to_i32(), [300, 400, 700][index]);
assert_eq!(
value
.flags()
.contains(AxisValueTableFlags::ELIDABLE_AXIS_VALUE_NAME),
index == 1
);
}
let os2 = font.os2().unwrap();
assert_eq!(os2.us_weight_class(), 400);
assert_eq!(os2.us_first_char_index(), 0xe001);
assert_eq!(os2.us_last_char_index(), 0xe002);
}
#[test]
fn makes_deterministic_postscript_safe_variant_names() {
let first = variant_postscript_name(
"Fónt /[](){}<>% with a very long family name that must be truncated",
"large/alt",
);
let collision = variant_postscript_name(
"Fónt /[](){}<>% with a very long family name that must be truncated",
"large%alt",
);
assert!(first.is_ascii());
assert!(first.len() <= 63);
assert!(!first.chars().any(|character| {
character.is_ascii_whitespace()
|| matches!(
character,
'[' | ']' | '(' | ')' | '{' | '}' | '<' | '>' | '/' | '%'
)
}));
assert_ne!(first, collision);
assert_eq!(
variant_postscript_name("", "💡"),
format!("{:x}", md5::compute(b"\0\xf0\x9f\x92\xa1"))
);
}
#[test]
fn rejects_physical_glyph_overflow_before_gid16_conversion() {
let error = checked_gids(usize::from(u16::MAX), vec![vec![1]]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"Variant font has too many physical glyphs."
);
}
#[test]
fn rejects_invalid_variant_metadata_and_glyph_matrix() {
let mut variants = resolved_variants();
variants.default_index = variants.variants.len();
let error = build_variant(options(), &family(), &variants)
.err()
.unwrap();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
let variants = resolved_variants();
let mut malformed_family = family();
malformed_family.glyphs[0].outlines = vec![None].into_boxed_slice();
let error = build_variant(options(), &malformed_family, &variants)
.err()
.unwrap();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
let mut family = family();
family.glyphs[1].codepoint = family.glyphs[0].codepoint;
let error = build_variant(options(), &family, &variants).err().unwrap();
assert_eq!(error.kind(), ErrorKind::InvalidData);
assert!(error.to_string().starts_with("Failed to build cmap table:"));
}