Skip to main content

azul_css/props/style/
effects.rs

1//! CSS properties for visual effects (opacity, blending, cursor), box sizing
2//! (object-fit, object-position, aspect-ratio), and text orientation.
3
4use alloc::string::{String, ToString};
5use core::fmt;
6
7#[cfg(feature = "parser")]
8use crate::props::basic::{
9    error::{InvalidValueErr, InvalidValueErrOwned},
10    length::parse_percentage_value,
11};
12use crate::props::{
13    basic::length::{PercentageParseError, PercentageValue},
14    formatter::PrintAsCssValue,
15};
16
17// -- Opacity --
18
19/// Represents an `opacity` attribute, a value from 0.0 to 1.0.
20#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
21#[repr(C)]
22pub struct StyleOpacity {
23    pub inner: PercentageValue,
24}
25
26impl Default for StyleOpacity {
27    fn default() -> Self {
28        Self {
29            inner: PercentageValue::const_new(100),
30        }
31    }
32}
33
34impl PrintAsCssValue for StyleOpacity {
35    fn print_as_css_value(&self) -> String {
36        format!("{}", self.inner.normalized())
37    }
38}
39
40#[cfg(feature = "parser")]
41impl_percentage_value!(StyleOpacity);
42
43// -- Visibility --
44
45/// Represents a `visibility` attribute, controlling element visibility.
46#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
47#[repr(C)]
48#[derive(Default)]
49pub enum StyleVisibility {
50    #[default]
51    Visible,
52    Hidden,
53    Collapse,
54}
55
56impl PrintAsCssValue for StyleVisibility {
57    fn print_as_css_value(&self) -> String {
58        String::from(match self {
59            Self::Visible => "visible",
60            Self::Hidden => "hidden",
61            Self::Collapse => "collapse",
62        })
63    }
64}
65
66// -- Mix Blend Mode --
67
68/// Represents a `mix-blend-mode` attribute, which determines how an element's
69/// content should blend with the content of the element's parent.
70#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
71#[repr(C)]
72#[derive(Default)]
73pub enum StyleMixBlendMode {
74    #[default]
75    Normal,
76    Multiply,
77    Screen,
78    Overlay,
79    Darken,
80    Lighten,
81    ColorDodge,
82    ColorBurn,
83    HardLight,
84    SoftLight,
85    Difference,
86    Exclusion,
87    Hue,
88    Saturation,
89    Color,
90    Luminosity,
91}
92
93impl fmt::Display for StyleMixBlendMode {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(
96            f,
97            "{}",
98            match self {
99                Self::Normal => "normal",
100                Self::Multiply => "multiply",
101                Self::Screen => "screen",
102                Self::Overlay => "overlay",
103                Self::Darken => "darken",
104                Self::Lighten => "lighten",
105                Self::ColorDodge => "color-dodge",
106                Self::ColorBurn => "color-burn",
107                Self::HardLight => "hard-light",
108                Self::SoftLight => "soft-light",
109                Self::Difference => "difference",
110                Self::Exclusion => "exclusion",
111                Self::Hue => "hue",
112                Self::Saturation => "saturation",
113                Self::Color => "color",
114                Self::Luminosity => "luminosity",
115            }
116        )
117    }
118}
119
120impl PrintAsCssValue for StyleMixBlendMode {
121    fn print_as_css_value(&self) -> String {
122        self.to_string()
123    }
124}
125
126// -- Cursor --
127
128/// Represents a `cursor` attribute, defining the mouse cursor to be displayed
129/// when pointing over an element.
130#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
131#[repr(C)]
132#[derive(Default)]
133pub enum StyleCursor {
134    Alias,
135    AllScroll,
136    Cell,
137    ColResize,
138    ContextMenu,
139    Copy,
140    Crosshair,
141    #[default]
142    Default,
143    EResize,
144    EwResize,
145    Grab,
146    Grabbing,
147    Help,
148    Move,
149    NResize,
150    NsResize,
151    NeswResize,
152    NwseResize,
153    Pointer,
154    Progress,
155    RowResize,
156    SResize,
157    SeResize,
158    Text,
159    Unset,
160    VerticalText,
161    WResize,
162    Wait,
163    ZoomIn,
164    ZoomOut,
165}
166
167impl PrintAsCssValue for StyleCursor {
168    fn print_as_css_value(&self) -> String {
169        String::from(match self {
170            Self::Alias => "alias",
171            Self::AllScroll => "all-scroll",
172            Self::Cell => "cell",
173            Self::ColResize => "col-resize",
174            Self::ContextMenu => "context-menu",
175            Self::Copy => "copy",
176            Self::Crosshair => "crosshair",
177            Self::Default => "default",
178            Self::EResize => "e-resize",
179            Self::EwResize => "ew-resize",
180            Self::Grab => "grab",
181            Self::Grabbing => "grabbing",
182            Self::Help => "help",
183            Self::Move => "move",
184            Self::NResize => "n-resize",
185            Self::NsResize => "ns-resize",
186            Self::NeswResize => "nesw-resize",
187            Self::NwseResize => "nwse-resize",
188            Self::Pointer => "pointer",
189            Self::Progress => "progress",
190            Self::RowResize => "row-resize",
191            Self::SResize => "s-resize",
192            Self::SeResize => "se-resize",
193            Self::Text => "text",
194            Self::Unset => "unset",
195            Self::VerticalText => "vertical-text",
196            Self::WResize => "w-resize",
197            Self::Wait => "wait",
198            Self::ZoomIn => "zoom-in",
199            Self::ZoomOut => "zoom-out",
200        })
201    }
202}
203
204// --- PARSERS ---
205
206#[cfg(feature = "parser")]
207pub mod parsers {
208    #[allow(clippy::wildcard_imports)]
209    // parser submodule reuses the parent module's value types
210    use super::*;
211    use crate::corety::AzString;
212    use crate::props::basic::error::{InvalidValueErr, InvalidValueErrOwned};
213
214    // -- Opacity Parser --
215
216    #[derive(Clone, PartialEq, Eq)]
217    pub enum OpacityParseError<'a> {
218        ParsePercentage(PercentageParseError, &'a str),
219        OutOfRange(&'a str),
220    }
221    impl_debug_as_display!(OpacityParseError<'a>);
222    impl_display! { OpacityParseError<'a>, {
223        ParsePercentage(e, s) => format!("Invalid opacity value \"{}\": {}", s, e),
224        OutOfRange(s) => format!("Invalid opacity value \"{}\": must be between 0 and 1", s),
225    }}
226
227    /// Wrapper for `PercentageParseError` with input string.
228    #[derive(Debug, Clone, PartialEq, Eq)]
229    #[repr(C)]
230    pub struct PercentageParseErrorWithInput {
231        pub error: PercentageParseError,
232        pub input: AzString,
233    }
234
235    #[derive(Debug, Clone, PartialEq, Eq)]
236    #[repr(C, u8)]
237    pub enum OpacityParseErrorOwned {
238        ParsePercentage(PercentageParseErrorWithInput),
239        OutOfRange(AzString),
240    }
241
242    impl OpacityParseError<'_> {
243        #[must_use]
244        pub fn to_contained(&self) -> OpacityParseErrorOwned {
245            match self {
246                Self::ParsePercentage(err, s) => {
247                    OpacityParseErrorOwned::ParsePercentage(PercentageParseErrorWithInput {
248                        error: err.clone(),
249                        input: (*s).to_string().into(),
250                    })
251                }
252                Self::OutOfRange(s) => OpacityParseErrorOwned::OutOfRange((*s).to_string().into()),
253            }
254        }
255    }
256
257    impl OpacityParseErrorOwned {
258        #[must_use]
259        pub fn to_shared(&self) -> OpacityParseError<'_> {
260            match self {
261                Self::ParsePercentage(e) => {
262                    OpacityParseError::ParsePercentage(e.error.clone(), e.input.as_str())
263                }
264                Self::OutOfRange(s) => OpacityParseError::OutOfRange(s.as_str()),
265            }
266        }
267    }
268
269    /// # Errors
270    ///
271    /// Returns an error if `input` is not a valid CSS `opacity` value.
272    pub fn parse_style_opacity(input: &str) -> Result<StyleOpacity, OpacityParseError<'_>> {
273        let val = parse_percentage_value(input)
274            .map_err(|e| OpacityParseError::ParsePercentage(e, input))?;
275
276        let normalized = val.normalized();
277        if !(0.0..=1.0).contains(&normalized) {
278            return Err(OpacityParseError::OutOfRange(input));
279        }
280
281        Ok(StyleOpacity { inner: val })
282    }
283
284    // -- Visibility Parser --
285
286    #[derive(Clone, PartialEq, Eq)]
287    pub enum StyleVisibilityParseError<'a> {
288        InvalidValue(InvalidValueErr<'a>),
289    }
290    impl_debug_as_display!(StyleVisibilityParseError<'a>);
291    impl_display! { StyleVisibilityParseError<'a>, {
292        InvalidValue(e) => format!("Invalid visibility value: \"{}\"", e.0),
293    }}
294    impl_from!(InvalidValueErr<'a>, StyleVisibilityParseError::InvalidValue);
295
296    #[derive(Debug, Clone, PartialEq, Eq)]
297    #[repr(C, u8)]
298    pub enum StyleVisibilityParseErrorOwned {
299        InvalidValue(InvalidValueErrOwned),
300    }
301
302    impl StyleVisibilityParseError<'_> {
303        #[must_use]
304        pub fn to_contained(&self) -> StyleVisibilityParseErrorOwned {
305            match self {
306                Self::InvalidValue(e) => {
307                    StyleVisibilityParseErrorOwned::InvalidValue(e.to_contained())
308                }
309            }
310        }
311    }
312
313    impl StyleVisibilityParseErrorOwned {
314        #[must_use]
315        pub fn to_shared(&self) -> StyleVisibilityParseError<'_> {
316            match self {
317                Self::InvalidValue(e) => StyleVisibilityParseError::InvalidValue(e.to_shared()),
318            }
319        }
320    }
321
322    /// # Errors
323    ///
324    /// Returns an error if `input` is not a valid CSS `visibility` value.
325    pub fn parse_style_visibility(
326        input: &str,
327    ) -> Result<StyleVisibility, StyleVisibilityParseError<'_>> {
328        let input = input.trim();
329        match input {
330            "visible" => Ok(StyleVisibility::Visible),
331            "hidden" => Ok(StyleVisibility::Hidden),
332            "collapse" => Ok(StyleVisibility::Collapse),
333            _ => Err(InvalidValueErr(input).into()),
334        }
335    }
336
337    // -- Mix Blend Mode Parser --
338
339    #[derive(Clone, PartialEq, Eq)]
340    pub enum MixBlendModeParseError<'a> {
341        InvalidValue(InvalidValueErr<'a>),
342    }
343    impl_debug_as_display!(MixBlendModeParseError<'a>);
344    impl_display! { MixBlendModeParseError<'a>, {
345        InvalidValue(e) => format!("Invalid mix-blend-mode value: \"{}\"", e.0),
346    }}
347    impl_from!(InvalidValueErr<'a>, MixBlendModeParseError::InvalidValue);
348
349    #[derive(Debug, Clone, PartialEq, Eq)]
350    #[repr(C, u8)]
351    pub enum MixBlendModeParseErrorOwned {
352        InvalidValue(InvalidValueErrOwned),
353    }
354
355    impl MixBlendModeParseError<'_> {
356        #[must_use]
357        pub fn to_contained(&self) -> MixBlendModeParseErrorOwned {
358            match self {
359                Self::InvalidValue(e) => {
360                    MixBlendModeParseErrorOwned::InvalidValue(e.to_contained())
361                }
362            }
363        }
364    }
365
366    impl MixBlendModeParseErrorOwned {
367        #[must_use]
368        pub fn to_shared(&self) -> MixBlendModeParseError<'_> {
369            match self {
370                Self::InvalidValue(e) => MixBlendModeParseError::InvalidValue(e.to_shared()),
371            }
372        }
373    }
374
375    /// # Errors
376    ///
377    /// Returns an error if `input` is not a valid CSS `mix-blend-mode` value.
378    pub fn parse_style_mix_blend_mode(
379        input: &str,
380    ) -> Result<StyleMixBlendMode, MixBlendModeParseError<'_>> {
381        let input = input.trim();
382        match input {
383            "normal" => Ok(StyleMixBlendMode::Normal),
384            "multiply" => Ok(StyleMixBlendMode::Multiply),
385            "screen" => Ok(StyleMixBlendMode::Screen),
386            "overlay" => Ok(StyleMixBlendMode::Overlay),
387            "darken" => Ok(StyleMixBlendMode::Darken),
388            "lighten" => Ok(StyleMixBlendMode::Lighten),
389            "color-dodge" => Ok(StyleMixBlendMode::ColorDodge),
390            "color-burn" => Ok(StyleMixBlendMode::ColorBurn),
391            "hard-light" => Ok(StyleMixBlendMode::HardLight),
392            "soft-light" => Ok(StyleMixBlendMode::SoftLight),
393            "difference" => Ok(StyleMixBlendMode::Difference),
394            "exclusion" => Ok(StyleMixBlendMode::Exclusion),
395            "hue" => Ok(StyleMixBlendMode::Hue),
396            "saturation" => Ok(StyleMixBlendMode::Saturation),
397            "color" => Ok(StyleMixBlendMode::Color),
398            "luminosity" => Ok(StyleMixBlendMode::Luminosity),
399            _ => Err(InvalidValueErr(input).into()),
400        }
401    }
402
403    // -- Cursor Parser --
404
405    #[derive(Clone, PartialEq, Eq)]
406    pub enum CursorParseError<'a> {
407        InvalidValue(InvalidValueErr<'a>),
408    }
409    impl_debug_as_display!(CursorParseError<'a>);
410    impl_display! { CursorParseError<'a>, {
411        InvalidValue(e) => format!("Invalid cursor value: \"{}\"", e.0),
412    }}
413    impl_from!(InvalidValueErr<'a>, CursorParseError::InvalidValue);
414
415    #[derive(Debug, Clone, PartialEq, Eq)]
416    #[repr(C, u8)]
417    pub enum CursorParseErrorOwned {
418        InvalidValue(InvalidValueErrOwned),
419    }
420
421    impl CursorParseError<'_> {
422        #[must_use]
423        pub fn to_contained(&self) -> CursorParseErrorOwned {
424            match self {
425                Self::InvalidValue(e) => CursorParseErrorOwned::InvalidValue(e.to_contained()),
426            }
427        }
428    }
429
430    impl CursorParseErrorOwned {
431        #[must_use]
432        pub fn to_shared(&self) -> CursorParseError<'_> {
433            match self {
434                Self::InvalidValue(e) => CursorParseError::InvalidValue(e.to_shared()),
435            }
436        }
437    }
438
439    /// # Errors
440    ///
441    /// Returns an error if `input` is not a valid CSS `cursor` value.
442    pub fn parse_style_cursor(input: &str) -> Result<StyleCursor, CursorParseError<'_>> {
443        let input = input.trim();
444        match input {
445            "alias" => Ok(StyleCursor::Alias),
446            "all-scroll" => Ok(StyleCursor::AllScroll),
447            "cell" => Ok(StyleCursor::Cell),
448            "col-resize" => Ok(StyleCursor::ColResize),
449            "context-menu" => Ok(StyleCursor::ContextMenu),
450            "copy" => Ok(StyleCursor::Copy),
451            "crosshair" => Ok(StyleCursor::Crosshair),
452            "default" => Ok(StyleCursor::Default),
453            "e-resize" => Ok(StyleCursor::EResize),
454            "ew-resize" => Ok(StyleCursor::EwResize),
455            "grab" => Ok(StyleCursor::Grab),
456            "grabbing" => Ok(StyleCursor::Grabbing),
457            "help" => Ok(StyleCursor::Help),
458            "move" => Ok(StyleCursor::Move),
459            "n-resize" => Ok(StyleCursor::NResize),
460            "ns-resize" => Ok(StyleCursor::NsResize),
461            "nesw-resize" => Ok(StyleCursor::NeswResize),
462            "nwse-resize" => Ok(StyleCursor::NwseResize),
463            "pointer" => Ok(StyleCursor::Pointer),
464            "progress" => Ok(StyleCursor::Progress),
465            "row-resize" => Ok(StyleCursor::RowResize),
466            "s-resize" => Ok(StyleCursor::SResize),
467            "se-resize" => Ok(StyleCursor::SeResize),
468            "text" => Ok(StyleCursor::Text),
469            "unset" => Ok(StyleCursor::Unset),
470            "vertical-text" => Ok(StyleCursor::VerticalText),
471            "w-resize" => Ok(StyleCursor::WResize),
472            "wait" => Ok(StyleCursor::Wait),
473            "zoom-in" => Ok(StyleCursor::ZoomIn),
474            "zoom-out" => Ok(StyleCursor::ZoomOut),
475            _ => Err(InvalidValueErr(input).into()),
476        }
477    }
478}
479
480#[cfg(feature = "parser")]
481pub use self::parsers::*;
482
483#[cfg(all(test, feature = "parser"))]
484mod tests {
485    // Tests assert that parsed values equal the exact source literals.
486    #![allow(clippy::float_cmp)]
487    use super::*;
488
489    #[test]
490    fn test_parse_opacity() {
491        assert_eq!(parse_style_opacity("0.5").unwrap().inner.normalized(), 0.5);
492        assert_eq!(parse_style_opacity("1").unwrap().inner.normalized(), 1.0);
493        assert_eq!(parse_style_opacity("50%").unwrap().inner.normalized(), 0.5);
494        assert_eq!(parse_style_opacity("0").unwrap().inner.normalized(), 0.0);
495        assert_eq!(
496            parse_style_opacity("  75%  ").unwrap().inner.normalized(),
497            0.75
498        );
499        assert!(parse_style_opacity("1.1").is_err());
500        assert!(parse_style_opacity("-0.1").is_err());
501        assert!(parse_style_opacity("auto").is_err());
502    }
503
504    #[test]
505    fn test_parse_mix_blend_mode() {
506        assert_eq!(
507            parse_style_mix_blend_mode("multiply").unwrap(),
508            StyleMixBlendMode::Multiply
509        );
510        assert_eq!(
511            parse_style_mix_blend_mode("screen").unwrap(),
512            StyleMixBlendMode::Screen
513        );
514        assert_eq!(
515            parse_style_mix_blend_mode("color-dodge").unwrap(),
516            StyleMixBlendMode::ColorDodge
517        );
518        assert!(parse_style_mix_blend_mode("mix").is_err());
519    }
520
521    #[test]
522    fn test_parse_visibility() {
523        assert_eq!(
524            parse_style_visibility("visible").unwrap(),
525            StyleVisibility::Visible
526        );
527        assert_eq!(
528            parse_style_visibility("hidden").unwrap(),
529            StyleVisibility::Hidden
530        );
531        assert_eq!(
532            parse_style_visibility("collapse").unwrap(),
533            StyleVisibility::Collapse
534        );
535        assert_eq!(
536            parse_style_visibility("  visible  ").unwrap(),
537            StyleVisibility::Visible
538        );
539        assert!(parse_style_visibility("none").is_err());
540        assert!(parse_style_visibility("show").is_err());
541    }
542
543    #[test]
544    fn test_parse_cursor() {
545        assert_eq!(parse_style_cursor("pointer").unwrap(), StyleCursor::Pointer);
546        assert_eq!(parse_style_cursor("wait").unwrap(), StyleCursor::Wait);
547        assert_eq!(
548            parse_style_cursor("col-resize").unwrap(),
549            StyleCursor::ColResize
550        );
551        assert_eq!(parse_style_cursor("  text  ").unwrap(), StyleCursor::Text);
552        assert!(parse_style_cursor("hand").is_err()); // "hand" is a legacy IE value
553    }
554
555    #[test]
556    fn test_parse_object_fit() {
557        assert_eq!(
558            parse_style_object_fit("fill").unwrap(),
559            StyleObjectFit::Fill
560        );
561        assert_eq!(
562            parse_style_object_fit("contain").unwrap(),
563            StyleObjectFit::Contain
564        );
565        assert_eq!(
566            parse_style_object_fit("cover").unwrap(),
567            StyleObjectFit::Cover
568        );
569        assert_eq!(
570            parse_style_object_fit("none").unwrap(),
571            StyleObjectFit::None
572        );
573        assert_eq!(
574            parse_style_object_fit("scale-down").unwrap(),
575            StyleObjectFit::ScaleDown
576        );
577        assert_eq!(
578            parse_style_object_fit("  cover  ").unwrap(),
579            StyleObjectFit::Cover
580        );
581        assert!(parse_style_object_fit("stretch").is_err());
582        assert!(parse_style_object_fit("").is_err());
583    }
584
585    #[test]
586    fn test_parse_text_orientation() {
587        assert_eq!(
588            parse_style_text_orientation("mixed").unwrap(),
589            StyleTextOrientation::Mixed
590        );
591        assert_eq!(
592            parse_style_text_orientation("upright").unwrap(),
593            StyleTextOrientation::Upright
594        );
595        assert_eq!(
596            parse_style_text_orientation("sideways").unwrap(),
597            StyleTextOrientation::Sideways
598        );
599        assert_eq!(
600            parse_style_text_orientation("  mixed  ").unwrap(),
601            StyleTextOrientation::Mixed
602        );
603        assert!(parse_style_text_orientation("vertical").is_err());
604    }
605
606    #[test]
607    fn test_parse_object_position() {
608        use crate::props::style::background::{
609            BackgroundPositionHorizontal, BackgroundPositionVertical,
610        };
611        let centered = parse_style_object_position("center").unwrap();
612        assert_eq!(
613            centered,
614            parse_style_object_position("center center").unwrap()
615        );
616
617        let lt = parse_style_object_position("left top").unwrap();
618        assert_eq!(lt.horizontal, BackgroundPositionHorizontal::Left);
619        assert_eq!(lt.vertical, BackgroundPositionVertical::Top);
620
621        let rb = parse_style_object_position("right bottom").unwrap();
622        assert_eq!(rb.horizontal, BackgroundPositionHorizontal::Right);
623        assert_eq!(rb.vertical, BackgroundPositionVertical::Bottom);
624
625        assert!(parse_style_object_position("left top center").is_err());
626        assert!(parse_style_object_position("invalid").is_err());
627    }
628
629    #[test]
630    fn test_parse_aspect_ratio() {
631        assert_eq!(
632            parse_style_aspect_ratio("auto").unwrap(),
633            StyleAspectRatio::Auto
634        );
635        assert_eq!(
636            parse_style_aspect_ratio("16 / 9").unwrap(),
637            StyleAspectRatio::Ratio(AspectRatioValue {
638                width: 16000,
639                height: 9000
640            })
641        );
642        assert_eq!(
643            parse_style_aspect_ratio("16/9").unwrap(),
644            StyleAspectRatio::Ratio(AspectRatioValue {
645                width: 16000,
646                height: 9000
647            })
648        );
649        assert_eq!(
650            parse_style_aspect_ratio("1.5").unwrap(),
651            StyleAspectRatio::Ratio(AspectRatioValue {
652                width: 1500,
653                height: 1000
654            })
655        );
656        assert_eq!(
657            parse_style_aspect_ratio("  4 / 3  ").unwrap(),
658            StyleAspectRatio::Ratio(AspectRatioValue {
659                width: 4000,
660                height: 3000
661            })
662        );
663        assert!(parse_style_aspect_ratio("0 / 1").is_err());
664        assert!(parse_style_aspect_ratio("1 / 0").is_err());
665        assert!(parse_style_aspect_ratio("-1 / 1").is_err());
666        assert!(parse_style_aspect_ratio("abc").is_err());
667    }
668}
669
670// -- StyleObjectFit --
671
672/// CSS object-fit property: how replaced element content is fitted to its box.
673/// CSS Images Level 3 §5.5
674#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
675#[repr(C)]
676#[derive(Default)]
677pub enum StyleObjectFit {
678    #[default]
679    Fill,
680    Contain,
681    Cover,
682    None,
683    ScaleDown,
684}
685
686crate::impl_option!(
687    StyleObjectFit,
688    OptionStyleObjectFit,
689    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
690);
691
692impl PrintAsCssValue for StyleObjectFit {
693    fn print_as_css_value(&self) -> String {
694        String::from(match self {
695            Self::Fill => "fill",
696            Self::Contain => "contain",
697            Self::Cover => "cover",
698            Self::None => "none",
699            Self::ScaleDown => "scale-down",
700        })
701    }
702}
703
704#[cfg(feature = "parser")]
705#[derive(Clone, PartialEq, Eq)]
706pub enum StyleObjectFitParseError<'a> {
707    InvalidValue(&'a str),
708}
709
710#[cfg(feature = "parser")]
711crate::impl_debug_as_display!(StyleObjectFitParseError<'a>);
712
713#[cfg(feature = "parser")]
714crate::impl_display! { StyleObjectFitParseError<'a>, {
715    InvalidValue(val) => format!("Invalid object-fit value: \"{}\"", val),
716}}
717
718#[cfg(feature = "parser")]
719#[derive(Debug, Clone, PartialEq, Eq)]
720#[repr(C, u8)]
721pub enum StyleObjectFitParseErrorOwned {
722    InvalidValue(crate::AzString),
723}
724
725#[cfg(feature = "parser")]
726impl StyleObjectFitParseError<'_> {
727    #[must_use]
728    pub fn to_contained(&self) -> StyleObjectFitParseErrorOwned {
729        match self {
730            Self::InvalidValue(s) => {
731                StyleObjectFitParseErrorOwned::InvalidValue((*s).to_string().into())
732            }
733        }
734    }
735}
736
737#[cfg(feature = "parser")]
738impl StyleObjectFitParseErrorOwned {
739    #[must_use]
740    pub fn to_shared(&self) -> StyleObjectFitParseError<'_> {
741        match self {
742            Self::InvalidValue(s) => StyleObjectFitParseError::InvalidValue(s.as_str()),
743        }
744    }
745}
746
747#[cfg(feature = "parser")]
748/// # Errors
749///
750/// Returns an error if `input` is not a valid CSS `object-fit` value.
751pub fn parse_style_object_fit(input: &str) -> Result<StyleObjectFit, StyleObjectFitParseError<'_>> {
752    let input = input.trim();
753    match input {
754        "fill" => Ok(StyleObjectFit::Fill),
755        "contain" => Ok(StyleObjectFit::Contain),
756        "cover" => Ok(StyleObjectFit::Cover),
757        "none" => Ok(StyleObjectFit::None),
758        "scale-down" => Ok(StyleObjectFit::ScaleDown),
759        _ => Err(StyleObjectFitParseError::InvalidValue(input)),
760    }
761}
762
763// -- StyleTextOrientation --
764
765/// CSS text-orientation property for vertical writing modes.
766/// CSS Writing Modes Level 4 §5.1
767#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
768#[repr(C)]
769#[derive(Default)]
770pub enum StyleTextOrientation {
771    #[default]
772    Mixed,
773    Upright,
774    Sideways,
775}
776
777crate::impl_option!(
778    StyleTextOrientation,
779    OptionStyleTextOrientation,
780    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
781);
782
783impl PrintAsCssValue for StyleTextOrientation {
784    fn print_as_css_value(&self) -> String {
785        String::from(match self {
786            Self::Mixed => "mixed",
787            Self::Upright => "upright",
788            Self::Sideways => "sideways",
789        })
790    }
791}
792
793#[cfg(feature = "parser")]
794#[derive(Clone, PartialEq, Eq)]
795pub enum StyleTextOrientationParseError<'a> {
796    InvalidValue(&'a str),
797}
798
799#[cfg(feature = "parser")]
800crate::impl_debug_as_display!(StyleTextOrientationParseError<'a>);
801
802#[cfg(feature = "parser")]
803crate::impl_display! { StyleTextOrientationParseError<'a>, {
804    InvalidValue(val) => format!("Invalid text-orientation value: \"{}\"", val),
805}}
806
807#[cfg(feature = "parser")]
808#[derive(Debug, Clone, PartialEq, Eq)]
809#[repr(C, u8)]
810pub enum StyleTextOrientationParseErrorOwned {
811    InvalidValue(crate::AzString),
812}
813
814#[cfg(feature = "parser")]
815impl StyleTextOrientationParseError<'_> {
816    #[must_use]
817    pub fn to_contained(&self) -> StyleTextOrientationParseErrorOwned {
818        match self {
819            Self::InvalidValue(s) => {
820                StyleTextOrientationParseErrorOwned::InvalidValue((*s).to_string().into())
821            }
822        }
823    }
824}
825
826#[cfg(feature = "parser")]
827impl StyleTextOrientationParseErrorOwned {
828    #[must_use]
829    pub fn to_shared(&self) -> StyleTextOrientationParseError<'_> {
830        match self {
831            Self::InvalidValue(s) => StyleTextOrientationParseError::InvalidValue(s.as_str()),
832        }
833    }
834}
835
836#[cfg(feature = "parser")]
837/// # Errors
838///
839/// Returns an error if `input` is not a valid CSS `text-orientation` value.
840pub fn parse_style_text_orientation(
841    input: &str,
842) -> Result<StyleTextOrientation, StyleTextOrientationParseError<'_>> {
843    let input = input.trim();
844    match input {
845        "mixed" => Ok(StyleTextOrientation::Mixed),
846        "upright" => Ok(StyleTextOrientation::Upright),
847        "sideways" => Ok(StyleTextOrientation::Sideways),
848        _ => Err(StyleTextOrientationParseError::InvalidValue(input)),
849    }
850}
851
852// -- StyleObjectPosition --
853
854/// CSS object-position property: position of replaced element content within its box.
855/// CSS Images Level 3 §5.6 — default: `50% 50%` (centered)
856#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
857#[repr(C)]
858pub struct StyleObjectPosition {
859    pub horizontal: crate::props::style::background::BackgroundPositionHorizontal,
860    pub vertical: crate::props::style::background::BackgroundPositionVertical,
861}
862
863impl Default for StyleObjectPosition {
864    fn default() -> Self {
865        use crate::props::basic::pixel::PixelValue;
866        Self {
867            horizontal: crate::props::style::background::BackgroundPositionHorizontal::Exact(
868                PixelValue::percent(50.0),
869            ),
870            vertical: crate::props::style::background::BackgroundPositionVertical::Exact(
871                PixelValue::percent(50.0),
872            ),
873        }
874    }
875}
876
877crate::impl_option!(
878    StyleObjectPosition,
879    OptionStyleObjectPosition,
880    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
881);
882
883impl PrintAsCssValue for StyleObjectPosition {
884    fn print_as_css_value(&self) -> String {
885        format!(
886            "{} {}",
887            self.horizontal.print_as_css_value(),
888            self.vertical.print_as_css_value()
889        )
890    }
891}
892
893#[cfg(feature = "parser")]
894#[derive(Clone, PartialEq, Eq)]
895pub enum StyleObjectPositionParseError<'a> {
896    InvalidValue(&'a str),
897}
898
899#[cfg(feature = "parser")]
900crate::impl_debug_as_display!(StyleObjectPositionParseError<'a>);
901
902#[cfg(feature = "parser")]
903crate::impl_display! { StyleObjectPositionParseError<'a>, {
904    InvalidValue(val) => format!("Invalid object-position value: \"{}\"", val),
905}}
906
907#[cfg(feature = "parser")]
908#[derive(Debug, Clone, PartialEq, Eq)]
909#[repr(C, u8)]
910pub enum StyleObjectPositionParseErrorOwned {
911    InvalidValue(crate::AzString),
912}
913
914#[cfg(feature = "parser")]
915impl StyleObjectPositionParseError<'_> {
916    #[must_use]
917    pub fn to_contained(&self) -> StyleObjectPositionParseErrorOwned {
918        match self {
919            Self::InvalidValue(s) => {
920                StyleObjectPositionParseErrorOwned::InvalidValue((*s).to_string().into())
921            }
922        }
923    }
924}
925
926#[cfg(feature = "parser")]
927impl StyleObjectPositionParseErrorOwned {
928    #[must_use]
929    pub fn to_shared(&self) -> StyleObjectPositionParseError<'_> {
930        match self {
931            Self::InvalidValue(s) => StyleObjectPositionParseError::InvalidValue(s.as_str()),
932        }
933    }
934}
935
936/// Parse object-position: accepts keyword pairs or percentage/length values.
937/// Examples: "center", "left top", "50% 50%", "10px 20px"
938#[cfg(feature = "parser")]
939/// # Errors
940///
941/// Returns an error if `input` is not a valid CSS `object-position` value.
942pub fn parse_style_object_position(
943    input: &str,
944) -> Result<StyleObjectPosition, StyleObjectPositionParseError<'_>> {
945    use crate::props::basic::pixel::parse_pixel_value;
946    use crate::props::style::background::{
947        BackgroundPositionHorizontal, BackgroundPositionVertical,
948    };
949
950    let input = input.trim();
951    let parts: Vec<&str> = input.split_whitespace().collect();
952
953    let (h, v) = match parts.len() {
954        1 => {
955            let val = parts[0];
956            match val {
957                "center" => (
958                    BackgroundPositionHorizontal::Center,
959                    BackgroundPositionVertical::Center,
960                ),
961                "left" => (
962                    BackgroundPositionHorizontal::Left,
963                    BackgroundPositionVertical::Center,
964                ),
965                "right" => (
966                    BackgroundPositionHorizontal::Right,
967                    BackgroundPositionVertical::Center,
968                ),
969                "top" => (
970                    BackgroundPositionHorizontal::Center,
971                    BackgroundPositionVertical::Top,
972                ),
973                "bottom" => (
974                    BackgroundPositionHorizontal::Center,
975                    BackgroundPositionVertical::Bottom,
976                ),
977                _ => {
978                    let px = parse_pixel_value(val)
979                        .map_err(|_| StyleObjectPositionParseError::InvalidValue(input))?;
980                    (
981                        BackgroundPositionHorizontal::Exact(px),
982                        BackgroundPositionVertical::Exact(px),
983                    )
984                }
985            }
986        }
987        2 => {
988            // <position>: [left|center|right|<len>] || [top|center|bottom|<len>].
989            // The `||` combinator lets two *keywords* appear in either order, so
990            // canonicalize to (horizontal, vertical) first. A length in either
991            // slot forces positional order (first = horizontal, second = vertical).
992            let (a, b) = (parts[0], parts[1]);
993            let both_keywords = matches!(a, "left" | "center" | "right" | "top" | "bottom")
994                && matches!(b, "left" | "center" | "right" | "top" | "bottom");
995            let reversed =
996                both_keywords && (matches!(a, "top" | "bottom") || matches!(b, "left" | "right"));
997            let (h_str, v_str) = if reversed { (b, a) } else { (a, b) };
998
999            let h = match h_str {
1000                "left" => BackgroundPositionHorizontal::Left,
1001                "center" => BackgroundPositionHorizontal::Center,
1002                "right" => BackgroundPositionHorizontal::Right,
1003                other => {
1004                    let px = parse_pixel_value(other)
1005                        .map_err(|_| StyleObjectPositionParseError::InvalidValue(input))?;
1006                    BackgroundPositionHorizontal::Exact(px)
1007                }
1008            };
1009            let v = match v_str {
1010                "top" => BackgroundPositionVertical::Top,
1011                "center" => BackgroundPositionVertical::Center,
1012                "bottom" => BackgroundPositionVertical::Bottom,
1013                other => {
1014                    let px = parse_pixel_value(other)
1015                        .map_err(|_| StyleObjectPositionParseError::InvalidValue(input))?;
1016                    BackgroundPositionVertical::Exact(px)
1017                }
1018            };
1019            (h, v)
1020        }
1021        _ => return Err(StyleObjectPositionParseError::InvalidValue(input)),
1022    };
1023
1024    Ok(StyleObjectPosition {
1025        horizontal: h,
1026        vertical: v,
1027    })
1028}
1029
1030// -- StyleAspectRatio --
1031
1032/// Width/height ratio stored as fixed-point (value * 1000).
1033#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1034#[repr(C)]
1035pub struct AspectRatioValue {
1036    pub width: u32,
1037    pub height: u32,
1038}
1039
1040impl AspectRatioValue {
1041    /// Format one fixed-point component (`value * 1000`) back to its CSS number,
1042    /// dropping the scale and any trailing fractional zeros: 16000 -> "16",
1043    /// 1500 -> "1.5". Used by `PrintAsCssValue` so a printed ratio re-parses to
1044    /// the same value (integer math, no lossy f32 cast).
1045    fn fmt_component(v: u32) -> String {
1046        let int = v / 1000;
1047        let frac = v % 1000;
1048        if frac == 0 {
1049            int.to_string()
1050        } else {
1051            let frac_str = format!("{frac:03}");
1052            format!("{int}.{}", frac_str.trim_end_matches('0'))
1053        }
1054    }
1055}
1056#[allow(variant_size_differences)]
1057// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
1058/// CSS aspect-ratio property: preferred aspect ratio for the box.
1059/// CSS Box Sizing Level 4 §6 — values: `auto | <ratio>` (initial: `auto`)
1060///
1061/// Stored as width/height ratio. Auto means no preferred ratio.
1062#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1063#[repr(C, u8)]
1064#[derive(Default)]
1065pub enum StyleAspectRatio {
1066    /// No preferred aspect ratio
1067    #[default]
1068    Auto,
1069    /// Fixed ratio (width / height), stored as fixed-point (value * 1000)
1070    Ratio(AspectRatioValue),
1071}
1072
1073crate::impl_option!(
1074    StyleAspectRatio,
1075    OptionStyleAspectRatio,
1076    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
1077);
1078
1079impl PrintAsCssValue for StyleAspectRatio {
1080    fn print_as_css_value(&self) -> String {
1081        match self {
1082            Self::Auto => String::from("auto"),
1083            Self::Ratio(r) => format!(
1084                "{} / {}",
1085                AspectRatioValue::fmt_component(r.width),
1086                AspectRatioValue::fmt_component(r.height)
1087            ),
1088        }
1089    }
1090}
1091
1092#[cfg(feature = "parser")]
1093#[derive(Clone, PartialEq, Eq)]
1094pub enum StyleAspectRatioParseError<'a> {
1095    InvalidValue(&'a str),
1096}
1097
1098#[cfg(feature = "parser")]
1099crate::impl_debug_as_display!(StyleAspectRatioParseError<'a>);
1100
1101#[cfg(feature = "parser")]
1102crate::impl_display! { StyleAspectRatioParseError<'a>, {
1103    InvalidValue(val) => format!("Invalid aspect-ratio value: \"{}\"", val),
1104}}
1105
1106#[cfg(feature = "parser")]
1107#[derive(Debug, Clone, PartialEq, Eq)]
1108#[repr(C, u8)]
1109pub enum StyleAspectRatioParseErrorOwned {
1110    InvalidValue(crate::AzString),
1111}
1112
1113#[cfg(feature = "parser")]
1114impl StyleAspectRatioParseError<'_> {
1115    #[must_use]
1116    pub fn to_contained(&self) -> StyleAspectRatioParseErrorOwned {
1117        match self {
1118            Self::InvalidValue(s) => {
1119                StyleAspectRatioParseErrorOwned::InvalidValue((*s).to_string().into())
1120            }
1121        }
1122    }
1123}
1124
1125#[cfg(feature = "parser")]
1126impl StyleAspectRatioParseErrorOwned {
1127    #[must_use]
1128    pub fn to_shared(&self) -> StyleAspectRatioParseError<'_> {
1129        match self {
1130            Self::InvalidValue(s) => StyleAspectRatioParseError::InvalidValue(s.as_str()),
1131        }
1132    }
1133}
1134
1135/// Truncating `f32` → `u32` for aspect-ratio values (callers validate the input
1136/// is positive and bounded, so the value always fits). Rust's `as u32` saturates
1137/// out-of-range floats; this isolates the one unavoidable float→int cast.
1138#[cfg(feature = "parser")]
1139#[inline]
1140#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1141const fn aspect_f32_to_u32(v: f32) -> u32 {
1142    v as u32
1143}
1144
1145/// Validate two ratio components and encode them into the fixed-point
1146/// [`AspectRatioValue`]. The positive-range checks are written as
1147/// `!(x > 0.0 && x <= MAX)` so NaN — which is false for every ordered
1148/// comparison — is rejected instead of sailing through the guards. A component
1149/// whose fixed-point encoding rounds to 0 (magnitude below ~0.0005) is a
1150/// degenerate divide-by-zero ratio and is rejected as well.
1151#[cfg(feature = "parser")]
1152fn ratio_from_components(
1153    w: f32,
1154    h: f32,
1155    input: &str,
1156) -> Result<StyleAspectRatio, StyleAspectRatioParseError<'_>> {
1157    if !(w > 0.0 && w <= 100_000.0 && h > 0.0 && h <= 100_000.0) {
1158        return Err(StyleAspectRatioParseError::InvalidValue(input));
1159    }
1160    let width = aspect_f32_to_u32((w * 1000.0).round());
1161    let height = aspect_f32_to_u32((h * 1000.0).round());
1162    if width == 0 || height == 0 {
1163        return Err(StyleAspectRatioParseError::InvalidValue(input));
1164    }
1165    Ok(StyleAspectRatio::Ratio(AspectRatioValue { width, height }))
1166}
1167
1168/// Parse aspect-ratio: "auto", "16 / 9", "1.5", "4/3"
1169#[cfg(feature = "parser")]
1170/// # Errors
1171///
1172/// Returns an error if `input` is not a valid CSS `aspect-ratio` value.
1173pub fn parse_style_aspect_ratio(
1174    input: &str,
1175) -> Result<StyleAspectRatio, StyleAspectRatioParseError<'_>> {
1176    let input = input.trim();
1177    if input == "auto" {
1178        return Ok(StyleAspectRatio::Auto);
1179    }
1180    // Try "w / h" or "w/h" format
1181    if let Some(slash_pos) = input.find('/') {
1182        let w_str = input[..slash_pos].trim();
1183        let h_str = input[slash_pos + 1..].trim();
1184        let w: f32 = w_str
1185            .parse()
1186            .map_err(|_| StyleAspectRatioParseError::InvalidValue(input))?;
1187        let h: f32 = h_str
1188            .parse()
1189            .map_err(|_| StyleAspectRatioParseError::InvalidValue(input))?;
1190        return ratio_from_components(w, h, input);
1191    }
1192    // A single number is the "<w> / 1" ratio.
1193    let w: f32 = input
1194        .parse()
1195        .map_err(|_| StyleAspectRatioParseError::InvalidValue(input))?;
1196    ratio_from_components(w, 1.0, input)
1197}
1198
1199#[cfg(all(test, feature = "parser"))]
1200#[allow(
1201    clippy::float_cmp,
1202    clippy::unreadable_literal,
1203    clippy::too_many_lines,
1204    clippy::cast_precision_loss
1205)]
1206mod autotest_generated {
1207    use super::*;
1208    use crate::props::{
1209        basic::{error::ParseFloatError as CssParseFloatError, pixel::PixelValue},
1210        formatter::PrintAsCssValue,
1211        style::background::{BackgroundPositionHorizontal, BackgroundPositionVertical},
1212    };
1213
1214    const ALL_VISIBILITY: [StyleVisibility; 3] = [
1215        StyleVisibility::Visible,
1216        StyleVisibility::Hidden,
1217        StyleVisibility::Collapse,
1218    ];
1219
1220    const ALL_BLEND_MODES: [StyleMixBlendMode; 16] = [
1221        StyleMixBlendMode::Normal,
1222        StyleMixBlendMode::Multiply,
1223        StyleMixBlendMode::Screen,
1224        StyleMixBlendMode::Overlay,
1225        StyleMixBlendMode::Darken,
1226        StyleMixBlendMode::Lighten,
1227        StyleMixBlendMode::ColorDodge,
1228        StyleMixBlendMode::ColorBurn,
1229        StyleMixBlendMode::HardLight,
1230        StyleMixBlendMode::SoftLight,
1231        StyleMixBlendMode::Difference,
1232        StyleMixBlendMode::Exclusion,
1233        StyleMixBlendMode::Hue,
1234        StyleMixBlendMode::Saturation,
1235        StyleMixBlendMode::Color,
1236        StyleMixBlendMode::Luminosity,
1237    ];
1238
1239    const ALL_CURSORS: [StyleCursor; 30] = [
1240        StyleCursor::Alias,
1241        StyleCursor::AllScroll,
1242        StyleCursor::Cell,
1243        StyleCursor::ColResize,
1244        StyleCursor::ContextMenu,
1245        StyleCursor::Copy,
1246        StyleCursor::Crosshair,
1247        StyleCursor::Default,
1248        StyleCursor::EResize,
1249        StyleCursor::EwResize,
1250        StyleCursor::Grab,
1251        StyleCursor::Grabbing,
1252        StyleCursor::Help,
1253        StyleCursor::Move,
1254        StyleCursor::NResize,
1255        StyleCursor::NsResize,
1256        StyleCursor::NeswResize,
1257        StyleCursor::NwseResize,
1258        StyleCursor::Pointer,
1259        StyleCursor::Progress,
1260        StyleCursor::RowResize,
1261        StyleCursor::SResize,
1262        StyleCursor::SeResize,
1263        StyleCursor::Text,
1264        StyleCursor::Unset,
1265        StyleCursor::VerticalText,
1266        StyleCursor::WResize,
1267        StyleCursor::Wait,
1268        StyleCursor::ZoomIn,
1269        StyleCursor::ZoomOut,
1270    ];
1271
1272    const ALL_OBJECT_FIT: [StyleObjectFit; 5] = [
1273        StyleObjectFit::Fill,
1274        StyleObjectFit::Contain,
1275        StyleObjectFit::Cover,
1276        StyleObjectFit::None,
1277        StyleObjectFit::ScaleDown,
1278    ];
1279
1280    const ALL_TEXT_ORIENTATION: [StyleTextOrientation; 3] = [
1281        StyleTextOrientation::Mixed,
1282        StyleTextOrientation::Upright,
1283        StyleTextOrientation::Sideways,
1284    ];
1285
1286    /// Inputs no keyword parser may ever accept, and none may panic on.
1287    /// Deliberately mixes empty / whitespace / punctuation / multibyte input.
1288    const HOSTILE_KEYWORDS: [&str; 14] = [
1289        "",
1290        " ",
1291        "\t\n\r",
1292        "\u{a0}", // NBSP — `str::trim` treats it as whitespace
1293        ";",
1294        "{}",
1295        "/*",
1296        "0",
1297        "-1",
1298        "NaN",
1299        "inf",
1300        "\u{1F600}", // emoji
1301        "e\u{0301}", // combining acute accent
1302        "\u{0665}",  // ARABIC-INDIC DIGIT FIVE (multibyte, `is_numeric`)
1303    ];
1304
1305    // ------------------------------------------------ StyleMixBlendMode::fmt ---
1306
1307    #[test]
1308    fn blend_mode_display_is_well_formed_for_every_variant() {
1309        for mode in ALL_BLEND_MODES {
1310            let shown = mode.to_string();
1311            assert!(!shown.is_empty(), "{mode:?} renders as an empty string");
1312            assert!(
1313                shown.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
1314                "{mode:?} renders as {shown:?}, which is not a CSS ident"
1315            );
1316            // `PrintAsCssValue` delegates to `Display`; pin them together so a
1317            // future divergence has to be deliberate.
1318            assert_eq!(shown, mode.print_as_css_value());
1319        }
1320    }
1321
1322    #[test]
1323    fn blend_mode_display_of_default_is_normal() {
1324        assert_eq!(StyleMixBlendMode::default().to_string(), "normal");
1325        assert_eq!(StyleMixBlendMode::default(), StyleMixBlendMode::Normal);
1326    }
1327
1328    #[test]
1329    fn blend_mode_display_survives_width_and_precision_flags() {
1330        // The impl forwards through `write!(f, "{}", ..)` instead of `f.pad(..)`,
1331        // so the caller's width/precision/fill flags are dropped rather than
1332        // applied. Not a panic, but pin it: `{:>10}` does NOT pad.
1333        assert_eq!(format!("{:>10}", StyleMixBlendMode::Normal), "normal");
1334        assert_eq!(format!("{:.2}", StyleMixBlendMode::Multiply), "multiply");
1335        assert_eq!(
1336            format!("{:*^30}", StyleMixBlendMode::ColorDodge),
1337            "color-dodge"
1338        );
1339    }
1340
1341    // ----------------------------------------------------- parse_style_opacity ---
1342
1343    #[test]
1344    fn opacity_rejects_empty_and_whitespace_only_input() {
1345        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}"] {
1346            assert!(
1347                parse_style_opacity(input).is_err(),
1348                "{input:?} must not parse as an opacity"
1349            );
1350        }
1351    }
1352
1353    #[test]
1354    fn opacity_rejects_garbage() {
1355        for input in [
1356            "auto",
1357            "abc",
1358            "%",
1359            ";;;",
1360            "50%%",
1361            "#0.5",
1362            "0.5;garbage",
1363            "1 2",
1364            "rgb(0,0,0)",
1365            "0,5",
1366            "--",
1367            "..",
1368            "-",
1369            ".",
1370        ] {
1371            assert!(
1372                parse_style_opacity(input).is_err(),
1373                "{input:?} must not parse as an opacity"
1374            );
1375        }
1376    }
1377
1378    #[test]
1379    fn opacity_boundary_numbers() {
1380        // In range.
1381        assert_eq!(parse_style_opacity("0").unwrap().inner.normalized(), 0.0);
1382        assert_eq!(parse_style_opacity("1").unwrap().inner.normalized(), 1.0);
1383        assert_eq!(parse_style_opacity("0%").unwrap().inner.normalized(), 0.0);
1384        assert_eq!(parse_style_opacity("100%").unwrap().inner.normalized(), 1.0);
1385        // `-0.0 == 0.0` under IEEE-754, so the `0.0..=1.0` guard accepts it.
1386        assert_eq!(parse_style_opacity("-0").unwrap().inner.normalized(), 0.0);
1387        assert_eq!(parse_style_opacity("-0%").unwrap().inner.normalized(), 0.0);
1388        // Below the fixed-point resolution: quantized to 0, still in range.
1389        assert!(parse_style_opacity("0.0000001").is_ok());
1390
1391        // Out of range.
1392        for input in ["1.001", "1.1", "2", "101%", "-0.001", "-1", "-100%"] {
1393            assert!(
1394                matches!(
1395                    parse_style_opacity(input),
1396                    Err(OpacityParseError::OutOfRange(_))
1397                ),
1398                "{input:?} should be rejected as out-of-range"
1399            );
1400        }
1401
1402        // Float extremes: `str::parse::<f32>` maps 1e39 to +inf, which must not
1403        // panic through the fixed-point cast and must land out of range.
1404        for input in ["1e39", "3.5e38", "9223372036854775807", "1e30"] {
1405            assert!(
1406                parse_style_opacity(input).is_err(),
1407                "{input:?} should be rejected as out-of-range"
1408            );
1409        }
1410
1411        // `NaN` / `inf` contain no numeric char, so the scanner bails out first.
1412        for input in ["NaN", "nan", "inf", "infinity", "-inf", "-NaN"] {
1413            assert!(
1414                parse_style_opacity(input).is_err(),
1415                "{input:?} should be rejected"
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn opacity_trims_but_rejects_trailing_junk() {
1422        assert_eq!(
1423            parse_style_opacity("  0.5  ").unwrap().inner.normalized(),
1424            0.5
1425        );
1426        assert_eq!(
1427            parse_style_opacity("\t50%\n").unwrap().inner.normalized(),
1428            0.5
1429        );
1430        for input in ["0.5;", "0.5 !important", "0.5px", "0.5 0.5"] {
1431            assert!(
1432                parse_style_opacity(input).is_err(),
1433                "{input:?} must not parse as an opacity"
1434            );
1435        }
1436
1437        // Lax, pinned: the unit is trimmed *after* being split off the number, so
1438        // an internal space between value and unit is accepted even though CSS
1439        // forbids it.
1440        assert_eq!(parse_style_opacity("50 %").unwrap().inner.normalized(), 0.5);
1441    }
1442
1443    #[test]
1444    fn opacity_non_numeric_unicode_does_not_panic() {
1445        // Multibyte input whose *last* numeric char is ASCII (or which has no
1446        // numeric char at all) must be rejected without slicing mid-codepoint.
1447        // See `known_bug_opacity_multibyte_numeric_char_panics` for the case
1448        // that does not hold.
1449        for input in [
1450            "\u{1F600}",        // emoji only
1451            "\u{1F600}0.5",     // emoji then ASCII digits
1452            "0.5\u{0301}",      // digits then a combining acute accent
1453            "\u{2603}%",        // snowman + percent sign
1454            "\u{4F60}\u{597D}", // CJK
1455            "\u{202E}0.5",      // RTL override
1456        ] {
1457            assert!(
1458                parse_style_opacity(input).is_err(),
1459                "{input:?} must not parse as an opacity"
1460            );
1461        }
1462    }
1463
1464    #[test]
1465    fn opacity_extremely_long_input_terminates() {
1466        // 100k digits overflow f32 to +inf => out of range, but must not hang.
1467        let huge = "1".repeat(100_000);
1468        assert!(parse_style_opacity(&huge).is_err());
1469
1470        // 100k *leading* fraction zeros exercise the slow float path and stay
1471        // in range.
1472        let tiny = format!("0.{}5", "0".repeat(100_000));
1473        assert_eq!(parse_style_opacity(&tiny).unwrap().inner.normalized(), 0.0);
1474
1475        // A long trailing unit is rejected, not truncated.
1476        let long_unit = format!("0.5{}", "z".repeat(100_000));
1477        assert!(parse_style_opacity(&long_unit).is_err());
1478    }
1479
1480    #[test]
1481    fn opacity_deeply_nested_brackets_do_not_stack_overflow() {
1482        let nested = "(".repeat(10_000);
1483        assert!(parse_style_opacity(&nested).is_err());
1484
1485        let wrapped = format!("{}0.5{}", "(".repeat(10_000), ")".repeat(10_000));
1486        assert!(parse_style_opacity(&wrapped).is_err());
1487    }
1488
1489    #[test]
1490    fn opacity_valid_minimal_positive_control() {
1491        assert!(parse_style_opacity("1").unwrap() == StyleOpacity::default());
1492        assert!(parse_style_opacity("50%").unwrap() == StyleOpacity::new(50.0));
1493        assert!(parse_style_opacity("0.5").unwrap() == StyleOpacity::new(50.0));
1494        // `0.5` (fraction) and `50%` are the same value.
1495        assert!(parse_style_opacity("0.5").unwrap() == parse_style_opacity("50%").unwrap());
1496    }
1497
1498    #[test]
1499    fn opacity_round_trips_through_print_as_css_value_and_display() {
1500        for pct in [0.0f32, 12.5, 25.0, 50.0, 75.0, 99.9, 100.0] {
1501            let opacity = StyleOpacity::new(pct);
1502
1503            // `PrintAsCssValue` emits the normalized 0..=1 fraction.
1504            let printed = opacity.print_as_css_value();
1505            let reparsed = parse_style_opacity(&printed)
1506                .unwrap_or_else(|e| panic!("{printed:?} (from {pct}%) failed to re-parse: {e}"));
1507            assert_eq!(
1508                reparsed.inner.normalized(),
1509                opacity.inner.normalized(),
1510                "{pct}% printed as {printed:?} but re-parsed differently"
1511            );
1512
1513            // `Display` emits the percentage form; that must re-parse too.
1514            let displayed = opacity.to_string();
1515            let reparsed = parse_style_opacity(&displayed)
1516                .unwrap_or_else(|e| panic!("{displayed:?} (from {pct}%) failed to re-parse: {e}"));
1517            assert_eq!(reparsed.inner.normalized(), opacity.inner.normalized());
1518        }
1519    }
1520
1521    // ------------------------------------------------- parse_style_visibility ---
1522
1523    #[test]
1524    fn visibility_parses_every_keyword_and_round_trips() {
1525        assert_eq!(
1526            parse_style_visibility("visible").unwrap(),
1527            StyleVisibility::Visible
1528        );
1529        assert_eq!(
1530            parse_style_visibility("hidden").unwrap(),
1531            StyleVisibility::Hidden
1532        );
1533        assert_eq!(
1534            parse_style_visibility("collapse").unwrap(),
1535            StyleVisibility::Collapse
1536        );
1537        assert_eq!(StyleVisibility::default(), StyleVisibility::Visible);
1538
1539        for v in ALL_VISIBILITY {
1540            let printed = v.print_as_css_value();
1541            assert!(!printed.is_empty());
1542            assert_eq!(parse_style_visibility(&printed).unwrap(), v);
1543            // Surrounding whitespace is trimmed, not rejected.
1544            assert_eq!(
1545                parse_style_visibility(&format!("  {printed}\t")).unwrap(),
1546                v
1547            );
1548        }
1549    }
1550
1551    #[test]
1552    fn visibility_rejects_hostile_input() {
1553        for input in HOSTILE_KEYWORDS {
1554            assert!(
1555                parse_style_visibility(input).is_err(),
1556                "{input:?} must not parse as a visibility"
1557            );
1558        }
1559        for input in [
1560            "none",
1561            "show",
1562            "visible hidden",
1563            "visible;",
1564            "vis",
1565            "visibleX",
1566        ] {
1567            assert!(
1568                parse_style_visibility(input).is_err(),
1569                "{input:?} must not parse as a visibility"
1570            );
1571        }
1572    }
1573
1574    // -------------------------------------------- parse_style_mix_blend_mode ---
1575
1576    #[test]
1577    fn blend_mode_parses_every_keyword_and_round_trips() {
1578        for mode in ALL_BLEND_MODES {
1579            let printed = mode.print_as_css_value();
1580            assert_eq!(
1581                parse_style_mix_blend_mode(&printed).unwrap(),
1582                mode,
1583                "{printed:?} did not round-trip"
1584            );
1585            assert_eq!(
1586                parse_style_mix_blend_mode(&format!(" {printed} ")).unwrap(),
1587                mode
1588            );
1589        }
1590        assert_eq!(StyleMixBlendMode::default(), StyleMixBlendMode::Normal);
1591    }
1592
1593    #[test]
1594    fn blend_mode_rejects_hostile_input() {
1595        for input in HOSTILE_KEYWORDS {
1596            assert!(
1597                parse_style_mix_blend_mode(input).is_err(),
1598                "{input:?} must not parse as a mix-blend-mode"
1599            );
1600        }
1601        // Near-misses: separator swaps, plain-CSS-adjacent words, partial idents.
1602        for input in [
1603            "mix",
1604            "color dodge",
1605            "color_dodge",
1606            "colordodge",
1607            "normal normal",
1608            "plus-lighter",
1609            "multiply;",
1610            "screen!",
1611        ] {
1612            assert!(
1613                parse_style_mix_blend_mode(input).is_err(),
1614                "{input:?} must not parse as a mix-blend-mode"
1615            );
1616        }
1617    }
1618
1619    // ------------------------------------------------------ parse_style_cursor ---
1620
1621    #[test]
1622    fn cursor_parses_every_keyword_and_round_trips() {
1623        for cursor in ALL_CURSORS {
1624            let printed = cursor.print_as_css_value();
1625            assert_eq!(
1626                parse_style_cursor(&printed).unwrap(),
1627                cursor,
1628                "{printed:?} did not round-trip"
1629            );
1630            assert_eq!(
1631                parse_style_cursor(&format!("\n{printed}  ")).unwrap(),
1632                cursor
1633            );
1634        }
1635        assert_eq!(StyleCursor::default(), StyleCursor::Default);
1636    }
1637
1638    #[test]
1639    fn cursor_keyword_printing_is_injective() {
1640        // Two variants mapping to the same CSS ident would silently collapse on
1641        // re-parse; the round-trip test above cannot catch that on its own.
1642        let mut printed: Vec<String> = ALL_CURSORS
1643            .iter()
1644            .map(PrintAsCssValue::print_as_css_value)
1645            .collect();
1646        printed.sort();
1647        let count = printed.len();
1648        printed.dedup();
1649        assert_eq!(
1650            printed.len(),
1651            count,
1652            "two StyleCursor variants print the same ident"
1653        );
1654    }
1655
1656    #[test]
1657    fn cursor_rejects_hostile_input() {
1658        for input in HOSTILE_KEYWORDS {
1659            assert!(
1660                parse_style_cursor(input).is_err(),
1661                "{input:?} must not parse as a cursor"
1662            );
1663        }
1664        for input in [
1665            "hand",       // legacy IE alias, deliberately unsupported
1666            "col resize", // space instead of hyphen
1667            "e_resize",
1668            "pointer pointer",
1669            "url(cursor.png)",
1670            "auto", // valid CSS, but not in the enum
1671        ] {
1672            assert!(
1673                parse_style_cursor(input).is_err(),
1674                "{input:?} must not parse as a cursor"
1675            );
1676        }
1677    }
1678
1679    // -------------------------------------------------- parse_style_object_fit ---
1680
1681    #[test]
1682    fn object_fit_parses_every_keyword_and_round_trips() {
1683        for fit in ALL_OBJECT_FIT {
1684            let printed = fit.print_as_css_value();
1685            assert_eq!(parse_style_object_fit(&printed).unwrap(), fit);
1686            assert_eq!(
1687                parse_style_object_fit(&format!("  {printed} ")).unwrap(),
1688                fit
1689            );
1690        }
1691        assert_eq!(StyleObjectFit::default(), StyleObjectFit::Fill);
1692    }
1693
1694    #[test]
1695    fn object_fit_rejects_hostile_input() {
1696        for input in HOSTILE_KEYWORDS {
1697            assert!(
1698                parse_style_object_fit(input).is_err(),
1699                "{input:?} must not parse as an object-fit"
1700            );
1701        }
1702        for input in [
1703            "stretch",
1704            "scale_down",
1705            "scale down",
1706            "cover cover",
1707            "fill;",
1708        ] {
1709            assert!(
1710                parse_style_object_fit(input).is_err(),
1711                "{input:?} must not parse as an object-fit"
1712            );
1713        }
1714    }
1715
1716    // -------------------------------------------- parse_style_text_orientation ---
1717
1718    #[test]
1719    fn text_orientation_parses_every_keyword_and_round_trips() {
1720        for orientation in ALL_TEXT_ORIENTATION {
1721            let printed = orientation.print_as_css_value();
1722            assert_eq!(parse_style_text_orientation(&printed).unwrap(), orientation);
1723            assert_eq!(
1724                parse_style_text_orientation(&format!("\t{printed}\n")).unwrap(),
1725                orientation
1726            );
1727        }
1728        assert_eq!(StyleTextOrientation::default(), StyleTextOrientation::Mixed);
1729    }
1730
1731    #[test]
1732    fn text_orientation_rejects_hostile_input() {
1733        for input in HOSTILE_KEYWORDS {
1734            assert!(
1735                parse_style_text_orientation(input).is_err(),
1736                "{input:?} must not parse as a text-orientation"
1737            );
1738        }
1739        for input in ["vertical", "sideways-right", "upright mixed", "mixed;"] {
1740            assert!(
1741                parse_style_text_orientation(input).is_err(),
1742                "{input:?} must not parse as a text-orientation"
1743            );
1744        }
1745    }
1746
1747    // ----------------------------------------- keyword parsers, shared invariant ---
1748
1749    #[test]
1750    fn keyword_parsers_are_case_sensitive() {
1751        // CSS idents are ASCII case-insensitive per spec, but every keyword
1752        // parser in this crate matches the lowercase form only. Pinned so that
1753        // adding case-folding is a deliberate, crate-wide change rather than an
1754        // accident in one parser.
1755        assert!(parse_style_visibility("VISIBLE").is_err());
1756        assert!(parse_style_mix_blend_mode("Multiply").is_err());
1757        assert!(parse_style_cursor("Pointer").is_err());
1758        assert!(parse_style_object_fit("COVER").is_err());
1759        assert!(parse_style_text_orientation("Upright").is_err());
1760        assert!(parse_style_aspect_ratio("AUTO").is_err());
1761    }
1762
1763    #[test]
1764    fn keyword_parsers_do_not_hang_on_extremely_long_input() {
1765        let long = "a".repeat(500_000);
1766        assert!(parse_style_visibility(&long).is_err());
1767        assert!(parse_style_mix_blend_mode(&long).is_err());
1768        assert!(parse_style_cursor(&long).is_err());
1769        assert!(parse_style_object_fit(&long).is_err());
1770        assert!(parse_style_text_orientation(&long).is_err());
1771
1772        // A valid keyword buried in 500k of padding is still just whitespace-
1773        // trimmed, so it parses; the padding must not be quadratic.
1774        let padded = format!("{}visible{}", " ".repeat(250_000), " ".repeat(250_000));
1775        assert_eq!(
1776            parse_style_visibility(&padded).unwrap(),
1777            StyleVisibility::Visible
1778        );
1779    }
1780
1781    #[test]
1782    fn keyword_parsers_do_not_stack_overflow_on_nested_input() {
1783        let nested = format!("{}center{}", "(".repeat(10_000), ")".repeat(10_000));
1784        assert!(parse_style_visibility(&nested).is_err());
1785        assert!(parse_style_cursor(&nested).is_err());
1786        assert!(parse_style_object_fit(&nested).is_err());
1787        assert!(parse_style_object_position(&nested).is_err());
1788        assert!(parse_style_aspect_ratio(&nested).is_err());
1789    }
1790
1791    // --------------------------------------------- parse_style_object_position ---
1792
1793    #[test]
1794    fn object_position_parses_single_keywords() {
1795        use BackgroundPositionHorizontal as H;
1796        use BackgroundPositionVertical as V;
1797
1798        for (input, h, v) in [
1799            ("center", H::Center, V::Center),
1800            ("left", H::Left, V::Center),
1801            ("right", H::Right, V::Center),
1802            ("top", H::Center, V::Top),
1803            ("bottom", H::Center, V::Bottom),
1804        ] {
1805            let parsed = parse_style_object_position(input).unwrap();
1806            assert_eq!(parsed.horizontal, h, "{input:?} horizontal");
1807            assert_eq!(parsed.vertical, v, "{input:?} vertical");
1808        }
1809    }
1810
1811    #[test]
1812    fn object_position_parses_lengths_and_percentages() {
1813        let px = parse_style_object_position("10px 20px").unwrap();
1814        assert_eq!(
1815            px.horizontal,
1816            BackgroundPositionHorizontal::Exact(PixelValue::px(10.0))
1817        );
1818        assert_eq!(
1819            px.vertical,
1820            BackgroundPositionVertical::Exact(PixelValue::px(20.0))
1821        );
1822
1823        let pct = parse_style_object_position("50% 50%").unwrap();
1824        assert_eq!(
1825            pct.horizontal,
1826            BackgroundPositionHorizontal::Exact(PixelValue::percent(50.0))
1827        );
1828        assert_eq!(
1829            pct.vertical,
1830            BackgroundPositionVertical::Exact(PixelValue::percent(50.0))
1831        );
1832
1833        // A single length applies to *both* axes.
1834        let single = parse_style_object_position("25%").unwrap();
1835        assert_eq!(
1836            single.horizontal,
1837            BackgroundPositionHorizontal::Exact(PixelValue::percent(25.0))
1838        );
1839        assert_eq!(
1840            single.vertical,
1841            BackgroundPositionVertical::Exact(PixelValue::percent(25.0))
1842        );
1843
1844        // Mixed keyword + length, both orders.
1845        assert_eq!(
1846            parse_style_object_position("left 25%").unwrap(),
1847            StyleObjectPosition {
1848                horizontal: BackgroundPositionHorizontal::Left,
1849                vertical: BackgroundPositionVertical::Exact(PixelValue::percent(25.0)),
1850            }
1851        );
1852        assert_eq!(
1853            parse_style_object_position("25% top").unwrap(),
1854            StyleObjectPosition {
1855                horizontal: BackgroundPositionHorizontal::Exact(PixelValue::percent(25.0)),
1856                vertical: BackgroundPositionVertical::Top,
1857            }
1858        );
1859    }
1860
1861    #[test]
1862    fn object_position_collapses_internal_whitespace() {
1863        // `split_whitespace` means any run of blanks separates the components.
1864        let expected = parse_style_object_position("left top").unwrap();
1865        for input in ["left  top", "left\ttop", "  left \n top  ", "left\r\ntop"] {
1866            assert_eq!(
1867                parse_style_object_position(input).unwrap(),
1868                expected,
1869                "{input:?} should be equivalent to \"left top\""
1870            );
1871        }
1872    }
1873
1874    #[test]
1875    fn object_position_rejects_wrong_component_counts_and_garbage() {
1876        for input in [
1877            "",
1878            "   ",
1879            "\t\n",
1880            "left top center",
1881            "10px 20px 30px",
1882            "center center center center",
1883            "invalid",
1884            "left left", // second component must be a vertical keyword or a length
1885            "top top",   // first component must be a horizontal keyword or a length
1886            "left,top",  // comma is not a component separator
1887            ";",
1888            "\u{1F600}",
1889            "\u{1F600} \u{1F600}",
1890        ] {
1891            assert!(
1892                parse_style_object_position(input).is_err(),
1893                "{input:?} must not parse as an object-position"
1894            );
1895        }
1896    }
1897
1898    #[test]
1899    fn object_position_extreme_lengths_do_not_panic() {
1900        // `parse_pixel_value` accepts bare floats (incl. NaN/inf) and saturates
1901        // them in the fixed-point cast — characterized in pixel.rs. All that is
1902        // asserted here is that object-position does not panic on them.
1903        for input in [
1904            "NaN NaN",
1905            "inf inf",
1906            "-inf",
1907            "1e39px",
1908            "-1e39px",
1909            "340282350000000000000000000000000000000px",
1910        ] {
1911            let _ = parse_style_object_position(input);
1912        }
1913        let long = format!("{}px", "9".repeat(100_000));
1914        let _ = parse_style_object_position(&long);
1915    }
1916
1917    #[test]
1918    fn object_position_round_trips_through_print_as_css_value() {
1919        use BackgroundPositionHorizontal as H;
1920        use BackgroundPositionVertical as V;
1921
1922        let horizontals = [
1923            H::Left,
1924            H::Center,
1925            H::Right,
1926            H::Exact(PixelValue::percent(25.0)),
1927        ];
1928        let verticals = [V::Top, V::Center, V::Bottom, V::Exact(PixelValue::px(30.0))];
1929
1930        for horizontal in horizontals {
1931            for vertical in verticals {
1932                let position = StyleObjectPosition {
1933                    horizontal,
1934                    vertical,
1935                };
1936                let printed = position.print_as_css_value();
1937                let reparsed = parse_style_object_position(&printed).unwrap_or_else(|e| {
1938                    panic!("{position:?} printed as {printed:?}, which failed to re-parse: {e}")
1939                });
1940                assert_eq!(reparsed, position, "{printed:?} did not round-trip");
1941            }
1942        }
1943
1944        // The documented initial value is `50% 50%`.
1945        let default = StyleObjectPosition::default();
1946        assert_eq!(default.print_as_css_value(), "50% 50%");
1947        assert_eq!(parse_style_object_position("50% 50%").unwrap(), default);
1948        assert_eq!(
1949            parse_style_object_position("center")
1950                .unwrap()
1951                .print_as_css_value(),
1952            "center center"
1953        );
1954    }
1955
1956    // ------------------------------------------------------ aspect_f32_to_u32 ---
1957
1958    #[test]
1959    fn aspect_f32_to_u32_saturates_instead_of_panicking() {
1960        // Zero / truncation.
1961        assert_eq!(aspect_f32_to_u32(0.0), 0);
1962        assert_eq!(aspect_f32_to_u32(-0.0), 0);
1963        assert_eq!(aspect_f32_to_u32(0.9), 0);
1964        assert_eq!(aspect_f32_to_u32(1.0), 1);
1965        assert_eq!(aspect_f32_to_u32(1.9), 1);
1966        assert_eq!(aspect_f32_to_u32(f32::MIN_POSITIVE), 0);
1967
1968        // Negatives saturate to 0 (`as` is a saturating cast since Rust 1.45).
1969        assert_eq!(aspect_f32_to_u32(-1.0), 0);
1970        assert_eq!(aspect_f32_to_u32(-0.5), 0);
1971        assert_eq!(aspect_f32_to_u32(-1e30), 0);
1972        assert_eq!(aspect_f32_to_u32(f32::MIN), 0);
1973        assert_eq!(aspect_f32_to_u32(f32::NEG_INFINITY), 0);
1974
1975        // Above u32::MAX saturates to u32::MAX.
1976        assert_eq!(aspect_f32_to_u32(f32::MAX), u32::MAX);
1977        assert_eq!(aspect_f32_to_u32(f32::INFINITY), u32::MAX);
1978        assert_eq!(aspect_f32_to_u32(1e30), u32::MAX);
1979        // `u32::MAX as f32` rounds *up* to 2^32, so it saturates back down.
1980        assert_eq!(aspect_f32_to_u32(u32::MAX as f32), u32::MAX);
1981
1982        // NaN is defined to be 0, not UB and not a panic.
1983        assert_eq!(aspect_f32_to_u32(f32::NAN), 0);
1984        assert_eq!(aspect_f32_to_u32(-f32::NAN), 0);
1985
1986        // The largest value the parser can hand it (100_000 * 1000) fits exactly.
1987        assert_eq!(aspect_f32_to_u32(100_000.0 * 1000.0), 100_000_000);
1988    }
1989
1990    #[test]
1991    fn aspect_f32_to_u32_is_usable_in_const_context() {
1992        const TRUNCATED: u32 = aspect_f32_to_u32(1.999);
1993        const SATURATED: u32 = aspect_f32_to_u32(f32::INFINITY);
1994        const NEGATIVE: u32 = aspect_f32_to_u32(-5.0);
1995        const NOT_A_NUMBER: u32 = aspect_f32_to_u32(f32::NAN);
1996        assert_eq!(
1997            (TRUNCATED, SATURATED, NEGATIVE, NOT_A_NUMBER),
1998            (1, u32::MAX, 0, 0)
1999        );
2000    }
2001
2002    // ------------------------------------------------ parse_style_aspect_ratio ---
2003
2004    #[test]
2005    fn aspect_ratio_parses_valid_forms() {
2006        assert_eq!(
2007            parse_style_aspect_ratio("auto").unwrap(),
2008            StyleAspectRatio::Auto
2009        );
2010        assert_eq!(StyleAspectRatio::default(), StyleAspectRatio::Auto);
2011
2012        for input in ["16 / 9", "16/9", "16 /9", "16/ 9", "  16  /  9  "] {
2013            assert_eq!(
2014                parse_style_aspect_ratio(input).unwrap(),
2015                StyleAspectRatio::Ratio(AspectRatioValue {
2016                    width: 16000,
2017                    height: 9000
2018                }),
2019                "{input:?} should parse as 16/9"
2020            );
2021        }
2022
2023        // A bare number is `<number> / 1`, stored as fixed-point * 1000.
2024        assert_eq!(
2025            parse_style_aspect_ratio("1").unwrap(),
2026            StyleAspectRatio::Ratio(AspectRatioValue {
2027                width: 1000,
2028                height: 1000
2029            })
2030        );
2031        assert_eq!(
2032            parse_style_aspect_ratio("1.5").unwrap(),
2033            StyleAspectRatio::Ratio(AspectRatioValue {
2034                width: 1500,
2035                height: 1000
2036            })
2037        );
2038
2039        // Boundary of the documented range: 100_000 is accepted, just above is not.
2040        assert_eq!(
2041            parse_style_aspect_ratio("100000").unwrap(),
2042            StyleAspectRatio::Ratio(AspectRatioValue {
2043                width: 100_000_000,
2044                height: 1000
2045            })
2046        );
2047        assert!(parse_style_aspect_ratio("100001").is_err());
2048        assert!(parse_style_aspect_ratio("100000.1 / 1").is_err());
2049        assert!(parse_style_aspect_ratio("1 / 100001").is_err());
2050    }
2051
2052    #[test]
2053    fn aspect_ratio_rejects_non_positive_and_malformed_input() {
2054        for input in [
2055            "",
2056            "   ",
2057            "\t\n",
2058            "abc",
2059            "auto / auto",
2060            "16 / 9 / 4",
2061            "1/2/3",
2062            "/",
2063            "//",
2064            "/9",
2065            "16/",
2066            "16 9",
2067            ";",
2068            "16,9",
2069            "\u{1F600}",
2070            "\u{1F600}/\u{1F600}",
2071        ] {
2072            assert!(
2073                parse_style_aspect_ratio(input).is_err(),
2074                "{input:?} must not parse as an aspect-ratio"
2075            );
2076        }
2077
2078        // Zero and negative components are explicitly rejected.
2079        for input in [
2080            "0", "0 / 1", "1 / 0", "0/0", "-0", "-0 / 1", "1 / -0", "-1 / 1", "-1", "-1.5",
2081        ] {
2082            assert!(
2083                parse_style_aspect_ratio(input).is_err(),
2084                "{input:?} must not parse as an aspect-ratio"
2085            );
2086        }
2087
2088        // Infinities exceed the 100_000 bound (or are non-positive).
2089        for input in [
2090            "inf", "inf / 1", "1 / inf", "-inf", "-inf / 1", "1e39", "1e39 / 1",
2091        ] {
2092            assert!(
2093                parse_style_aspect_ratio(input).is_err(),
2094                "{input:?} should be rejected: out of the [0, 100_000] range"
2095            );
2096        }
2097    }
2098
2099    #[test]
2100    fn aspect_ratio_extremely_long_input_terminates() {
2101        let long = "9".repeat(100_000);
2102        assert!(parse_style_aspect_ratio(&long).is_err());
2103        assert!(parse_style_aspect_ratio(&format!("{long}/{long}")).is_err());
2104
2105        // 100k slashes: `find('/')` hits the first one, both sides fail to parse.
2106        let slashes = "/".repeat(100_000);
2107        assert!(parse_style_aspect_ratio(&slashes).is_err());
2108    }
2109
2110    #[test]
2111    fn aspect_ratio_auto_round_trips() {
2112        let printed = StyleAspectRatio::Auto.print_as_css_value();
2113        assert_eq!(printed, "auto");
2114        assert_eq!(
2115            parse_style_aspect_ratio(&printed).unwrap(),
2116            StyleAspectRatio::Auto
2117        );
2118    }
2119
2120    // ------------------------------------------- error types: to_contained/to_shared ---
2121
2122    #[test]
2123    fn opacity_parse_error_round_trips_through_the_owned_form() {
2124        let errors = [
2125            OpacityParseError::ParsePercentage(
2126                PercentageParseError::ValueParseErr(CssParseFloatError::Empty),
2127                "",
2128            ),
2129            OpacityParseError::ParsePercentage(
2130                PercentageParseError::ValueParseErr(CssParseFloatError::Invalid),
2131                "abc",
2132            ),
2133            OpacityParseError::ParsePercentage(PercentageParseError::NoPercentSign, "0.5"),
2134            OpacityParseError::ParsePercentage(
2135                PercentageParseError::InvalidUnit(String::from("px").into()),
2136                "5px",
2137            ),
2138            OpacityParseError::OutOfRange("1.5"),
2139            OpacityParseError::OutOfRange(""),
2140            OpacityParseError::OutOfRange("\u{1F600}"),
2141        ];
2142
2143        for error in errors {
2144            let owned = error.to_contained();
2145            assert_eq!(owned.to_shared(), error, "{error:?} did not round-trip");
2146            assert_eq!(owned.to_shared().to_contained(), owned);
2147
2148            let shown = error.to_string();
2149            assert!(!shown.is_empty(), "{error:?} renders as an empty message");
2150            // `impl_debug_as_display` forwards Debug to Display.
2151            assert_eq!(format!("{error:?}"), shown);
2152        }
2153    }
2154
2155    #[test]
2156    fn opacity_parse_error_to_contained_copies_the_borrowed_input() {
2157        // The owned form must not alias the (possibly temporary) input slice.
2158        let owned = {
2159            let input = String::from("1.5");
2160            parse_style_opacity(&input).unwrap_err().to_contained()
2161        };
2162        assert_eq!(
2163            owned,
2164            OpacityParseErrorOwned::OutOfRange(String::from("1.5").into())
2165        );
2166        assert!(owned.to_shared().to_string().contains("1.5"));
2167    }
2168
2169    #[test]
2170    fn keyword_parse_errors_round_trip_through_the_owned_form() {
2171        // All four `InvalidValueErr`-backed error types, over hostile payloads.
2172        for payload in ["", "junk", "  ", "\u{1F600}", "a\0b", "\u{0665}"] {
2173            let visibility = StyleVisibilityParseError::InvalidValue(InvalidValueErr(payload));
2174            assert_eq!(visibility.to_contained().to_shared(), visibility);
2175            assert!(!visibility.to_string().is_empty());
2176            assert_eq!(format!("{visibility:?}"), visibility.to_string());
2177
2178            let blend = MixBlendModeParseError::InvalidValue(InvalidValueErr(payload));
2179            assert_eq!(blend.to_contained().to_shared(), blend);
2180            assert!(!blend.to_string().is_empty());
2181
2182            let cursor = CursorParseError::InvalidValue(InvalidValueErr(payload));
2183            assert_eq!(cursor.to_contained().to_shared(), cursor);
2184            assert!(!cursor.to_string().is_empty());
2185
2186            // The `&str`-backed error types.
2187            let object_fit = StyleObjectFitParseError::InvalidValue(payload);
2188            assert_eq!(object_fit.to_contained().to_shared(), object_fit);
2189            assert!(!object_fit.to_string().is_empty());
2190
2191            let orientation = StyleTextOrientationParseError::InvalidValue(payload);
2192            assert_eq!(orientation.to_contained().to_shared(), orientation);
2193            assert!(!orientation.to_string().is_empty());
2194
2195            let position = StyleObjectPositionParseError::InvalidValue(payload);
2196            assert_eq!(position.to_contained().to_shared(), position);
2197            assert!(!position.to_string().is_empty());
2198
2199            let ratio = StyleAspectRatioParseError::InvalidValue(payload);
2200            assert_eq!(ratio.to_contained().to_shared(), ratio);
2201            assert!(!ratio.to_string().is_empty());
2202        }
2203    }
2204
2205    #[test]
2206    fn parse_errors_quote_the_offending_input() {
2207        // The rejected value has to survive into the message, or authors cannot
2208        // find the bad declaration.
2209        assert!(parse_style_visibility("show")
2210            .unwrap_err()
2211            .to_string()
2212            .contains("show"));
2213        assert!(parse_style_mix_blend_mode("mix")
2214            .unwrap_err()
2215            .to_string()
2216            .contains("mix"));
2217        assert!(parse_style_cursor("hand")
2218            .unwrap_err()
2219            .to_string()
2220            .contains("hand"));
2221        assert!(parse_style_object_fit("stretch")
2222            .unwrap_err()
2223            .to_string()
2224            .contains("stretch"));
2225        assert!(parse_style_text_orientation("vertical")
2226            .unwrap_err()
2227            .to_string()
2228            .contains("vertical"));
2229        assert!(parse_style_object_position("nope")
2230            .unwrap_err()
2231            .to_string()
2232            .contains("nope"));
2233        assert!(parse_style_aspect_ratio("nope")
2234            .unwrap_err()
2235            .to_string()
2236            .contains("nope"));
2237        assert!(parse_style_opacity("1.5")
2238            .unwrap_err()
2239            .to_string()
2240            .contains("1.5"));
2241    }
2242
2243    #[test]
2244    fn parse_errors_report_the_trimmed_input_not_the_raw_slice() {
2245        // Every keyword parser trims *before* constructing the error, so the
2246        // message never contains the caller's padding.
2247        let shown = parse_style_cursor("  hand  ").unwrap_err().to_string();
2248        assert!(
2249            shown.contains("\"hand\""),
2250            "expected the trimmed value, got {shown:?}"
2251        );
2252
2253        // ...except `parse_style_opacity`, which passes the *untrimmed* input to
2254        // the error. Pinned so the inconsistency is visible.
2255        let shown = parse_style_opacity("  1.5  ").unwrap_err().to_string();
2256        assert!(
2257            shown.contains("\"  1.5  \""),
2258            "expected the raw value, got {shown:?}"
2259        );
2260    }
2261
2262    #[test]
2263    fn owned_error_forms_are_independent_of_the_source_buffer() {
2264        // `to_contained` must deep-copy: the owned error has to outlive the
2265        // String it was parsed from.
2266        let owned = {
2267            let input = String::from("stretch");
2268            parse_style_object_fit(&input).unwrap_err().to_contained()
2269        };
2270        assert_eq!(
2271            owned,
2272            StyleObjectFitParseErrorOwned::InvalidValue(String::from("stretch").into())
2273        );
2274        assert!(owned.to_shared().to_string().contains("stretch"));
2275    }
2276
2277    // ------------------------------------------------------------ known bugs ---
2278    //
2279    // The tests below assert the behaviour these functions must have; they are
2280    // regression guards for bugs that have since been fixed.
2281
2282    #[test]
2283    fn known_bug_opacity_multibyte_numeric_char_panics() {
2284        // `char::is_numeric()` is true for Nd/Nl/No, including multi-byte chars
2285        // like '½' (U+00BD) and '٥' (U+0665). `parse_percentage_value` records
2286        // the *start* byte index of the last such char and then slices at
2287        // `split_pos + 1`, which lands inside the codepoint => the slice panics.
2288        //
2289        // `opacity: ½` in any author stylesheet therefore panics the CSS parser.
2290        // See `known_bug_percentage_multibyte_numeric_char_panics` in length.rs.
2291        for input in ["\u{00BD}", "\u{00BD}%", "0.5\u{0665}", "\u{FF15}%"] {
2292            assert!(
2293                parse_style_opacity(input).is_err(),
2294                "{input:?} should be rejected, not panic"
2295            );
2296        }
2297    }
2298
2299    #[test]
2300    fn known_bug_aspect_ratio_nan_bypasses_the_range_guards() {
2301        // Every guard in `parse_style_aspect_ratio` is a float comparison
2302        // (`h <= 0.0 || w <= 0.0 || w > 100_000.0 || h > 100_000.0`), and every
2303        // comparison against NaN is false — so a NaN component sails through and
2304        // `aspect_f32_to_u32(NaN)` turns it into 0. The parser explicitly rejects
2305        // "0 / 1", but happily returns `Ratio { width: 0, height: 1000 }` for
2306        // "NaN", which is a division by zero waiting to happen in layout.
2307        for input in ["NaN", "nan", "NaN / 1", "1 / NaN", "nan/nan", "-NaN"] {
2308            assert!(
2309                parse_style_aspect_ratio(input).is_err(),
2310                "{input:?} should be rejected, but parsed as {:?}",
2311                parse_style_aspect_ratio(input)
2312            );
2313        }
2314    }
2315
2316    #[test]
2317    fn known_bug_aspect_ratio_tiny_positive_values_round_down_to_zero() {
2318        // `w > 0.0` passes, but `(w * 1000.0).round()` is 0 for anything below
2319        // 0.0005 — so a positive ratio silently becomes the degenerate 0 that the
2320        // guard exists to prevent.
2321        for input in ["0.0001", "0.0004 / 1", "1 / 0.0001", "1e-10"] {
2322            let Ok(StyleAspectRatio::Ratio(ratio)) = parse_style_aspect_ratio(input) else {
2323                continue; // rejected outright — that is the fix
2324            };
2325            assert!(
2326                ratio.width > 0 && ratio.height > 0,
2327                "{input:?} produced the degenerate ratio {ratio:?}"
2328            );
2329        }
2330    }
2331
2332    #[test]
2333    fn known_bug_aspect_ratio_does_not_survive_a_print_reparse_cycle() {
2334        // `Ratio { width: 16000, height: 9000 }` (i.e. 16/9) prints as
2335        // "16000 / 9000", so every print/parse cycle multiplies both components
2336        // by 1000. One cycle changes the stored value; two cycles exceed the
2337        // 100_000 bound and fail to parse at all.
2338        let ratio = parse_style_aspect_ratio("16 / 9").unwrap();
2339        let printed = ratio.print_as_css_value();
2340        assert_eq!(
2341            printed, "16 / 9",
2342            "printed the fixed-point form: {printed:?}"
2343        );
2344        assert_eq!(parse_style_aspect_ratio(&printed).unwrap(), ratio);
2345    }
2346
2347    #[test]
2348    fn known_bug_object_position_rejects_reversed_keyword_pairs() {
2349        // `<position>` is `[left|center|right] || [top|center|bottom]` — the `||`
2350        // means either order is valid, so `object-position: top left` is legal
2351        // CSS. The parser only ever reads parts[0] as the horizontal component,
2352        // so it hands "top" to `parse_pixel_value` and fails.
2353        assert_eq!(
2354            parse_style_object_position("top left").unwrap(),
2355            parse_style_object_position("left top").unwrap()
2356        );
2357        assert_eq!(
2358            parse_style_object_position("bottom right").unwrap(),
2359            parse_style_object_position("right bottom").unwrap()
2360        );
2361    }
2362}