diff options
| author | pml68 <contact@pml68.dev> | 2025-04-28 10:57:42 +0200 |
|---|---|---|
| committer | pml68 <contact@pml68.dev> | 2025-04-28 10:58:49 +0200 |
| commit | 9dfb469d1ba975e59f39f3bb799b019204315784 (patch) | |
| tree | 246271173fad2464170fec0fa5d09504b346c1c7 /crates/iced_fontello/src | |
| parent | refactor(material_theme)!: make `Theme` an enum with `Custom` variant (diff) | |
| download | iced-builder-9dfb469d1ba975e59f39f3bb799b019204315784.tar.gz | |
feat: switch to modified `iced_fontello` for custom Theme support
Diffstat (limited to 'crates/iced_fontello/src')
| -rw-r--r-- | crates/iced_fontello/src/lib.rs | 394 |
1 files changed, 394 insertions, 0 deletions
diff --git a/crates/iced_fontello/src/lib.rs b/crates/iced_fontello/src/lib.rs new file mode 100644 index 0000000..2b39647 --- /dev/null +++ b/crates/iced_fontello/src/lib.rs @@ -0,0 +1,394 @@ +#![allow(clippy::needless_doctest_main)] +//! A compile-time, type-safe icon font generator for [`iced`]. +//! Powered by [Fontello]. +//! +//! [`iced`]: https://github.com/iced-rs/iced +//! [Fontello]: https://github.com/fontello/fontello +//! +//! # Usage +//! Create a `.toml` file somewhere in your crate with the font definition: +//! +//! ```toml +//! # fonts/example-icons.toml +//! module = "icon" +//! +//! [glyphs] +//! edit = "fontawesome-pencil" +//! save = "entypo-floppy" +//! trash = "typicons-trash" +//! ``` +//! +//! The `module` value defines the Rust module that will be generated in your `src` +//! directory containing a type-safe API to use the font. +//! +//! Each entry in the `[glyphs]` section corresponds to an icon. The keys will be +//! used as names for the functions of the module of the font; while the values +//! specify the glyph for that key using the format: `<font>-<glyph>`. You can browse +//! the available glyphs in [Fontello] or [the `fonts.json` file](fonts.json). +//! +//! Next, add `iced_fontello` to your `build-dependencies`: +//! +//! ```toml +//! [build-dependencies] +//! iced_fontello = "0.13" +//! ``` +//! +//! Then, call `iced_fontello::build` in your [build script](https://doc.rust-lang.org/cargo/reference/build-scripts.html), +//! passing the path of your font definition: +//! +//! ```rust,no_run +//! pub fn main() { +//! println!("cargo::rerun-if-changed=fonts/example-icons.toml"); +//! iced_fontello::build("fonts/example-icons.toml").expect("Build example-icons font"); +//! } +//! ``` +//! +//! The library will generate the font and save its `.ttf` file right next to its definition. +//! In this example, the library would generate `fonts/example-icons.ttf`. +//! +//! Finally, it will generate a type-safe `iced` API that lets you use the font. In our example: +//! +//! ```rust,ignore +//! // Generated automatically by iced_fontello at build time. +//! // Do not edit manually. +//! // d24460a00249b2acd0ccc64c3176452c546ad12d1038974e974d7bdb4cdb4a8f +//! use iced::widget::{text, Text}; +//! use iced::Font; +//! +//! pub const FONT: &[u8] = include_bytes!("../fonts/example-icons.ttf"); +//! +//! pub fn edit<'a>() -> Text<'a> { +//! icon("\u{270E}") +//! } +//! +//! pub fn save<'a>() -> Text<'a> { +//! icon("\u{1F4BE}") +//! } +//! +//! pub fn trash<'a>() -> Text<'a> { +//! icon("\u{E10A}") +//! } +//! +//! fn icon<'a>(codepoint: &'a str) -> Text<'a> { +//! text(codepoint).font(Font::with_name("example-icons")) +//! } +//! ``` +//! +//! Now you can simply add `mod icon;` to your `lib.rs` or `main.rs` file and enjoy your new font: +//! +//! ```rust,ignore +//! mod icon; +//! +//! use iced::widget::row; +//! +//! // ... +//! +//! row![icon::edit(), icon::save(), icon::trash()].spacing(10) +//! +//! // ... +//! ``` +//! +//! Check out [the full example](example) to see it all in action. +//! +//! # Packaging +//! If you plan to package your crate, you must make sure you include the generated module +//! and font file in the final package. `build` is effectively a no-op when the module and +//! the font already exist and are up-to-date. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use reqwest::blocking as reqwest; +use serde::{Deserialize, Serialize}; + +pub fn build(path: impl AsRef<Path>) -> Result<(), Error> { + let path = path.as_ref(); + + let definition: Definition = { + let contents = fs::read_to_string(path).unwrap_or_else(|error| { + panic!( + "Font definition {path} could not be read: {error}", + path = path.display() + ) + }); + + toml::from_str(&contents).unwrap_or_else(|error| { + panic!( + "Font definition {path} is invalid: {error}", + path = path.display() + ) + }) + }; + + let fonts = parse_fonts(); + + let glyphs: BTreeMap<String, ChosenGlyph> = definition + .glyphs + .into_iter() + .map(|(name, id)| { + let Some((font_name, glyph)) = id.split_once('-') else { + panic!( + "Invalid glyph identifier: \"{id}\"\n\ + Glyph identifier must have \"<font>-<name>\" format" + ) + }; + + let Some(font) = fonts.get(font_name) else { + panic!( + "Font \"{font_name}\" was not found. Available fonts are:\n{}", + fonts + .keys() + .map(|name| format!("- {name}")) + .collect::<Vec<_>>() + .join("\n") + ); + }; + + let Some(glyph) = font.glyphs.get(glyph) else { + // TODO: Display similarly named candidates + panic!( + "Glyph \"{glyph}\" was not found. Available glyphs are:\n{}", + font.glyphs + .keys() + .map(|name| format!("- {name}")) + .collect::<Vec<_>>() + .join("\n") + ); + }; + + ( + name, + ChosenGlyph { + uid: glyph.uid.clone(), + css: glyph.name.clone(), + code: glyph.code, + src: font.name.clone(), + }, + ) + }) + .collect(); + + #[derive(Serialize)] + struct Config { + name: String, + css_prefix_text: &'static str, + css_use_suffix: bool, + hinting: bool, + units_per_em: u32, + ascent: u32, + glyphs: Vec<ChosenGlyph>, + } + + #[derive(Clone, Serialize)] + struct ChosenGlyph { + uid: Id, + css: String, + code: u64, + src: String, + } + + let file_name = path + .file_stem() + .expect("Get file stem from definition path") + .to_string_lossy() + .into_owned(); + + let config = Config { + name: file_name.clone(), + css_prefix_text: "icon-", + css_use_suffix: false, + hinting: true, + units_per_em: 1000, + ascent: 850, + glyphs: glyphs.values().cloned().collect(), + }; + + let hash = { + use sha2::Digest as _; + + let mut hasher = sha2::Sha256::new(); + hasher.update( + serde_json::to_string(&config).expect("Serialize config as JSON"), + ); + + format!("{:x}", hasher.finalize()) + }; + + let module_target = PathBuf::new() + .join("src") + .join(definition.module.replace("::", "/")) + .with_extension("rs"); + + let module_contents = + fs::read_to_string(&module_target).unwrap_or_default(); + let module_hash = module_contents + .lines() + .nth(2) + .unwrap_or_default() + .trim_start_matches("// "); + + if hash != module_hash || !path.with_extension("ttf").exists() { + let client = reqwest::Client::new(); + let session = client + .post("https://fontello.com/") + .multipart( + reqwest::multipart::Form::new().part( + "config", + reqwest::multipart::Part::text( + serde_json::to_string(&config) + .expect("Serialize Fontello config"), + ) + .file_name("config.json"), + ), + ) + .send() + .and_then(reqwest::Response::error_for_status) + .and_then(reqwest::Response::text) + .expect("Create Fontello session"); + + let font = client + .get(format!("https://fontello.com/{session}/get")) + .send() + .and_then(reqwest::Response::error_for_status) + .and_then(reqwest::Response::bytes) + .expect("Download Fontello font"); + + let mut archive = zip::ZipArchive::new(io::Cursor::new(font)) + .expect("Parse compressed font"); + + let mut font_file = (0..archive.len()) + .find(|i| { + let file = + archive.by_index(*i).expect("Access zip archive by index"); + + file.name().ends_with(&format!("{file_name}.ttf")) + }) + .and_then(|i| archive.by_index(i).ok()) + .expect("Find font file in zipped archive"); + + io::copy( + &mut font_file, + &mut fs::File::create(path.with_extension("ttf")) + .expect("Create font file"), + ) + .expect("Extract font file"); + } + + let relative_path = PathBuf::from( + std::iter::repeat("../") + .take(definition.module.split("::").count()) + .collect::<String>(), + ); + + let mut module = String::new(); + + module.push_str(&format!( + "// Generated automatically by iced_fontello at build time.\n\ + // Do not edit manually. Source: {source}\n\ + // {hash}\n\ + use iced::Font;\n\ + use iced::widget::text;\n\n\ + use crate::widget::Text;\n\n\ + pub const FONT: &[u8] = include_bytes!(\"{path}\");\n\n", + source = relative_path.join(path.with_extension("toml")).display(), + path = relative_path.join(path.with_extension("ttf")).display() + )); + + for (name, glyph) in glyphs { + module.push_str(&format!( + "\ +pub fn {name}<'a>() -> Text<'a> {{ + icon(\"\\u{{{code:X}}}\") +}}\n\n", + code = glyph.code + )); + } + + module.push_str(&format!( + "\ +fn icon(codepoint: &str) -> Text<'_> {{ + text(codepoint).font(Font::with_name(\"{file_name}\")) +}}\n" + )); + + if module != module_contents { + if let Some(directory) = module_target.parent() { + fs::create_dir_all(directory) + .expect("Create parent directory of font module"); + } + + fs::write(module_target, module).expect("Write font module"); + } + + Ok(()) +} + +#[derive(Debug, Clone)] +pub enum Error {} + +#[derive(Debug, Clone, Deserialize)] +struct Definition { + module: String, + glyphs: BTreeMap<String, String>, +} + +#[derive(Debug, Clone)] +struct Font { + name: String, + glyphs: BTreeMap<String, Glyph>, +} + +#[derive(Debug, Clone, Deserialize)] +struct Glyph { + uid: Id, + code: u64, + #[serde(rename = "css")] + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct Id(String); + +fn parse_fonts() -> BTreeMap<String, Font> { + #[derive(Deserialize)] + struct ItemSchema { + font: FontSchema, + glyphs: Vec<Glyph>, + } + + #[derive(Deserialize)] + struct FontSchema { + fontname: String, + } + + let items: Vec<ItemSchema> = + serde_json::from_str(include_str!("../fonts.json")) + .expect("Deserialize fonts"); + + items + .into_iter() + .map(|item| { + ( + item.font.fontname.clone(), + Font { + name: item.font.fontname, + glyphs: item + .glyphs + .into_iter() + .map(|glyph| (glyph.name.clone(), glyph)) + .collect(), + }, + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_parses_fonts() { + assert!(!parse_fonts().is_empty()); + } +} |
