use std::collections::{HashSet, hash_set::Iter};
#[cfg(feature = "pyo3")]
use pyo3::prelude::*;
use serde::{Deserialize, Serialize};
#[cfg_attr(
feature = "pyo3",
pyclass(module = "fontsource_downloader", frozen, get_all, from_py_object)
)]
#[cfg_attr(
not(feature = "pyo3"),
doc = "Use [`QueryBuilder`] to construct a [`FontQuery`]."
)]
#[derive(Debug, Clone)]
pub struct FontQuery {
family: String,
style: HashSet<String>,
weight: HashSet<Weight>,
subset: HashSet<String>,
pub(crate) file_type: HashSet<FontFileType>,
}
impl Default for FontQuery {
fn default() -> Self {
Self {
family: String::from("Roboto"),
style: HashSet::from_iter([String::from("normal")]),
weight: HashSet::from_iter([Weight::default()]),
subset: HashSet::from_iter([String::from("latin")]),
file_type: HashSet::from_iter([FontFileType::default()]),
}
}
}
#[derive(Debug, Default)]
pub struct QueryBuilder {
family: String,
style: HashSet<String>,
weight: HashSet<Weight>,
subset: HashSet<String>,
file_type: HashSet<FontFileType>,
}
impl From<FontQuery> for QueryBuilder {
fn from(value: FontQuery) -> Self {
Self::from(&value)
}
}
impl From<&FontQuery> for QueryBuilder {
fn from(value: &FontQuery) -> Self {
Self {
family: value.family.clone(),
style: value.style.clone(),
weight: value.weight.clone(),
subset: value.subset.clone(),
file_type: value.file_type.clone(),
}
}
}
impl QueryBuilder {
pub fn new(family: &str) -> Self {
Self {
family: family.trim().to_string(),
..Default::default()
}
}
pub fn with_style(self, style: &str) -> Self {
let mut styles = self.style;
styles.insert(Self::normalized_style(style).to_string());
Self {
style: styles,
..self
}
}
fn normalized_style(style: &str) -> &'static str {
let style = style.trim();
if style.eq_ignore_ascii_case("italic") {
return "italic";
} else if style.eq_ignore_ascii_case("oblique") {
return "oblique";
}
"normal"
}
pub fn with_weight(self, weight: Weight) -> Self {
let mut weights = self.weight;
weights.insert(weight);
Self {
weight: weights,
..self
}
}
pub fn with_subset(self, subset: &str) -> Self {
let mut subsets = self.subset;
subsets.insert(Self::normalized_subset(subset).to_string());
Self {
subset: subsets,
..self
}
}
fn normalized_subset(subset: &str) -> &str {
let result = subset.trim();
if !result.is_empty() { result } else { "latin" }
}
pub fn with_file_type(self, file_type: FontFileType) -> Self {
let mut file_types = self.file_type;
file_types.insert(file_type);
Self {
file_type: file_types,
..self
}
}
pub fn build(self) -> FontQuery {
let style = if self.style.is_empty() {
HashSet::from_iter(["normal".to_string()])
} else {
self.style
};
let weight = if self.weight.is_empty() {
HashSet::from_iter([Weight::Normal])
} else {
self.weight
};
let subset = if self.subset.is_empty() {
HashSet::from_iter(["latin".to_string()])
} else {
self.subset
};
let file_type = if self.file_type.is_empty() {
HashSet::from_iter([FontFileType::Ttf])
} else {
self.file_type
};
FontQuery {
family: self.family,
style,
weight,
subset,
file_type,
}
}
}
impl FontQuery {
pub fn family(&self) -> &str {
&self.family
}
pub fn styles(&self) -> Iter<'_, String> {
self.style.iter()
}
pub fn weights(&self) -> Iter<'_, Weight> {
self.weight.iter()
}
pub fn subsets(&self) -> Iter<'_, String> {
self.subset.iter()
}
pub fn file_types(&'_ self) -> Iter<'_, FontFileType> {
self.file_type.iter()
}
pub(crate) fn filter_subsets<'a>(&'a self, available: &[String]) -> Vec<&'a String> {
self.subsets().filter(|v| available.contains(v)).collect()
}
pub(crate) fn filter_styles<'a>(&'a self, available: &[String]) -> Vec<&'a String> {
self.styles().filter(|v| available.contains(v)).collect()
}
pub(crate) fn filter_weights(&self, available: &[u16]) -> Vec<u16> {
self.weights()
.filter_map(|v| {
let int_weight: u16 = (*v).into();
if available.contains(&int_weight) {
Some(int_weight)
} else {
None
}
})
.collect()
}
}
#[cfg(feature = "pyo3")]
#[pymethods]
impl FontQuery {
#[new]
#[pyo3(
signature = (family, styles=None, weights=None, subsets=None, file_types=None),
text_signature = "(family: str, styles: str | None = None, weights: list[Weight] | None = None, subsets: list[str] | None = None, file_types: list[FontFileType] | None = None) -> FontQuery"
)]
pub fn new_py(
family: String,
styles: Option<Vec<String>>,
weights: Option<Vec<Weight>>,
subsets: Option<Vec<String>>,
file_types: Option<Vec<FontFileType>>,
) -> Self {
let mut result = QueryBuilder::new(&family);
if let Some(styles) = styles {
for style in styles {
result = result.with_style(&style);
}
}
if let Some(weight) = weights {
for weight in weight {
result = result.with_weight(weight);
}
}
if let Some(subsets) = subsets {
for subset in subsets {
result = result.with_subset(&subset);
}
}
if let Some(file_types) = file_types {
for file_type in file_types {
result = result.with_file_type(file_type);
}
}
result.build()
}
#[getter]
pub fn get_family(&self) -> &str {
&self.family
}
#[getter]
pub fn get_styles(&self) -> Vec<String> {
self.styles().cloned().collect()
}
#[getter]
pub fn get_weights(&self) -> Vec<Weight> {
self.weights().cloned().collect()
}
#[getter]
pub fn get_subsets(&self) -> Vec<String> {
self.subsets().cloned().collect()
}
#[getter]
pub fn get_file_types(&self) -> Vec<FontFileType> {
self.file_types().cloned().collect()
}
}
#[cfg_attr(
feature = "pyo3",
pyclass(module = "fontsource_downloader", eq, from_py_object)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum FontFileType {
Woff2,
Woff,
#[default]
Ttf,
}
impl FontFileType {
pub(crate) fn extension(&self) -> &'static str {
match self {
FontFileType::Woff2 => "woff2",
FontFileType::Woff => "woff",
FontFileType::Ttf => "ttf",
}
}
}
#[cfg(feature = "pyo3")]
#[cfg_attr(feature = "pyo3", pymethods)]
impl FontFileType {
pub fn __str__(&self) -> &'static str {
self.extension()
}
}
#[cfg_attr(
feature = "pyo3",
pyclass(module = "fontsource_downloader", eq_int, eq, from_py_object)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
#[allow(missing_docs)]
pub enum Weight {
Thin = 100,
ExtraLight = 200,
Light = 300,
#[default]
Normal = 400,
Medium = 500,
SemiBold = 600,
Bold = 700,
ExtraBold = 800,
Black = 900,
}
impl From<Weight> for u16 {
fn from(value: Weight) -> Self {
value as u16
}
}
impl From<&Weight> for u16 {
fn from(value: &Weight) -> Self {
(*value).into()
}
}
impl From<u16> for Weight {
fn from(value: u16) -> Self {
let value = (value.clamp(100, 900) / 100) * 100;
match value {
100 => Weight::Thin,
200 => Weight::ExtraLight,
300 => Weight::Light,
500 => Weight::Medium,
600 => Weight::SemiBold,
700 => Weight::Bold,
800 => Weight::ExtraBold,
900 => Weight::Black,
_ => Weight::Normal, }
}
}
#[cfg(feature = "pyo3")]
#[cfg_attr(feature = "pyo3", pymethods)]
impl Weight {
#[new]
#[pyo3(signature = (value), text_signature = "(value: int) -> Weight")]
pub fn new(value: i32) -> Self {
(value.clamp(100, 900) as u16).into()
}
pub fn __int__(&self) -> u16 {
(*self).into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weight_conversion() {
assert_eq!(Weight::from(50), Weight::Thin); assert_eq!(Weight::from(256), Weight::ExtraLight);
assert_eq!(Weight::from(322), Weight::Light);
assert_eq!(Weight::from(404), Weight::Normal);
assert_eq!(Weight::from(555), Weight::Medium);
assert_eq!(Weight::from(666), Weight::SemiBold);
assert_eq!(Weight::from(750), Weight::Bold);
assert_eq!(Weight::from(888), Weight::ExtraBold);
assert_eq!(Weight::from(950), Weight::Black);
assert_eq!(u16::from(Weight::Thin), 100);
assert_eq!(u16::from(Weight::ExtraLight), 200);
assert_eq!(u16::from(Weight::Light), 300);
assert_eq!(u16::from(Weight::Normal), 400);
assert_eq!(u16::from(Weight::Medium), 500);
assert_eq!(u16::from(Weight::SemiBold), 600);
assert_eq!(u16::from(Weight::Bold), 700);
assert_eq!(u16::from(Weight::ExtraBold), 800);
assert_eq!(u16::from(Weight::Black), 900);
let weight_ref = &Weight::Medium;
assert_eq!(u16::from(weight_ref), 500);
}
#[test]
fn font_query_defaults() {
let mut query = QueryBuilder::new("Roboto")
.with_subset("") .with_style(" Italic ")
.build(); assert_eq!(query.family, "Roboto");
assert_eq!(query.styles().collect::<Vec<&String>>(), vec!["italic"]);
assert_eq!(
query.weights().collect::<Vec<&Weight>>(),
vec![&Weight::Normal]
);
assert_eq!(
query.file_types().collect::<Vec<&FontFileType>>(),
vec![&FontFileType::Ttf]
);
assert_eq!(query.subsets().collect::<Vec<&String>>(), vec!["latin"]);
query = QueryBuilder::from(query)
.with_subset(" cyrillic ")
.with_style("Oblique")
.build();
let subsets = query.subsets().map(|v| v.as_str()).collect::<Vec<&str>>();
assert!(subsets.contains(&"latin"));
assert!(subsets.contains(&"cyrillic"));
let styles = query.styles().map(|v| v.as_str()).collect::<Vec<&str>>();
assert!(styles.contains(&"italic"));
assert!(styles.contains(&"oblique"));
let default_query = QueryBuilder::new(&query.family).build();
assert_eq!(default_query.family(), "Roboto");
assert_eq!(
default_query.styles().collect::<Vec<&String>>(),
vec!["normal"]
);
assert_eq!(
default_query.weights().collect::<Vec<&Weight>>(),
vec![&Weight::Normal]
);
assert_eq!(
default_query.file_types().collect::<Vec<&FontFileType>>(),
vec![&FontFileType::Ttf]
);
assert_eq!(
default_query.subsets().collect::<Vec<&String>>(),
vec!["latin"]
);
}
}