use crate::error::IconResolutionError;
use crate::ligatures::Ligatures;
use skrifa::{
instance::LocationRef,
raw::{
tables::{
gsub::{Gsub, SingleSubst, SubstitutionSubtables},
layout::ConditionSet,
varc::Condition,
},
types::BigEndian,
FontRef, ReadError, TableProvider, TopLevelTable,
},
GlyphId, GlyphId16, MetadataProvider,
};
use smallvec::SmallVec;
use smol_str::SmolStr;
use std::{collections::HashMap, iter::once, ops::RangeInclusive};
const _PUA_CODEPOINTS: [RangeInclusive<u32>; 3] =
[0xE000..=0xF8FF, 0xF0000..=0xFFFFD, 0x100000..=0x10FFFD];
#[derive(Clone, Debug)]
pub enum IconIdentifier {
GlyphId(GlyphId),
Codepoint(u32),
Name(SmolStr),
}
impl IconIdentifier {
pub fn resolve<'a>(
&self,
font: &FontRef,
location: impl Into<LocationRef<'a>>,
) -> Result<GlyphId, IconResolutionError> {
let gid = match self {
IconIdentifier::GlyphId(gid) => Ok(*gid),
IconIdentifier::Codepoint(cp) => font
.cmap()
.map_err(IconResolutionError::ReadError)?
.map_codepoint(*cp)
.ok_or(IconResolutionError::NoCmapEntry(*cp)),
IconIdentifier::Name(name) => {
font.resolve_ligature(name.as_str())
.and_then(|maybe_gid| match maybe_gid {
Some(gid) => Ok(gid),
None => Err(IconResolutionError::NoLigature(name.to_string())),
})
}
}?;
apply_location_based_substitution(font, location.into(), gid)
.map_err(IconResolutionError::ReadError)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Icon {
pub gid: GlyphId,
pub names: Vec<String>,
pub codepoints: Vec<u32>,
}
impl Icon {
pub fn new(name: &str, codepoints: impl Into<Vec<u32>>, gid: u16) -> Self {
Icon {
names: vec![String::from(name)],
codepoints: codepoints.into(),
gid: GlyphId::new(gid.into()),
}
}
}
fn matches(
condition_set: Option<Result<ConditionSet<'_>, ReadError>>,
location: LocationRef,
) -> Result<bool, ReadError> {
let Some(condition_set) = condition_set else {
return Ok(true);
};
let coords = location.coords();
let condition_set = condition_set?;
for condition in condition_set.conditions().iter() {
match condition? {
Condition::Format1AxisRange(condition) => {
let pos = coords
.get(condition.axis_index() as usize)
.map(|p| p.to_f32())
.unwrap_or_default();
let min = condition.filter_range_min_value().to_f32();
let max = condition.filter_range_max_value().to_f32();
if pos < min || pos > max {
return Ok(false); }
}
condition => eprintln!("Unsupported condition {condition:?}"),
}
}
Ok(true)
}
fn apply_location_based_substitution(
font: &FontRef,
location: LocationRef,
gid: GlyphId,
) -> Result<GlyphId, ReadError> {
if font.table_data(Gsub::TAG).is_none() {
return Ok(gid);
}
let gsub = font.gsub()?;
let Some(feature_variations) = gsub.feature_variations() else {
return Ok(gid);
};
let feature_variations = feature_variations?;
let lookups = gsub.lookup_list()?;
let mut lookup_indices = SmallVec::<[u16; 32]>::new();
for record in feature_variations.feature_variation_records() {
if !matches(
record.condition_set(feature_variations.offset_data()),
location,
)? {
continue;
}
let Some(feature_table_substitution) =
record.feature_table_substitution(feature_variations.offset_data())
else {
return Ok(gid);
};
let feature_table_substitution = feature_table_substitution?;
for sub in feature_table_substitution.substitutions() {
let alt = sub.alternate_feature(feature_table_substitution.offset_data())?;
lookup_indices.clear();
for lookup_idx in alt.lookup_list_indices() {
lookup_indices.push(lookup_idx.get());
}
lookup_indices.sort_unstable();
for lookup_idx in lookup_indices.iter() {
let lookup = lookups.lookups().get(*lookup_idx as usize)?;
let SubstitutionSubtables::Single(table) = lookup.subtables()? else {
continue;
};
for single in table.iter() {
let single = &single?;
let coverage = match single {
SingleSubst::Format1(single) => single.coverage()?,
SingleSubst::Format2(single) => single.coverage()?,
};
let Some(coverage_idx) = coverage.get(gid) else {
continue;
};
let new_gid = match single {
SingleSubst::Format1(single) => GlyphId::new(
(gid.to_u32() as i32 + single.delta_glyph_id() as i32) as u32,
),
SingleSubst::Format2(single) => single
.substitute_glyph_ids()
.get(coverage_idx as usize)
.map(|be| be.get().into())
.unwrap_or(gid),
};
return Ok(new_gid);
}
}
}
break;
}
Ok(gid)
}
pub trait Icons {
fn icons(&self) -> Result<Vec<Icon>, IconResolutionError>;
}
impl Icons for FontRef<'_> {
fn icons(&self) -> Result<Vec<Icon>, IconResolutionError> {
let charmap = self.charmap();
let mut rev_non_pua_cmap: HashMap<GlyphId, u32> = HashMap::new();
let mut rev_pua_cmap: HashMap<GlyphId, Vec<u32>> = HashMap::new();
for (codepoint, gid) in charmap.mappings() {
if is_pua(codepoint) {
rev_pua_cmap.entry(gid).or_default().push(codepoint);
} else {
rev_non_pua_cmap.insert(gid, codepoint);
}
}
let single_charc_icons = rev_non_pua_cmap
.iter()
.filter(|(k, _)| rev_pua_cmap.contains_key(k))
.map(|(k, c)| {
Ok::<(GlyphId, String), IconResolutionError>((
*k,
String::from(
char::from_u32(*c).ok_or(IconResolutionError::InvalidCharacter(*c))?,
),
))
});
let icons = self
.ligatures()
.filter(|(_, liga)| !rev_non_pua_cmap.contains_key(&liga.ligature_glyph().into()))
.map(|(liga_first, liga)| {
Ok::<(GlyphId, String), IconResolutionError>((
liga.ligature_glyph().into(),
build_icon_name(liga_first, liga.component_glyph_ids(), &rev_non_pua_cmap)?,
))
});
let mut icons: Vec<(GlyphId, String)> = single_charc_icons
.chain(icons)
.collect::<Result<Vec<_>, _>>()?;
icons.sort_by(|a, b| a.0.cmp(&b.0));
icons
.chunk_by(|a, b| a.0 == b.0)
.map(|group| {
Ok(Icon {
gid: group[0].0,
codepoints: rev_pua_cmap
.get(&group[0].0)
.ok_or_else(|| IconResolutionError::NoCmapEntryForGid(group[0].0.to_u32()))?
.clone(),
names: group.iter().map(|(_, name)| name.clone()).collect(),
})
})
.collect()
}
}
fn build_icon_name(
first_gid: GlyphId,
gids: &[BigEndian<GlyphId16>],
rev_non_pua_cmap: &HashMap<GlyphId, u32>,
) -> Result<String, IconResolutionError> {
Ok(once(first_gid)
.chain(gids.iter().map(|g| g.get().into()))
.map(|gid| gid_to_char(&gid, rev_non_pua_cmap))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.collect())
}
fn is_pua(codepoint: u32) -> bool {
_PUA_CODEPOINTS.iter().any(|r| r.contains(&codepoint))
}
fn gid_to_char(
gid: &GlyphId,
rev_non_pua_cmap: &HashMap<GlyphId, u32>,
) -> Result<char, IconResolutionError> {
let codepoint = *rev_non_pua_cmap
.get(gid)
.ok_or_else(|| IconResolutionError::NoCmapEntryForGid(gid.to_u32()))?;
char::from_u32(codepoint).ok_or(IconResolutionError::InvalidCharacter(codepoint))
}
#[cfg(test)]
pub static MAIL: IconIdentifier = IconIdentifier::Codepoint(57688);
#[cfg(test)]
pub static PLAY_ARROW: IconIdentifier = IconIdentifier::Codepoint(57399);
#[cfg(test)]
pub static LAN: IconIdentifier = IconIdentifier::Name(SmolStr::new_static("lan"));
#[cfg(test)]
pub static MAN: IconIdentifier = IconIdentifier::GlyphId(GlyphId::new(5));
#[cfg(test)]
mod tests {
use skrifa::{setting::VariationSetting, FontRef, GlyphId, MetadataProvider};
use write_fonts::{tables::cmap::Cmap, FontBuilder};
use crate::{
assert_slice_unordered_eq,
iconid::{Icon, Icons, LAN, MAIL, MAN, PLAY_ARROW},
testdata::{self, MATERIAL_SYMBOLS_POPULAR},
};
use super::IconIdentifier;
fn assert_gid_at<I>(identifier: &IconIdentifier, location: I, expected: GlyphId)
where
I: IntoIterator,
I::Item: Into<VariationSetting>,
{
assert_gid_at_with_font(testdata::ICON_FONT, identifier, location, expected)
}
fn assert_gid_at_with_font<I>(
font_bytes: &[u8],
identifier: &IconIdentifier,
location: I,
expected: GlyphId,
) where
I: IntoIterator,
I::Item: Into<VariationSetting>,
{
let font = FontRef::new(font_bytes).unwrap();
let location = font.axes().location(location);
assert_eq!(expected, identifier.resolve(&font, &location).unwrap());
}
#[test]
fn resolve_mail_icon_at_default() {
assert_gid_at::<[(&str, f32); 0]>(&MAIL, [], GlyphId::new(1));
}
#[test]
#[allow(non_snake_case)]
fn resolve_mail_icon_at_FILL_0_98() {
assert_gid_at(&MAIL, [("FILL", 0.98)], GlyphId::new(1));
}
#[test]
#[allow(non_snake_case)]
fn resolve_play_arrow_icon_at_FILL_1_ROND_100() {
assert_gid_at_with_font(
testdata::PLAY_ARROW_VF,
&PLAY_ARROW,
[("FILL", 1.0), ("ROND", 100.0)],
GlyphId::new(3),
);
}
#[test]
#[allow(non_snake_case)]
fn resolve_play_arrow_icon_at_FILL_1_ROND_50() {
assert_gid_at_with_font(
testdata::PLAY_ARROW_VF,
&PLAY_ARROW,
[("FILL", 1.0), ("ROND", 50.0)],
GlyphId::new(4),
);
}
#[test]
#[allow(non_snake_case)]
fn resolve_play_arrow_icon_at_FILL_0_ROND_50() {
assert_gid_at_with_font(
testdata::PLAY_ARROW_VF,
&PLAY_ARROW,
[("FILL", 0.0), ("ROND", 50.0)],
GlyphId::new(1),
);
}
#[test]
#[allow(non_snake_case)]
fn resolve_play_arrow_icon_at_FILL_0_ROND_100() {
assert_gid_at_with_font(
testdata::PLAY_ARROW_VF,
&PLAY_ARROW,
[("FILL", 0.0), ("ROND", 100.0)],
GlyphId::new(2),
);
}
#[test]
#[allow(non_snake_case)]
fn resolve_mail_icon_at_FILL_1() {
assert_gid_at(&MAIL, [("FILL", 1.0)], GlyphId::new(2));
}
#[test]
fn resolve_lan_icon_at_default() {
assert_gid_at::<[(&str, f32); 0]>(&LAN, [], GlyphId::new(3));
}
#[test]
#[allow(non_snake_case)]
fn resolve_lan_icon_at_FILL_0_99() {
assert_gid_at(&LAN, [("FILL", 0.99)], GlyphId::new(4));
}
#[test]
fn resolve_man_icon_at_default() {
assert_gid_at::<[(&str, f32); 0]>(&MAN, [], GlyphId::new(5));
}
#[test]
fn icons_default() {
let font_data = rebuild_font_with_cmap(
testdata::LIGA_TESTS_FONT,
|(_, _)| true,
vec![('\u{E358}', GlyphId::new(3))],
);
let expected = [
Icon::new("x", [58180], 6),
Icon::new("box_check", [58199, 58200], 3),
Icon::new("news", [57394], 4),
Icon::new("wrench", [59334], 5),
];
let actual = FontRef::new(&font_data).unwrap().icons().unwrap();
assert_slice_unordered_eq!(actual, expected);
}
#[test]
fn icons_multiple_names() {
let font = FontRef::new(MATERIAL_SYMBOLS_POPULAR).unwrap();
let actual = font.icons();
assert!(actual.unwrap().contains(&Icon {
gid: GlyphId::new(31),
codepoints: vec![57385, 57386, 58141],
names: vec![String::from("mic_none"), String::from("mic")]
}))
}
#[test]
fn icons_missing_component_cmap() {
let font_data = rebuild_font_with_cmap(
testdata::LIGA_TESTS_FONT,
|(codepoint, _)| codepoint != &'b',
vec![],
);
let actual = FontRef::new(&font_data).unwrap().icons();
actual.expect_err("Expected error for missing cmap entry");
}
#[test]
fn icons_missing_ligature_cmap() {
let font_data = rebuild_font_with_cmap(
testdata::LIGA_TESTS_FONT,
|(codepoint, _)| codepoint != &'\u{E357}',
vec![],
);
let actual = FontRef::new(&font_data).unwrap().icons();
actual.expect_err("Expected error for missing cmap entry");
}
fn rebuild_font_with_cmap<T>(
fontdata: &[u8],
predicate: T,
additional: Vec<(char, GlyphId)>,
) -> Vec<u8>
where
T: FnMut(&(char, GlyphId)) -> bool,
{
let font = FontRef::new(fontdata).unwrap();
let new_cmap = Cmap::from_mappings(
font.charmap()
.mappings()
.map(|(codepoint, glyph)| (std::char::from_u32(codepoint).unwrap(), glyph))
.filter(predicate)
.chain(additional)
.collect::<Vec<_>>(),
)
.unwrap();
FontBuilder::new()
.add_table(&new_cmap)
.unwrap() .copy_missing_tables(font)
.build()
}
}