Skip to main content

azul_core/
paged.rs

1//! Paged media layout primitives.
2//!
3//! Provides the [`FragmentationContext`] that the layout solver threads through to
4//! distinguish continuous (screen) from paged (print) media.
5//!
6//! For continuous media (screens), content flows into a single infinitely tall
7//! container. For paged media (print), content is laid out on a continuous canvas
8//! and afterwards sliced into fixed-size pages by the display-list slicer
9//! (`paginate_display_list_with_slicer_and_breaks` in `azul_layout::solver3::display_list`).
10//! This lets the layout engine make break decisions while respecting CSS properties
11//! like `break-before`, `break-after`, and `break-inside`.
12//!
13//! Page *decoration* (headers, footers, margin boxes, counters) lives in
14//! `azul_layout::solver3::pagination`.
15
16use crate::geom::LogicalSize;
17
18/// Selects how content is fragmented during layout.
19///
20/// This is the core abstraction for fragmentation support:
21/// - Screen rendering: [`Continuous`](Self::Continuous) — a single infinite container.
22/// - Print rendering: [`Paged`](Self::Paged) — a series of fixed-size page containers.
23#[derive(Debug, Clone, Copy)]
24pub enum FragmentationContext {
25    /// Continuous media (screen): a single, infinitely tall container.
26    ///
27    /// Used for normal screen rendering where content can scroll indefinitely;
28    /// breaks are never forced.
29    Continuous {
30        /// Width of the viewport.
31        width: f32,
32    },
33
34    /// Paged media (print): fixed-size pages.
35    ///
36    /// Used for PDF generation and print preview. Content flows from one page to
37    /// the next when a page is full.
38    Paged {
39        /// Size of each page.
40        page_size: LogicalSize,
41    },
42}
43
44impl FragmentationContext {
45    /// Create a continuous fragmentation context for screen rendering.
46    #[must_use] pub const fn new_continuous(width: f32) -> Self {
47        Self::Continuous { width }
48    }
49
50    /// Create a paged fragmentation context for print rendering.
51    #[must_use] pub const fn new_paged(page_size: LogicalSize) -> Self {
52        Self::Paged { page_size }
53    }
54
55    /// Get the page content height (page height for paged media).
56    ///
57    /// For continuous media, returns `f32::MAX`.
58    #[must_use] pub const fn page_content_height(&self) -> f32 {
59        match self {
60            Self::Continuous { .. } => f32::MAX,
61            Self::Paged { page_size, .. } => page_size.height,
62        }
63    }
64
65    /// Check if this is paged media.
66    #[must_use] pub const fn is_paged(&self) -> bool {
67        matches!(self, Self::Paged { .. })
68    }
69}
70
71/// Page margins in points.
72///
73/// Canonical paged-media margin type (formerly defined in the now-removed
74/// `crate::fragmentation` module). Re-exported from the crate root as
75/// `azul_layout::PageMargins`.
76#[derive(Debug, Clone, Copy, Default)]
77pub struct PageMargins {
78    pub top: f32,
79    pub right: f32,
80    pub bottom: f32,
81    pub left: f32,
82}
83
84impl PageMargins {
85    #[must_use] pub const fn new(top: f32, right: f32, bottom: f32, left: f32) -> Self {
86        Self {
87            top,
88            right,
89            bottom,
90            left,
91        }
92    }
93
94    #[must_use] pub const fn uniform(margin: f32) -> Self {
95        Self {
96            top: margin,
97            right: margin,
98            bottom: margin,
99            left: margin,
100        }
101    }
102
103    #[must_use] pub fn horizontal(&self) -> f32 {
104        self.left + self.right
105    }
106
107    #[must_use] pub fn vertical(&self) -> f32 {
108        self.top + self.bottom
109    }
110}
111
112#[cfg(test)]
113mod autotest_generated {
114    #![allow(clippy::float_cmp)]
115
116    use super::*;
117
118    /// Every hostile `f32` a caller can hand to these constructors.
119    const HOSTILE_F32: [f32; 12] = [
120        0.0,
121        -0.0,
122        1.0,
123        -1.0,
124        f32::MAX,
125        f32::MIN,
126        f32::MIN_POSITIVE,
127        -f32::MIN_POSITIVE,
128        f32::EPSILON,
129        f32::INFINITY,
130        f32::NEG_INFINITY,
131        f32::NAN,
132    ];
133
134    /// Bit-exact float compare: distinguishes `0.0` from `-0.0`, and treats any
135    /// NaN as equal to any other NaN (so it can be used on the hostile list).
136    fn same_f32(a: f32, b: f32) -> bool {
137        if a.is_nan() && b.is_nan() {
138            return true;
139        }
140        a.to_bits() == b.to_bits()
141    }
142
143    // --- FragmentationContext::new_continuous / new_paged (constructor) -------
144
145    #[test]
146    fn new_continuous_stores_width_bit_exactly_for_hostile_input() {
147        for w in HOSTILE_F32 {
148            let ctx = FragmentationContext::new_continuous(w);
149            match ctx {
150                FragmentationContext::Continuous { width } => {
151                    assert!(same_f32(width, w), "width mangled: {w:?} -> {width:?}");
152                }
153                FragmentationContext::Paged { .. } => {
154                    panic!("new_continuous produced a Paged variant for width {w:?}")
155                }
156            }
157        }
158    }
159
160    #[test]
161    fn new_paged_stores_page_size_bit_exactly_for_hostile_input() {
162        for w in HOSTILE_F32 {
163            for h in HOSTILE_F32 {
164                let ctx = FragmentationContext::new_paged(LogicalSize::new(w, h));
165                match ctx {
166                    FragmentationContext::Paged { page_size } => {
167                        assert!(same_f32(page_size.width, w));
168                        assert!(same_f32(page_size.height, h));
169                    }
170                    FragmentationContext::Continuous { .. } => {
171                        panic!("new_paged produced a Continuous variant for {w:?}x{h:?}")
172                    }
173                }
174            }
175        }
176    }
177
178    /// The `const fn` marker is part of the public contract: a caller may put
179    /// these in a `const` item. If constness regresses, this stops compiling.
180    #[test]
181    fn constructors_and_accessors_are_usable_in_const_context() {
182        const CONT: FragmentationContext = FragmentationContext::new_continuous(1024.0);
183        const A4: FragmentationContext =
184            FragmentationContext::new_paged(LogicalSize::new(595.0, 842.0));
185        const CONT_H: f32 = CONT.page_content_height();
186        const A4_H: f32 = A4.page_content_height();
187        const CONT_PAGED: bool = CONT.is_paged();
188        const A4_PAGED: bool = A4.is_paged();
189        const UNIFORM: PageMargins = PageMargins::uniform(10.0);
190        const EXPLICIT: PageMargins = PageMargins::new(1.0, 2.0, 3.0, 4.0);
191
192        assert_eq!(CONT_H, f32::MAX);
193        assert_eq!(A4_H, 842.0);
194        assert!(!CONT_PAGED);
195        assert!(A4_PAGED);
196        assert_eq!(UNIFORM.top, 10.0);
197        assert_eq!(EXPLICIT.left, 4.0);
198    }
199
200    // --- FragmentationContext::page_content_height (getter) ------------------
201
202    #[test]
203    fn page_content_height_is_f32_max_for_every_continuous_width() {
204        // Continuous ignores `width` entirely — even NaN/inf must not leak out.
205        for w in HOSTILE_F32 {
206            let h = FragmentationContext::new_continuous(w).page_content_height();
207            assert!(
208                same_f32(h, f32::MAX),
209                "continuous width {w:?} leaked into page_content_height: {h:?}"
210            );
211        }
212    }
213
214    #[test]
215    fn page_content_height_returns_paged_height_verbatim_and_ignores_width() {
216        for h in HOSTILE_F32 {
217            // Width must never contaminate the result, however hostile it is.
218            for w in HOSTILE_F32 {
219                let got = FragmentationContext::new_paged(LogicalSize::new(w, h))
220                    .page_content_height();
221                assert!(
222                    same_f32(got, h),
223                    "page {w:?}x{h:?} -> page_content_height {got:?}, expected {h:?}"
224                );
225            }
226        }
227    }
228
229    #[test]
230    fn page_content_height_does_not_saturate_or_clamp_degenerate_pages() {
231        // A zero/negative page height is nonsense for print, but the getter is a
232        // plain accessor: it must report what it was given, not silently repair it.
233        let zero = FragmentationContext::new_paged(LogicalSize::zero());
234        assert_eq!(zero.page_content_height(), 0.0);
235
236        let negative = FragmentationContext::new_paged(LogicalSize::new(595.0, -842.0));
237        assert_eq!(negative.page_content_height(), -842.0);
238
239        let nan = FragmentationContext::new_paged(LogicalSize::new(595.0, f32::NAN));
240        assert!(nan.page_content_height().is_nan());
241    }
242
243    /// `f32::MAX` is the sentinel for "infinitely tall". A paged context whose page
244    /// is exactly `f32::MAX` tall is therefore indistinguishable from a continuous
245    /// one *by height alone* — `is_paged()` is the only safe discriminator.
246    #[test]
247    fn f32_max_tall_page_collides_with_continuous_sentinel_but_is_paged_disambiguates() {
248        let continuous = FragmentationContext::new_continuous(595.0);
249        let max_page =
250            FragmentationContext::new_paged(LogicalSize::new(595.0, f32::MAX));
251
252        assert_eq!(
253            continuous.page_content_height(),
254            max_page.page_content_height()
255        );
256        assert!(!continuous.is_paged());
257        assert!(max_page.is_paged());
258    }
259
260    // --- FragmentationContext::is_paged (predicate) --------------------------
261
262    #[test]
263    fn is_paged_is_true_only_for_paged_regardless_of_hostile_fields() {
264        for v in HOSTILE_F32 {
265            assert!(
266                !FragmentationContext::new_continuous(v).is_paged(),
267                "continuous({v:?}) reported as paged"
268            );
269            assert!(
270                FragmentationContext::new_paged(LogicalSize::new(v, v)).is_paged(),
271                "paged({v:?}x{v:?}) reported as continuous"
272            );
273        }
274        // Fully degenerate page: still paged. The predicate inspects the variant,
275        // never the payload.
276        assert!(FragmentationContext::new_paged(LogicalSize::zero()).is_paged());
277    }
278
279    #[test]
280    fn is_paged_is_deterministic_across_repeated_calls_and_copies() {
281        let ctx = FragmentationContext::new_paged(LogicalSize::new(f32::NAN, f32::NAN));
282        let copied = ctx; // Copy
283        assert!(ctx.is_paged());
284        assert!(ctx.is_paged());
285        assert!(copied.is_paged());
286    }
287
288    // --- PageMargins::new (constructor) --------------------------------------
289
290    #[test]
291    fn new_assigns_each_argument_to_its_own_field_no_transposition() {
292        // Distinct values in every slot: catches any top/right/bottom/left swap.
293        let m = PageMargins::new(1.0, 2.0, 3.0, 4.0);
294        assert_eq!(m.top, 1.0);
295        assert_eq!(m.right, 2.0);
296        assert_eq!(m.bottom, 3.0);
297        assert_eq!(m.left, 4.0);
298    }
299
300    #[test]
301    fn new_stores_hostile_floats_bit_exactly() {
302        for v in HOSTILE_F32 {
303            // Rotate the hostile value through each slot; the other three stay sane
304            // so a misassignment shows up as a mismatch rather than cancelling out.
305            let m = PageMargins::new(v, 2.0, 3.0, 4.0);
306            assert!(same_f32(m.top, v));
307            assert_eq!(m.right, 2.0);
308
309            let m = PageMargins::new(1.0, v, 3.0, 4.0);
310            assert!(same_f32(m.right, v));
311            assert_eq!(m.bottom, 3.0);
312
313            let m = PageMargins::new(1.0, 2.0, v, 4.0);
314            assert!(same_f32(m.bottom, v));
315            assert_eq!(m.left, 4.0);
316
317            let m = PageMargins::new(1.0, 2.0, 3.0, v);
318            assert!(same_f32(m.left, v));
319            assert_eq!(m.top, 1.0);
320        }
321    }
322
323    #[test]
324    fn default_margins_are_positive_zero_and_sum_to_zero() {
325        let d = PageMargins::default();
326        for f in [d.top, d.right, d.bottom, d.left] {
327            assert!(same_f32(f, 0.0), "default field is not +0.0: {f:?}");
328        }
329        assert_eq!(d.horizontal(), 0.0);
330        assert_eq!(d.vertical(), 0.0);
331    }
332
333    // --- PageMargins::uniform (numeric) --------------------------------------
334
335    #[test]
336    fn uniform_zero_and_negative_zero_preserve_sign() {
337        let z = PageMargins::uniform(0.0);
338        assert!(same_f32(z.top, 0.0) && same_f32(z.left, 0.0));
339        assert_eq!(z.horizontal(), 0.0);
340        assert_eq!(z.vertical(), 0.0);
341
342        // -0.0 + -0.0 == -0.0, so the sign must survive all the way through.
343        let nz = PageMargins::uniform(-0.0);
344        assert!(same_f32(nz.top, -0.0), "uniform(-0.0) lost the sign bit");
345        assert!(same_f32(nz.horizontal(), -0.0));
346        assert!(same_f32(nz.vertical(), -0.0));
347    }
348
349    #[test]
350    fn uniform_replicates_hostile_value_into_all_four_fields() {
351        for v in HOSTILE_F32 {
352            let m = PageMargins::uniform(v);
353            assert!(same_f32(m.top, v), "top != {v:?}");
354            assert!(same_f32(m.right, v), "right != {v:?}");
355            assert!(same_f32(m.bottom, v), "bottom != {v:?}");
356            assert!(same_f32(m.left, v), "left != {v:?}");
357        }
358    }
359
360    #[test]
361    fn uniform_negative_margins_are_kept_not_clamped() {
362        // Negative margins are legal CSS (`margin: -10pt`); nothing here may clamp.
363        let m = PageMargins::uniform(-10.0);
364        assert_eq!(m.top, -10.0);
365        assert_eq!(m.horizontal(), -20.0);
366        assert_eq!(m.vertical(), -20.0);
367    }
368
369    #[test]
370    fn uniform_min_max_do_not_panic_and_overflow_to_infinity_not_wrap() {
371        // f32::MAX + f32::MAX overflows. IEEE-754 says +inf; it must not panic,
372        // wrap to a negative, or saturate back to f32::MAX.
373        let hi = PageMargins::uniform(f32::MAX);
374        assert_eq!(hi.horizontal(), f32::INFINITY);
375        assert_eq!(hi.vertical(), f32::INFINITY);
376
377        let lo = PageMargins::uniform(f32::MIN);
378        assert_eq!(lo.horizontal(), f32::NEG_INFINITY);
379        assert_eq!(lo.vertical(), f32::NEG_INFINITY);
380    }
381
382    #[test]
383    fn uniform_exactly_at_the_overflow_boundary_stays_finite() {
384        // MAX/2 is exactly representable, so doubling it must land on MAX exactly
385        // — the last finite step before the overflow tested above.
386        let edge = PageMargins::uniform(f32::MAX / 2.0);
387        assert_eq!(edge.horizontal(), f32::MAX);
388        assert!(edge.horizontal().is_finite());
389    }
390
391    #[test]
392    fn uniform_smallest_normal_doubles_exactly_without_flushing_to_zero() {
393        let tiny = PageMargins::uniform(f32::MIN_POSITIVE);
394        assert_eq!(tiny.horizontal(), f32::MIN_POSITIVE * 2.0);
395        assert!(tiny.horizontal() > 0.0, "tiny margin flushed to zero");
396    }
397
398    #[test]
399    fn uniform_nan_and_inf_propagate_without_panicking() {
400        let nan = PageMargins::uniform(f32::NAN);
401        assert!(nan.horizontal().is_nan());
402        assert!(nan.vertical().is_nan());
403
404        let inf = PageMargins::uniform(f32::INFINITY);
405        assert_eq!(inf.horizontal(), f32::INFINITY);
406        assert_eq!(inf.vertical(), f32::INFINITY);
407
408        let neg_inf = PageMargins::uniform(f32::NEG_INFINITY);
409        assert_eq!(neg_inf.horizontal(), f32::NEG_INFINITY);
410        assert_eq!(neg_inf.vertical(), f32::NEG_INFINITY);
411    }
412
413    #[test]
414    fn uniform_agrees_with_new_given_the_same_value() {
415        for v in HOSTILE_F32 {
416            let u = PageMargins::uniform(v);
417            let n = PageMargins::new(v, v, v, v);
418            assert!(same_f32(u.top, n.top));
419            assert!(same_f32(u.right, n.right));
420            assert!(same_f32(u.bottom, n.bottom));
421            assert!(same_f32(u.left, n.left));
422            assert!(same_f32(u.horizontal(), n.horizontal()));
423            assert!(same_f32(u.vertical(), n.vertical()));
424        }
425    }
426
427    // --- PageMargins::horizontal / vertical (getters) ------------------------
428
429    #[test]
430    fn horizontal_sums_left_and_right_vertical_sums_top_and_bottom() {
431        // new(top, right, bottom, left) = (1, 2, 3, 4)
432        //   horizontal = left + right   = 4 + 2 = 6
433        //   vertical   = top  + bottom  = 1 + 3 = 4
434        // Distinct values, so picking the wrong pair cannot produce these sums.
435        let m = PageMargins::new(1.0, 2.0, 3.0, 4.0);
436        assert_eq!(m.horizontal(), 6.0);
437        assert_eq!(m.vertical(), 4.0);
438    }
439
440    #[test]
441    fn horizontal_and_vertical_are_axis_independent() {
442        // Loading one axis must not move the other.
443        let h_only = PageMargins::new(0.0, 7.0, 0.0, 11.0);
444        assert_eq!(h_only.horizontal(), 18.0);
445        assert_eq!(h_only.vertical(), 0.0);
446
447        let v_only = PageMargins::new(7.0, 0.0, 11.0, 0.0);
448        assert_eq!(v_only.vertical(), 18.0);
449        assert_eq!(v_only.horizontal(), 0.0);
450    }
451
452    #[test]
453    fn opposing_infinities_yield_nan_not_a_panic() {
454        // +inf + -inf is NaN by IEEE-754. The getter must return it, not trap.
455        let m = PageMargins::new(f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
456        assert!(m.vertical().is_nan(), "inf + -inf should be NaN");
457        assert!(m.horizontal().is_nan(), "-inf + inf should be NaN");
458    }
459
460    #[test]
461    fn opposing_extremes_cancel_to_zero_rather_than_overflowing() {
462        // MAX + MIN == MAX + (-MAX) == 0.0, exactly. No overflow, no panic.
463        let m = PageMargins::new(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
464        assert_eq!(m.vertical(), 0.0);
465        assert_eq!(m.horizontal(), 0.0);
466    }
467
468    #[test]
469    fn getters_absorb_a_tiny_operand_next_to_a_huge_one_without_error() {
470        // Classic float-precision trap: 1e30 + 1.0 == 1e30. Documented, not a bug —
471        // pin it so nobody "fixes" the sum into something lossier.
472        let m = PageMargins::new(1.0e30, 1.0, 1.0e30, 1.0);
473        assert_eq!(m.horizontal(), 1.0e30);
474        assert_eq!(m.vertical(), 1.0e30);
475    }
476
477    #[test]
478    fn getters_do_not_panic_on_any_hostile_field_combination() {
479        for a in HOSTILE_F32 {
480            for b in HOSTILE_F32 {
481                // new(top=a, right=b, bottom=b, left=a): both axes sum the same pair,
482                // so horizontal() and vertical() must agree bit-for-bit for every one
483                // of the 144 hostile combinations — and neither may panic.
484                let m = PageMargins::new(a, b, b, a);
485                assert!(
486                    same_f32(m.horizontal(), m.vertical()),
487                    "axes disagree for {a:?}/{b:?}: h={:?} v={:?}",
488                    m.horizontal(),
489                    m.vertical()
490                );
491            }
492        }
493    }
494
495    #[test]
496    fn getters_are_pure_and_do_not_mutate_the_receiver() {
497        let m = PageMargins::new(1.0, 2.0, 3.0, 4.0);
498        let first_h = m.horizontal();
499        let first_v = m.vertical();
500        // Repeat calls: same answer, fields untouched.
501        assert_eq!(m.horizontal(), first_h);
502        assert_eq!(m.vertical(), first_v);
503        assert_eq!(m.top, 1.0);
504        assert_eq!(m.right, 2.0);
505        assert_eq!(m.bottom, 3.0);
506        assert_eq!(m.left, 4.0);
507    }
508
509    #[test]
510    fn page_margins_copy_does_not_alias_the_original() {
511        let original = PageMargins::uniform(5.0);
512        let mut copy = original; // Copy, not a move
513        copy.top = 99.0;
514        assert_eq!(original.top, 5.0, "mutating a copy wrote through to the original");
515        assert_eq!(original.vertical(), 10.0);
516        assert_eq!(copy.vertical(), 104.0);
517    }
518
519    // --- round-trip ----------------------------------------------------------
520
521    #[test]
522    fn round_trip_new_fields_reconstruct_an_identical_margin() {
523        for v in HOSTILE_F32 {
524            let a = PageMargins::new(v, 1.0, 2.0, 3.0);
525            let b = PageMargins::new(a.top, a.right, a.bottom, a.left);
526            assert!(same_f32(a.top, b.top));
527            assert!(same_f32(a.right, b.right));
528            assert!(same_f32(a.bottom, b.bottom));
529            assert!(same_f32(a.left, b.left));
530        }
531    }
532
533    #[test]
534    fn round_trip_paged_context_returns_the_height_it_was_built_from() {
535        for h in [0.0_f32, 1.0, 842.0, -842.0, f32::MAX, f32::MIN, f32::MIN_POSITIVE] {
536            let ctx = FragmentationContext::new_paged(LogicalSize::new(595.0, h));
537            assert!(same_f32(ctx.page_content_height(), h));
538            assert!(ctx.is_paged());
539        }
540    }
541}