use crate::{FontQuery, FontTag, OpenTypeFeatureInfo, VariationAxisInfo};
use anyhow::{Context, Result, ensure};
use std::collections::BTreeMap;
pub(crate) fn query_axes(
read: impl FnOnce(FontTag) -> Result<FontQuery<Option<Vec<u8>>>>,
) -> Result<FontQuery<Vec<VariationAxisInfo>>> {
match read("fvar".parse().unwrap())? {
FontQuery::Unsupported => Ok(FontQuery::Unsupported),
FontQuery::Supported(table) => Ok(FontQuery::Supported(variation_axes(table.as_deref())?)),
}
}
pub(crate) fn query_features(
mut read: impl FnMut(FontTag) -> Result<FontQuery<Option<Vec<u8>>>>,
) -> Result<FontQuery<Vec<OpenTypeFeatureInfo>>> {
let FontQuery::Supported(gsub) = read("GSUB".parse().unwrap())? else {
return Ok(FontQuery::Unsupported);
};
let FontQuery::Supported(gpos) = read("GPOS".parse().unwrap())? else {
return Ok(FontQuery::Unsupported);
};
Ok(FontQuery::Supported(opentype_features(
gsub.as_deref(),
gpos.as_deref(),
)?))
}
pub(crate) fn opentype_features(
gsub: Option<&[u8]>,
gpos: Option<&[u8]>,
) -> Result<Vec<OpenTypeFeatureInfo>> {
let mut features = BTreeMap::new();
for (data, substitution) in [(gsub, true), (gpos, false)] {
let Some(data) = data else { continue };
let read = |at: usize| -> Result<usize> {
let bytes = data
.get(at..at + 2)
.context("truncated layout feature metadata")?;
Ok(u16::from_be_bytes(bytes.try_into().unwrap()) as usize)
};
ensure!(
read(0)? == 1 && read(2)? <= 1,
"unsupported layout table version"
);
data.get(..if read(2)? == 1 { 14 } else { 10 })
.context("truncated layout header")?;
let offset = read(6)?;
ensure!(
offset >= if read(2)? == 1 { 14 } else { 10 },
"invalid feature list offset"
);
let count = read(offset)?;
let records_end = offset + 2 + count * 6;
data.get(..records_end)
.context("truncated feature records")?;
for i in 0..count {
let at = offset + 2 + i * 6;
let tag: FontTag = std::str::from_utf8(&data[at..at + 4])?.parse()?;
let feature = offset + read(at + 4)?;
ensure!(feature >= records_end, "invalid feature table offset");
let lookup_count = read(feature + 2)?;
data.get(feature..feature + 4 + lookup_count * 2)
.context("truncated feature table")?;
let info = features.entry(tag).or_insert(OpenTypeFeatureInfo {
tag,
substitution: false,
positioning: false,
});
if substitution {
info.substitution = true;
} else {
info.positioning = true;
}
}
}
Ok(features.into_values().collect())
}
pub(crate) fn variation_axes(table: Option<&[u8]>) -> Result<Vec<VariationAxisInfo>> {
let Some(data) = table else {
return Ok(Vec::new());
};
let u16_at = |at: usize| -> Result<usize> {
Ok(u16::from_be_bytes(
data.get(at..at + 2)
.context("truncated fvar header")?
.try_into()?,
) as usize)
};
ensure!(
u16_at(0)? == 1 && u16_at(2)? == 0,
"unsupported fvar version"
);
let offset = u16_at(4)?;
let count = u16_at(8)?;
let size = u16_at(10)?;
ensure!(
data.len() >= 16 && offset >= 16 && size >= 20,
"invalid fvar header"
);
data.get(..offset + count * size)
.context("truncated fvar axes")?;
let mut axes = BTreeMap::new();
for index in 0..count {
let at = offset + index * size;
let tag: FontTag = std::str::from_utf8(&data[at..at + 4])?.parse()?;
let coordinate = |relative| {
i32::from_be_bytes(data[at + relative..at + relative + 4].try_into().unwrap()) as f32
/ 65536.0
};
let min_value = coordinate(4);
let default_value = coordinate(8);
let max_value = coordinate(12);
ensure!(
min_value <= default_value && default_value <= max_value,
"invalid fvar axis range"
);
ensure!(!axes.contains_key(&tag), "duplicate fvar axis");
axes.insert(
tag,
VariationAxisInfo {
tag,
min_value,
default_value,
max_value,
hidden: u16_at(at + 16)? & 1 != 0,
},
);
}
Ok(axes.into_values().collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn axis_query_preserves_unsupported_missing_and_failure() {
assert_eq!(
query_axes(|_| Ok(FontQuery::Unsupported)).unwrap(),
FontQuery::Unsupported
);
assert_eq!(
query_axes(|tag| {
assert_eq!(tag.to_string(), "fvar");
Ok(FontQuery::Supported(None))
})
.unwrap(),
FontQuery::Supported(Vec::new())
);
assert!(query_axes(|_| Err(anyhow::anyhow!("read failed"))).is_err());
assert!(query_axes(|_| Ok(FontQuery::Supported(Some(vec![0])))).is_err());
}
#[test]
fn feature_query_reads_both_tables_and_preserves_failure_states() {
assert_eq!(
query_features(|_| Ok(FontQuery::Unsupported)).unwrap(),
FontQuery::Unsupported
);
let mut tags = Vec::new();
let features = query_features(|tag| {
tags.push(tag.to_string());
Ok(FontQuery::Supported(Some(table(b"kern"))))
})
.unwrap();
assert_eq!(tags, ["GSUB", "GPOS"]);
let FontQuery::Supported(features) = features else {
panic!("unsupported")
};
assert_eq!(features.len(), 1);
assert!(features[0].substitution && features[0].positioning);
assert_eq!(
query_features(|_| Ok(FontQuery::Supported(None))).unwrap(),
FontQuery::Supported(Vec::new())
);
assert!(
query_features(|tag| {
if tag.to_string() == "GPOS" {
Err(anyhow::anyhow!("read failed"))
} else {
Ok(FontQuery::Supported(None))
}
})
.is_err()
);
assert_eq!(
query_features(|tag| {
Ok(if tag.to_string() == "GPOS" {
FontQuery::Unsupported
} else {
FontQuery::Supported(None)
})
})
.unwrap(),
FontQuery::Unsupported
);
assert!(query_features(|_| Ok(FontQuery::Supported(Some(vec![0])))).is_err());
}
#[test]
fn axis_metadata_preserves_negative_design_units_and_rejects_truncation() {
let mut table = vec![0, 1, 0, 0, 0, 16, 0, 2, 0, 1, 0, 20, 0, 0, 0, 0];
table.extend(b"slnt");
for value in [-10.25f32, 0.0, 0.0] {
table.extend(((value * 65536.0) as i32).to_be_bytes());
}
table.extend([0, 1, 1, 0]);
let axis = variation_axes(Some(&table)).unwrap().remove(0);
assert_eq!(axis.min_value, -10.25);
assert!(axis.hidden);
for length in 0..table.len() {
assert!(variation_axes(Some(&table[..length])).is_err());
}
assert!(variation_axes(None).unwrap().is_empty());
}
fn table(tag: &[u8; 4]) -> Vec<u8> {
let mut bytes = vec![0, 1, 0, 0, 0, 0, 0, 10, 0, 0, 0, 1];
bytes.extend(tag);
bytes.extend([0, 8, 0, 0, 0, 0]);
bytes
}
#[test]
fn distinguishes_empty_and_merges_sources() {
assert!(opentype_features(None, None).unwrap().is_empty());
let table = table(b"kern");
let infos = opentype_features(Some(&table), Some(&table)).unwrap();
assert_eq!(infos.len(), 1);
assert!(infos[0].substitution && infos[0].positioning);
for len in 0..table.len() {
assert!(opentype_features(Some(&table[..len]), None).is_err());
}
}
#[test]
fn rejects_malformed_feature_metadata() {
let valid = table(b"liga");
for (at, value) in [(0, 2), (7, 0), (17, 0), (20, 255)] {
let mut invalid = valid.clone();
invalid[at] = value;
assert!(opentype_features(Some(&valid), Some(&invalid)).is_err());
}
let empty = [0, 1, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0];
assert!(opentype_features(Some(&empty), None).unwrap().is_empty());
}
#[test]
fn rejects_invalid_axis_ranges_and_duplicates() {
let mut table = vec![0, 1, 0, 0, 0, 16, 0, 2, 0, 1, 0, 20, 0, 0, 0, 0];
table.extend(b"wght");
for value in [100i32, 400, 900] {
table.extend((value * 65536).to_be_bytes());
}
table.extend([0, 0, 1, 0]);
assert!(variation_axes(Some(&table)).is_ok());
let mut invalid = table.clone();
invalid[20..24].copy_from_slice(&(500i32 * 65536).to_be_bytes());
assert!(variation_axes(Some(&invalid)).is_err());
table[9] = 2;
table.extend_from_within(16..36);
assert!(variation_axes(Some(&table)).is_err());
}
}