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