Skip to main content

azul_layout/
icon.rs

1//! Default icon resolver implementations for Azul
2//!
3//! This module provides the standard callback implementations for icon resolution.
4//! The core types and resolution infrastructure are in `azul_core::icon`.
5//!
6//! # Usage
7//!
8//! ```rust,ignore
9//! use azul_core::icon::IconProviderHandle;
10//! use azul_layout::icon::{default_icon_resolver, ImageIconData, FontIconData};
11//!
12//! // Create provider with the default resolver
13//! let provider = IconProviderHandle::with_resolver(default_icon_resolver);
14//!
15//! // Register an image icon
16//! provider.register_icon("app-images", "logo", RefAny::new(ImageIconData { 
17//!     image: image_ref, width: 32.0, height: 32.0 
18//! }));
19//!
20//! // Register a font icon
21//! provider.register_icon("material-icons", "home", RefAny::new(FontIconData {
22//!     font: font_ref, icon_char: "\u{e88a}".to_string()
23//! }));
24//! ```
25
26use alloc::{
27    string::{String, ToString},
28    vec::Vec,
29};
30
31use azul_css::{
32    system::SystemStyle,
33    props::basic::{FontRef, StyleFontFamily, StyleFontFamilyVec},
34    props::basic::length::FloatValue,
35    props::layout::{LayoutWidth, LayoutHeight},
36    props::property::CssProperty,
37    props::style::filter::{StyleFilter, StyleFilterVec, StyleColorMatrix},
38    props::style::text::StyleTextColor,
39    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
40    css::{Css, CssPropertyValue},
41};
42
43use azul_core::{
44    dom::{Dom, NodeData},
45    icon::IconProviderHandle,
46    refany::{OptionRefAny, RefAny},
47    resources::ImageRef,
48    styled_dom::StyledDom,
49};
50
51// ============================================================================
52// Icon Data Marker Structs (for RefAny::downcast)
53// ============================================================================
54
55/// Image-based icon data stored in `RefAny` for the icon resolver.
56///
57/// Pass to `register_image_icon` or wrap in `RefAny::new(...)` and register
58/// directly via `IconProviderHandle::register_icon`.
59#[derive(Debug)]
60pub struct ImageIconData {
61    pub image: ImageRef,
62    /// Width duplicated from `ImageRef` at registration time
63    pub width: f32,
64    /// Height duplicated from `ImageRef` at registration time
65    pub height: f32,
66}
67
68/// Font-based icon data stored in `RefAny` for the icon resolver.
69///
70/// Pass to `register_font_icon` or wrap in `RefAny::new(...)` and register
71/// directly via `IconProviderHandle::register_icon`.
72#[derive(Debug)]
73pub struct FontIconData {
74    pub font: FontRef,
75    /// The character/codepoint for this specific icon (e.g., "\u{e88a}" for home)
76    pub icon_char: String,
77}
78
79// ============================================================================
80// Default Icon Resolver
81// ============================================================================
82
83/// Default icon resolver that handles both image and font icons.
84///
85/// Resolution logic:
86/// 1. If `icon_data` is None -> return empty div (icon not found)
87/// 2. If `icon_data` contains `ImageIconData` -> render as image
88/// 3. If `icon_data` contains `FontIconData` -> render as text with font
89/// 4. Unknown data type -> return empty div
90///
91/// Styles from the original icon DOM are copied to the result,
92/// filtered based on `SystemStyle` preferences.
93#[must_use] pub extern "C" fn default_icon_resolver(
94    icon_data: OptionRefAny,
95    original_icon_dom: &StyledDom,
96    system_style: &SystemStyle,
97) -> StyledDom {
98    // No icon found → empty div
99    let Some(mut data) = icon_data.into_option() else {
100        let mut dom = Dom::create_div();
101        return StyledDom::create(&mut dom, Css::empty());
102    };
103    
104    // Try ImageIconData
105    if let Some(img) = data.downcast_ref::<ImageIconData>() {
106        return create_image_icon_from_original(&img, original_icon_dom, system_style);
107    }
108    
109    // Try FontIconData
110    if let Some(font_icon) = data.downcast_ref::<FontIconData>() {
111        return create_font_icon_from_original(&font_icon, original_icon_dom, system_style);
112    }
113    
114    // Unknown data type → empty div
115    let mut dom = Dom::create_div();
116    StyledDom::create(&mut dom, Css::empty())
117}
118
119// Icon DOM Creation (from original)
120
121/// Create a `StyledDom` for an image-based icon, copying styles from original.
122///
123/// Applies SystemStyle-aware modifications:
124/// - Grayscale filter if `prefer_grayscale` is true
125/// - Tint color overlay if `tint_color` is set
126fn create_image_icon_from_original(
127    img: &ImageIconData,
128    original: &StyledDom,
129    system_style: &SystemStyle,
130) -> StyledDom {
131    let mut dom = Dom::create_image(img.image.clone());
132    
133    // Copy appropriate styles from original
134    if let Some(original_node) = original.node_data.as_ref().first() {
135        let mut props_vec = copy_appropriate_styles_vec(original_node);
136        
137        // Add default dimensions if not specified in original styles
138        let has_width = props_vec.iter().any(|p| matches!(&p.property, CssProperty::Width(_)));
139        let has_height = props_vec.iter().any(|p| matches!(&p.property, CssProperty::Height(_)));
140        
141        if !has_width {
142            props_vec.push(CssPropertyWithConditions::simple(
143                CssProperty::width(LayoutWidth::px(img.width))
144            ));
145        }
146        if !has_height {
147            props_vec.push(CssPropertyWithConditions::simple(
148                CssProperty::height(LayoutHeight::px(img.height))
149            ));
150        }
151        
152        // Apply SystemStyle-aware filters
153        apply_icon_style_filters(&mut props_vec, system_style);
154        
155        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
156        
157        // Copy accessibility info
158        if let Some(a11y) = original_node.get_accessibility_info() {
159            dom = dom.with_accessibility_info(a11y.clone());
160        }
161    } else {
162        // No original node, use default dimensions
163        let mut props_vec = vec![
164            CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(img.width))),
165            CssPropertyWithConditions::simple(CssProperty::height(LayoutHeight::px(img.height))),
166        ];
167        
168        // Apply SystemStyle-aware filters even without original node
169        apply_icon_style_filters(&mut props_vec, system_style);
170        
171        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
172    }
173    
174    StyledDom::create(&mut dom, Css::empty())
175}
176
177/// Create a `StyledDom` for a font-based icon, copying styles from original.
178///
179/// Applies SystemStyle-aware modifications:
180/// - Text color override if `inherit_text_color` is true
181/// - Tint color if `tint_color` is set
182fn create_font_icon_from_original(
183    font_icon: &FontIconData,
184    original: &StyledDom,
185    system_style: &SystemStyle,
186) -> StyledDom {
187    let mut dom = Dom::create_text(font_icon.icon_char.clone());
188    
189    // Add font family
190    let font_prop = CssPropertyWithConditions::simple(
191        CssProperty::font_family(StyleFontFamilyVec::from_vec(vec![
192            StyleFontFamily::Ref(font_icon.font.clone())
193        ]))
194    );
195    
196    if let Some(original_node) = original.node_data.as_ref().first() {
197        let mut props_vec = copy_appropriate_styles_vec(original_node);
198        props_vec.push(font_prop);
199        
200        // Apply SystemStyle-aware color modifications for font icons
201        apply_font_icon_color(&mut props_vec, system_style);
202        
203        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
204        
205        // Copy accessibility info
206        if let Some(a11y) = original_node.get_accessibility_info() {
207            dom = dom.with_accessibility_info(a11y.clone());
208        }
209    } else {
210        // No original node, just set the font
211        let mut props_vec = vec![font_prop];
212        
213        // Apply SystemStyle-aware color modifications
214        apply_font_icon_color(&mut props_vec, system_style);
215        
216        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
217    }
218    
219    StyledDom::create(&mut dom, Css::empty())
220}
221
222/// Copy styles from original node
223/// Returns a Vec for easier manipulation
224fn copy_appropriate_styles_vec(
225    original_node: &NodeData,
226) -> Vec<CssPropertyWithConditions> {
227    // Reconstruct the legacy flat list from the unified Css store.
228    original_node
229        .get_style()
230        .iter_inline_properties()
231        .map(|(prop, conds)| CssPropertyWithConditions {
232            property: prop.clone(),
233            apply_if: conds.clone(),
234        })
235        .collect()
236}
237
238/// Apply SystemStyle-aware filters to icon properties.
239///
240/// This adds CSS filters based on accessibility and theming settings:
241/// - Grayscale filter if `prefer_grayscale` is true
242fn apply_icon_style_filters(
243    props_vec: &mut Vec<CssPropertyWithConditions>,
244    system_style: &SystemStyle,
245) {
246    let icon_style = &system_style.icon_style;
247    
248    // Collect filters to apply
249    let mut filters = Vec::new();
250    
251    // Grayscale filter: Uses a color matrix that converts to grayscale
252    // Standard luminance weights: R*0.2126 + G*0.7152 + B*0.0722
253    if icon_style.prefer_grayscale {
254        // Grayscale color matrix (4x5):
255        // [0.2126, 0.7152, 0.0722, 0, 0]  <- R output
256        // [0.2126, 0.7152, 0.0722, 0, 0]  <- G output
257        // [0.2126, 0.7152, 0.0722, 0, 0]  <- B output
258        // [0,      0,      0,      1, 0]  <- A output
259        let grayscale_matrix = StyleColorMatrix {
260            m0: FloatValue::new(0.2126),
261            m1: FloatValue::new(0.7152),
262            m2: FloatValue::new(0.0722),
263            m3: FloatValue::new(0.0),
264            m4: FloatValue::new(0.0),
265            m5: FloatValue::new(0.2126),
266            m6: FloatValue::new(0.7152),
267            m7: FloatValue::new(0.0722),
268            m8: FloatValue::new(0.0),
269            m9: FloatValue::new(0.0),
270            m10: FloatValue::new(0.2126),
271            m11: FloatValue::new(0.7152),
272            m12: FloatValue::new(0.0722),
273            m13: FloatValue::new(0.0),
274            m14: FloatValue::new(0.0),
275            m15: FloatValue::new(0.0),
276            m16: FloatValue::new(0.0),
277            m17: FloatValue::new(0.0),
278            m18: FloatValue::new(1.0),
279            m19: FloatValue::new(0.0),
280        };
281        filters.push(StyleFilter::ColorMatrix(grayscale_matrix));
282    }
283    
284    // Apply tint color as a flood filter if specified
285    if let azul_css::props::basic::color::OptionColorU::Some(tint) = &icon_style.tint_color {
286        filters.push(StyleFilter::Flood(*tint));
287    }
288    
289    // Add filters if any were collected
290    if !filters.is_empty() {
291        props_vec.push(CssPropertyWithConditions::simple(
292            CssProperty::Filter(CssPropertyValue::Exact(StyleFilterVec::from_vec(filters)))
293        ));
294    }
295}
296
297/// Apply SystemStyle-aware color modifications for font icons.
298///
299/// Font icons can use text color directly, so we can:
300/// - Apply tint color as text color
301/// - Inherit text color from parent
302fn apply_font_icon_color(
303    props_vec: &mut Vec<CssPropertyWithConditions>,
304    system_style: &SystemStyle,
305) {
306    let icon_style = &system_style.icon_style;
307    
308    // If tint color is specified, use it as the text color
309    if let azul_css::props::basic::color::OptionColorU::Some(tint) = &icon_style.tint_color {
310        props_vec.push(CssPropertyWithConditions::simple(
311            CssProperty::TextColor(CssPropertyValue::Exact(StyleTextColor { inner: *tint }))
312        ));
313    }
314    // Note: inherit_text_color doesn't need explicit handling - text color
315    // is inherited by default in CSS. We only need to NOT override it.
316}
317
318// IconProviderHandle Helper Functions
319
320/// Register an image icon in a pack
321pub fn register_image_icon(provider: &mut IconProviderHandle, pack_name: &str, icon_name: &str, image: ImageRef) {
322    // Get dimensions from ImageRef
323    let size = image.get_size();
324    let data = ImageIconData { 
325        image, 
326        width: size.width, 
327        height: size.height,
328    };
329    provider.register_icon(pack_name, icon_name, RefAny::new(data));
330}
331
332/// Register icons from a ZIP file (file names become icon names)
333#[cfg(feature = "zip")]
334pub fn register_icons_from_zip(provider: &mut IconProviderHandle, pack_name: &str, zip_bytes: &[u8]) {
335    for (icon_name, image, width, height) in load_images_from_zip(zip_bytes) {
336        let data = ImageIconData { image, width, height };
337        provider.register_icon(pack_name, &icon_name, RefAny::new(data));
338    }
339}
340
341#[cfg(not(feature = "zip"))]
342pub fn register_icons_from_zip(_provider: &mut IconProviderHandle, _pack_name: &str, _zip_bytes: &[u8]) {
343    // ZIP support not enabled
344}
345
346/// Register a font icon in a pack
347pub fn register_font_icon(provider: &mut IconProviderHandle, pack_name: &str, icon_name: &str, font: FontRef, icon_char: &str) {
348    let data = FontIconData { 
349        font, 
350        icon_char: icon_char.to_string() 
351    };
352    provider.register_icon(pack_name, icon_name, RefAny::new(data));
353}
354
355// ============================================================================
356// ZIP Support
357// ============================================================================
358
359/// Load all images from a ZIP file, returning (`icon_name`, `ImageRef`, width, height)
360#[cfg(all(feature = "zip", feature = "image_decoding"))]
361#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
362fn load_images_from_zip(zip_bytes: &[u8]) -> Vec<(String, ImageRef, f32, f32)> {
363    use crate::zip::{ZipFile, ZipReadConfig};
364    use crate::image::decode::{decode_raw_image_from_any_bytes, ResultRawImageDecodeImageError};
365    use std::path::Path;
366    
367    let mut result = Vec::new();
368    let config = ZipReadConfig::default();
369    let Ok(entries) = ZipFile::list(zip_bytes, &config) else {
370        return result;
371    };
372    
373    for entry in &entries {
374        if entry.path.ends_with('/') { continue; } // Skip directories
375        
376        let Ok(Some(file_bytes)) = ZipFile::get_single_file(zip_bytes, entry, &config) else {
377            continue;
378        };
379        
380        // Decode as image
381        if let ResultRawImageDecodeImageError::Ok(raw_image) = decode_raw_image_from_any_bytes(&file_bytes) {
382            // Icon name = filename without extension
383            let path = Path::new(&entry.path);
384            let icon_name = path.file_stem()
385                .and_then(|s| s.to_str())
386                .unwrap_or("")
387                .to_string();
388            
389            let width = raw_image.width as f32;
390            let height = raw_image.height as f32;
391            
392            if let Some(image) = ImageRef::new_rawimage(raw_image) {
393                result.push((icon_name, image, width, height));
394            }
395        }
396    }
397    
398    result
399}
400
401#[cfg(not(all(feature = "zip", feature = "image_decoding")))]
402fn load_images_from_zip(_zip_bytes: &[u8]) -> Vec<(String, ImageRef, f32, f32)> {
403    Vec::new()
404}
405
406// ============================================================================
407// Material Icons Registration
408// ============================================================================
409
410/// Register all Material Icons in the provider.
411/// 
412/// This registers all 2234 Material Icons from the `material-icons` crate.
413/// Each icon is registered under the "material-icons" pack with its HTML name
414/// (e.g., "home", "settings", "`arrow_back`", etc.).
415/// 
416/// Requires the "icons" feature with material-icons crate.
417#[cfg(feature = "icons")]
418pub fn register_material_icons(provider: &mut IconProviderHandle, font: &FontRef) {
419    use material_icons::{ALL_ICONS, icon_to_char, icon_to_html_name};
420    
421    // Register all Material Icons with their Unicode codepoints
422    for icon in &ALL_ICONS {
423        let icon_char = icon_to_char(*icon);
424        let name = icon_to_html_name(icon);
425        
426        let data = FontIconData {
427            font: font.clone(),
428            icon_char: icon_char.to_string(),
429        };
430        provider.register_icon("material-icons", name, RefAny::new(data));
431    }
432}
433
434#[cfg(not(feature = "icons"))]
435pub fn register_material_icons(_provider: &mut IconProviderHandle, _font: FontRef) {
436    // Icons feature not enabled
437}
438
439/// Load the embedded Material Icons font and register all standard icons.
440/// 
441/// This uses the `material-icons` crate which embeds the Material Icons TTF font.
442/// The font is Apache 2.0 licensed by Google.
443/// 
444/// Returns true if registration was successful.
445/// Register all Material Icons from caller-supplied TTF bytes.
446///
447/// The font bytes are NOT embedded here. `azul-doc codegen all` generates
448/// `target/codegen/material_icons.ttf.br`, and `azul-doc` builds (depends
449/// on) `azul-layout` — so `include!`ing that generated artifact in this
450/// crate is a build cycle (it bit us on `cargo clean`). The `include!` +
451/// brotli-decompression live in `azul-dll` (downstream of codegen), which
452/// passes the decompressed TTF in here.
453#[cfg(all(feature = "icons", feature = "text_layout"))]
454pub fn register_embedded_material_icons(
455    provider: &mut IconProviderHandle,
456    font_bytes: &[u8],
457) -> bool {
458    use crate::font::parsed::ParsedFont;
459    use crate::parsed_font_to_font_ref;
460
461    let mut warnings = Vec::new();
462    let Some(parsed_font) = ParsedFont::from_bytes(font_bytes, 0, &mut warnings) else {
463        return false;
464    };
465
466    let font_ref = parsed_font_to_font_ref(parsed_font);
467    register_material_icons(provider, &font_ref);
468
469    true
470}
471
472#[cfg(not(all(feature = "icons", feature = "text_layout")))]
473pub fn register_embedded_material_icons(
474    _provider: &mut IconProviderHandle,
475    _font_bytes: &[u8],
476) -> bool {
477    // Icons or text_layout feature not enabled
478    false
479}
480
481// ============================================================================
482// Convenience Functions
483// ============================================================================
484
485/// Create an `IconProviderHandle` with the default resolver.
486pub fn create_default_icon_provider() -> IconProviderHandle {
487    IconProviderHandle::with_resolver(default_icon_resolver)
488}
489
490// The embedded Material Icons font bytes (the `include!` of the
491// codegen-generated `target/codegen/material_icons.ttf.br` + brotli
492// decompression) deliberately live in `azul-dll`, not here — see
493// `register_embedded_material_icons` above for why (build-cycle: azul-doc
494// builds azul-layout to generate that artifact).
495
496// ============================================================================
497// Tests
498// ============================================================================
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn test_default_resolver_no_data() {
506        let style = SystemStyle::default();
507        let original = StyledDom::default();
508        
509        let result = default_icon_resolver(OptionRefAny::None, &original, &style);
510        
511        // Without data, should return empty div StyledDom
512        assert_eq!(result.node_data.as_ref().len(), 1);
513    }
514    
515    #[test]
516    fn test_create_default_provider() {
517        let provider = create_default_icon_provider();
518        assert!(provider.list_packs().is_empty());
519    }
520}
521
522#[cfg(test)]
523#[allow(
524    clippy::float_cmp,
525    clippy::items_after_statements,
526    clippy::redundant_clone,
527    clippy::cast_possible_truncation,
528    clippy::cast_precision_loss,
529    clippy::cast_sign_loss,
530    clippy::cast_lossless,
531    clippy::unreadable_literal,
532    clippy::too_many_lines,
533    clippy::many_single_char_names,
534    clippy::similar_names,
535    unused_qualifications,
536    unreachable_pub,
537    private_interfaces
538)] // pedantic lints are noise in adversarial test code
539mod autotest_generated {
540    use azul_core::{
541        a11y::SmallAriaInfo,
542        dom::NodeType,
543        resources::RawImageFormat,
544    };
545    use azul_css::props::basic::color::{ColorU, OptionColorU};
546
547    use super::*;
548
549    // ---------------------------------------------------------------------
550    // helpers
551    // ---------------------------------------------------------------------
552
553    /// A `FontRef` whose `parsed` pointer addresses a `'static` byte and whose
554    /// destructor is a no-op, so nothing is freed on drop. Sound here because
555    /// nothing on the icon-resolution path ever dereferences `parsed` (only
556    /// `cpurender::raster` does, and that is not reached from `StyledDom::create`).
557    fn dummy_font_ref() -> FontRef {
558        static DUMMY_FONT_DATA: u8 = 0;
559        extern "C" fn dummy_destructor(_: *mut core::ffi::c_void) {}
560        FontRef::new(
561            core::ptr::addr_of!(DUMMY_FONT_DATA).cast::<core::ffi::c_void>(),
562            dummy_destructor,
563        )
564    }
565
566    /// A null (non-decoded) `ImageRef` of the given pixel size — `get_size()`
567    /// reports exactly `width` / `height`, with no allocation.
568    fn null_img(width: usize, height: usize) -> ImageRef {
569        ImageRef::null_image(width, height, RawImageFormat::RGBA8, Vec::new())
570    }
571
572    /// `ImageIconData` with explicitly-chosen (possibly hostile) f32 dimensions.
573    fn image_icon(width: f32, height: f32) -> ImageIconData {
574        ImageIconData {
575            image: null_img(1, 1),
576            width,
577            height,
578        }
579    }
580
581    fn font_icon(icon_char: &str) -> FontIconData {
582        FontIconData {
583            font: dummy_font_ref(),
584            icon_char: icon_char.to_string(),
585        }
586    }
587
588    fn grayscale_style() -> SystemStyle {
589        let mut s = SystemStyle::default();
590        s.icon_style.prefer_grayscale = true;
591        s
592    }
593
594    fn tint_style(color: ColorU) -> SystemStyle {
595        let mut s = SystemStyle::default();
596        s.icon_style.tint_color = OptionColorU::Some(color);
597        s
598    }
599
600    /// A "normal" original icon DOM: a single div carrying `props` as inline style.
601    fn original_with(props: Vec<CssPropertyWithConditions>) -> StyledDom {
602        let mut dom = Dom::create_div();
603        dom.root
604            .set_css_props(CssPropertyWithConditionsVec::from_vec(props));
605        StyledDom::create(&mut dom, Css::empty())
606    }
607
608    /// A degenerate `StyledDom` with **zero** nodes — drives the `else` branch of
609    /// `create_{image,font}_icon_from_original`, which `StyledDom::default()` never
610    /// reaches (it always has a body node).
611    fn original_without_nodes() -> StyledDom {
612        StyledDom {
613            node_data: Vec::new().into(),
614            ..StyledDom::default()
615        }
616    }
617
618    /// Every inline property on every node of the result, in document order.
619    /// (Collected across all nodes rather than `node_data[0]` so the assertions
620    /// survive any future anonymous-node insertion in `StyledDom::create`.)
621    fn all_props(dom: &StyledDom) -> Vec<CssPropertyWithConditions> {
622        dom.node_data
623            .as_ref()
624            .iter()
625            .flat_map(|nd| {
626                nd.get_style()
627                    .iter_inline_properties()
628                    .map(|(property, apply_if)| CssPropertyWithConditions {
629                        property: property.clone(),
630                        apply_if: apply_if.clone(),
631                    })
632                    .collect::<Vec<_>>()
633            })
634            .collect()
635    }
636
637    fn width_px(dom: &StyledDom) -> Option<f32> {
638        all_props(dom).into_iter().find_map(|p| match p.property {
639            CssProperty::Width(CssPropertyValue::Exact(LayoutWidth::Px(px))) => Some(px.number.get()),
640            _ => None,
641        })
642    }
643
644    fn height_px(dom: &StyledDom) -> Option<f32> {
645        all_props(dom).into_iter().find_map(|p| match p.property {
646            CssProperty::Height(CssPropertyValue::Exact(LayoutHeight::Px(px))) => {
647                Some(px.number.get())
648            }
649            _ => None,
650        })
651    }
652
653    fn count_widths(dom: &StyledDom) -> usize {
654        all_props(dom)
655            .iter()
656            .filter(|p| matches!(p.property, CssProperty::Width(_)))
657            .count()
658    }
659
660    fn text_of(dom: &StyledDom) -> Option<String> {
661        dom.node_data
662            .as_ref()
663            .iter()
664            .find_map(|nd| match nd.get_node_type() {
665                NodeType::Text(t) => Some(t.as_str().to_string()),
666                _ => None,
667            })
668    }
669
670    fn has_image_node(dom: &StyledDom) -> bool {
671        dom.node_data
672            .as_ref()
673            .iter()
674            .any(|nd| matches!(nd.get_node_type(), NodeType::Image(_)))
675    }
676
677    /// All `StyleFilter`s across every `filter:` property in the list.
678    fn filters_of(props: &[CssPropertyWithConditions]) -> Vec<StyleFilter> {
679        props
680            .iter()
681            .filter_map(|p| match &p.property {
682                CssProperty::Filter(CssPropertyValue::Exact(v)) => Some(v.as_ref().to_vec()),
683                _ => None,
684            })
685            .flatten()
686            .collect()
687    }
688
689    fn text_color_of(props: &[CssPropertyWithConditions]) -> Option<ColorU> {
690        props.iter().find_map(|p| match &p.property {
691            CssProperty::TextColor(CssPropertyValue::Exact(c)) => Some(c.inner),
692            _ => None,
693        })
694    }
695
696    fn resolve(data: RefAny, original: &StyledDom, style: &SystemStyle) -> StyledDom {
697        default_icon_resolver(OptionRefAny::Some(data), original, style)
698    }
699
700    // ---------------------------------------------------------------------
701    // default_icon_resolver — dispatch
702    // ---------------------------------------------------------------------
703
704    #[test]
705    fn resolver_none_yields_single_unstyled_div() {
706        let out = default_icon_resolver(
707            OptionRefAny::None,
708            &StyledDom::default(),
709            &SystemStyle::default(),
710        );
711        assert_eq!(out.node_data.as_ref().len(), 1);
712        assert!(matches!(
713            out.node_data.as_ref()[0].get_node_type(),
714            NodeType::Div
715        ));
716        // The "not found" placeholder must carry no styling at all — in particular
717        // it must not inherit the original's width/height.
718        assert!(all_props(&out).is_empty());
719    }
720
721    #[test]
722    fn resolver_unknown_refany_type_yields_empty_div() {
723        // A RefAny holding neither ImageIconData nor FontIconData must fall through
724        // to the placeholder rather than panicking on a bad downcast.
725        struct NotAnIconAtAll {
726            _payload: [u64; 4],
727        }
728        let data = RefAny::new(NotAnIconAtAll { _payload: [7; 4] });
729        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
730
731        assert_eq!(out.node_data.as_ref().len(), 1);
732        assert!(matches!(
733            out.node_data.as_ref()[0].get_node_type(),
734            NodeType::Div
735        ));
736        assert!(!has_image_node(&out));
737        assert!(text_of(&out).is_none());
738    }
739
740    #[test]
741    fn resolver_image_icon_yields_image_node_with_default_dimensions() {
742        let out = resolve(
743            RefAny::new(image_icon(32.0, 24.0)),
744            &StyledDom::default(),
745            &SystemStyle::default(),
746        );
747        assert!(has_image_node(&out));
748        assert_eq!(width_px(&out), Some(32.0));
749        assert_eq!(height_px(&out), Some(24.0));
750    }
751
752    #[test]
753    fn resolver_font_icon_yields_text_node_with_font_family() {
754        let font = dummy_font_ref();
755        let data = RefAny::new(FontIconData {
756            font: font.clone(),
757            icon_char: "\u{e88a}".to_string(),
758        });
759        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
760
761        assert_eq!(out.node_data.as_ref().len(), 1);
762        assert_eq!(text_of(&out).as_deref(), Some("\u{e88a}"));
763
764        // The registered font must be the one that ends up in `font-family`.
765        let has_font = all_props(&out).iter().any(|p| match &p.property {
766            CssProperty::FontFamily(CssPropertyValue::Exact(families)) => families
767                .as_ref()
768                .iter()
769                .any(|f| matches!(f, StyleFontFamily::Ref(fr) if *fr == font)),
770            _ => false,
771        });
772        assert!(has_font, "font-family with the icon's FontRef must be set");
773    }
774
775    // ---------------------------------------------------------------------
776    // default_icon_resolver — degenerate originals
777    // ---------------------------------------------------------------------
778
779    #[test]
780    fn image_icon_with_node_less_original_still_gets_dimensions() {
781        // `original.node_data.first()` is None -> the fallback branch must still
782        // produce a fully-sized image instead of panicking / emitting no style.
783        let original = original_without_nodes();
784        let out = resolve(
785            RefAny::new(image_icon(16.0, 16.0)),
786            &original,
787            &SystemStyle::default(),
788        );
789        assert!(has_image_node(&out));
790        assert_eq!(width_px(&out), Some(16.0));
791        assert_eq!(height_px(&out), Some(16.0));
792    }
793
794    #[test]
795    fn font_icon_with_node_less_original_still_gets_font() {
796        let original = original_without_nodes();
797        let out = resolve(RefAny::new(font_icon("A")), &original, &SystemStyle::default());
798        assert_eq!(text_of(&out).as_deref(), Some("A"));
799        assert!(all_props(&out)
800            .iter()
801            .any(|p| matches!(p.property, CssProperty::FontFamily(_))));
802    }
803
804    // ---------------------------------------------------------------------
805    // numeric limits: the icon dimensions are attacker-controlled f32s
806    // ---------------------------------------------------------------------
807
808    #[test]
809    fn image_icon_nan_dimensions_saturate_to_zero_without_panicking() {
810        // FloatValue stores `(v * 1000.0) as isize`; `NaN as isize` saturates to 0,
811        // so a NaN-sized icon degrades to a 0x0 box rather than poisoning layout.
812        let out = resolve(
813            RefAny::new(image_icon(f32::NAN, f32::NAN)),
814            &StyledDom::default(),
815            &SystemStyle::default(),
816        );
817        let (w, h) = (
818            width_px(&out).expect("width emitted"),
819            height_px(&out).expect("height emitted"),
820        );
821        assert!(w.is_finite() && h.is_finite(), "NaN must not survive into CSS");
822        assert_eq!(w, 0.0);
823        assert_eq!(h, 0.0);
824    }
825
826    #[test]
827    fn image_icon_infinite_dimensions_saturate_to_finite_values() {
828        let out = resolve(
829            RefAny::new(image_icon(f32::INFINITY, f32::NEG_INFINITY)),
830            &StyledDom::default(),
831            &SystemStyle::default(),
832        );
833        let w = width_px(&out).expect("width emitted");
834        let h = height_px(&out).expect("height emitted");
835        assert!(w.is_finite(), "+inf must saturate, got {w}");
836        assert!(h.is_finite(), "-inf must saturate, got {h}");
837        assert!(w > 0.0 && h < 0.0, "saturation must keep the sign");
838    }
839
840    #[test]
841    fn image_icon_negative_dimensions_are_passed_through_unclamped() {
842        // Documents current behaviour: the resolver does NOT reject negative sizes,
843        // it forwards them verbatim into `width` / `height`.
844        let out = resolve(
845            RefAny::new(image_icon(-32.0, -1.5)),
846            &StyledDom::default(),
847            &SystemStyle::default(),
848        );
849        assert_eq!(width_px(&out), Some(-32.0));
850        assert_eq!(height_px(&out), Some(-1.5));
851    }
852
853    #[test]
854    fn register_image_icon_with_usize_max_size_saturates() {
855        // `ImageRef::get_size()` casts usize -> f32 (1.8e19); FloatValue then scales
856        // by 1000 and casts to isize, which must saturate rather than wrap/panic.
857        let mut provider = create_default_icon_provider();
858        register_image_icon(
859            &mut provider,
860            "huge",
861            "big",
862            null_img(usize::MAX, usize::MAX),
863        );
864        let data = provider.lookup("big").expect("icon registered");
865        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
866
867        let w = width_px(&out).expect("width emitted");
868        assert!(w.is_finite() && w > 0.0, "usize::MAX size must saturate finitely, got {w}");
869    }
870
871    #[test]
872    fn image_icon_zero_size_is_preserved() {
873        let out = resolve(
874            RefAny::new(image_icon(0.0, 0.0)),
875            &StyledDom::default(),
876            &SystemStyle::default(),
877        );
878        assert_eq!(width_px(&out), Some(0.0));
879        assert_eq!(height_px(&out), Some(0.0));
880    }
881
882    // ---------------------------------------------------------------------
883    // style copying / precedence
884    // ---------------------------------------------------------------------
885
886    #[test]
887    fn original_dimensions_win_over_image_defaults() {
888        let original = original_with(vec![
889            CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(999.0))),
890            CssPropertyWithConditions::simple(CssProperty::height(LayoutHeight::px(888.0))),
891        ]);
892        let out = resolve(
893            RefAny::new(image_icon(32.0, 32.0)),
894            &original,
895            &SystemStyle::default(),
896        );
897
898        assert_eq!(width_px(&out), Some(999.0));
899        assert_eq!(height_px(&out), Some(888.0));
900        // ...and the 32px default must not be appended as a *second* width.
901        assert_eq!(count_widths(&out), 1);
902    }
903
904    #[test]
905    fn copy_appropriate_styles_vec_round_trips_exactly() {
906        // encode (set_css_props -> Css) == decode (copy_appropriate_styles_vec)
907        let props = vec![
908            CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(12.5))),
909            CssPropertyWithConditions::simple(CssProperty::height(LayoutHeight::px(7.0))),
910        ];
911        let mut nd = NodeData::create_div();
912        nd.set_css_props(CssPropertyWithConditionsVec::from_vec(props.clone()));
913
914        assert_eq!(copy_appropriate_styles_vec(&nd), props);
915    }
916
917    #[test]
918    fn copy_appropriate_styles_vec_of_unstyled_node_is_empty() {
919        let nd = NodeData::create_div();
920        assert!(copy_appropriate_styles_vec(&nd).is_empty());
921    }
922
923    #[test]
924    fn copy_appropriate_styles_vec_preserves_order_of_many_props() {
925        // 512 same-typed declarations: nothing may be deduplicated or reordered,
926        // otherwise the last-wins cascade of the copied icon style would flip.
927        let props: Vec<CssPropertyWithConditions> = (0..512u32)
928            .map(|i| {
929                CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(i as f32)))
930            })
931            .collect();
932        let mut nd = NodeData::create_div();
933        nd.set_css_props(CssPropertyWithConditionsVec::from_vec(props.clone()));
934
935        let copied = copy_appropriate_styles_vec(&nd);
936        assert_eq!(copied.len(), 512);
937        assert_eq!(copied, props);
938    }
939
940    #[test]
941    fn accessibility_info_is_copied_onto_the_resolved_icon() {
942        let mut dom = Dom::create_div().with_accessibility_info(SmallAriaInfo::label("Save").to_full_info());
943        let original = StyledDom::create(&mut dom, Css::empty());
944
945        let out = resolve(
946            RefAny::new(image_icon(8.0, 8.0)),
947            &original,
948            &SystemStyle::default(),
949        );
950
951        let a11y = out
952            .node_data
953            .as_ref()
954            .iter()
955            .find_map(azul_core::dom::NodeData::get_accessibility_info)
956            .expect("a11y info must survive icon resolution");
957        assert_eq!(
958            a11y.accessibility_name.as_ref().map(|s| s.as_str()),
959            Some("Save")
960        );
961    }
962
963    // ---------------------------------------------------------------------
964    // apply_icon_style_filters
965    // ---------------------------------------------------------------------
966
967    #[test]
968    fn icon_filters_default_style_adds_nothing() {
969        let mut props = Vec::new();
970        apply_icon_style_filters(&mut props, &SystemStyle::default());
971        assert!(props.is_empty(), "default SystemStyle must not synthesise a filter");
972    }
973
974    #[test]
975    fn icon_filters_grayscale_uses_quantised_luminance_matrix() {
976        let mut props = Vec::new();
977        apply_icon_style_filters(&mut props, &grayscale_style());
978
979        let filters = filters_of(&props);
980        assert_eq!(filters.len(), 1);
981        let StyleFilter::ColorMatrix(m) = &filters[0] else {
982            panic!("prefer_grayscale must emit a ColorMatrix filter, got {:?}", filters[0]);
983        };
984
985        // Rec.709 luminance weights, rounded through FloatValue's 1/1000 fixed point.
986        for r in [m.m0, m.m5, m.m10] {
987            assert!((r.get() - 0.2126).abs() < 0.001, "R weight {}", r.get());
988        }
989        for g in [m.m1, m.m6, m.m11] {
990            assert!((g.get() - 0.7152).abs() < 0.001, "G weight {}", g.get());
991        }
992        for b in [m.m2, m.m7, m.m12] {
993            assert!((b.get() - 0.0722).abs() < 0.001, "B weight {}", b.get());
994        }
995        // Alpha row must be pass-through, or grayscale icons would turn opaque/invisible.
996        assert_eq!(m.m18.get(), 1.0);
997        assert_eq!(m.m15.get(), 0.0);
998        assert_eq!(m.m19.get(), 0.0);
999
1000        // FloatValue truncates at 3 decimals: the 4th digit of 0.2126 is lost.
1001        assert_ne!(m.m0.get(), 0.2126);
1002    }
1003
1004    #[test]
1005    fn icon_filters_tint_emits_flood_even_when_fully_transparent() {
1006        // a == 0 is still forwarded — the resolver does not treat it as "no tint".
1007        let transparent = ColorU { r: 1, g: 2, b: 3, a: 0 };
1008        let mut props = Vec::new();
1009        apply_icon_style_filters(&mut props, &tint_style(transparent));
1010
1011        let filters = filters_of(&props);
1012        assert_eq!(filters.len(), 1);
1013        assert!(matches!(filters[0], StyleFilter::Flood(c) if c == transparent));
1014    }
1015
1016    #[test]
1017    fn icon_filters_grayscale_and_tint_are_ordered_matrix_then_flood() {
1018        let tint = ColorU { r: 255, g: 0, b: 128, a: 255 };
1019        let mut style = grayscale_style();
1020        style.icon_style.tint_color = OptionColorU::Some(tint);
1021
1022        let mut props = Vec::new();
1023        apply_icon_style_filters(&mut props, &style);
1024
1025        // Both filters must live in ONE `filter:` declaration (a second declaration
1026        // would overwrite the first in the cascade, silently dropping the grayscale).
1027        let filter_decls = props
1028            .iter()
1029            .filter(|p| matches!(p.property, CssProperty::Filter(_)))
1030            .count();
1031        assert_eq!(filter_decls, 1);
1032
1033        let filters = filters_of(&props);
1034        assert_eq!(filters.len(), 2);
1035        assert!(matches!(filters[0], StyleFilter::ColorMatrix(_)));
1036        assert!(matches!(filters[1], StyleFilter::Flood(c) if c == tint));
1037    }
1038
1039    #[test]
1040    fn icon_filters_preserve_pre_existing_properties() {
1041        let mut props = vec![CssPropertyWithConditions::simple(CssProperty::width(
1042            LayoutWidth::px(4.0),
1043        ))];
1044        apply_icon_style_filters(&mut props, &grayscale_style());
1045
1046        assert_eq!(props.len(), 2);
1047        assert!(matches!(props[0].property, CssProperty::Width(_)), "existing props must not be clobbered");
1048        assert!(matches!(props[1].property, CssProperty::Filter(_)));
1049    }
1050
1051    #[test]
1052    fn image_icon_grayscale_reaches_the_resolved_dom() {
1053        let out = resolve(
1054            RefAny::new(image_icon(10.0, 10.0)),
1055            &StyledDom::default(),
1056            &grayscale_style(),
1057        );
1058        let filters = filters_of(&all_props(&out));
1059        assert_eq!(filters.len(), 1);
1060        assert!(matches!(filters[0], StyleFilter::ColorMatrix(_)));
1061    }
1062
1063    // ---------------------------------------------------------------------
1064    // apply_font_icon_color
1065    // ---------------------------------------------------------------------
1066
1067    #[test]
1068    fn font_icon_color_default_style_adds_nothing() {
1069        let mut props = Vec::new();
1070        apply_font_icon_color(&mut props, &SystemStyle::default());
1071        assert!(props.is_empty());
1072    }
1073
1074    #[test]
1075    fn font_icon_color_inherit_text_color_alone_is_a_noop() {
1076        // Documented: inheritance is CSS's default, so `inherit_text_color` must
1077        // *not* synthesise a `color:` declaration (that would break inheritance).
1078        let mut style = SystemStyle::default();
1079        style.icon_style.inherit_text_color = true;
1080        let mut props = Vec::new();
1081        apply_font_icon_color(&mut props, &style);
1082        assert!(props.is_empty());
1083    }
1084
1085    #[test]
1086    fn font_icon_color_tint_becomes_text_color() {
1087        let tint = ColorU { r: 9, g: 8, b: 7, a: 6 };
1088        let mut props = Vec::new();
1089        apply_font_icon_color(&mut props, &tint_style(tint));
1090
1091        assert_eq!(props.len(), 1);
1092        assert_eq!(text_color_of(&props), Some(tint));
1093    }
1094
1095    #[test]
1096    fn font_icon_color_tint_wins_over_inherit_text_color() {
1097        let tint = ColorU { r: 1, g: 1, b: 1, a: 255 };
1098        let mut style = tint_style(tint);
1099        style.icon_style.inherit_text_color = true;
1100
1101        let mut props = Vec::new();
1102        apply_font_icon_color(&mut props, &style);
1103        assert_eq!(text_color_of(&props), Some(tint));
1104    }
1105
1106    #[test]
1107    fn font_icons_never_get_a_grayscale_filter() {
1108        // Font icons take the color path, not the filter path — a ColorMatrix here
1109        // would double-apply on top of the (inherited) text color.
1110        let out = resolve(
1111            RefAny::new(font_icon("\u{e88a}")),
1112            &StyledDom::default(),
1113            &grayscale_style(),
1114        );
1115        assert!(filters_of(&all_props(&out)).is_empty());
1116    }
1117
1118    // ---------------------------------------------------------------------
1119    // unicode / huge strings in the icon char
1120    // ---------------------------------------------------------------------
1121
1122    #[test]
1123    fn font_icon_empty_char_yields_empty_text_node() {
1124        let out = resolve(
1125            RefAny::new(font_icon("")),
1126            &StyledDom::default(),
1127            &SystemStyle::default(),
1128        );
1129        assert_eq!(text_of(&out).as_deref(), Some(""));
1130    }
1131
1132    #[test]
1133    fn font_icon_hostile_unicode_round_trips_verbatim() {
1134        // ZWJ emoji sequence, RTL override, combining marks, an embedded NUL and a
1135        // lone PUA codepoint: none may be normalised, truncated or panicked on.
1136        for s in [
1137            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}",
1138            "\u{202E}gnippilf\u{202C}",
1139            "e\u{0301}\u{0327}\u{0328}",
1140            "a\0b",
1141            "\u{F8FF}",
1142            "\u{FFFD}",
1143        ] {
1144            let out = resolve(
1145                RefAny::new(font_icon(s)),
1146                &StyledDom::default(),
1147                &SystemStyle::default(),
1148            );
1149            assert_eq!(text_of(&out).as_deref(), Some(s), "icon_char {s:?} was altered");
1150        }
1151    }
1152
1153    #[test]
1154    fn font_icon_huge_char_string_does_not_panic() {
1155        let huge = "\u{e88a}".repeat(65_536);
1156        let out = resolve(
1157            RefAny::new(font_icon(&huge)),
1158            &StyledDom::default(),
1159            &SystemStyle::default(),
1160        );
1161        assert_eq!(text_of(&out).map(|s| s.chars().count()), Some(65_536));
1162    }
1163
1164    // ---------------------------------------------------------------------
1165    // registration helpers
1166    // ---------------------------------------------------------------------
1167
1168    #[test]
1169    fn register_image_icon_lowercases_the_name_and_reads_size_from_the_imageref() {
1170        let mut provider = create_default_icon_provider();
1171        register_image_icon(&mut provider, "App-Images", "HOME", null_img(64, 32));
1172
1173        // pack names are case-sensitive, icon names are normalised to lowercase
1174        assert_eq!(provider.list_packs(), vec![String::from("App-Images")]);
1175        assert_eq!(
1176            provider.list_icons_in_pack("App-Images"),
1177            vec![String::from("home")]
1178        );
1179        assert!(provider.list_icons_in_pack("app-images").is_empty());
1180        assert!(provider.has_icon("hOmE"));
1181
1182        let data = provider.lookup("HOME").expect("case-insensitive lookup");
1183        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
1184        assert_eq!(width_px(&out), Some(64.0));
1185        assert_eq!(height_px(&out), Some(32.0));
1186    }
1187
1188    #[test]
1189    fn register_font_icon_accepts_empty_pack_and_icon_names() {
1190        let mut provider = create_default_icon_provider();
1191        register_font_icon(&mut provider, "", "", dummy_font_ref(), "");
1192
1193        assert_eq!(provider.list_packs(), vec![String::new()]);
1194        assert!(provider.has_icon(""));
1195        let data = provider.lookup("").expect("empty-named icon is still addressable");
1196        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
1197        assert_eq!(text_of(&out).as_deref(), Some(""));
1198    }
1199
1200    #[test]
1201    fn register_icon_handles_oversized_and_unicode_names() {
1202        let mut provider = create_default_icon_provider();
1203        let long_name = "n".repeat(10_000);
1204        register_font_icon(&mut provider, "p", &long_name, dummy_font_ref(), "x");
1205        assert!(provider.has_icon(&long_name));
1206
1207        // "İ" (U+0130) lowercases to TWO chars (i + U+0307); the key is the
1208        // lowercased form, so the dotless "i" must NOT match.
1209        register_font_icon(&mut provider, "p", "\u{130}", dummy_font_ref(), "y");
1210        let folded = "\u{130}".to_lowercase();
1211        assert!(provider.has_icon("\u{130}"));
1212        assert!(provider.has_icon(&folded));
1213        assert!(!provider.has_icon("i"));
1214    }
1215
1216    #[test]
1217    fn duplicate_icon_across_packs_resolves_to_the_alphabetically_first_pack() {
1218        // "First match wins" iterates a BTreeMap => pack *name* order, NOT the
1219        // registration order. Registering into "zzz" first must not shadow "aaa".
1220        let mut provider = create_default_icon_provider();
1221        register_image_icon(&mut provider, "zzz", "dup", null_img(1, 1));
1222        register_image_icon(&mut provider, "aaa", "dup", null_img(2, 2));
1223
1224        let data = provider.lookup("dup").expect("icon registered");
1225        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
1226        assert_eq!(
1227            width_px(&out),
1228            Some(2.0),
1229            "lookup must return the alphabetically-first pack's icon"
1230        );
1231    }
1232
1233    #[test]
1234    fn re_registering_an_icon_replaces_it_and_unregistering_drops_the_empty_pack() {
1235        let mut provider = create_default_icon_provider();
1236        register_image_icon(&mut provider, "p", "icon", null_img(1, 1));
1237        register_image_icon(&mut provider, "p", "ICON", null_img(5, 5));
1238
1239        assert_eq!(provider.list_icons_in_pack("p").len(), 1);
1240        let data = provider.lookup("icon").expect("icon registered");
1241        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
1242        assert_eq!(width_px(&out), Some(5.0));
1243
1244        provider.unregister_icon("p", "IcOn");
1245        assert!(!provider.has_icon("icon"));
1246        assert!(provider.list_packs().is_empty(), "empty pack must be removed");
1247    }
1248
1249    #[test]
1250    fn create_default_icon_provider_starts_empty_and_misses_resolve_to_a_placeholder() {
1251        let provider = create_default_icon_provider();
1252        assert!(provider.list_packs().is_empty());
1253        assert!(provider.lookup("nope").is_none());
1254        assert!(!provider.has_icon("nope"));
1255
1256        let out = default_icon_resolver(
1257            OptionRefAny::from(provider.lookup("nope")),
1258            &StyledDom::default(),
1259            &SystemStyle::default(),
1260        );
1261        assert_eq!(out.node_data.as_ref().len(), 1);
1262        assert!(all_props(&out).is_empty());
1263    }
1264
1265    // ---------------------------------------------------------------------
1266    // ZIP / font-bytes entry points (both cfg variants share these signatures)
1267    // ---------------------------------------------------------------------
1268
1269    #[test]
1270    fn load_images_from_zip_rejects_malformed_archives() {
1271        assert!(load_images_from_zip(&[]).is_empty());
1272        assert!(load_images_from_zip(b"definitely not a zip file").is_empty());
1273        // valid local-file-header magic, truncated body
1274        assert!(load_images_from_zip(b"PK\x03\x04\x00\x00\x00\x00").is_empty());
1275        // End-of-central-directory magic claiming 0xFFFF entries that don't exist
1276        assert!(load_images_from_zip(b"PK\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF").is_empty());
1277        assert!(load_images_from_zip(&[0xFFu8; 4096]).is_empty());
1278    }
1279
1280    #[test]
1281    fn register_icons_from_zip_registers_nothing_for_garbage_bytes() {
1282        for bytes in [
1283            &b""[..],
1284            &b"not a zip"[..],
1285            &b"PK\x03\x04\x00\x00\x00\x00"[..],
1286            &[0x00u8; 512][..],
1287        ] {
1288            let mut provider = create_default_icon_provider();
1289            register_icons_from_zip(&mut provider, "pack", bytes);
1290            assert!(
1291                provider.list_packs().is_empty(),
1292                "a malformed ZIP must not create a pack"
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn register_embedded_material_icons_rejects_non_font_bytes() {
1299        for bytes in [
1300            &b""[..],
1301            &b"this is not a TTF"[..],
1302            // sfnt version tag + nothing else
1303            &b"\x00\x01\x00\x00"[..],
1304            &[0xFFu8; 256][..],
1305        ] {
1306            let mut provider = create_default_icon_provider();
1307            let ok = register_embedded_material_icons(&mut provider, bytes);
1308            assert!(!ok, "corrupt font bytes must not report success");
1309            assert!(provider.list_packs().is_empty());
1310        }
1311    }
1312
1313    #[cfg(feature = "icons")]
1314    #[test]
1315    fn register_material_icons_fills_a_single_lowercase_pack() {
1316        let mut provider = create_default_icon_provider();
1317        let font = dummy_font_ref();
1318        register_material_icons(&mut provider, &font);
1319
1320        assert_eq!(provider.list_packs(), vec![String::from("material-icons")]);
1321        let names = provider.list_icons_in_pack("material-icons");
1322        assert!(names.len() > 1000, "expected the full icon set, got {}", names.len());
1323        assert!(
1324            names.iter().all(|n| *n == n.to_lowercase()),
1325            "every registered icon name must be normalised to lowercase"
1326        );
1327        assert!(provider.has_icon("home"));
1328        assert!(provider.has_icon("HOME"));
1329
1330        let data = provider.lookup("home").expect("material 'home' icon");
1331        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
1332        assert!(text_of(&out).is_some(), "a material icon must resolve to a text node");
1333    }
1334}