include!(concat!(env!("OUT_DIR"), "/icon_catalog.rs"));
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Icon {
pub ch: char,
pub name: &'static str,
pub uri: &'static str,
pub svg: &'static str,
pub aspect: f32,
pub mono: bool,
}
pub fn lookup(ch: char) -> Option<&'static Icon> {
let i = ICONS.binary_search_by_key(&ch, |icon| icon.ch).ok()?;
Some(&ICONS[i])
}
pub fn has(ch: char) -> bool {
lookup(ch).is_some()
}
pub fn artwork(glyph: &str) -> Option<&'static Icon> {
let mut chars = glyph.chars();
let icon = lookup(chars.next()?)?;
chars.next().is_none().then_some(icon)
}
#[cfg(test)]
mod tests {
use super::*;
const COLOUR: &[char] = &[
'\u{270D}', '\u{1F41E}', '\u{1F4DA}', '\u{E010}', '\u{E011}', '\u{E012}', '\u{E013}', '\u{E014}', '\u{E015}', '\u{E016}', '\u{E017}', '\u{E018}', ];
const FEATURES: std::ops::RangeInclusive<char> = '\u{E030}'..='\u{E059}';
fn is_colour(ch: char) -> bool {
COLOUR.contains(&ch) || FEATURES.contains(&ch)
}
fn fills(svg: &str) -> impl Iterator<Item = &str> {
svg.match_indices("fill=\"").filter_map(|(i, m)| {
let rest = &svg[i + m.len()..];
rest.find('"').map(|end| &rest[..end])
})
}
#[test]
fn catalog_is_populated_and_sorted() {
assert!(ICONS.len() >= 50, "catalog looks truncated: {}", ICONS.len());
assert!(
ICONS.windows(2).all(|w| w[0].ch < w[1].ch),
"ICONS must be sorted by codepoint for `lookup` to binary-search it"
);
}
#[test]
fn lookup_finds_every_entry() {
for icon in ICONS {
assert_eq!(lookup(icon.ch), Some(icon), "lookup missed {}", icon.name);
}
}
#[test]
fn notdef_is_not_catalogued() {
assert!(ICONS.iter().all(|i| i.name != ".notdef"));
}
#[test]
fn uncatalogued_characters_stay_text() {
for ch in ['a', ' ', '1', '\n', 'é'] {
assert!(!has(ch), "{ch:?} must not be an icon");
}
}
#[test]
fn the_ink_sentinel_rewrites_mono_glyphs_and_spares_coloured_ones() {
for icon in ICONS {
if icon.mono {
assert!(!icon.svg.contains("#111"), "{}: ink sentinel not rewritten", icon.name);
assert!(icon.svg.contains("#fff"), "{}: no white fill after rewrite", icon.name);
} else {
assert!(
icon.svg.contains("fill=\""),
"{}: a non-mono glyph must carry its own fills",
icon.name
);
}
}
}
#[test]
fn the_composed_colour_icons_are_catalogued_in_colour() {
for (ch, fills) in [
('\u{1F41E}', &["#e1544a", "#5e1a14"][..]), ('\u{1F4DA}', &["#2e9e8f", "#124840"][..]), ('\u{270D}', &["#f2b33d", "#6e4a12"][..]), ('\u{E010}', &["#70b7f6", "#1c5384"][..]), ('\u{E011}', &["#cd8b19", "#fac437"][..]), ('\u{E012}', &["#4597e5", "#13416c"][..]), ('\u{E014}', &["#2fa469", "#7be8ae"][..]), ('\u{E015}', &["#da762a", "#ffbb69"][..]), ('\u{E018}', &["#40abda", "#d3f5ff"][..]), ] {
let icon = lookup(ch).unwrap_or_else(|| panic!("U+{:04X} is not catalogued", ch as u32));
assert!(!icon.mono, "{} must not be tinted — it has authored colour", icon.name);
for fill in fills {
assert!(icon.svg.contains(fill), "{}: lost its {fill} layer", icon.name);
}
}
}
#[test]
fn the_colour_set_is_exactly_the_declared_one() {
for ch in COLOUR.iter().copied().chain(FEATURES) {
let icon = lookup(ch).unwrap_or_else(|| panic!("U+{:04X} missing", ch as u32));
assert!(!icon.mono, "{} is declared colour but catalogued as ink", icon.name);
}
for icon in ICONS.iter().filter(|i| !is_colour(i.ch)) {
assert!(icon.mono, "{} carries colour but is not declared colour", icon.name);
}
}
const FEATURE_PALETTE: &[&str] = &[
"#ffd977", "#f0b840", "#d4901f", "#c9871a", "#7a4d10", "#a8d4f7", "#3d86c4", "#1c5384", "#a5e8c4", "#2fa469", "#125236", "#bcd7e8", "#5c86a6", "#2b4c63", "#c9b6f0", "#8b6fd4", "#3d2a6b", "#d94438", ];
#[test]
fn every_feature_icon_is_colour_from_the_shared_palette() {
for ch in FEATURES {
let icon =
lookup(ch).unwrap_or_else(|| panic!("U+{:04X} is not catalogued", ch as u32));
assert!(!icon.mono, "{}: feature artwork must carry its own colours", icon.name);
for fill in fills(icon.svg) {
assert!(
FEATURE_PALETTE.contains(&fill),
"{}: {fill} is not in the feature palette",
icon.name
);
}
}
}
#[test]
fn every_feature_type_has_catalogued_artwork() {
let catalogue = brep_render::features::feature_catalogue();
let features = catalogue["features"].as_array().expect("catalogue features");
assert!(!features.is_empty(), "empty feature catalogue");
let missing: Vec<String> = features
.iter()
.filter_map(|feature| {
let ty = feature["type"].as_str()?;
match brep_render::features::feature_icon(ty) {
Some(ch) if has(ch) => None,
Some(ch) => Some(format!("{ty}: U+{:04X} has no glyph", ch as u32)),
None => Some(format!("{ty}: no icon mapping at all")),
}
})
.collect();
assert!(missing.is_empty(), "features without artwork: {missing:#?}");
}
#[test]
fn aspects_are_sane() {
for icon in ICONS {
assert!(
icon.aspect > 0.05 && icon.aspect < 5.0,
"{}: implausible aspect {}",
icon.name,
icon.aspect
);
}
}
#[test]
fn uris_are_unique_and_svg_suffixed() {
let mut seen = std::collections::HashSet::new();
for icon in ICONS {
assert!(icon.uri.ends_with(".svg"), "{}: loader needs a .svg URI", icon.name);
assert!(seen.insert(icon.uri), "{}: duplicate URI {}", icon.name, icon.uri);
}
}
}