Skip to main content

azul_css/props/layout/
fragmentation.rs

1//! CSS properties for controlling fragmentation (page/column breaks).
2//!
3//! Defines [`PageBreak`], [`BreakInside`], [`Widows`], [`Orphans`], and
4//! [`BoxDecorationBreak`]. The `parser` sub-module (behind the `parser`
5//! feature) provides CSS-value parsing for each type.
6
7use alloc::string::{String, ToString};
8
9use crate::props::formatter::PrintAsCssValue;
10
11// --- break-before / break-after ---
12
13/// Represents a `break-before` or `break-after` CSS property value.
14#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15#[repr(C)]
16#[derive(Default)]
17pub enum PageBreak {
18    #[default]
19    Auto,
20    Avoid,
21    Always,
22    All,
23    Page,
24    AvoidPage,
25    Left,
26    Right,
27    Recto,
28    Verso,
29    Column,
30    AvoidColumn,
31}
32
33
34impl PrintAsCssValue for PageBreak {
35    fn print_as_css_value(&self) -> String {
36        String::from(match self {
37            Self::Auto => "auto",
38            Self::Avoid => "avoid",
39            Self::Always => "always",
40            Self::All => "all",
41            Self::Page => "page",
42            Self::AvoidPage => "avoid-page",
43            Self::Left => "left",
44            Self::Right => "right",
45            Self::Recto => "recto",
46            Self::Verso => "verso",
47            Self::Column => "column",
48            Self::AvoidColumn => "avoid-column",
49        })
50    }
51}
52
53// --- break-inside ---
54
55/// Represents a `break-inside` CSS property value.
56#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57#[repr(C)]
58#[derive(Default)]
59pub enum BreakInside {
60    #[default]
61    Auto,
62    Avoid,
63    AvoidPage,
64    AvoidColumn,
65}
66
67
68impl PrintAsCssValue for BreakInside {
69    fn print_as_css_value(&self) -> String {
70        String::from(match self {
71            Self::Auto => "auto",
72            Self::Avoid => "avoid",
73            Self::AvoidPage => "avoid-page",
74            Self::AvoidColumn => "avoid-column",
75        })
76    }
77}
78
79// --- widows / orphans ---
80
81/// CSS `widows` property - minimum number of lines in a block container
82/// that must be shown at the top of a page, region, or column.
83#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
84#[repr(C)]
85pub struct Widows {
86    pub inner: u32,
87}
88
89impl Default for Widows {
90    fn default() -> Self {
91        Self { inner: 2 }
92    }
93}
94
95impl PrintAsCssValue for Widows {
96    fn print_as_css_value(&self) -> String {
97        self.inner.to_string()
98    }
99}
100
101/// CSS `orphans` property - minimum number of lines in a block container
102/// that must be shown at the bottom of a page, region, or column.
103#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
104#[repr(C)]
105pub struct Orphans {
106    pub inner: u32,
107}
108
109impl Default for Orphans {
110    fn default() -> Self {
111        Self { inner: 2 }
112    }
113}
114
115impl PrintAsCssValue for Orphans {
116    fn print_as_css_value(&self) -> String {
117        self.inner.to_string()
118    }
119}
120
121// --- box-decoration-break ---
122
123/// Represents a `box-decoration-break` CSS property value.
124#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
125#[repr(C)]
126#[derive(Default)]
127pub enum BoxDecorationBreak {
128    #[default]
129    Slice,
130    Clone,
131}
132
133
134impl PrintAsCssValue for BoxDecorationBreak {
135    fn print_as_css_value(&self) -> String {
136        String::from(match self {
137            Self::Slice => "slice",
138            Self::Clone => "clone",
139        })
140    }
141}
142
143// Formatting to Rust code
144impl crate::codegen::format::FormatAsRustCode for PageBreak {
145    fn format_as_rust_code(&self, _tabs: usize) -> String {
146        match self {
147            Self::Auto => String::from("PageBreak::Auto"),
148            Self::Avoid => String::from("PageBreak::Avoid"),
149            Self::Always => String::from("PageBreak::Always"),
150            Self::All => String::from("PageBreak::All"),
151            Self::Page => String::from("PageBreak::Page"),
152            Self::AvoidPage => String::from("PageBreak::AvoidPage"),
153            Self::Left => String::from("PageBreak::Left"),
154            Self::Right => String::from("PageBreak::Right"),
155            Self::Recto => String::from("PageBreak::Recto"),
156            Self::Verso => String::from("PageBreak::Verso"),
157            Self::Column => String::from("PageBreak::Column"),
158            Self::AvoidColumn => String::from("PageBreak::AvoidColumn"),
159        }
160    }
161}
162
163impl crate::codegen::format::FormatAsRustCode for BreakInside {
164    fn format_as_rust_code(&self, _tabs: usize) -> String {
165        match self {
166            Self::Auto => String::from("BreakInside::Auto"),
167            Self::Avoid => String::from("BreakInside::Avoid"),
168            Self::AvoidPage => String::from("BreakInside::AvoidPage"),
169            Self::AvoidColumn => String::from("BreakInside::AvoidColumn"),
170        }
171    }
172}
173
174impl crate::codegen::format::FormatAsRustCode for Widows {
175    fn format_as_rust_code(&self, _tabs: usize) -> String {
176        format!("Widows {{ inner: {} }}", self.inner)
177    }
178}
179
180impl crate::codegen::format::FormatAsRustCode for Orphans {
181    fn format_as_rust_code(&self, _tabs: usize) -> String {
182        format!("Orphans {{ inner: {} }}", self.inner)
183    }
184}
185
186impl crate::codegen::format::FormatAsRustCode for BoxDecorationBreak {
187    fn format_as_rust_code(&self, _tabs: usize) -> String {
188        match self {
189            Self::Slice => String::from("BoxDecorationBreak::Slice"),
190            Self::Clone => String::from("BoxDecorationBreak::Clone"),
191        }
192    }
193}
194
195// --- PARSERS ---
196
197#[cfg(feature = "parser")]
198pub mod parser {
199    #[allow(clippy::wildcard_imports)] // parser submodule reuses the parent module's value types
200    use super::*;
201    use core::num::ParseIntError;
202    use crate::corety::AzString;
203    use crate::props::layout::position::ParseIntErrorWithInput;
204
205    // -- PageBreak parser (`break-before`, `break-after`)
206
207    /// Error returned when parsing a `break-before` or `break-after` value.
208    #[derive(Clone, PartialEq, Eq)]
209    pub enum PageBreakParseError<'a> {
210        InvalidValue(&'a str),
211    }
212
213    impl_debug_as_display!(PageBreakParseError<'a>);
214    impl_display! { PageBreakParseError<'a>, {
215        InvalidValue(v) => format!("Invalid break value: \"{}\"", v),
216    }}
217
218    /// Owned version of [`PageBreakParseError`] for FFI and storage.
219    #[derive(Debug, Clone, PartialEq, Eq)]
220    #[repr(C, u8)]
221    pub enum PageBreakParseErrorOwned {
222        InvalidValue(AzString),
223    }
224
225    impl PageBreakParseError<'_> {
226        #[must_use] pub fn to_contained(&self) -> PageBreakParseErrorOwned {
227            match self {
228                Self::InvalidValue(s) => PageBreakParseErrorOwned::InvalidValue((*s).to_string().into()),
229            }
230        }
231    }
232
233    impl PageBreakParseErrorOwned {
234        #[must_use] pub fn to_shared(&self) -> PageBreakParseError<'_> {
235            match self {
236                Self::InvalidValue(s) => PageBreakParseError::InvalidValue(s.as_str()),
237            }
238        }
239    }
240
241    /// # Errors
242    ///
243    /// Returns an error if `input` is not a valid CSS `page-break` value.
244    pub fn parse_page_break(input: &str) -> Result<PageBreak, PageBreakParseError<'_>> {
245        match input.trim() {
246            "auto" => Ok(PageBreak::Auto),
247            "avoid" => Ok(PageBreak::Avoid),
248            "always" => Ok(PageBreak::Always),
249            "all" => Ok(PageBreak::All),
250            "page" => Ok(PageBreak::Page),
251            "avoid-page" => Ok(PageBreak::AvoidPage),
252            "left" => Ok(PageBreak::Left),
253            "right" => Ok(PageBreak::Right),
254            "recto" => Ok(PageBreak::Recto),
255            "verso" => Ok(PageBreak::Verso),
256            "column" => Ok(PageBreak::Column),
257            "avoid-column" => Ok(PageBreak::AvoidColumn),
258            _ => Err(PageBreakParseError::InvalidValue(input)),
259        }
260    }
261
262    // -- BreakInside parser
263
264    /// Error returned when parsing a `break-inside` value.
265    #[derive(Clone, PartialEq, Eq)]
266    pub enum BreakInsideParseError<'a> {
267        InvalidValue(&'a str),
268    }
269
270    impl_debug_as_display!(BreakInsideParseError<'a>);
271    impl_display! { BreakInsideParseError<'a>, {
272        InvalidValue(v) => format!("Invalid break-inside value: \"{}\"", v),
273    }}
274
275    /// Owned version of [`BreakInsideParseError`] for FFI and storage.
276    #[derive(Debug, Clone, PartialEq, Eq)]
277    #[repr(C, u8)]
278    pub enum BreakInsideParseErrorOwned {
279        InvalidValue(AzString),
280    }
281
282    impl BreakInsideParseError<'_> {
283        #[must_use] pub fn to_contained(&self) -> BreakInsideParseErrorOwned {
284            match self {
285                Self::InvalidValue(s) => BreakInsideParseErrorOwned::InvalidValue((*s).to_string().into()),
286            }
287        }
288    }
289
290    impl BreakInsideParseErrorOwned {
291        #[must_use] pub fn to_shared(&self) -> BreakInsideParseError<'_> {
292            match self {
293                Self::InvalidValue(s) => BreakInsideParseError::InvalidValue(s.as_str()),
294            }
295        }
296    }
297
298    /// # Errors
299    ///
300    /// Returns an error if `input` is not a valid CSS `break-inside` value.
301    pub fn parse_break_inside(
302        input: &str,
303    ) -> Result<BreakInside, BreakInsideParseError<'_>> {
304        match input.trim() {
305            "auto" => Ok(BreakInside::Auto),
306            "avoid" => Ok(BreakInside::Avoid),
307            "avoid-page" => Ok(BreakInside::AvoidPage),
308            "avoid-column" => Ok(BreakInside::AvoidColumn),
309            _ => Err(BreakInsideParseError::InvalidValue(input)),
310        }
311    }
312
313    // -- Widows / Orphans parsers
314
315    macro_rules! define_widow_orphan_parser {
316        ($fn_name:ident, $struct_name:ident, $error_name:ident, $error_owned_name:ident, $prop_name:expr) => {
317            #[derive(Clone, PartialEq, Eq)]
318            pub enum $error_name<'a> {
319                ParseInt(ParseIntError, &'a str),
320                ParseIntOwned(&'a str, &'a str),
321                NegativeValue(&'a str),
322            }
323
324            impl_debug_as_display!($error_name<'a>);
325            impl_display! { $error_name<'a>, {
326                ParseInt(e, s) => format!("Invalid integer for {}: \"{}\". Reason: {}", $prop_name, s, e),
327                ParseIntOwned(e, s) => format!("Invalid integer for {}: \"{}\". Reason: {}", $prop_name, s, e),
328                NegativeValue(s) => format!("Invalid value for {}: \"{}\". Value cannot be negative.", $prop_name, s),
329            }}
330
331            #[derive(Debug, Clone, PartialEq, Eq)]
332            #[repr(C, u8)]
333            pub enum $error_owned_name {
334                ParseInt(ParseIntErrorWithInput),
335                NegativeValue(AzString),
336            }
337
338            impl $error_name<'_> {
339                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
340                    match self {
341                        Self::ParseInt(e, s) => $error_owned_name::ParseInt(ParseIntErrorWithInput { error: e.to_string().into(), input: s.to_string().into() }),
342                        Self::ParseIntOwned(e, s) => $error_owned_name::ParseInt(ParseIntErrorWithInput { error: e.to_string().into(), input: s.to_string().into() }),
343                        Self::NegativeValue(s) => $error_owned_name::NegativeValue(s.to_string().into()),
344                    }
345                }
346            }
347
348            impl $error_owned_name {
349                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
350                     match self {
351                        Self::ParseInt(e) => $error_name::ParseIntOwned(e.error.as_str(), e.input.as_str()),
352                        Self::NegativeValue(s) => $error_name::NegativeValue(s),
353                    }
354                }
355            }
356
357            /// # Errors
358            ///
359            /// Returns an error if `input` is not a valid CSS value for this property.
360            pub fn $fn_name(input: &str) -> Result<$struct_name, $error_name<'_>> {
361                let trimmed = input.trim();
362                let val: i32 = trimmed.parse().map_err(|e| $error_name::ParseInt(e, trimmed))?;
363                if val < 0 {
364                    return Err($error_name::NegativeValue(trimmed));
365                }
366                Ok($struct_name { inner: u32::try_from(val).unwrap_or(0) })
367            }
368        };
369    }
370
371    define_widow_orphan_parser!(
372        parse_widows,
373        Widows,
374        WidowsParseError,
375        WidowsParseErrorOwned,
376        "widows"
377    );
378    define_widow_orphan_parser!(
379        parse_orphans,
380        Orphans,
381        OrphansParseError,
382        OrphansParseErrorOwned,
383        "orphans"
384    );
385
386    // -- BoxDecorationBreak parser
387
388    /// Error returned when parsing a `box-decoration-break` value.
389    #[derive(Clone, PartialEq, Eq)]
390    pub enum BoxDecorationBreakParseError<'a> {
391        InvalidValue(&'a str),
392    }
393
394    impl_debug_as_display!(BoxDecorationBreakParseError<'a>);
395    impl_display! { BoxDecorationBreakParseError<'a>, {
396        InvalidValue(v) => format!("Invalid box-decoration-break value: \"{}\"", v),
397    }}
398
399    /// Owned version of [`BoxDecorationBreakParseError`] for FFI and storage.
400    #[derive(Debug, Clone, PartialEq, Eq)]
401    #[repr(C, u8)]
402    pub enum BoxDecorationBreakParseErrorOwned {
403        InvalidValue(AzString),
404    }
405
406    impl BoxDecorationBreakParseError<'_> {
407        #[must_use] pub fn to_contained(&self) -> BoxDecorationBreakParseErrorOwned {
408            match self {
409                Self::InvalidValue(s) => {
410                    BoxDecorationBreakParseErrorOwned::InvalidValue((*s).to_string().into())
411                }
412            }
413        }
414    }
415
416    impl BoxDecorationBreakParseErrorOwned {
417        #[must_use] pub fn to_shared(&self) -> BoxDecorationBreakParseError<'_> {
418            match self {
419                Self::InvalidValue(s) => BoxDecorationBreakParseError::InvalidValue(s.as_str()),
420            }
421        }
422    }
423
424    /// # Errors
425    ///
426    /// Returns an error if `input` is not a valid CSS `box-decoration-break` value.
427    pub fn parse_box_decoration_break(
428        input: &str,
429    ) -> Result<BoxDecorationBreak, BoxDecorationBreakParseError<'_>> {
430        match input.trim() {
431            "slice" => Ok(BoxDecorationBreak::Slice),
432            "clone" => Ok(BoxDecorationBreak::Clone),
433            _ => Err(BoxDecorationBreakParseError::InvalidValue(input)),
434        }
435    }
436}
437
438#[cfg(feature = "parser")]
439pub use parser::*;
440
441#[cfg(all(test, feature = "parser"))]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_parse_page_break() {
447        assert_eq!(parse_page_break("auto").unwrap(), PageBreak::Auto);
448        assert_eq!(parse_page_break("page").unwrap(), PageBreak::Page);
449        assert_eq!(
450            parse_page_break("avoid-column").unwrap(),
451            PageBreak::AvoidColumn
452        );
453        assert!(parse_page_break("invalid").is_err());
454    }
455
456    #[test]
457    fn test_parse_break_inside() {
458        assert_eq!(parse_break_inside("auto").unwrap(), BreakInside::Auto);
459        assert_eq!(parse_break_inside("avoid").unwrap(), BreakInside::Avoid);
460        assert!(parse_break_inside("always").is_err());
461    }
462
463    #[test]
464    fn test_parse_widows_orphans() {
465        assert_eq!(parse_widows("3").unwrap().inner, 3);
466        assert_eq!(parse_orphans("  1  ").unwrap().inner, 1);
467        assert!(parse_widows("-2").is_err());
468        assert!(parse_orphans("auto").is_err());
469    }
470
471    #[test]
472    fn test_parse_box_decoration_break() {
473        assert_eq!(
474            parse_box_decoration_break("slice").unwrap(),
475            BoxDecorationBreak::Slice
476        );
477        assert_eq!(
478            parse_box_decoration_break("clone").unwrap(),
479            BoxDecorationBreak::Clone
480        );
481        assert!(parse_box_decoration_break("copy").is_err());
482    }
483}
484
485#[cfg(all(test, feature = "parser"))]
486mod autotest_generated {
487    use alloc::{format, string::String, vec::Vec};
488
489    use super::*;
490    use crate::props::formatter::PrintAsCssValue;
491
492    /// Every `PageBreak` variant, so the round-trip tests stay exhaustive if a
493    /// variant is added.
494    const ALL_PAGE_BREAKS: [PageBreak; 12] = [
495        PageBreak::Auto,
496        PageBreak::Avoid,
497        PageBreak::Always,
498        PageBreak::All,
499        PageBreak::Page,
500        PageBreak::AvoidPage,
501        PageBreak::Left,
502        PageBreak::Right,
503        PageBreak::Recto,
504        PageBreak::Verso,
505        PageBreak::Column,
506        PageBreak::AvoidColumn,
507    ];
508
509    const ALL_BREAK_INSIDES: [BreakInside; 4] = [
510        BreakInside::Auto,
511        BreakInside::Avoid,
512        BreakInside::AvoidPage,
513        BreakInside::AvoidColumn,
514    ];
515
516    const ALL_BOX_DECORATION_BREAKS: [BoxDecorationBreak; 2] =
517        [BoxDecorationBreak::Slice, BoxDecorationBreak::Clone];
518
519    /// Inputs that must never parse and must never panic, for any of the
520    /// keyword parsers.
521    fn hostile_inputs() -> Vec<String> {
522        let mut v = Vec::new();
523        for s in [
524            "",
525            " ",
526            "   ",
527            "\t\n\r\x0c",
528            "\0",
529            "auto\0",
530            "\0auto",
531            ";",
532            "auto;",
533            "auto;garbage",
534            "auto garbage",
535            "auto auto",
536            "auto/**/",
537            "/* auto */",
538            "\"auto\"",
539            "'auto'",
540            "-",
541            "--",
542            "0",
543            "-0",
544            "+0",
545            "1",
546            "-1",
547            "0.0",
548            "1e309",
549            "-1e309",
550            "NaN",
551            "nan",
552            "inf",
553            "-inf",
554            "Infinity",
555            "9223372036854775807",  // i64::MAX
556            "-9223372036854775808", // i64::MIN
557            "18446744073709551615", // u64::MAX
558            "4294967295",           // u32::MAX
559            "AUTO",
560            "Auto",
561            "aUtO",
562            "AVOID-PAGE",
563            "\u{1F600}",
564            "auto\u{1F600}",
565            "\u{0301}",       // lone combining acute accent
566            "auto\u{0301}",   // "auto" + combining mark
567            "\u{200B}auto",   // zero-width space (NOT Unicode whitespace)
568            "auto\u{200B}",
569            "\u{FEFF}auto",   // BOM (NOT Unicode whitespace)
570            "аuto",           // Cyrillic 'а' (U+0430) homoglyph
571            "auto\u{0000}\u{FFFD}",
572            "initial",
573            "inherit",
574            "unset",
575            "revert",
576            "none",
577        ] {
578            v.push(String::from(s));
579        }
580        v
581    }
582
583    // ---------------------------------------------------------------
584    // parse_page_break
585    // ---------------------------------------------------------------
586
587    #[test]
588    fn page_break_valid_minimal() {
589        assert_eq!(parse_page_break("auto"), Ok(PageBreak::Auto));
590    }
591
592    #[test]
593    fn page_break_all_keywords_parse() {
594        for expected in ALL_PAGE_BREAKS {
595            let printed = expected.print_as_css_value();
596            assert_eq!(
597                parse_page_break(&printed),
598                Ok(expected),
599                "keyword {printed:?} must parse back"
600            );
601        }
602    }
603
604    /// Round-trip: `print_as_css_value` -> `parse_page_break` is the identity on
605    /// every variant, and the printed form is stable across a second pass.
606    #[test]
607    fn page_break_round_trip_is_identity() {
608        for expected in ALL_PAGE_BREAKS {
609            let once = expected.print_as_css_value();
610            let decoded = parse_page_break(&once).expect("printed value must re-parse");
611            assert_eq!(decoded, expected);
612            assert_eq!(decoded.print_as_css_value(), once, "encoding must be stable");
613        }
614    }
615
616    /// Printed forms must be distinct, otherwise the round-trip above would be
617    /// lossy (two variants collapsing onto one keyword).
618    #[test]
619    fn page_break_printed_forms_are_distinct() {
620        let mut seen: Vec<String> = Vec::new();
621        for pb in ALL_PAGE_BREAKS {
622            let s = pb.print_as_css_value();
623            assert!(!seen.contains(&s), "duplicate printed form: {s:?}");
624            seen.push(s);
625        }
626        assert_eq!(seen.len(), ALL_PAGE_BREAKS.len());
627    }
628
629    #[test]
630    fn page_break_hostile_inputs_are_rejected_without_panic() {
631        for input in hostile_inputs() {
632            let result = parse_page_break(&input);
633            assert!(
634                result.is_err(),
635                "expected Err for {input:?}, got {result:?}"
636            );
637            // The error must be constructible/printable for every input.
638            let err = result.unwrap_err();
639            let _ = format!("{err}");
640            let _ = err.to_contained();
641        }
642    }
643
644    /// The error borrows the *untrimmed* input, not the trimmed slice.
645    #[test]
646    fn page_break_error_carries_untrimmed_input() {
647        let err = parse_page_break("  bogus  ").unwrap_err();
648        let PageBreakParseError::InvalidValue(v) = err;
649        assert_eq!(v, "  bogus  ");
650    }
651
652    /// Surrounding ASCII whitespace is trimmed; interior junk is not.
653    #[test]
654    fn page_break_leading_trailing_junk() {
655        assert_eq!(parse_page_break("  auto  "), Ok(PageBreak::Auto));
656        assert_eq!(parse_page_break("\n\tavoid-page\r\n"), Ok(PageBreak::AvoidPage));
657        assert!(parse_page_break("auto;").is_err());
658        assert!(parse_page_break("auto garbage").is_err());
659        assert!(parse_page_break("(auto)").is_err());
660    }
661
662    /// Characterization: `str::trim` follows the Unicode `White_Space` property,
663    /// so NBSP (U+00A0) *is* stripped even though CSS tokenization would not
664    /// treat it as whitespace. ZWSP/BOM are not whitespace and stay.
665    #[test]
666    fn page_break_unicode_whitespace_semantics() {
667        assert_eq!(
668            parse_page_break("\u{00A0}auto\u{00A0}"),
669            Ok(PageBreak::Auto),
670            "NBSP is Unicode whitespace, so trim() removes it (lenient vs. CSS)"
671        );
672        assert!(parse_page_break("\u{200B}auto").is_err(), "ZWSP is not whitespace");
673        assert!(parse_page_break("\u{FEFF}auto").is_err(), "BOM is not whitespace");
674    }
675
676    /// Characterization: keyword matching is byte-exact, so CSS's ASCII
677    /// case-insensitivity for keywords is *not* implemented.
678    #[test]
679    fn page_break_is_case_sensitive() {
680        assert!(parse_page_break("AUTO").is_err());
681        assert!(parse_page_break("Auto").is_err());
682        assert!(parse_page_break("Avoid-Column").is_err());
683        assert_eq!(parse_page_break("auto"), Ok(PageBreak::Auto));
684    }
685
686    #[test]
687    fn page_break_extremely_long_input_does_not_hang() {
688        let long = "auto".repeat(250_000); // 1M chars, trims to itself
689        assert_eq!(long.len(), 1_000_000);
690        assert!(parse_page_break(&long).is_err());
691
692        // 1M chars of pure padding around a valid keyword still trims to "auto".
693        let padded = format!("{}auto{}", " ".repeat(500_000), "\t".repeat(500_000));
694        assert_eq!(parse_page_break(&padded), Ok(PageBreak::Auto));
695
696        // A 1M-char run of a single byte must not blow up either.
697        let blob = "x".repeat(1_000_000);
698        assert!(parse_page_break(&blob).is_err());
699    }
700
701    #[test]
702    fn page_break_deeply_nested_input_does_not_stack_overflow() {
703        let nested = format!("{}auto{}", "(".repeat(10_000), ")".repeat(10_000));
704        assert!(parse_page_break(&nested).is_err());
705    }
706
707    // ---------------------------------------------------------------
708    // parse_break_inside
709    // ---------------------------------------------------------------
710
711    #[test]
712    fn break_inside_valid_minimal() {
713        assert_eq!(parse_break_inside("auto"), Ok(BreakInside::Auto));
714    }
715
716    #[test]
717    fn break_inside_round_trip_is_identity() {
718        for expected in ALL_BREAK_INSIDES {
719            let printed = expected.print_as_css_value();
720            let decoded = parse_break_inside(&printed).expect("printed value must re-parse");
721            assert_eq!(decoded, expected);
722            assert_eq!(decoded.print_as_css_value(), printed);
723        }
724    }
725
726    /// `break-inside` accepts a strict subset of the `page-break` keywords;
727    /// the ones it does not accept must be rejected rather than silently
728    /// falling through to `Auto`.
729    #[test]
730    fn break_inside_rejects_page_break_only_keywords() {
731        for pb in ALL_PAGE_BREAKS {
732            let kw = pb.print_as_css_value();
733            let accepted = ALL_BREAK_INSIDES
734                .iter()
735                .any(|bi| bi.print_as_css_value() == kw);
736            assert_eq!(
737                parse_break_inside(&kw).is_ok(),
738                accepted,
739                "break-inside acceptance of {kw:?} must match its keyword set"
740            );
741        }
742        assert!(parse_break_inside("always").is_err());
743        assert!(parse_break_inside("left").is_err());
744        assert!(parse_break_inside("recto").is_err());
745    }
746
747    #[test]
748    fn break_inside_hostile_inputs_are_rejected_without_panic() {
749        for input in hostile_inputs() {
750            let result = parse_break_inside(&input);
751            assert!(
752                result.is_err(),
753                "expected Err for {input:?}, got {result:?}"
754            );
755            let err = result.unwrap_err();
756            let _ = format!("{err}");
757            let _ = err.to_contained();
758        }
759    }
760
761    #[test]
762    fn break_inside_error_carries_untrimmed_input() {
763        let err = parse_break_inside(" \u{1F600} ").unwrap_err();
764        let BreakInsideParseError::InvalidValue(v) = err;
765        assert_eq!(v, " \u{1F600} ");
766    }
767
768    #[test]
769    fn break_inside_extremely_long_input_does_not_hang() {
770        let long = "avoid-column".repeat(100_000);
771        assert!(parse_break_inside(&long).is_err());
772
773        let nested = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
774        assert!(parse_break_inside(&nested).is_err());
775    }
776
777    // ---------------------------------------------------------------
778    // parse_box_decoration_break
779    // ---------------------------------------------------------------
780
781    #[test]
782    fn box_decoration_break_valid_minimal() {
783        assert_eq!(
784            parse_box_decoration_break("slice"),
785            Ok(BoxDecorationBreak::Slice)
786        );
787    }
788
789    #[test]
790    fn box_decoration_break_round_trip_is_identity() {
791        for expected in ALL_BOX_DECORATION_BREAKS {
792            let printed = expected.print_as_css_value();
793            let decoded =
794                parse_box_decoration_break(&printed).expect("printed value must re-parse");
795            assert_eq!(decoded, expected);
796            assert_eq!(decoded.print_as_css_value(), printed);
797        }
798    }
799
800    #[test]
801    fn box_decoration_break_hostile_inputs_are_rejected_without_panic() {
802        for input in hostile_inputs() {
803            let result = parse_box_decoration_break(&input);
804            assert!(
805                result.is_err(),
806                "expected Err for {input:?}, got {result:?}"
807            );
808            let err = result.unwrap_err();
809            let _ = format!("{err}");
810            let _ = err.to_contained();
811        }
812        // "clone" is a keyword here but nowhere else; "copy"/"slice-clone" are not.
813        assert!(parse_box_decoration_break("copy").is_err());
814        assert!(parse_box_decoration_break("slice-clone").is_err());
815        assert!(parse_box_decoration_break("Clone").is_err());
816    }
817
818    #[test]
819    fn box_decoration_break_whitespace_and_long_input() {
820        assert_eq!(
821            parse_box_decoration_break("\t\n clone \r\n"),
822            Ok(BoxDecorationBreak::Clone)
823        );
824        let long = "slice".repeat(200_000);
825        assert!(parse_box_decoration_break(&long).is_err());
826    }
827
828    // ---------------------------------------------------------------
829    // Error <-> Owned conversions (to_contained / to_shared)
830    // ---------------------------------------------------------------
831
832    #[test]
833    fn page_break_error_to_contained_basic_and_round_trip() {
834        let shared = PageBreakParseError::InvalidValue("bogus");
835        let owned = shared.to_contained();
836        assert_eq!(
837            owned,
838            PageBreakParseErrorOwned::InvalidValue(String::from("bogus").into())
839        );
840        // shared -> owned -> shared is lossless
841        assert_eq!(owned.to_shared(), shared);
842        // and owned -> shared -> owned is idempotent
843        assert_eq!(owned.to_shared().to_contained(), owned);
844    }
845
846    /// Empty / whitespace / huge / non-ASCII payloads must survive the FFI
847    /// round-trip byte-for-byte and must not panic.
848    #[test]
849    fn page_break_error_to_contained_edge_payloads() {
850        let huge = "\u{1F600}".repeat(100_000);
851        for payload in [
852            String::new(),
853            String::from(" "),
854            String::from("\0"),
855            String::from("\u{1F600}\u{0301}"),
856            String::from("\u{FFFD}"),
857            huge,
858        ] {
859            let shared = PageBreakParseError::InvalidValue(payload.as_str());
860            let owned = shared.to_contained();
861            let back = owned.to_shared();
862            let PageBreakParseError::InvalidValue(v) = back;
863            assert_eq!(v, payload.as_str());
864            assert_eq!(owned.to_shared().to_contained(), owned);
865            let _ = format!("{shared}");
866        }
867    }
868
869    #[test]
870    fn break_inside_error_to_contained_round_trip() {
871        for payload in ["", " ", "always", "\u{1F600}", "\0\0\0"] {
872            let shared = BreakInsideParseError::InvalidValue(payload);
873            let owned = shared.to_contained();
874            assert_eq!(
875                owned,
876                BreakInsideParseErrorOwned::InvalidValue(String::from(payload).into())
877            );
878            assert_eq!(owned.to_shared(), shared);
879            assert_eq!(owned.to_shared().to_contained(), owned);
880            let _ = format!("{shared}");
881        }
882    }
883
884    #[test]
885    fn box_decoration_break_error_to_contained_round_trip() {
886        for payload in ["", "copy", "  ", "\u{1F600}"] {
887            let shared = BoxDecorationBreakParseError::InvalidValue(payload);
888            let owned = shared.to_contained();
889            assert_eq!(
890                owned,
891                BoxDecorationBreakParseErrorOwned::InvalidValue(String::from(payload).into())
892            );
893            assert_eq!(owned.to_shared(), shared);
894            assert_eq!(owned.to_shared().to_contained(), owned);
895            let _ = format!("{shared}");
896        }
897    }
898
899    /// The `Display` impl embeds the raw input; a 1M-char input must format
900    /// without panicking (and must actually contain the payload).
901    #[test]
902    fn error_display_handles_huge_payload() {
903        let payload = "x".repeat(1_000_000);
904        let err = parse_page_break(&payload).unwrap_err();
905        let msg = format!("{err}");
906        assert!(msg.contains(&payload));
907        assert!(msg.starts_with("Invalid break value: \""));
908    }
909
910    // ---------------------------------------------------------------
911    // Widows / Orphans: numeric limits, overflow, saturation
912    // ---------------------------------------------------------------
913
914    #[test]
915    fn widows_orphans_defaults_are_two() {
916        assert_eq!(Widows::default().inner, 2);
917        assert_eq!(Orphans::default().inner, 2);
918        assert_eq!(Widows::default().print_as_css_value(), "2");
919        assert_eq!(Orphans::default().print_as_css_value(), "2");
920    }
921
922    /// Round-trip through the printed form for boundary values that are
923    /// representable (i.e. `<= i32::MAX`, since the parser goes through `i32`).
924    #[test]
925    fn widows_round_trip_representable_values() {
926        for inner in [0_u32, 1, 2, 3, 100, 65_535, 2_147_483_647] {
927            let printed = Widows { inner }.print_as_css_value();
928            assert_eq!(parse_widows(&printed).unwrap().inner, inner);
929            let printed = Orphans { inner }.print_as_css_value();
930            assert_eq!(parse_orphans(&printed).unwrap().inner, inner);
931        }
932    }
933
934    /// Characterization: parsing goes through `i32`, so values above
935    /// `i32::MAX` are *rejected*, not saturated — even though the field is
936    /// `u32` and can hold them. `Widows { inner: u32::MAX }` therefore does not
937    /// survive a print/parse round-trip.
938    #[test]
939    fn widows_above_i32_max_is_rejected_not_saturated() {
940        assert!(parse_widows("2147483648").is_err()); // i32::MAX + 1
941        assert!(parse_widows("4294967295").is_err()); // u32::MAX
942        assert!(parse_orphans("4294967295").is_err());
943
944        let printed = Widows { inner: u32::MAX }.print_as_css_value();
945        assert_eq!(printed, "4294967295");
946        assert!(
947            parse_widows(&printed).is_err(),
948            "u32::MAX widows cannot round-trip through the i32-based parser"
949        );
950    }
951
952    #[test]
953    fn widows_negative_and_zero_boundaries() {
954        // "-0" parses as 0 and is *not* treated as negative.
955        assert_eq!(parse_widows("-0").unwrap().inner, 0);
956        assert_eq!(parse_orphans("-0").unwrap().inner, 0);
957        assert_eq!(parse_widows("0").unwrap().inner, 0);
958
959        assert!(matches!(
960            parse_widows("-1"),
961            Err(WidowsParseError::NegativeValue("-1"))
962        ));
963        assert!(matches!(
964            parse_widows("-2147483648"), // i32::MIN, parses then fails the sign check
965            Err(WidowsParseError::NegativeValue("-2147483648"))
966        ));
967        assert!(matches!(
968            parse_orphans("-1"),
969            Err(OrphansParseError::NegativeValue("-1"))
970        ));
971    }
972
973    /// The `NegativeValue` / `ParseInt` errors carry the *trimmed* input (unlike
974    /// the keyword parsers, which carry the raw input).
975    #[test]
976    fn widows_error_carries_trimmed_input() {
977        assert!(matches!(
978            parse_widows("  -5  "),
979            Err(WidowsParseError::NegativeValue("-5"))
980        ));
981        match parse_widows("  abc  ") {
982            Err(WidowsParseError::ParseInt(_, s)) => assert_eq!(s, "abc"),
983            other => panic!("expected ParseInt error, got {other:?}"),
984        }
985    }
986
987    #[test]
988    fn widows_orphans_reject_non_integers_without_panic() {
989        for input in [
990            "",
991            "   ",
992            "\t\n",
993            "auto",
994            "NaN",
995            "nan",
996            "inf",
997            "-inf",
998            "Infinity",
999            "1e309",
1000            "0.0",
1001            "2.5",
1002            "1_000",
1003            "0x10",
1004            "1 2",
1005            "2;",
1006            "١٢", // Arabic-Indic digits — not accepted by i32::from_str
1007            "\u{1F600}",
1008            "9223372036854775807",  // i64::MAX
1009            "-9223372036854775808", // i64::MIN
1010            "18446744073709551615", // u64::MAX
1011            "+",
1012            "-",
1013        ] {
1014            let w = parse_widows(input);
1015            assert!(w.is_err(), "expected Err for widows {input:?}, got {w:?}");
1016            let _ = format!("{}", w.unwrap_err());
1017
1018            let o = parse_orphans(input);
1019            assert!(o.is_err(), "expected Err for orphans {input:?}, got {o:?}");
1020            let _ = format!("{}", o.unwrap_err());
1021        }
1022    }
1023
1024    /// A leading `+` is accepted by `i32::from_str`, so it is accepted here too.
1025    #[test]
1026    fn widows_accepts_explicit_plus_sign() {
1027        assert_eq!(parse_widows("+3").unwrap().inner, 3);
1028        assert_eq!(parse_orphans("  +0  ").unwrap().inner, 0);
1029    }
1030
1031    #[test]
1032    fn widows_extremely_long_digit_run_does_not_hang() {
1033        let digits = "9".repeat(1_000_000);
1034        let err = parse_widows(&digits).unwrap_err();
1035        let _ = format!("{err}"); // Display embeds the 1M-char input
1036        assert!(parse_orphans(&digits).is_err());
1037    }
1038
1039    /// `to_contained` folds both `ParseInt` and `ParseIntOwned` onto the single
1040    /// owned `ParseInt` variant, so `owned -> shared -> owned` must be a
1041    /// fixed point (it is not the identity on the *shared* side).
1042    #[test]
1043    fn widows_error_owned_round_trip_is_a_fixed_point() {
1044        let parse_err = "abc".parse::<i32>().unwrap_err();
1045        let shared = WidowsParseError::ParseInt(parse_err, "abc");
1046        let owned = shared.to_contained();
1047
1048        let back = owned.to_shared();
1049        // shared -> owned -> shared is *lossy*: ParseInt becomes ParseIntOwned.
1050        match &back {
1051            WidowsParseError::ParseIntOwned(_, input) => assert_eq!(*input, "abc"),
1052            other => panic!("expected ParseIntOwned, got {other:?}"),
1053        }
1054        // ...but the owned representation is stable under a further round-trip.
1055        assert_eq!(back.to_contained(), owned);
1056
1057        // Both spellings render the same message.
1058        assert_eq!(format!("{shared}"), format!("{back}"));
1059
1060        let neg = WidowsParseError::NegativeValue("-7");
1061        let neg_owned = neg.to_contained();
1062        assert_eq!(neg_owned.to_shared(), neg);
1063        assert_eq!(neg_owned.to_shared().to_contained(), neg_owned);
1064    }
1065
1066    #[test]
1067    fn orphans_error_owned_round_trip_is_a_fixed_point() {
1068        let err = parse_orphans("nope").unwrap_err();
1069        let owned = err.to_contained();
1070        assert_eq!(owned.to_shared().to_contained(), owned);
1071        let _ = format!("{}", owned.to_shared());
1072
1073        let neg = parse_orphans("-3").unwrap_err().to_contained();
1074        assert_eq!(neg.to_shared().to_contained(), neg);
1075    }
1076
1077    // ---------------------------------------------------------------
1078    // Value-type invariants
1079    // ---------------------------------------------------------------
1080
1081    #[test]
1082    fn enum_defaults_match_the_auto_slice_keywords() {
1083        assert_eq!(PageBreak::default(), PageBreak::Auto);
1084        assert_eq!(BreakInside::default(), BreakInside::Auto);
1085        assert_eq!(BoxDecorationBreak::default(), BoxDecorationBreak::Slice);
1086
1087        assert_eq!(
1088            parse_page_break(&PageBreak::default().print_as_css_value()),
1089            Ok(PageBreak::default())
1090        );
1091        assert_eq!(
1092            parse_break_inside(&BreakInside::default().print_as_css_value()),
1093            Ok(BreakInside::default())
1094        );
1095        assert_eq!(
1096            parse_box_decoration_break(&BoxDecorationBreak::default().print_as_css_value()),
1097            Ok(BoxDecorationBreak::default())
1098        );
1099    }
1100
1101    /// `Ord` is derived, so it follows declaration order. Anything relying on
1102    /// `PageBreak::Auto` sorting first (e.g. a `BTreeMap` keyed by these) would
1103    /// break if the variants were reordered.
1104    #[test]
1105    fn derived_ord_follows_declaration_order() {
1106        let mut sorted = ALL_PAGE_BREAKS;
1107        sorted.sort_unstable();
1108        assert_eq!(sorted, ALL_PAGE_BREAKS);
1109        assert!(PageBreak::Auto < PageBreak::AvoidColumn);
1110
1111        let mut bi = ALL_BREAK_INSIDES;
1112        bi.sort_unstable();
1113        assert_eq!(bi, ALL_BREAK_INSIDES);
1114
1115        assert!(BoxDecorationBreak::Slice < BoxDecorationBreak::Clone);
1116    }
1117
1118    #[test]
1119    fn widows_orphans_print_saturating_extremes() {
1120        assert_eq!(Widows { inner: 0 }.print_as_css_value(), "0");
1121        assert_eq!(
1122            Widows { inner: u32::MAX }.print_as_css_value(),
1123            "4294967295"
1124        );
1125        assert_eq!(
1126            Orphans { inner: u32::MAX }.print_as_css_value(),
1127            "4294967295"
1128        );
1129        // Ord on the newtypes follows the inner u32.
1130        assert!(Widows { inner: 0 } < Widows { inner: u32::MAX });
1131        assert!(Orphans { inner: 1 } < Orphans::default());
1132    }
1133}