Skip to main content

material_ui_rs/design/
fonts.rs

1//! Bundled Material typefaces and icon fonts.
2
3use iced_widget::Text;
4use iced_widget::core::Font;
5use iced_widget::core::font::{Family, Stretch, Style, Weight};
6use iced_widget::core::text as core_text;
7use iced_widget::text::{self, LineHeight};
8
9use crate::{Theme, tokens};
10
11use std::borrow::Cow;
12use std::fmt;
13
14#[cfg(target_arch = "wasm32")]
15#[path = "web_font.rs"]
16mod web_font;
17
18pub const ROBOTO_FAMILY: &str = "Roboto";
19pub const NOTO_SANS_CJK_SC_FAMILY: &str = "Noto Sans CJK SC";
20pub const MATERIAL_SYMBOLS_ROUNDED_FAMILY: &str = "Material Symbols Rounded";
21pub const MATERIAL_SYMBOLS_ROUNDED_FILLED_FAMILY: &str = "Material Symbols Rounded Filled";
22
23pub const ROBOTO_REGULAR_BYTES: &[u8] = include_bytes!("../fonts/Roboto-Regular.ttf");
24pub const ROBOTO_MEDIUM_BYTES: &[u8] = include_bytes!("../fonts/Roboto-Medium.ttf");
25pub const ROBOTO_BOLD_BYTES: &[u8] = include_bytes!("../fonts/Roboto-Bold.ttf");
26pub const MATERIAL_SYMBOLS_ROUNDED_BYTES: &[u8] =
27    include_bytes!("../fonts/MaterialSymbolsRounded-Regular.ttf");
28pub const MATERIAL_SYMBOLS_ROUNDED_FILLED_BYTES: &[u8] =
29    include_bytes!("../fonts/MaterialSymbolsRounded-Filled.ttf");
30
31pub const ROBOTO: Font = roboto_for_weight(tokens::typography::WEIGHT_REGULAR);
32pub const ROBOTO_MEDIUM: Font = roboto_for_weight(tokens::typography::WEIGHT_MEDIUM);
33pub const ROBOTO_BOLD: Font = roboto_for_weight(tokens::typography::WEIGHT_BOLD);
34pub const NOTO_SANS_CJK_SC: Font = noto_sans_cjk_sc_for_weight(tokens::typography::WEIGHT_REGULAR);
35pub const NOTO_SANS_CJK_SC_MEDIUM: Font =
36    noto_sans_cjk_sc_for_weight(tokens::typography::WEIGHT_MEDIUM);
37pub const NOTO_SANS_CJK_SC_BOLD: Font =
38    noto_sans_cjk_sc_for_weight(tokens::typography::WEIGHT_BOLD);
39pub const MATERIAL_SYMBOLS_ROUNDED: Font = Font {
40    family: Family::Name(MATERIAL_SYMBOLS_ROUNDED_FAMILY),
41    weight: Weight::Normal,
42    stretch: Stretch::Normal,
43    style: Style::Normal,
44};
45pub const MATERIAL_SYMBOLS_ROUNDED_FILLED: Font = Font {
46    family: Family::Name(MATERIAL_SYMBOLS_ROUNDED_FILLED_FAMILY),
47    weight: Weight::Normal,
48    stretch: Stretch::Normal,
49    style: Style::Normal,
50};
51
52/// An error produced while loading a web font from a URL.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum WebFontError {
55    /// URL loading is only available in WebAssembly builds.
56    UnsupportedPlatform,
57    /// A browser API needed to fetch the font was not available.
58    MissingBrowserApi(&'static str),
59    /// The browser rejected the fetch request.
60    RequestFailed,
61    /// The server returned an unsuccessful HTTP status.
62    HttpStatus(u16),
63    /// The response body could not be read.
64    ReadFailed,
65    /// The response was not a TrueType, OpenType, or TrueType Collection font.
66    UnsupportedFormat,
67    /// The renderer could not load the downloaded font.
68    FontLoad(iced::font::Error),
69}
70
71impl fmt::Display for WebFontError {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::UnsupportedPlatform => {
75                formatter.write_str("web fonts can only be fetched on WebAssembly")
76            }
77            Self::MissingBrowserApi(api) => write!(formatter, "browser API `{api}` is unavailable"),
78            Self::RequestFailed => formatter.write_str("the browser rejected the font request"),
79            Self::HttpStatus(status) => {
80                write!(formatter, "the font server returned HTTP status {status}")
81            }
82            Self::ReadFailed => formatter.write_str("the font response body could not be read"),
83            Self::UnsupportedFormat => formatter.write_str(
84                "the downloaded file is not a TrueType, OpenType, or TrueType Collection font",
85            ),
86            Self::FontLoad(_) => formatter.write_str("the renderer could not load the web font"),
87        }
88    }
89}
90
91impl std::error::Error for WebFontError {}
92
93pub fn all() -> [Cow<'static, [u8]>; 5] {
94    [
95        Cow::Borrowed(ROBOTO_REGULAR_BYTES),
96        Cow::Borrowed(ROBOTO_MEDIUM_BYTES),
97        Cow::Borrowed(ROBOTO_BOLD_BYTES),
98        Cow::Borrowed(MATERIAL_SYMBOLS_ROUNDED_BYTES),
99        Cow::Borrowed(MATERIAL_SYMBOLS_ROUNDED_FILLED_BYTES),
100    ]
101}
102
103/// Downloads and loads a font without embedding its bytes in the WASM binary.
104///
105/// The returned task starts the request when it is returned from application
106/// boot or update. The URL must serve a TrueType (`.ttf`), OpenType (`.otf`),
107/// or TrueType Collection (`.ttc`) file and must permit a browser CORS request.
108/// Web-only font formats such as WOFF2 are not accepted by the iced renderer.
109///
110/// On non-WASM targets, the task resolves to
111/// [`WebFontError::UnsupportedPlatform`].
112///
113/// ```no_run
114/// # use material_ui_rs::fonts;
115/// # #[derive(Debug, Clone)]
116/// # enum Message { FontLoaded(Result<(), fonts::WebFontError>) }
117/// let task = fonts::load_web_font("fonts/NotoSansCJKsc-Regular.otf")
118///     .map(Message::FontLoaded);
119/// # let _ = task;
120/// ```
121pub fn load_web_font(url: impl Into<String>) -> iced::Task<Result<(), WebFontError>> {
122    #[cfg(target_arch = "wasm32")]
123    {
124        web_font::load(url.into())
125    }
126
127    #[cfg(not(target_arch = "wasm32"))]
128    {
129        let _ = url.into();
130
131        iced::Task::done(Err(WebFontError::UnsupportedPlatform))
132    }
133}
134
135pub const fn roboto_for_type_scale(scale: tokens::typography::TypeScale) -> Font {
136    roboto_for_weight(scale.weight)
137}
138
139pub const fn noto_sans_cjk_sc_for_type_scale(scale: tokens::typography::TypeScale) -> Font {
140    noto_sans_cjk_sc_for_weight(scale.weight)
141}
142
143pub fn font_for_content_type_scale(content: &str, scale: tokens::typography::TypeScale) -> Font {
144    if contains_cjk(content) {
145        noto_sans_cjk_sc_for_type_scale(scale)
146    } else {
147        roboto_for_type_scale(scale)
148    }
149}
150
151pub const fn roboto_for_weight(weight: u16) -> Font {
152    Font {
153        family: Family::Name(ROBOTO_FAMILY),
154        weight: match weight {
155            tokens::typography::WEIGHT_BOLD => Weight::Bold,
156            tokens::typography::WEIGHT_MEDIUM => Weight::Medium,
157            _ => Weight::Normal,
158        },
159        stretch: Stretch::Normal,
160        style: Style::Normal,
161    }
162}
163
164pub const fn noto_sans_cjk_sc_for_weight(weight: u16) -> Font {
165    Font {
166        family: Family::Name(NOTO_SANS_CJK_SC_FAMILY),
167        weight: match weight {
168            tokens::typography::WEIGHT_BOLD => Weight::Bold,
169            tokens::typography::WEIGHT_MEDIUM => Weight::Medium,
170            _ => Weight::Normal,
171        },
172        stretch: Stretch::Normal,
173        style: Style::Normal,
174    }
175}
176
177pub fn contains_cjk(content: &str) -> bool {
178    content.chars().any(is_cjk_codepoint)
179}
180
181pub fn material_symbol_codepoint(name: &str) -> Option<char> {
182    let codepoint = match name {
183        "info" => 0xe88e,
184        "input" => 0xe890,
185        "layers" => 0xe53b,
186        "menu" => 0xe5d2,
187        "navigation" => 0xe55d,
188        "tune" => 0xe429,
189        _ => return None,
190    };
191
192    char::from_u32(codepoint)
193}
194
195fn material_symbol_fragment<'a>(name: impl text::IntoFragment<'a>) -> text::Fragment<'a> {
196    let fragment = name.into_fragment();
197
198    material_symbol_codepoint(fragment.as_ref())
199        .map(|codepoint| text::Fragment::Owned(codepoint.to_string()))
200        .unwrap_or(fragment)
201}
202
203fn is_cjk_codepoint(character: char) -> bool {
204    matches!(
205        character,
206        '\u{2E80}'..='\u{2EFF}'
207            | '\u{3000}'..='\u{303F}'
208            | '\u{3040}'..='\u{30FF}'
209            | '\u{3100}'..='\u{312F}'
210            | '\u{31A0}'..='\u{31BF}'
211            | '\u{31F0}'..='\u{31FF}'
212            | '\u{3400}'..='\u{4DBF}'
213            | '\u{4E00}'..='\u{9FFF}'
214            | '\u{AC00}'..='\u{D7AF}'
215            | '\u{F900}'..='\u{FAFF}'
216            | '\u{20000}'..='\u{2A6DF}'
217            | '\u{2A700}'..='\u{2B73F}'
218            | '\u{2B740}'..='\u{2B81F}'
219            | '\u{2B820}'..='\u{2CEAF}'
220            | '\u{2CEB0}'..='\u{2EBEF}'
221            | '\u{30000}'..='\u{323AF}'
222    )
223}
224
225#[cfg(any(target_arch = "wasm32", test))]
226fn is_supported_web_font(bytes: &[u8]) -> bool {
227    bytes.starts_with(&[0x00, 0x01, 0x00, 0x00])
228        || bytes.starts_with(b"OTTO")
229        || bytes.starts_with(b"ttcf")
230}
231
232pub fn icon<'a, Renderer>(name: impl text::IntoFragment<'a>, size: f32) -> Text<'a, Theme, Renderer>
233where
234    Renderer: core_text::Renderer,
235    Font: Into<Renderer::Font>,
236{
237    Text::new(material_symbol_fragment(name))
238        .font(MATERIAL_SYMBOLS_ROUNDED)
239        .size(size)
240        .line_height(LineHeight::Absolute(size.into()))
241        .shaping(text::Shaping::Advanced)
242}
243
244pub fn filled_icon<'a, Renderer>(
245    name: impl text::IntoFragment<'a>,
246    size: f32,
247) -> Text<'a, Theme, Renderer>
248where
249    Renderer: core_text::Renderer,
250    Font: Into<Renderer::Font>,
251{
252    Text::new(material_symbol_fragment(name))
253        .font(MATERIAL_SYMBOLS_ROUNDED_FILLED)
254        .size(size)
255        .line_height(LineHeight::Absolute(size.into()))
256        .shaping(text::Shaping::Advanced)
257}
258
259#[cfg(test)]
260#[path = "../../tests/design/fonts.rs"]
261mod tests;