1
//! CSS properties for visual effects (opacity, blending, cursor), box sizing
2
//! (object-fit, object-position, aspect-ratio), and text orientation.
3

            
4
use alloc::string::{String, ToString};
5
use core::fmt;
6

            
7
#[cfg(feature = "parser")]
8
use crate::props::basic::{
9
    error::{InvalidValueErr, InvalidValueErrOwned},
10
    length::parse_percentage_value,
11
};
12
use 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)]
22
pub struct StyleOpacity {
23
    pub inner: PercentageValue,
24
}
25

            
26
impl Default for StyleOpacity {
27
1
    fn default() -> Self {
28
1
        Self {
29
1
            inner: PercentageValue::const_new(100),
30
1
        }
31
1
    }
32
}
33

            
34
impl PrintAsCssValue for StyleOpacity {
35
7
    fn print_as_css_value(&self) -> String {
36
7
        format!("{}", self.inner.normalized())
37
7
    }
38
}
39

            
40
#[cfg(feature = "parser")]
41
impl_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)]
49
pub enum StyleVisibility {
50
    #[default]
51
    Visible,
52
    Hidden,
53
    Collapse,
54
}
55

            
56

            
57
impl PrintAsCssValue for StyleVisibility {
58
3
    fn print_as_css_value(&self) -> String {
59
3
        String::from(match self {
60
1
            Self::Visible => "visible",
61
1
            Self::Hidden => "hidden",
62
1
            Self::Collapse => "collapse",
63
        })
64
3
    }
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)]
74
pub 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

            
95
impl fmt::Display for StyleMixBlendMode {
96
55
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97
55
        write!(
98
55
            f,
99
55
            "{}",
100
55
            match self {
101
5
                Self::Normal => "normal",
102
6
                Self::Multiply => "multiply",
103
3
                Self::Screen => "screen",
104
3
                Self::Overlay => "overlay",
105
3
                Self::Darken => "darken",
106
3
                Self::Lighten => "lighten",
107
5
                Self::ColorDodge => "color-dodge",
108
3
                Self::ColorBurn => "color-burn",
109
3
                Self::HardLight => "hard-light",
110
3
                Self::SoftLight => "soft-light",
111
3
                Self::Difference => "difference",
112
3
                Self::Exclusion => "exclusion",
113
3
                Self::Hue => "hue",
114
3
                Self::Saturation => "saturation",
115
3
                Self::Color => "color",
116
3
                Self::Luminosity => "luminosity",
117
            }
118
        )
119
55
    }
120
}
121

            
122
impl PrintAsCssValue for StyleMixBlendMode {
123
35
    fn print_as_css_value(&self) -> String {
124
35
        self.to_string()
125
35
    }
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)]
135
pub 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

            
170
impl PrintAsCssValue for StyleCursor {
171
840
    fn print_as_css_value(&self) -> String {
172
840
        String::from(match self {
173
2
            Self::Alias => "alias",
174
2
            Self::AllScroll => "all-scroll",
175
2
            Self::Cell => "cell",
176
2
            Self::ColResize => "col-resize",
177
2
            Self::ContextMenu => "context-menu",
178
2
            Self::Copy => "copy",
179
2
            Self::Crosshair => "crosshair",
180
2
            Self::Default => "default",
181
2
            Self::EResize => "e-resize",
182
2
            Self::EwResize => "ew-resize",
183
2
            Self::Grab => "grab",
184
2
            Self::Grabbing => "grabbing",
185
2
            Self::Help => "help",
186
2
            Self::Move => "move",
187
2
            Self::NResize => "n-resize",
188
2
            Self::NsResize => "ns-resize",
189
2
            Self::NeswResize => "nesw-resize",
190
2
            Self::NwseResize => "nwse-resize",
191
2
            Self::Pointer => "pointer",
192
2
            Self::Progress => "progress",
193
2
            Self::RowResize => "row-resize",
194
2
            Self::SResize => "s-resize",
195
2
            Self::SeResize => "se-resize",
196
782
            Self::Text => "text",
197
2
            Self::Unset => "unset",
198
2
            Self::VerticalText => "vertical-text",
199
2
            Self::WResize => "w-resize",
200
2
            Self::Wait => "wait",
201
2
            Self::ZoomIn => "zoom-in",
202
2
            Self::ZoomOut => "zoom-out",
203
        })
204
840
    }
205
}
206

            
207
// --- PARSERS ---
208

            
209
#[cfg(feature = "parser")]
210
pub 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
16
        #[must_use] pub fn to_contained(&self) -> OpacityParseErrorOwned {
246
16
            match self {
247
9
                Self::ParsePercentage(err, s) => {
248
9
                    OpacityParseErrorOwned::ParsePercentage(PercentageParseErrorWithInput { error: err.clone(), input: (*s).to_string().into() })
249
                }
250
7
                Self::OutOfRange(s) => OpacityParseErrorOwned::OutOfRange((*s).to_string().into()),
251
            }
252
16
        }
253
    }
254

            
255
    impl OpacityParseErrorOwned {
256
16
        #[must_use] pub fn to_shared(&self) -> OpacityParseError<'_> {
257
16
            match self {
258
9
                Self::ParsePercentage(e) => {
259
9
                    OpacityParseError::ParsePercentage(e.error.clone(), e.input.as_str())
260
                }
261
7
                Self::OutOfRange(s) => OpacityParseError::OutOfRange(s.as_str()),
262
            }
263
16
        }
264
    }
265

            
266
    /// # Errors
267
    ///
268
    /// Returns an error if `input` is not a valid CSS `opacity` value.
269
618
    pub fn parse_style_opacity(input: &str) -> Result<StyleOpacity, OpacityParseError<'_>> {
270
618
        let val = parse_percentage_value(input)
271
618
            .map_err(|e| OpacityParseError::ParsePercentage(e, input))?;
272

            
273
531
        let normalized = val.normalized();
274
531
        if !(0.0..=1.0).contains(&normalized) {
275
118
            return Err(OpacityParseError::OutOfRange(input));
276
413
        }
277

            
278
413
        Ok(StyleOpacity { inner: val })
279
618
    }
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
6
        #[must_use] pub fn to_contained(&self) -> StyleVisibilityParseErrorOwned {
301
6
            match self {
302
6
                Self::InvalidValue(e) => {
303
6
                    StyleVisibilityParseErrorOwned::InvalidValue(e.to_contained())
304
                }
305
            }
306
6
        }
307
    }
308

            
309
    impl StyleVisibilityParseErrorOwned {
310
6
        #[must_use] pub fn to_shared(&self) -> StyleVisibilityParseError<'_> {
311
6
            match self {
312
6
                Self::InvalidValue(e) => StyleVisibilityParseError::InvalidValue(e.to_shared()),
313
            }
314
6
        }
315
    }
316

            
317
    /// # Errors
318
    ///
319
    /// Returns an error if `input` is not a valid CSS `visibility` value.
320
61
    pub fn parse_style_visibility(
321
61
        input: &str,
322
61
    ) -> Result<StyleVisibility, StyleVisibilityParseError<'_>> {
323
61
        let input = input.trim();
324
61
        match input {
325
61
            "visible" => Ok(StyleVisibility::Visible),
326
55
            "hidden" => Ok(StyleVisibility::Hidden),
327
30
            "collapse" => Ok(StyleVisibility::Collapse),
328
26
            _ => Err(InvalidValueErr(input).into()),
329
        }
330
61
    }
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
6
        #[must_use] pub fn to_contained(&self) -> MixBlendModeParseErrorOwned {
352
6
            match self {
353
6
                Self::InvalidValue(e) => {
354
6
                    MixBlendModeParseErrorOwned::InvalidValue(e.to_contained())
355
                }
356
            }
357
6
        }
358
    }
359

            
360
    impl MixBlendModeParseErrorOwned {
361
6
        #[must_use] pub fn to_shared(&self) -> MixBlendModeParseError<'_> {
362
6
            match self {
363
6
                Self::InvalidValue(e) => MixBlendModeParseError::InvalidValue(e.to_shared()),
364
            }
365
6
        }
366
    }
367

            
368
    /// # Errors
369
    ///
370
    /// Returns an error if `input` is not a valid CSS `mix-blend-mode` value.
371
67
    pub fn parse_style_mix_blend_mode(
372
67
        input: &str,
373
67
    ) -> Result<StyleMixBlendMode, MixBlendModeParseError<'_>> {
374
67
        let input = input.trim();
375
67
        match input {
376
67
            "normal" => Ok(StyleMixBlendMode::Normal),
377
64
            "multiply" => Ok(StyleMixBlendMode::Multiply),
378
59
            "screen" => Ok(StyleMixBlendMode::Screen),
379
56
            "overlay" => Ok(StyleMixBlendMode::Overlay),
380
54
            "darken" => Ok(StyleMixBlendMode::Darken),
381
52
            "lighten" => Ok(StyleMixBlendMode::Lighten),
382
50
            "color-dodge" => Ok(StyleMixBlendMode::ColorDodge),
383
45
            "color-burn" => Ok(StyleMixBlendMode::ColorBurn),
384
43
            "hard-light" => Ok(StyleMixBlendMode::HardLight),
385
41
            "soft-light" => Ok(StyleMixBlendMode::SoftLight),
386
39
            "difference" => Ok(StyleMixBlendMode::Difference),
387
37
            "exclusion" => Ok(StyleMixBlendMode::Exclusion),
388
35
            "hue" => Ok(StyleMixBlendMode::Hue),
389
33
            "saturation" => Ok(StyleMixBlendMode::Saturation),
390
31
            "color" => Ok(StyleMixBlendMode::Color),
391
29
            "luminosity" => Ok(StyleMixBlendMode::Luminosity),
392
27
            _ => Err(InvalidValueErr(input).into()),
393
        }
394
67
    }
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
6
        #[must_use] pub fn to_contained(&self) -> CursorParseErrorOwned {
416
6
            match self {
417
6
                Self::InvalidValue(e) => CursorParseErrorOwned::InvalidValue(e.to_contained()),
418
            }
419
6
        }
420
    }
421

            
422
    impl CursorParseErrorOwned {
423
6
        #[must_use] pub fn to_shared(&self) -> CursorParseError<'_> {
424
6
            match self {
425
6
                Self::InvalidValue(e) => CursorParseError::InvalidValue(e.to_shared()),
426
            }
427
6
        }
428
    }
429

            
430
    /// # Errors
431
    ///
432
    /// Returns an error if `input` is not a valid CSS `cursor` value.
433
17546
    pub fn parse_style_cursor(input: &str) -> Result<StyleCursor, CursorParseError<'_>> {
434
17546
        let input = input.trim();
435
17546
        match input {
436
17546
            "alias" => Ok(StyleCursor::Alias),
437
17544
            "all-scroll" => Ok(StyleCursor::AllScroll),
438
17542
            "cell" => Ok(StyleCursor::Cell),
439
17540
            "col-resize" => Ok(StyleCursor::ColResize),
440
17537
            "context-menu" => Ok(StyleCursor::ContextMenu),
441
17535
            "copy" => Ok(StyleCursor::Copy),
442
17533
            "crosshair" => Ok(StyleCursor::Crosshair),
443
17219
            "default" => Ok(StyleCursor::Default),
444
16905
            "e-resize" => Ok(StyleCursor::EResize),
445
16903
            "ew-resize" => Ok(StyleCursor::EwResize),
446
16901
            "grab" => Ok(StyleCursor::Grab),
447
16871
            "grabbing" => Ok(StyleCursor::Grabbing),
448
16869
            "help" => Ok(StyleCursor::Help),
449
16867
            "move" => Ok(StyleCursor::Move),
450
16865
            "n-resize" => Ok(StyleCursor::NResize),
451
16863
            "ns-resize" => Ok(StyleCursor::NsResize),
452
16861
            "nesw-resize" => Ok(StyleCursor::NeswResize),
453
16859
            "nwse-resize" => Ok(StyleCursor::NwseResize),
454
16857
            "pointer" => Ok(StyleCursor::Pointer),
455
50
            "progress" => Ok(StyleCursor::Progress),
456
48
            "row-resize" => Ok(StyleCursor::RowResize),
457
46
            "s-resize" => Ok(StyleCursor::SResize),
458
44
            "se-resize" => Ok(StyleCursor::SeResize),
459
42
            "text" => Ok(StyleCursor::Text),
460
39
            "unset" => Ok(StyleCursor::Unset),
461
37
            "vertical-text" => Ok(StyleCursor::VerticalText),
462
35
            "w-resize" => Ok(StyleCursor::WResize),
463
33
            "wait" => Ok(StyleCursor::Wait),
464
30
            "zoom-in" => Ok(StyleCursor::ZoomIn),
465
28
            "zoom-out" => Ok(StyleCursor::ZoomOut),
466
26
            _ => Err(InvalidValueErr(input).into()),
467
        }
468
17546
    }
469
}
470

            
471
#[cfg(feature = "parser")]
472
pub use self::parsers::*;
473

            
474
#[cfg(all(test, feature = "parser"))]
475
mod tests {
476
    // Tests assert that parsed values equal the exact source literals.
477
    #![allow(clippy::float_cmp)]
478
    use super::*;
479

            
480
    #[test]
481
1
    fn test_parse_opacity() {
482
1
        assert_eq!(parse_style_opacity("0.5").unwrap().inner.normalized(), 0.5);
483
1
        assert_eq!(parse_style_opacity("1").unwrap().inner.normalized(), 1.0);
484
1
        assert_eq!(parse_style_opacity("50%").unwrap().inner.normalized(), 0.5);
485
1
        assert_eq!(parse_style_opacity("0").unwrap().inner.normalized(), 0.0);
486
1
        assert_eq!(
487
1
            parse_style_opacity("  75%  ").unwrap().inner.normalized(),
488
            0.75
489
        );
490
1
        assert!(parse_style_opacity("1.1").is_err());
491
1
        assert!(parse_style_opacity("-0.1").is_err());
492
1
        assert!(parse_style_opacity("auto").is_err());
493
1
    }
494

            
495
    #[test]
496
1
    fn test_parse_mix_blend_mode() {
497
1
        assert_eq!(
498
1
            parse_style_mix_blend_mode("multiply").unwrap(),
499
            StyleMixBlendMode::Multiply
500
        );
501
1
        assert_eq!(
502
1
            parse_style_mix_blend_mode("screen").unwrap(),
503
            StyleMixBlendMode::Screen
504
        );
505
1
        assert_eq!(
506
1
            parse_style_mix_blend_mode("color-dodge").unwrap(),
507
            StyleMixBlendMode::ColorDodge
508
        );
509
1
        assert!(parse_style_mix_blend_mode("mix").is_err());
510
1
    }
511

            
512
    #[test]
513
1
    fn test_parse_visibility() {
514
1
        assert_eq!(
515
1
            parse_style_visibility("visible").unwrap(),
516
            StyleVisibility::Visible
517
        );
518
1
        assert_eq!(
519
1
            parse_style_visibility("hidden").unwrap(),
520
            StyleVisibility::Hidden
521
        );
522
1
        assert_eq!(
523
1
            parse_style_visibility("collapse").unwrap(),
524
            StyleVisibility::Collapse
525
        );
526
1
        assert_eq!(
527
1
            parse_style_visibility("  visible  ").unwrap(),
528
            StyleVisibility::Visible
529
        );
530
1
        assert!(parse_style_visibility("none").is_err());
531
1
        assert!(parse_style_visibility("show").is_err());
532
1
    }
533

            
534
    #[test]
535
1
    fn test_parse_cursor() {
536
1
        assert_eq!(parse_style_cursor("pointer").unwrap(), StyleCursor::Pointer);
537
1
        assert_eq!(parse_style_cursor("wait").unwrap(), StyleCursor::Wait);
538
1
        assert_eq!(
539
1
            parse_style_cursor("col-resize").unwrap(),
540
            StyleCursor::ColResize
541
        );
542
1
        assert_eq!(parse_style_cursor("  text  ").unwrap(), StyleCursor::Text);
543
1
        assert!(parse_style_cursor("hand").is_err()); // "hand" is a legacy IE value
544
1
    }
545

            
546
    #[test]
547
1
    fn test_parse_object_fit() {
548
1
        assert_eq!(parse_style_object_fit("fill").unwrap(), StyleObjectFit::Fill);
549
1
        assert_eq!(parse_style_object_fit("contain").unwrap(), StyleObjectFit::Contain);
550
1
        assert_eq!(parse_style_object_fit("cover").unwrap(), StyleObjectFit::Cover);
551
1
        assert_eq!(parse_style_object_fit("none").unwrap(), StyleObjectFit::None);
552
1
        assert_eq!(parse_style_object_fit("scale-down").unwrap(), StyleObjectFit::ScaleDown);
553
1
        assert_eq!(parse_style_object_fit("  cover  ").unwrap(), StyleObjectFit::Cover);
554
1
        assert!(parse_style_object_fit("stretch").is_err());
555
1
        assert!(parse_style_object_fit("").is_err());
556
1
    }
557

            
558
    #[test]
559
1
    fn test_parse_text_orientation() {
560
1
        assert_eq!(parse_style_text_orientation("mixed").unwrap(), StyleTextOrientation::Mixed);
561
1
        assert_eq!(parse_style_text_orientation("upright").unwrap(), StyleTextOrientation::Upright);
562
1
        assert_eq!(parse_style_text_orientation("sideways").unwrap(), StyleTextOrientation::Sideways);
563
1
        assert_eq!(parse_style_text_orientation("  mixed  ").unwrap(), StyleTextOrientation::Mixed);
564
1
        assert!(parse_style_text_orientation("vertical").is_err());
565
1
    }
566

            
567
    #[test]
568
1
    fn test_parse_object_position() {
569
        use crate::props::style::background::{BackgroundPositionHorizontal, BackgroundPositionVertical};
570
1
        let centered = parse_style_object_position("center").unwrap();
571
1
        assert_eq!(centered, parse_style_object_position("center center").unwrap());
572

            
573
1
        let lt = parse_style_object_position("left top").unwrap();
574
1
        assert_eq!(lt.horizontal, BackgroundPositionHorizontal::Left);
575
1
        assert_eq!(lt.vertical, BackgroundPositionVertical::Top);
576

            
577
1
        let rb = parse_style_object_position("right bottom").unwrap();
578
1
        assert_eq!(rb.horizontal, BackgroundPositionHorizontal::Right);
579
1
        assert_eq!(rb.vertical, BackgroundPositionVertical::Bottom);
580

            
581
1
        assert!(parse_style_object_position("left top center").is_err());
582
1
        assert!(parse_style_object_position("invalid").is_err());
583
1
    }
584

            
585
    #[test]
586
1
    fn test_parse_aspect_ratio() {
587
1
        assert_eq!(parse_style_aspect_ratio("auto").unwrap(), StyleAspectRatio::Auto);
588
1
        assert_eq!(
589
1
            parse_style_aspect_ratio("16 / 9").unwrap(),
590
            StyleAspectRatio::Ratio(AspectRatioValue { width: 16000, height: 9000 })
591
        );
592
1
        assert_eq!(
593
1
            parse_style_aspect_ratio("16/9").unwrap(),
594
            StyleAspectRatio::Ratio(AspectRatioValue { width: 16000, height: 9000 })
595
        );
596
1
        assert_eq!(
597
1
            parse_style_aspect_ratio("1.5").unwrap(),
598
            StyleAspectRatio::Ratio(AspectRatioValue { width: 1500, height: 1000 })
599
        );
600
1
        assert_eq!(
601
1
            parse_style_aspect_ratio("  4 / 3  ").unwrap(),
602
            StyleAspectRatio::Ratio(AspectRatioValue { width: 4000, height: 3000 })
603
        );
604
1
        assert!(parse_style_aspect_ratio("0 / 1").is_err());
605
1
        assert!(parse_style_aspect_ratio("1 / 0").is_err());
606
1
        assert!(parse_style_aspect_ratio("-1 / 1").is_err());
607
1
        assert!(parse_style_aspect_ratio("abc").is_err());
608
1
    }
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)]
618
pub enum StyleObjectFit {
619
    #[default]
620
    Fill,
621
    Contain,
622
    Cover,
623
    None,
624
    ScaleDown,
625
}
626

            
627

            
628
crate::impl_option!(StyleObjectFit, OptionStyleObjectFit, [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]);
629

            
630
impl PrintAsCssValue for StyleObjectFit {
631
5
    fn print_as_css_value(&self) -> String {
632
5
        String::from(match self {
633
1
            Self::Fill => "fill",
634
1
            Self::Contain => "contain",
635
1
            Self::Cover => "cover",
636
1
            Self::None => "none",
637
1
            Self::ScaleDown => "scale-down",
638
        })
639
5
    }
640
}
641

            
642
#[cfg(feature = "parser")]
643
#[derive(Clone, PartialEq, Eq)]
644
pub enum StyleObjectFitParseError<'a> {
645
    InvalidValue(&'a str),
646
}
647

            
648
#[cfg(feature = "parser")]
649
crate::impl_debug_as_display!(StyleObjectFitParseError<'a>);
650

            
651
#[cfg(feature = "parser")]
652
crate::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)]
659
pub enum StyleObjectFitParseErrorOwned {
660
    InvalidValue(crate::AzString),
661
}
662

            
663
#[cfg(feature = "parser")]
664
impl StyleObjectFitParseError<'_> {
665
7
    #[must_use] pub fn to_contained(&self) -> StyleObjectFitParseErrorOwned {
666
7
        match self {
667
7
            Self::InvalidValue(s) => StyleObjectFitParseErrorOwned::InvalidValue((*s).to_string().into()),
668
        }
669
7
    }
670
}
671

            
672
#[cfg(feature = "parser")]
673
impl StyleObjectFitParseErrorOwned {
674
7
    #[must_use] pub fn to_shared(&self) -> StyleObjectFitParseError<'_> {
675
7
        match self {
676
7
            Self::InvalidValue(s) => StyleObjectFitParseError::InvalidValue(s.as_str()),
677
        }
678
7
    }
679
}
680

            
681
#[cfg(feature = "parser")]
682
/// # Errors
683
///
684
/// Returns an error if `input` is not a valid CSS `object-fit` value.
685
42
pub fn parse_style_object_fit(
686
42
    input: &str,
687
42
) -> Result<StyleObjectFit, StyleObjectFitParseError<'_>> {
688
42
    let input = input.trim();
689
42
    match input {
690
42
        "fill" => Ok(StyleObjectFit::Fill),
691
39
        "contain" => Ok(StyleObjectFit::Contain),
692
36
        "cover" => Ok(StyleObjectFit::Cover),
693
32
        "none" => Ok(StyleObjectFit::None),
694
29
        "scale-down" => Ok(StyleObjectFit::ScaleDown),
695
26
        _ => Err(StyleObjectFitParseError::InvalidValue(input)),
696
    }
697
42
}
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)]
706
pub enum StyleTextOrientation {
707
    #[default]
708
    Mixed,
709
    Upright,
710
    Sideways,
711
}
712

            
713

            
714
crate::impl_option!(StyleTextOrientation, OptionStyleTextOrientation, [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]);
715

            
716
impl PrintAsCssValue for StyleTextOrientation {
717
3
    fn print_as_css_value(&self) -> String {
718
3
        String::from(match self {
719
1
            Self::Mixed => "mixed",
720
1
            Self::Upright => "upright",
721
1
            Self::Sideways => "sideways",
722
        })
723
3
    }
724
}
725

            
726
#[cfg(feature = "parser")]
727
#[derive(Clone, PartialEq, Eq)]
728
pub enum StyleTextOrientationParseError<'a> {
729
    InvalidValue(&'a str),
730
}
731

            
732
#[cfg(feature = "parser")]
733
crate::impl_debug_as_display!(StyleTextOrientationParseError<'a>);
734

            
735
#[cfg(feature = "parser")]
736
crate::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)]
743
pub enum StyleTextOrientationParseErrorOwned {
744
    InvalidValue(crate::AzString),
745
}
746

            
747
#[cfg(feature = "parser")]
748
impl StyleTextOrientationParseError<'_> {
749
6
    #[must_use] pub fn to_contained(&self) -> StyleTextOrientationParseErrorOwned {
750
6
        match self {
751
6
            Self::InvalidValue(s) => StyleTextOrientationParseErrorOwned::InvalidValue((*s).to_string().into()),
752
        }
753
6
    }
754
}
755

            
756
#[cfg(feature = "parser")]
757
impl StyleTextOrientationParseErrorOwned {
758
6
    #[must_use] pub fn to_shared(&self) -> StyleTextOrientationParseError<'_> {
759
6
        match self {
760
6
            Self::InvalidValue(s) => StyleTextOrientationParseError::InvalidValue(s.as_str()),
761
        }
762
6
    }
763
}
764

            
765
#[cfg(feature = "parser")]
766
/// # Errors
767
///
768
/// Returns an error if `input` is not a valid CSS `text-orientation` value.
769
32
pub fn parse_style_text_orientation(
770
32
    input: &str,
771
32
) -> Result<StyleTextOrientation, StyleTextOrientationParseError<'_>> {
772
32
    let input = input.trim();
773
32
    match input {
774
32
        "mixed" => Ok(StyleTextOrientation::Mixed),
775
28
        "upright" => Ok(StyleTextOrientation::Upright),
776
25
        "sideways" => Ok(StyleTextOrientation::Sideways),
777
22
        _ => Err(StyleTextOrientationParseError::InvalidValue(input)),
778
    }
779
32
}
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)]
787
pub struct StyleObjectPosition {
788
    pub horizontal: crate::props::style::background::BackgroundPositionHorizontal,
789
    pub vertical: crate::props::style::background::BackgroundPositionVertical,
790
}
791

            
792
impl Default for StyleObjectPosition {
793
1
    fn default() -> Self {
794
        use crate::props::basic::pixel::PixelValue;
795
1
        Self {
796
1
            horizontal: crate::props::style::background::BackgroundPositionHorizontal::Exact(
797
1
                PixelValue::percent(50.0),
798
1
            ),
799
1
            vertical: crate::props::style::background::BackgroundPositionVertical::Exact(
800
1
                PixelValue::percent(50.0),
801
1
            ),
802
1
        }
803
1
    }
804
}
805

            
806
crate::impl_option!(StyleObjectPosition, OptionStyleObjectPosition, [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]);
807

            
808
impl PrintAsCssValue for StyleObjectPosition {
809
18
    fn print_as_css_value(&self) -> String {
810
18
        format!(
811
18
            "{} {}",
812
18
            self.horizontal.print_as_css_value(),
813
18
            self.vertical.print_as_css_value()
814
        )
815
18
    }
816
}
817

            
818
#[cfg(feature = "parser")]
819
#[derive(Clone, PartialEq, Eq)]
820
pub enum StyleObjectPositionParseError<'a> {
821
    InvalidValue(&'a str),
822
}
823

            
824
#[cfg(feature = "parser")]
825
crate::impl_debug_as_display!(StyleObjectPositionParseError<'a>);
826

            
827
#[cfg(feature = "parser")]
828
crate::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)]
835
pub enum StyleObjectPositionParseErrorOwned {
836
    InvalidValue(crate::AzString),
837
}
838

            
839
#[cfg(feature = "parser")]
840
impl StyleObjectPositionParseError<'_> {
841
6
    #[must_use] pub fn to_contained(&self) -> StyleObjectPositionParseErrorOwned {
842
6
        match self {
843
6
            Self::InvalidValue(s) => StyleObjectPositionParseErrorOwned::InvalidValue((*s).to_string().into()),
844
        }
845
6
    }
846
}
847

            
848
#[cfg(feature = "parser")]
849
impl StyleObjectPositionParseErrorOwned {
850
6
    #[must_use] pub fn to_shared(&self) -> StyleObjectPositionParseError<'_> {
851
6
        match self {
852
6
            Self::InvalidValue(s) => StyleObjectPositionParseError::InvalidValue(s.as_str()),
853
        }
854
6
    }
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.
863
65
pub fn parse_style_object_position(
864
65
    input: &str,
865
65
) -> Result<StyleObjectPosition, StyleObjectPositionParseError<'_>> {
866
    use crate::props::style::background::{
867
        BackgroundPositionHorizontal, BackgroundPositionVertical,
868
    };
869
    use crate::props::basic::pixel::parse_pixel_value;
870

            
871
65
    let input = input.trim();
872
65
    let parts: Vec<&str> = input.split_whitespace().collect();
873

            
874
65
    let (h, v) = match parts.len() {
875
        1 => {
876
20
            let val = parts[0];
877
20
            match val {
878
20
                "center" => (BackgroundPositionHorizontal::Center, BackgroundPositionVertical::Center),
879
17
                "left" => (BackgroundPositionHorizontal::Left, BackgroundPositionVertical::Center),
880
16
                "right" => (BackgroundPositionHorizontal::Right, BackgroundPositionVertical::Center),
881
15
                "top" => (BackgroundPositionHorizontal::Center, BackgroundPositionVertical::Top),
882
14
                "bottom" => (BackgroundPositionHorizontal::Center, BackgroundPositionVertical::Bottom),
883
                _ => {
884
13
                    let px = parse_pixel_value(val)
885
13
                        .map_err(|_| StyleObjectPositionParseError::InvalidValue(input))?;
886
6
                    (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
38
            let (a, b) = (parts[0], parts[1]);
896
38
            let both_keywords = matches!(a, "left" | "center" | "right" | "top" | "bottom")
897
27
                && matches!(b, "left" | "center" | "right" | "top" | "bottom");
898
38
            let reversed = both_keywords
899
23
                && (matches!(a, "top" | "bottom") || matches!(b, "left" | "right"));
900
38
            let (h_str, v_str) = if reversed { (b, a) } else { (a, b) };
901

            
902
38
            let h = match h_str {
903
38
                "left" => BackgroundPositionHorizontal::Left,
904
24
                "center" => BackgroundPositionHorizontal::Center,
905
19
                "right" => BackgroundPositionHorizontal::Right,
906
12
                other => {
907
12
                    let px = parse_pixel_value(other)
908
12
                        .map_err(|_| StyleObjectPositionParseError::InvalidValue(input))?;
909
10
                    BackgroundPositionHorizontal::Exact(px)
910
                }
911
            };
912
36
            let v = match v_str {
913
36
                "top" => BackgroundPositionVertical::Top,
914
23
                "center" => BackgroundPositionVertical::Center,
915
18
                "bottom" => BackgroundPositionVertical::Bottom,
916
11
                other => {
917
11
                    let px = parse_pixel_value(other)
918
11
                        .map_err(|_| StyleObjectPositionParseError::InvalidValue(input))?;
919
10
                    BackgroundPositionVertical::Exact(px)
920
                }
921
            };
922
35
            (h, v)
923
        }
924
7
        _ => return Err(StyleObjectPositionParseError::InvalidValue(input)),
925
    };
926

            
927
48
    Ok(StyleObjectPosition { horizontal: h, vertical: v })
928
65
}
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)]
935
pub struct AspectRatioValue {
936
    pub width: u32,
937
    pub height: u32,
938
}
939

            
940
impl 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
2
    fn fmt_component(v: u32) -> String {
946
2
        let int = v / 1000;
947
2
        let frac = v % 1000;
948
2
        if frac == 0 {
949
2
            int.to_string()
950
        } else {
951
            let frac_str = format!("{frac:03}");
952
            format!("{int}.{}", frac_str.trim_end_matches('0'))
953
        }
954
2
    }
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)]
964
pub 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

            
973
crate::impl_option!(StyleAspectRatio, OptionStyleAspectRatio, [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]);
974

            
975
impl PrintAsCssValue for StyleAspectRatio {
976
2
    fn print_as_css_value(&self) -> String {
977
2
        match self {
978
1
            Self::Auto => String::from("auto"),
979
1
            Self::Ratio(r) => format!(
980
1
                "{} / {}",
981
1
                AspectRatioValue::fmt_component(r.width),
982
1
                AspectRatioValue::fmt_component(r.height)
983
            ),
984
        }
985
2
    }
986
}
987

            
988
#[cfg(feature = "parser")]
989
#[derive(Clone, PartialEq, Eq)]
990
pub enum StyleAspectRatioParseError<'a> {
991
    InvalidValue(&'a str),
992
}
993

            
994
#[cfg(feature = "parser")]
995
crate::impl_debug_as_display!(StyleAspectRatioParseError<'a>);
996

            
997
#[cfg(feature = "parser")]
998
crate::impl_display! { StyleAspectRatioParseError<'a>, {
999
    InvalidValue(val) => format!("Invalid aspect-ratio value: \"{}\"", val),
}}
#[cfg(feature = "parser")]
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C, u8)]
pub enum StyleAspectRatioParseErrorOwned {
    InvalidValue(crate::AzString),
}
#[cfg(feature = "parser")]
impl StyleAspectRatioParseError<'_> {
6
    #[must_use] pub fn to_contained(&self) -> StyleAspectRatioParseErrorOwned {
6
        match self {
6
            Self::InvalidValue(s) => StyleAspectRatioParseErrorOwned::InvalidValue((*s).to_string().into()),
        }
6
    }
}
#[cfg(feature = "parser")]
impl StyleAspectRatioParseErrorOwned {
6
    #[must_use] pub fn to_shared(&self) -> StyleAspectRatioParseError<'_> {
6
        match self {
6
            Self::InvalidValue(s) => StyleAspectRatioParseError::InvalidValue(s.as_str()),
        }
6
    }
}
/// Truncating `f32` → `u32` for aspect-ratio values (callers validate the input
/// is positive and bounded, so the value always fits). Rust's `as u32` saturates
/// out-of-range floats; this isolates the one unavoidable float→int cast.
#[cfg(feature = "parser")]
#[inline]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
102
const fn aspect_f32_to_u32(v: f32) -> u32 {
102
    v as u32
102
}
/// Validate two ratio components and encode them into the fixed-point
/// [`AspectRatioValue`]. The positive-range checks are written as
/// `!(x > 0.0 && x <= MAX)` so NaN — which is false for every ordered
/// comparison — is rejected instead of sailing through the guards. A component
/// whose fixed-point encoding rounds to 0 (magnitude below ~0.0005) is a
/// degenerate divide-by-zero ratio and is rejected as well.
#[cfg(feature = "parser")]
73
fn ratio_from_components(
73
    w: f32,
73
    h: f32,
73
    input: &str,
73
) -> Result<StyleAspectRatio, StyleAspectRatioParseError<'_>> {
73
    if !(w > 0.0 && w <= 100_000.0 && h > 0.0 && h <= 100_000.0) {
31
        return Err(StyleAspectRatioParseError::InvalidValue(input));
42
    }
42
    let width = aspect_f32_to_u32((w * 1000.0).round());
42
    let height = aspect_f32_to_u32((h * 1000.0).round());
42
    if width == 0 || height == 0 {
4
        return Err(StyleAspectRatioParseError::InvalidValue(input));
38
    }
38
    Ok(StyleAspectRatio::Ratio(AspectRatioValue { width, height }))
73
}
/// Parse aspect-ratio: "auto", "16 / 9", "1.5", "4/3"
#[cfg(feature = "parser")]
/// # Errors
///
/// Returns an error if `input` is not a valid CSS `aspect-ratio` value.
97
pub fn parse_style_aspect_ratio(
97
    input: &str,
97
) -> Result<StyleAspectRatio, StyleAspectRatioParseError<'_>> {
97
    let input = input.trim();
97
    if input == "auto" {
3
        return Ok(StyleAspectRatio::Auto);
94
    }
    // Try "w / h" or "w/h" format
94
    if let Some(slash_pos) = input.find('/') {
40
        let w_str = input[..slash_pos].trim();
40
        let h_str = input[slash_pos + 1..].trim();
40
        let w: f32 = w_str.parse().map_err(|_| StyleAspectRatioParseError::InvalidValue(input))?;
34
        let h: f32 = h_str.parse().map_err(|_| StyleAspectRatioParseError::InvalidValue(input))?;
31
        return ratio_from_components(w, h, input);
54
    }
    // A single number is the "<w> / 1" ratio.
54
    let w: f32 = input.parse().map_err(|_| StyleAspectRatioParseError::InvalidValue(input))?;
42
    ratio_from_components(w, 1.0, input)
97
}
#[cfg(all(test, feature = "parser"))]
#[allow(
    clippy::float_cmp,
    clippy::unreadable_literal,
    clippy::too_many_lines,
    clippy::cast_precision_loss
)]
mod autotest_generated {
    use super::*;
    use crate::{
        props::{
            basic::{
                error::ParseFloatError as CssParseFloatError, pixel::PixelValue,
            },
            formatter::PrintAsCssValue,
            style::background::{BackgroundPositionHorizontal, BackgroundPositionVertical},
        },
    };
    const ALL_VISIBILITY: [StyleVisibility; 3] = [
        StyleVisibility::Visible,
        StyleVisibility::Hidden,
        StyleVisibility::Collapse,
    ];
    const ALL_BLEND_MODES: [StyleMixBlendMode; 16] = [
        StyleMixBlendMode::Normal,
        StyleMixBlendMode::Multiply,
        StyleMixBlendMode::Screen,
        StyleMixBlendMode::Overlay,
        StyleMixBlendMode::Darken,
        StyleMixBlendMode::Lighten,
        StyleMixBlendMode::ColorDodge,
        StyleMixBlendMode::ColorBurn,
        StyleMixBlendMode::HardLight,
        StyleMixBlendMode::SoftLight,
        StyleMixBlendMode::Difference,
        StyleMixBlendMode::Exclusion,
        StyleMixBlendMode::Hue,
        StyleMixBlendMode::Saturation,
        StyleMixBlendMode::Color,
        StyleMixBlendMode::Luminosity,
    ];
    const ALL_CURSORS: [StyleCursor; 30] = [
        StyleCursor::Alias,
        StyleCursor::AllScroll,
        StyleCursor::Cell,
        StyleCursor::ColResize,
        StyleCursor::ContextMenu,
        StyleCursor::Copy,
        StyleCursor::Crosshair,
        StyleCursor::Default,
        StyleCursor::EResize,
        StyleCursor::EwResize,
        StyleCursor::Grab,
        StyleCursor::Grabbing,
        StyleCursor::Help,
        StyleCursor::Move,
        StyleCursor::NResize,
        StyleCursor::NsResize,
        StyleCursor::NeswResize,
        StyleCursor::NwseResize,
        StyleCursor::Pointer,
        StyleCursor::Progress,
        StyleCursor::RowResize,
        StyleCursor::SResize,
        StyleCursor::SeResize,
        StyleCursor::Text,
        StyleCursor::Unset,
        StyleCursor::VerticalText,
        StyleCursor::WResize,
        StyleCursor::Wait,
        StyleCursor::ZoomIn,
        StyleCursor::ZoomOut,
    ];
    const ALL_OBJECT_FIT: [StyleObjectFit; 5] = [
        StyleObjectFit::Fill,
        StyleObjectFit::Contain,
        StyleObjectFit::Cover,
        StyleObjectFit::None,
        StyleObjectFit::ScaleDown,
    ];
    const ALL_TEXT_ORIENTATION: [StyleTextOrientation; 3] = [
        StyleTextOrientation::Mixed,
        StyleTextOrientation::Upright,
        StyleTextOrientation::Sideways,
    ];
    /// Inputs no keyword parser may ever accept, and none may panic on.
    /// Deliberately mixes empty / whitespace / punctuation / multibyte input.
    const HOSTILE_KEYWORDS: [&str; 14] = [
        "",
        " ",
        "\t\n\r",
        "\u{a0}",           // NBSP — `str::trim` treats it as whitespace
        ";",
        "{}",
        "/*",
        "0",
        "-1",
        "NaN",
        "inf",
        "\u{1F600}",        // emoji
        "e\u{0301}",        // combining acute accent
        "\u{0665}",         // ARABIC-INDIC DIGIT FIVE (multibyte, `is_numeric`)
    ];
    // ------------------------------------------------ StyleMixBlendMode::fmt ---
    #[test]
    fn blend_mode_display_is_well_formed_for_every_variant() {
        for mode in ALL_BLEND_MODES {
            let shown = mode.to_string();
            assert!(!shown.is_empty(), "{mode:?} renders as an empty string");
            assert!(
                shown
                    .chars()
                    .all(|c| c.is_ascii_lowercase() || c == '-'),
                "{mode:?} renders as {shown:?}, which is not a CSS ident"
            );
            // `PrintAsCssValue` delegates to `Display`; pin them together so a
            // future divergence has to be deliberate.
            assert_eq!(shown, mode.print_as_css_value());
        }
    }
    #[test]
    fn blend_mode_display_of_default_is_normal() {
        assert_eq!(StyleMixBlendMode::default().to_string(), "normal");
        assert_eq!(StyleMixBlendMode::default(), StyleMixBlendMode::Normal);
    }
    #[test]
    fn blend_mode_display_survives_width_and_precision_flags() {
        // The impl forwards through `write!(f, "{}", ..)` instead of `f.pad(..)`,
        // so the caller's width/precision/fill flags are dropped rather than
        // applied. Not a panic, but pin it: `{:>10}` does NOT pad.
        assert_eq!(format!("{:>10}", StyleMixBlendMode::Normal), "normal");
        assert_eq!(format!("{:.2}", StyleMixBlendMode::Multiply), "multiply");
        assert_eq!(format!("{:*^30}", StyleMixBlendMode::ColorDodge), "color-dodge");
    }
    // ----------------------------------------------------- parse_style_opacity ---
    #[test]
    fn opacity_rejects_empty_and_whitespace_only_input() {
        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}"] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} must not parse as an opacity"
            );
        }
    }
    #[test]
    fn opacity_rejects_garbage() {
        for input in [
            "auto", "abc", "%", ";;;", "50%%", "#0.5", "0.5;garbage", "1 2", "rgb(0,0,0)",
            "0,5", "--", "..", "-", ".",
        ] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} must not parse as an opacity"
            );
        }
    }
    #[test]
    fn opacity_boundary_numbers() {
        // In range.
        assert_eq!(parse_style_opacity("0").unwrap().inner.normalized(), 0.0);
        assert_eq!(parse_style_opacity("1").unwrap().inner.normalized(), 1.0);
        assert_eq!(parse_style_opacity("0%").unwrap().inner.normalized(), 0.0);
        assert_eq!(parse_style_opacity("100%").unwrap().inner.normalized(), 1.0);
        // `-0.0 == 0.0` under IEEE-754, so the `0.0..=1.0` guard accepts it.
        assert_eq!(parse_style_opacity("-0").unwrap().inner.normalized(), 0.0);
        assert_eq!(parse_style_opacity("-0%").unwrap().inner.normalized(), 0.0);
        // Below the fixed-point resolution: quantized to 0, still in range.
        assert!(parse_style_opacity("0.0000001").is_ok());
        // Out of range.
        for input in ["1.001", "1.1", "2", "101%", "-0.001", "-1", "-100%"] {
            assert!(
                matches!(
                    parse_style_opacity(input),
                    Err(OpacityParseError::OutOfRange(_))
                ),
                "{input:?} should be rejected as out-of-range"
            );
        }
        // Float extremes: `str::parse::<f32>` maps 1e39 to +inf, which must not
        // panic through the fixed-point cast and must land out of range.
        for input in ["1e39", "3.5e38", "9223372036854775807", "1e30"] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} should be rejected as out-of-range"
            );
        }
        // `NaN` / `inf` contain no numeric char, so the scanner bails out first.
        for input in ["NaN", "nan", "inf", "infinity", "-inf", "-NaN"] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} should be rejected"
            );
        }
    }
    #[test]
    fn opacity_trims_but_rejects_trailing_junk() {
        assert_eq!(
            parse_style_opacity("  0.5  ").unwrap().inner.normalized(),
            0.5
        );
        assert_eq!(
            parse_style_opacity("\t50%\n").unwrap().inner.normalized(),
            0.5
        );
        for input in ["0.5;", "0.5 !important", "0.5px", "0.5 0.5"] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} must not parse as an opacity"
            );
        }
        // Lax, pinned: the unit is trimmed *after* being split off the number, so
        // an internal space between value and unit is accepted even though CSS
        // forbids it.
        assert_eq!(parse_style_opacity("50 %").unwrap().inner.normalized(), 0.5);
    }
    #[test]
    fn opacity_non_numeric_unicode_does_not_panic() {
        // Multibyte input whose *last* numeric char is ASCII (or which has no
        // numeric char at all) must be rejected without slicing mid-codepoint.
        // See `known_bug_opacity_multibyte_numeric_char_panics` for the case
        // that does not hold.
        for input in [
            "\u{1F600}",         // emoji only
            "\u{1F600}0.5",      // emoji then ASCII digits
            "0.5\u{0301}",       // digits then a combining acute accent
            "\u{2603}%",         // snowman + percent sign
            "\u{4F60}\u{597D}",  // CJK
            "\u{202E}0.5",       // RTL override
        ] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} must not parse as an opacity"
            );
        }
    }
    #[test]
    fn opacity_extremely_long_input_terminates() {
        // 100k digits overflow f32 to +inf => out of range, but must not hang.
        let huge = "1".repeat(100_000);
        assert!(parse_style_opacity(&huge).is_err());
        // 100k *leading* fraction zeros exercise the slow float path and stay
        // in range.
        let tiny = format!("0.{}5", "0".repeat(100_000));
        assert_eq!(parse_style_opacity(&tiny).unwrap().inner.normalized(), 0.0);
        // A long trailing unit is rejected, not truncated.
        let long_unit = format!("0.5{}", "z".repeat(100_000));
        assert!(parse_style_opacity(&long_unit).is_err());
    }
    #[test]
    fn opacity_deeply_nested_brackets_do_not_stack_overflow() {
        let nested = "(".repeat(10_000);
        assert!(parse_style_opacity(&nested).is_err());
        let wrapped = format!("{}0.5{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_style_opacity(&wrapped).is_err());
    }
    #[test]
    fn opacity_valid_minimal_positive_control() {
        assert!(parse_style_opacity("1").unwrap() == StyleOpacity::default());
        assert!(parse_style_opacity("50%").unwrap() == StyleOpacity::new(50.0));
        assert!(parse_style_opacity("0.5").unwrap() == StyleOpacity::new(50.0));
        // `0.5` (fraction) and `50%` are the same value.
        assert!(parse_style_opacity("0.5").unwrap() == parse_style_opacity("50%").unwrap());
    }
    #[test]
    fn opacity_round_trips_through_print_as_css_value_and_display() {
        for pct in [0.0f32, 12.5, 25.0, 50.0, 75.0, 99.9, 100.0] {
            let opacity = StyleOpacity::new(pct);
            // `PrintAsCssValue` emits the normalized 0..=1 fraction.
            let printed = opacity.print_as_css_value();
            let reparsed = parse_style_opacity(&printed)
                .unwrap_or_else(|e| panic!("{printed:?} (from {pct}%) failed to re-parse: {e}"));
            assert_eq!(
                reparsed.inner.normalized(),
                opacity.inner.normalized(),
                "{pct}% printed as {printed:?} but re-parsed differently"
            );
            // `Display` emits the percentage form; that must re-parse too.
            let displayed = opacity.to_string();
            let reparsed = parse_style_opacity(&displayed)
                .unwrap_or_else(|e| panic!("{displayed:?} (from {pct}%) failed to re-parse: {e}"));
            assert_eq!(reparsed.inner.normalized(), opacity.inner.normalized());
        }
    }
    // ------------------------------------------------- parse_style_visibility ---
    #[test]
    fn visibility_parses_every_keyword_and_round_trips() {
        assert_eq!(parse_style_visibility("visible").unwrap(), StyleVisibility::Visible);
        assert_eq!(parse_style_visibility("hidden").unwrap(), StyleVisibility::Hidden);
        assert_eq!(parse_style_visibility("collapse").unwrap(), StyleVisibility::Collapse);
        assert_eq!(StyleVisibility::default(), StyleVisibility::Visible);
        for v in ALL_VISIBILITY {
            let printed = v.print_as_css_value();
            assert!(!printed.is_empty());
            assert_eq!(parse_style_visibility(&printed).unwrap(), v);
            // Surrounding whitespace is trimmed, not rejected.
            assert_eq!(parse_style_visibility(&format!("  {printed}\t")).unwrap(), v);
        }
    }
    #[test]
    fn visibility_rejects_hostile_input() {
        for input in HOSTILE_KEYWORDS {
            assert!(
                parse_style_visibility(input).is_err(),
                "{input:?} must not parse as a visibility"
            );
        }
        for input in ["none", "show", "visible hidden", "visible;", "vis", "visibleX"] {
            assert!(
                parse_style_visibility(input).is_err(),
                "{input:?} must not parse as a visibility"
            );
        }
    }
    // -------------------------------------------- parse_style_mix_blend_mode ---
    #[test]
    fn blend_mode_parses_every_keyword_and_round_trips() {
        for mode in ALL_BLEND_MODES {
            let printed = mode.print_as_css_value();
            assert_eq!(
                parse_style_mix_blend_mode(&printed).unwrap(),
                mode,
                "{printed:?} did not round-trip"
            );
            assert_eq!(parse_style_mix_blend_mode(&format!(" {printed} ")).unwrap(), mode);
        }
        assert_eq!(StyleMixBlendMode::default(), StyleMixBlendMode::Normal);
    }
    #[test]
    fn blend_mode_rejects_hostile_input() {
        for input in HOSTILE_KEYWORDS {
            assert!(
                parse_style_mix_blend_mode(input).is_err(),
                "{input:?} must not parse as a mix-blend-mode"
            );
        }
        // Near-misses: separator swaps, plain-CSS-adjacent words, partial idents.
        for input in [
            "mix", "color dodge", "color_dodge", "colordodge", "normal normal", "plus-lighter",
            "multiply;", "screen!",
        ] {
            assert!(
                parse_style_mix_blend_mode(input).is_err(),
                "{input:?} must not parse as a mix-blend-mode"
            );
        }
    }
    // ------------------------------------------------------ parse_style_cursor ---
    #[test]
    fn cursor_parses_every_keyword_and_round_trips() {
        for cursor in ALL_CURSORS {
            let printed = cursor.print_as_css_value();
            assert_eq!(
                parse_style_cursor(&printed).unwrap(),
                cursor,
                "{printed:?} did not round-trip"
            );
            assert_eq!(parse_style_cursor(&format!("\n{printed}  ")).unwrap(), cursor);
        }
        assert_eq!(StyleCursor::default(), StyleCursor::Default);
    }
    #[test]
    fn cursor_keyword_printing_is_injective() {
        // Two variants mapping to the same CSS ident would silently collapse on
        // re-parse; the round-trip test above cannot catch that on its own.
        let mut printed: Vec<String> = ALL_CURSORS.iter().map(PrintAsCssValue::print_as_css_value).collect();
        printed.sort();
        let count = printed.len();
        printed.dedup();
        assert_eq!(printed.len(), count, "two StyleCursor variants print the same ident");
    }
    #[test]
    fn cursor_rejects_hostile_input() {
        for input in HOSTILE_KEYWORDS {
            assert!(
                parse_style_cursor(input).is_err(),
                "{input:?} must not parse as a cursor"
            );
        }
        for input in [
            "hand",           // legacy IE alias, deliberately unsupported
            "col resize",     // space instead of hyphen
            "e_resize",
            "pointer pointer",
            "url(cursor.png)",
            "auto",           // valid CSS, but not in the enum
        ] {
            assert!(
                parse_style_cursor(input).is_err(),
                "{input:?} must not parse as a cursor"
            );
        }
    }
    // -------------------------------------------------- parse_style_object_fit ---
    #[test]
    fn object_fit_parses_every_keyword_and_round_trips() {
        for fit in ALL_OBJECT_FIT {
            let printed = fit.print_as_css_value();
            assert_eq!(parse_style_object_fit(&printed).unwrap(), fit);
            assert_eq!(parse_style_object_fit(&format!("  {printed} ")).unwrap(), fit);
        }
        assert_eq!(StyleObjectFit::default(), StyleObjectFit::Fill);
    }
    #[test]
    fn object_fit_rejects_hostile_input() {
        for input in HOSTILE_KEYWORDS {
            assert!(
                parse_style_object_fit(input).is_err(),
                "{input:?} must not parse as an object-fit"
            );
        }
        for input in ["stretch", "scale_down", "scale down", "cover cover", "fill;"] {
            assert!(
                parse_style_object_fit(input).is_err(),
                "{input:?} must not parse as an object-fit"
            );
        }
    }
    // -------------------------------------------- parse_style_text_orientation ---
    #[test]
    fn text_orientation_parses_every_keyword_and_round_trips() {
        for orientation in ALL_TEXT_ORIENTATION {
            let printed = orientation.print_as_css_value();
            assert_eq!(parse_style_text_orientation(&printed).unwrap(), orientation);
            assert_eq!(
                parse_style_text_orientation(&format!("\t{printed}\n")).unwrap(),
                orientation
            );
        }
        assert_eq!(StyleTextOrientation::default(), StyleTextOrientation::Mixed);
    }
    #[test]
    fn text_orientation_rejects_hostile_input() {
        for input in HOSTILE_KEYWORDS {
            assert!(
                parse_style_text_orientation(input).is_err(),
                "{input:?} must not parse as a text-orientation"
            );
        }
        for input in ["vertical", "sideways-right", "upright mixed", "mixed;"] {
            assert!(
                parse_style_text_orientation(input).is_err(),
                "{input:?} must not parse as a text-orientation"
            );
        }
    }
    // ----------------------------------------- keyword parsers, shared invariant ---
    #[test]
    fn keyword_parsers_are_case_sensitive() {
        // CSS idents are ASCII case-insensitive per spec, but every keyword
        // parser in this crate matches the lowercase form only. Pinned so that
        // adding case-folding is a deliberate, crate-wide change rather than an
        // accident in one parser.
        assert!(parse_style_visibility("VISIBLE").is_err());
        assert!(parse_style_mix_blend_mode("Multiply").is_err());
        assert!(parse_style_cursor("Pointer").is_err());
        assert!(parse_style_object_fit("COVER").is_err());
        assert!(parse_style_text_orientation("Upright").is_err());
        assert!(parse_style_aspect_ratio("AUTO").is_err());
    }
    #[test]
    fn keyword_parsers_do_not_hang_on_extremely_long_input() {
        let long = "a".repeat(500_000);
        assert!(parse_style_visibility(&long).is_err());
        assert!(parse_style_mix_blend_mode(&long).is_err());
        assert!(parse_style_cursor(&long).is_err());
        assert!(parse_style_object_fit(&long).is_err());
        assert!(parse_style_text_orientation(&long).is_err());
        // A valid keyword buried in 500k of padding is still just whitespace-
        // trimmed, so it parses; the padding must not be quadratic.
        let padded = format!("{}visible{}", " ".repeat(250_000), " ".repeat(250_000));
        assert_eq!(parse_style_visibility(&padded).unwrap(), StyleVisibility::Visible);
    }
    #[test]
    fn keyword_parsers_do_not_stack_overflow_on_nested_input() {
        let nested = format!("{}center{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_style_visibility(&nested).is_err());
        assert!(parse_style_cursor(&nested).is_err());
        assert!(parse_style_object_fit(&nested).is_err());
        assert!(parse_style_object_position(&nested).is_err());
        assert!(parse_style_aspect_ratio(&nested).is_err());
    }
    // --------------------------------------------- parse_style_object_position ---
    #[test]
    fn object_position_parses_single_keywords() {
        use BackgroundPositionHorizontal as H;
        use BackgroundPositionVertical as V;
        for (input, h, v) in [
            ("center", H::Center, V::Center),
            ("left", H::Left, V::Center),
            ("right", H::Right, V::Center),
            ("top", H::Center, V::Top),
            ("bottom", H::Center, V::Bottom),
        ] {
            let parsed = parse_style_object_position(input).unwrap();
            assert_eq!(parsed.horizontal, h, "{input:?} horizontal");
            assert_eq!(parsed.vertical, v, "{input:?} vertical");
        }
    }
    #[test]
    fn object_position_parses_lengths_and_percentages() {
        let px = parse_style_object_position("10px 20px").unwrap();
        assert_eq!(px.horizontal, BackgroundPositionHorizontal::Exact(PixelValue::px(10.0)));
        assert_eq!(px.vertical, BackgroundPositionVertical::Exact(PixelValue::px(20.0)));
        let pct = parse_style_object_position("50% 50%").unwrap();
        assert_eq!(
            pct.horizontal,
            BackgroundPositionHorizontal::Exact(PixelValue::percent(50.0))
        );
        assert_eq!(
            pct.vertical,
            BackgroundPositionVertical::Exact(PixelValue::percent(50.0))
        );
        // A single length applies to *both* axes.
        let single = parse_style_object_position("25%").unwrap();
        assert_eq!(
            single.horizontal,
            BackgroundPositionHorizontal::Exact(PixelValue::percent(25.0))
        );
        assert_eq!(
            single.vertical,
            BackgroundPositionVertical::Exact(PixelValue::percent(25.0))
        );
        // Mixed keyword + length, both orders.
        assert_eq!(
            parse_style_object_position("left 25%").unwrap(),
            StyleObjectPosition {
                horizontal: BackgroundPositionHorizontal::Left,
                vertical: BackgroundPositionVertical::Exact(PixelValue::percent(25.0)),
            }
        );
        assert_eq!(
            parse_style_object_position("25% top").unwrap(),
            StyleObjectPosition {
                horizontal: BackgroundPositionHorizontal::Exact(PixelValue::percent(25.0)),
                vertical: BackgroundPositionVertical::Top,
            }
        );
    }
    #[test]
    fn object_position_collapses_internal_whitespace() {
        // `split_whitespace` means any run of blanks separates the components.
        let expected = parse_style_object_position("left top").unwrap();
        for input in ["left  top", "left\ttop", "  left \n top  ", "left\r\ntop"] {
            assert_eq!(
                parse_style_object_position(input).unwrap(),
                expected,
                "{input:?} should be equivalent to \"left top\""
            );
        }
    }
    #[test]
    fn object_position_rejects_wrong_component_counts_and_garbage() {
        for input in [
            "",
            "   ",
            "\t\n",
            "left top center",
            "10px 20px 30px",
            "center center center center",
            "invalid",
            "left left",     // second component must be a vertical keyword or a length
            "top top",       // first component must be a horizontal keyword or a length
            "left,top",      // comma is not a component separator
            ";",
            "\u{1F600}",
            "\u{1F600} \u{1F600}",
        ] {
            assert!(
                parse_style_object_position(input).is_err(),
                "{input:?} must not parse as an object-position"
            );
        }
    }
    #[test]
    fn object_position_extreme_lengths_do_not_panic() {
        // `parse_pixel_value` accepts bare floats (incl. NaN/inf) and saturates
        // them in the fixed-point cast — characterized in pixel.rs. All that is
        // asserted here is that object-position does not panic on them.
        for input in [
            "NaN NaN", "inf inf", "-inf", "1e39px", "-1e39px", "340282350000000000000000000000000000000px",
        ] {
            let _ = parse_style_object_position(input);
        }
        let long = format!("{}px", "9".repeat(100_000));
        let _ = parse_style_object_position(&long);
    }
    #[test]
    fn object_position_round_trips_through_print_as_css_value() {
        use BackgroundPositionHorizontal as H;
        use BackgroundPositionVertical as V;
        let horizontals = [H::Left, H::Center, H::Right, H::Exact(PixelValue::percent(25.0))];
        let verticals = [V::Top, V::Center, V::Bottom, V::Exact(PixelValue::px(30.0))];
        for horizontal in horizontals {
            for vertical in verticals {
                let position = StyleObjectPosition { horizontal, vertical };
                let printed = position.print_as_css_value();
                let reparsed = parse_style_object_position(&printed)
                    .unwrap_or_else(|e| panic!("{position:?} printed as {printed:?}, which failed to re-parse: {e}"));
                assert_eq!(reparsed, position, "{printed:?} did not round-trip");
            }
        }
        // The documented initial value is `50% 50%`.
        let default = StyleObjectPosition::default();
        assert_eq!(default.print_as_css_value(), "50% 50%");
        assert_eq!(parse_style_object_position("50% 50%").unwrap(), default);
        assert_eq!(parse_style_object_position("center").unwrap().print_as_css_value(), "center center");
    }
    // ------------------------------------------------------ aspect_f32_to_u32 ---
    #[test]
    fn aspect_f32_to_u32_saturates_instead_of_panicking() {
        // Zero / truncation.
        assert_eq!(aspect_f32_to_u32(0.0), 0);
        assert_eq!(aspect_f32_to_u32(-0.0), 0);
        assert_eq!(aspect_f32_to_u32(0.9), 0);
        assert_eq!(aspect_f32_to_u32(1.0), 1);
        assert_eq!(aspect_f32_to_u32(1.9), 1);
        assert_eq!(aspect_f32_to_u32(f32::MIN_POSITIVE), 0);
        // Negatives saturate to 0 (`as` is a saturating cast since Rust 1.45).
        assert_eq!(aspect_f32_to_u32(-1.0), 0);
        assert_eq!(aspect_f32_to_u32(-0.5), 0);
        assert_eq!(aspect_f32_to_u32(-1e30), 0);
        assert_eq!(aspect_f32_to_u32(f32::MIN), 0);
        assert_eq!(aspect_f32_to_u32(f32::NEG_INFINITY), 0);
        // Above u32::MAX saturates to u32::MAX.
        assert_eq!(aspect_f32_to_u32(f32::MAX), u32::MAX);
        assert_eq!(aspect_f32_to_u32(f32::INFINITY), u32::MAX);
        assert_eq!(aspect_f32_to_u32(1e30), u32::MAX);
        // `u32::MAX as f32` rounds *up* to 2^32, so it saturates back down.
        assert_eq!(aspect_f32_to_u32(u32::MAX as f32), u32::MAX);
        // NaN is defined to be 0, not UB and not a panic.
        assert_eq!(aspect_f32_to_u32(f32::NAN), 0);
        assert_eq!(aspect_f32_to_u32(-f32::NAN), 0);
        // The largest value the parser can hand it (100_000 * 1000) fits exactly.
        assert_eq!(aspect_f32_to_u32(100_000.0 * 1000.0), 100_000_000);
    }
    #[test]
    fn aspect_f32_to_u32_is_usable_in_const_context() {
        const TRUNCATED: u32 = aspect_f32_to_u32(1.999);
        const SATURATED: u32 = aspect_f32_to_u32(f32::INFINITY);
        const NEGATIVE: u32 = aspect_f32_to_u32(-5.0);
        const NOT_A_NUMBER: u32 = aspect_f32_to_u32(f32::NAN);
        assert_eq!((TRUNCATED, SATURATED, NEGATIVE, NOT_A_NUMBER), (1, u32::MAX, 0, 0));
    }
    // ------------------------------------------------ parse_style_aspect_ratio ---
    #[test]
    fn aspect_ratio_parses_valid_forms() {
        assert_eq!(parse_style_aspect_ratio("auto").unwrap(), StyleAspectRatio::Auto);
        assert_eq!(StyleAspectRatio::default(), StyleAspectRatio::Auto);
        for input in ["16 / 9", "16/9", "16 /9", "16/ 9", "  16  /  9  "] {
            assert_eq!(
                parse_style_aspect_ratio(input).unwrap(),
                StyleAspectRatio::Ratio(AspectRatioValue { width: 16000, height: 9000 }),
                "{input:?} should parse as 16/9"
            );
        }
        // A bare number is `<number> / 1`, stored as fixed-point * 1000.
        assert_eq!(
            parse_style_aspect_ratio("1").unwrap(),
            StyleAspectRatio::Ratio(AspectRatioValue { width: 1000, height: 1000 })
        );
        assert_eq!(
            parse_style_aspect_ratio("1.5").unwrap(),
            StyleAspectRatio::Ratio(AspectRatioValue { width: 1500, height: 1000 })
        );
        // Boundary of the documented range: 100_000 is accepted, just above is not.
        assert_eq!(
            parse_style_aspect_ratio("100000").unwrap(),
            StyleAspectRatio::Ratio(AspectRatioValue { width: 100_000_000, height: 1000 })
        );
        assert!(parse_style_aspect_ratio("100001").is_err());
        assert!(parse_style_aspect_ratio("100000.1 / 1").is_err());
        assert!(parse_style_aspect_ratio("1 / 100001").is_err());
    }
    #[test]
    fn aspect_ratio_rejects_non_positive_and_malformed_input() {
        for input in [
            "", "   ", "\t\n", "abc", "auto / auto", "16 / 9 / 4", "1/2/3", "/", "//", "/9",
            "16/", "16 9", ";", "16,9", "\u{1F600}", "\u{1F600}/\u{1F600}",
        ] {
            assert!(
                parse_style_aspect_ratio(input).is_err(),
                "{input:?} must not parse as an aspect-ratio"
            );
        }
        // Zero and negative components are explicitly rejected.
        for input in ["0", "0 / 1", "1 / 0", "0/0", "-0", "-0 / 1", "1 / -0", "-1 / 1", "-1", "-1.5"] {
            assert!(
                parse_style_aspect_ratio(input).is_err(),
                "{input:?} must not parse as an aspect-ratio"
            );
        }
        // Infinities exceed the 100_000 bound (or are non-positive).
        for input in ["inf", "inf / 1", "1 / inf", "-inf", "-inf / 1", "1e39", "1e39 / 1"] {
            assert!(
                parse_style_aspect_ratio(input).is_err(),
                "{input:?} should be rejected: out of the [0, 100_000] range"
            );
        }
    }
    #[test]
    fn aspect_ratio_extremely_long_input_terminates() {
        let long = "9".repeat(100_000);
        assert!(parse_style_aspect_ratio(&long).is_err());
        assert!(parse_style_aspect_ratio(&format!("{long}/{long}")).is_err());
        // 100k slashes: `find('/')` hits the first one, both sides fail to parse.
        let slashes = "/".repeat(100_000);
        assert!(parse_style_aspect_ratio(&slashes).is_err());
    }
    #[test]
    fn aspect_ratio_auto_round_trips() {
        let printed = StyleAspectRatio::Auto.print_as_css_value();
        assert_eq!(printed, "auto");
        assert_eq!(parse_style_aspect_ratio(&printed).unwrap(), StyleAspectRatio::Auto);
    }
    // ------------------------------------------- error types: to_contained/to_shared ---
    #[test]
    fn opacity_parse_error_round_trips_through_the_owned_form() {
        let errors = [
            OpacityParseError::ParsePercentage(
                PercentageParseError::ValueParseErr(CssParseFloatError::Empty),
                "",
            ),
            OpacityParseError::ParsePercentage(
                PercentageParseError::ValueParseErr(CssParseFloatError::Invalid),
                "abc",
            ),
            OpacityParseError::ParsePercentage(PercentageParseError::NoPercentSign, "0.5"),
            OpacityParseError::ParsePercentage(
                PercentageParseError::InvalidUnit(String::from("px").into()),
                "5px",
            ),
            OpacityParseError::OutOfRange("1.5"),
            OpacityParseError::OutOfRange(""),
            OpacityParseError::OutOfRange("\u{1F600}"),
        ];
        for error in errors {
            let owned = error.to_contained();
            assert_eq!(owned.to_shared(), error, "{error:?} did not round-trip");
            assert_eq!(owned.to_shared().to_contained(), owned);
            let shown = error.to_string();
            assert!(!shown.is_empty(), "{error:?} renders as an empty message");
            // `impl_debug_as_display` forwards Debug to Display.
            assert_eq!(format!("{error:?}"), shown);
        }
    }
    #[test]
    fn opacity_parse_error_to_contained_copies_the_borrowed_input() {
        // The owned form must not alias the (possibly temporary) input slice.
        let owned = {
            let input = String::from("1.5");
            parse_style_opacity(&input).unwrap_err().to_contained()
        };
        assert_eq!(owned, OpacityParseErrorOwned::OutOfRange(String::from("1.5").into()));
        assert!(owned.to_shared().to_string().contains("1.5"));
    }
    #[test]
    fn keyword_parse_errors_round_trip_through_the_owned_form() {
        // All four `InvalidValueErr`-backed error types, over hostile payloads.
        for payload in ["", "junk", "  ", "\u{1F600}", "a\0b", "\u{0665}"] {
            let visibility = StyleVisibilityParseError::InvalidValue(InvalidValueErr(payload));
            assert_eq!(visibility.to_contained().to_shared(), visibility);
            assert!(!visibility.to_string().is_empty());
            assert_eq!(format!("{visibility:?}"), visibility.to_string());
            let blend = MixBlendModeParseError::InvalidValue(InvalidValueErr(payload));
            assert_eq!(blend.to_contained().to_shared(), blend);
            assert!(!blend.to_string().is_empty());
            let cursor = CursorParseError::InvalidValue(InvalidValueErr(payload));
            assert_eq!(cursor.to_contained().to_shared(), cursor);
            assert!(!cursor.to_string().is_empty());
            // The `&str`-backed error types.
            let object_fit = StyleObjectFitParseError::InvalidValue(payload);
            assert_eq!(object_fit.to_contained().to_shared(), object_fit);
            assert!(!object_fit.to_string().is_empty());
            let orientation = StyleTextOrientationParseError::InvalidValue(payload);
            assert_eq!(orientation.to_contained().to_shared(), orientation);
            assert!(!orientation.to_string().is_empty());
            let position = StyleObjectPositionParseError::InvalidValue(payload);
            assert_eq!(position.to_contained().to_shared(), position);
            assert!(!position.to_string().is_empty());
            let ratio = StyleAspectRatioParseError::InvalidValue(payload);
            assert_eq!(ratio.to_contained().to_shared(), ratio);
            assert!(!ratio.to_string().is_empty());
        }
    }
    #[test]
    fn parse_errors_quote_the_offending_input() {
        // The rejected value has to survive into the message, or authors cannot
        // find the bad declaration.
        assert!(parse_style_visibility("show").unwrap_err().to_string().contains("show"));
        assert!(parse_style_mix_blend_mode("mix").unwrap_err().to_string().contains("mix"));
        assert!(parse_style_cursor("hand").unwrap_err().to_string().contains("hand"));
        assert!(parse_style_object_fit("stretch").unwrap_err().to_string().contains("stretch"));
        assert!(parse_style_text_orientation("vertical").unwrap_err().to_string().contains("vertical"));
        assert!(parse_style_object_position("nope").unwrap_err().to_string().contains("nope"));
        assert!(parse_style_aspect_ratio("nope").unwrap_err().to_string().contains("nope"));
        assert!(parse_style_opacity("1.5").unwrap_err().to_string().contains("1.5"));
    }
    #[test]
    fn parse_errors_report_the_trimmed_input_not_the_raw_slice() {
        // Every keyword parser trims *before* constructing the error, so the
        // message never contains the caller's padding.
        let shown = parse_style_cursor("  hand  ").unwrap_err().to_string();
        assert!(shown.contains("\"hand\""), "expected the trimmed value, got {shown:?}");
        // ...except `parse_style_opacity`, which passes the *untrimmed* input to
        // the error. Pinned so the inconsistency is visible.
        let shown = parse_style_opacity("  1.5  ").unwrap_err().to_string();
        assert!(shown.contains("\"  1.5  \""), "expected the raw value, got {shown:?}");
    }
    #[test]
    fn owned_error_forms_are_independent_of_the_source_buffer() {
        // `to_contained` must deep-copy: the owned error has to outlive the
        // String it was parsed from.
        let owned = {
            let input = String::from("stretch");
            parse_style_object_fit(&input).unwrap_err().to_contained()
        };
        assert_eq!(
            owned,
            StyleObjectFitParseErrorOwned::InvalidValue(String::from("stretch").into())
        );
        assert!(owned.to_shared().to_string().contains("stretch"));
    }
    // ------------------------------------------------------------ known bugs ---
    //
    // The tests below assert the behaviour these functions must have; they are
    // regression guards for bugs that have since been fixed.
    #[test]
    fn known_bug_opacity_multibyte_numeric_char_panics() {
        // `char::is_numeric()` is true for Nd/Nl/No, including multi-byte chars
        // like '½' (U+00BD) and '٥' (U+0665). `parse_percentage_value` records
        // the *start* byte index of the last such char and then slices at
        // `split_pos + 1`, which lands inside the codepoint => the slice panics.
        //
        // `opacity: ½` in any author stylesheet therefore panics the CSS parser.
        // See `known_bug_percentage_multibyte_numeric_char_panics` in length.rs.
        for input in ["\u{00BD}", "\u{00BD}%", "0.5\u{0665}", "\u{FF15}%"] {
            assert!(
                parse_style_opacity(input).is_err(),
                "{input:?} should be rejected, not panic"
            );
        }
    }
    #[test]
    fn known_bug_aspect_ratio_nan_bypasses_the_range_guards() {
        // Every guard in `parse_style_aspect_ratio` is a float comparison
        // (`h <= 0.0 || w <= 0.0 || w > 100_000.0 || h > 100_000.0`), and every
        // comparison against NaN is false — so a NaN component sails through and
        // `aspect_f32_to_u32(NaN)` turns it into 0. The parser explicitly rejects
        // "0 / 1", but happily returns `Ratio { width: 0, height: 1000 }` for
        // "NaN", which is a division by zero waiting to happen in layout.
        for input in ["NaN", "nan", "NaN / 1", "1 / NaN", "nan/nan", "-NaN"] {
            assert!(
                parse_style_aspect_ratio(input).is_err(),
                "{input:?} should be rejected, but parsed as {:?}",
                parse_style_aspect_ratio(input)
            );
        }
    }
    #[test]
    fn known_bug_aspect_ratio_tiny_positive_values_round_down_to_zero() {
        // `w > 0.0` passes, but `(w * 1000.0).round()` is 0 for anything below
        // 0.0005 — so a positive ratio silently becomes the degenerate 0 that the
        // guard exists to prevent.
        for input in ["0.0001", "0.0004 / 1", "1 / 0.0001", "1e-10"] {
            let Ok(StyleAspectRatio::Ratio(ratio)) = parse_style_aspect_ratio(input) else {
                continue; // rejected outright — that is the fix
            };
            assert!(
                ratio.width > 0 && ratio.height > 0,
                "{input:?} produced the degenerate ratio {ratio:?}"
            );
        }
    }
    #[test]
    fn known_bug_aspect_ratio_does_not_survive_a_print_reparse_cycle() {
        // `Ratio { width: 16000, height: 9000 }` (i.e. 16/9) prints as
        // "16000 / 9000", so every print/parse cycle multiplies both components
        // by 1000. One cycle changes the stored value; two cycles exceed the
        // 100_000 bound and fail to parse at all.
        let ratio = parse_style_aspect_ratio("16 / 9").unwrap();
        let printed = ratio.print_as_css_value();
        assert_eq!(printed, "16 / 9", "printed the fixed-point form: {printed:?}");
        assert_eq!(parse_style_aspect_ratio(&printed).unwrap(), ratio);
    }
    #[test]
    fn known_bug_object_position_rejects_reversed_keyword_pairs() {
        // `<position>` is `[left|center|right] || [top|center|bottom]` — the `||`
        // means either order is valid, so `object-position: top left` is legal
        // CSS. The parser only ever reads parts[0] as the horizontal component,
        // so it hands "top" to `parse_pixel_value` and fails.
        assert_eq!(
            parse_style_object_position("top left").unwrap(),
            parse_style_object_position("left top").unwrap()
        );
        assert_eq!(
            parse_style_object_position("bottom right").unwrap(),
            parse_style_object_position("right bottom").unwrap()
        );
    }
}