// Copyright (c) 2024 Xu Shaohua . All rights reserved. // Use of this source is governed by Lesser General Public License // that can be found in the LICENSE file. //! Download icons from gstatic.com and convert to SvgIcon components #![allow(dead_code)] use inflections::Inflect; use lazy_static::lazy_static; use regex::Regex; use serde::Deserialize; use std::collections::HashMap; use std::error::Error; use std::ffi::OsStr; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::PathBuf; use zu_util::icon::{get_svg_path_data, need_update_with_name}; const SVG_DIR: &str = "icons"; const CUSTOM_DIR: &str = "custom"; const TEMPLATE_FILE: &str = include_str!("src/template.rs"); #[derive(Debug, Clone, Deserialize)] struct IconsIndex { host: String, asset_url_pattern: String, families: Vec, icons: Vec, } #[derive(Debug, Clone, Deserialize)] struct Icon { name: String, version: i32, popularity: i32, codepoint: i32, unsupported_families: Vec, categories: Vec, tags: Vec, sizes_px: Vec, } fn download_index() -> Result> { let url = "https://fonts.google.com/metadata/icons"; let resp = reqwest::blocking::get(url)?.text()?; let resp = resp.replace(")]}'", ""); let index: IconsIndex = serde_json::from_str(&resp)?; Ok(index) } const IGNORED_NAMES: &[&str] = &[ "123", "6_ft_apart", "add_chart", // Leads to inconsistent casing with `Addchart` "area_chart", "compost", "cruelty_free", "data_exploration", "disabled_visible", "drive_file_move_rtl", "emergency", "exposure_neg_1", // Google product "exposure_neg_2", // Google product "exposure_plus_1", // Google product "exposure_plus_2", // Google product "exposure_zero", // Google product "free_cancellation", "front_hand", "generating_tokens", "group_off", "horizontal_distribute", // Advanced text editor "hotel_class", "incomplete_circle", "motion_photos_on", // Google product "motion_photos_pause", // Google product "motion_photos_paused", // Google product "new_label", "personal_injury", "pin_end", "pin_invoke", "polymer", // Legacy brand "private_connectivity", "real_estate_agent", "vertical_distribute", // Advanced text editor ]; fn is_icon_ignored(icon: &Icon) -> bool { IGNORED_NAMES.contains(&icon.name.as_str()) } fn download_icons(index: &IconsIndex) -> Result> { let theme_map = &[ ("baseline", ""), // filled ("outline", "_outlined"), ("round", "_round"), ("twotone", "_two_tone"), ("sharp", "_sharp"), ]; let theme_file_map = [ ("baseline", ""), // filled ("outline", "_outlined"), ("round", "_rounded"), ("twotone", "_two_tone"), ("sharp", "_sharp"), ]; let theme_file_map = HashMap::from(theme_file_map); let _ret = fs::create_dir(SVG_DIR); let mut count = 0; for (theme, value) in theme_map { let formatted_theme = value.split('_').collect::>().join(""); for icon in &index.icons { if is_icon_ignored(icon) { continue; } let name = &icon.name; let version = &icon.version; let url = format!("https://fonts.gstatic.com/s/i/materialicons{formatted_theme}/{name}/v{version}/24px.svg"); println!("Downloading icon {url}"); let file_map = theme_file_map.get(theme).unwrap(); let output_file = format!("{SVG_DIR}/{name}{file_map}_24px.svg"); if fs::exists(&output_file).unwrap() { count += 1; continue; } let resp = reqwest::blocking::get(url)?.text()?; fs::write(output_file, resp)?; count += 1; } } Ok(count) } fn map_filename(name: &str) -> String { let name: String = name.replace("_24px", ""); const KEYWORD_NAMES: &[&str] = &["box", "try", "loop", "html"]; //if NAMES.contains(&name.as_str()) || name.chars().next().unwrap().is_ascii_digit() { if KEYWORD_NAMES.contains(&name.as_str()) { return format!("icon-{name}"); } const NAME_MAP: &[(&str, &str)] = &[ ("1x", "TimesOne"), ("3d_", "ThreeD_"), ("3g", "ThreeG"), ("3p", "ThreeP"), ("30fps", "ThirtyFps"), ("4g", "FourG"), ("5g", "FiveG"), ("60fps", "SixtyFps"), ("360", "ThreeSixty"), ]; for (old, new) in NAME_MAP { if name.starts_with(old) { return name.replace(old, new); } } const SINGLE_DIGIT_NUMBERS: &[&str] = &[ "Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", ]; const TWO_DIGIT_NUMBERS1: &[&str] = &[ "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen", ]; const TWO_DIGIT_NUMBERS2: &[&str] = &[ "Twenty", "Thirty", "Fourty", "Fifty", "Sxity", "Seventy", "Eighty", "Ninety", ]; lazy_static! { static ref RE: Regex = Regex::new(r"^(\d+)([km]?)").unwrap(); } if let Some(cap) = RE.captures(&name) { let integer = cap[1].parse::().unwrap(); let m = cap.get(0).unwrap(); let digits = if integer < 10 { SINGLE_DIGIT_NUMBERS[integer].to_owned() } else if integer < 20 { TWO_DIGIT_NUMBERS1[integer - 10].to_owned() } else if integer < 100 { let quotient = integer / 10; let remainder = integer % 10; format!( "{}{}", TWO_DIGIT_NUMBERS2[quotient], SINGLE_DIGIT_NUMBERS[remainder] ) } else { panic!("Integer out of range"); }; let suffix = if "k" == &cap[2] { "K" } else if "m" == &cap[2] { "M" } else { "" }; return format!("{digits}{suffix}{}", &name[m.end()..]); } name } fn build_icons(icons_dir: &str) -> Result, Box> { let mut dir = PathBuf::new(); dir.push(icons_dir); let mut module_names = Vec::new(); let svg_extension = OsStr::new("svg"); for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if !path.is_file() { continue; } if path.extension() != Some(svg_extension) { println!("Ignore non svg file {path:?}"); continue; } let stem = path.file_stem().unwrap(); let stem_str = stem.to_str().unwrap(); let stem_str = map_filename(stem_str); let node_name = stem_str.to_pascal_case(); let module_name = stem_str.to_snake_case(); let mut rs_filepath = PathBuf::new(); rs_filepath.push("src"); rs_filepath.push(icons_dir); rs_filepath.push(&module_name); rs_filepath.set_extension("rs"); let svg_content = fs::read_to_string(&path)?; let path_data = get_svg_path_data(&svg_content)?; let rs_content = TEMPLATE_FILE .replace("{MODULE_NAME}", &module_name) .replace("{NODE_NAME}", &node_name) .replace("{ICON_NAME}", &node_name) .replace("{PATH_DATA}", &path_data); fs::write(rs_filepath, rs_content).unwrap(); module_names.push((module_name, node_name)); } module_names.sort(); Ok(module_names) } fn generate_components(icons_dir: &str) -> Result<(), Box> { let module_names = build_icons(icons_dir)?; let mut icons_file = OpenOptions::new() .append(true) .open(format!("src/{icons_dir}.rs"))?; for (module_name, node_name) in module_names.iter() { let line = format!( r#"mod {module_name}; pub use {module_name}::{node_name}; "# ); icons_file.write_all(line.as_bytes())?; } drop(icons_file); Ok(()) } fn run() -> Result<(), Box> { if std::env::var_os("MATERIAL_DOWNLOAD").map_or(false, |val| !val.is_empty()) { // 1. Download icon index let icons_index = download_index()?; // 2. Download icons let _count = download_icons(&icons_index)?; } // 3. Convert to SvgIcon components. generate_components(SVG_DIR)?; generate_components(CUSTOM_DIR)?; Ok(()) } fn main() { // Check ZU_ICON_UPDATE="material" environment. // Set MATERIAL_DOWNLOAD=1 to download icon files from gstatic. if need_update_with_name("material") { run().unwrap(); } }