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        const _: () = assert!(!CONT_PAGED && A4_PAGED);
195        assert_eq!(UNIFORM.top, 10.0);
196        assert_eq!(EXPLICIT.left, 4.0);
197    }
198
199    // --- FragmentationContext::page_content_height (getter) ------------------
200
201    #[test]
202    fn page_content_height_is_f32_max_for_every_continuous_width() {
203        // Continuous ignores `width` entirely — even NaN/inf must not leak out.
204        for w in HOSTILE_F32 {
205            let h = FragmentationContext::new_continuous(w).page_content_height();
206            assert!(
207                same_f32(h, f32::MAX),
208                "continuous width {w:?} leaked into page_content_height: {h:?}"
209            );
210        }
211    }
212
213    #[test]
214    fn page_content_height_returns_paged_height_verbatim_and_ignores_width() {
215        for h in HOSTILE_F32 {
216            // Width must never contaminate the result, however hostile it is.
217            for w in HOSTILE_F32 {
218                let got = FragmentationContext::new_paged(LogicalSize::new(w, h))
219                    .page_content_height();
220                assert!(
221                    same_f32(got, h),
222                    "page {w:?}x{h:?} -> page_content_height {got:?}, expected {h:?}"
223                );
224            }
225        }
226    }
227
228    #[test]
229    fn page_content_height_does_not_saturate_or_clamp_degenerate_pages() {
230        // A zero/negative page height is nonsense for print, but the getter is a
231        // plain accessor: it must report what it was given, not silently repair it.
232        let zero = FragmentationContext::new_paged(LogicalSize::zero());
233        assert_eq!(zero.page_content_height(), 0.0);
234
235        let negative = FragmentationContext::new_paged(LogicalSize::new(595.0, -842.0));
236        assert_eq!(negative.page_content_height(), -842.0);
237
238        let nan = FragmentationContext::new_paged(LogicalSize::new(595.0, f32::NAN));
239        assert!(nan.page_content_height().is_nan());
240    }
241
242    /// `f32::MAX` is the sentinel for "infinitely tall". A paged context whose page
243    /// is exactly `f32::MAX` tall is therefore indistinguishable from a continuous
244    /// one *by height alone* — `is_paged()` is the only safe discriminator.
245    #[test]
246    fn f32_max_tall_page_collides_with_continuous_sentinel_but_is_paged_disambiguates() {
247        let continuous = FragmentationContext::new_continuous(595.0);
248        let max_page =
249            FragmentationContext::new_paged(LogicalSize::new(595.0, f32::MAX));
250
251        assert_eq!(
252            continuous.page_content_height(),
253            max_page.page_content_height()
254        );
255        assert!(!continuous.is_paged());
256        assert!(max_page.is_paged());
257    }
258
259    // --- FragmentationContext::is_paged (predicate) --------------------------
260
261    #[test]
262    fn is_paged_is_true_only_for_paged_regardless_of_hostile_fields() {
263        for v in HOSTILE_F32 {
264            assert!(
265                !FragmentationContext::new_continuous(v).is_paged(),
266                "continuous({v:?}) reported as paged"
267            );
268            assert!(
269                FragmentationContext::new_paged(LogicalSize::new(v, v)).is_paged(),
270                "paged({v:?}x{v:?}) reported as continuous"
271            );
272        }
273        // Fully degenerate page: still paged. The predicate inspects the variant,
274        // never the payload.
275        assert!(FragmentationContext::new_paged(LogicalSize::zero()).is_paged());
276    }
277
278    #[test]
279    fn is_paged_is_deterministic_across_repeated_calls_and_copies() {
280        let ctx = FragmentationContext::new_paged(LogicalSize::new(f32::NAN, f32::NAN));
281        let copied = ctx; // Copy
282        assert!(ctx.is_paged());
283        assert!(ctx.is_paged());
284        assert!(copied.is_paged());
285    }
286
287    // --- PageMargins::new (constructor) --------------------------------------
288
289    #[test]
290    fn new_assigns_each_argument_to_its_own_field_no_transposition() {
291        // Distinct values in every slot: catches any top/right/bottom/left swap.
292        let m = PageMargins::new(1.0, 2.0, 3.0, 4.0);
293        assert_eq!(m.top, 1.0);
294        assert_eq!(m.right, 2.0);
295        assert_eq!(m.bottom, 3.0);
296        assert_eq!(m.left, 4.0);
297    }
298
299    #[test]
300    fn new_stores_hostile_floats_bit_exactly() {
301        for v in HOSTILE_F32 {
302            // Rotate the hostile value through each slot; the other three stay sane
303            // so a misassignment shows up as a mismatch rather than cancelling out.
304            let m = PageMargins::new(v, 2.0, 3.0, 4.0);
305            assert!(same_f32(m.top, v));
306            assert_eq!(m.right, 2.0);
307
308            let m = PageMargins::new(1.0, v, 3.0, 4.0);
309            assert!(same_f32(m.right, v));
310            assert_eq!(m.bottom, 3.0);
311
312            let m = PageMargins::new(1.0, 2.0, v, 4.0);
313            assert!(same_f32(m.bottom, v));
314            assert_eq!(m.left, 4.0);
315
316            let m = PageMargins::new(1.0, 2.0, 3.0, v);
317            assert!(same_f32(m.left, v));
318            assert_eq!(m.top, 1.0);
319        }
320    }
321
322    #[test]
323    fn default_margins_are_positive_zero_and_sum_to_zero() {
324        let d = PageMargins::default();
325        for f in [d.top, d.right, d.bottom, d.left] {
326            assert!(same_f32(f, 0.0), "default field is not +0.0: {f:?}");
327        }
328        assert_eq!(d.horizontal(), 0.0);
329        assert_eq!(d.vertical(), 0.0);
330    }
331
332    // --- PageMargins::uniform (numeric) --------------------------------------
333
334    #[test]
335    fn uniform_zero_and_negative_zero_preserve_sign() {
336        let z = PageMargins::uniform(0.0);
337        assert!(same_f32(z.top, 0.0) && same_f32(z.left, 0.0));
338        assert_eq!(z.horizontal(), 0.0);
339        assert_eq!(z.vertical(), 0.0);
340
341        // -0.0 + -0.0 == -0.0, so the sign must survive all the way through.
342        let nz = PageMargins::uniform(-0.0);
343        assert!(same_f32(nz.top, -0.0), "uniform(-0.0) lost the sign bit");
344        assert!(same_f32(nz.horizontal(), -0.0));
345        assert!(same_f32(nz.vertical(), -0.0));
346    }
347
348    #[test]
349    fn uniform_replicates_hostile_value_into_all_four_fields() {
350        for v in HOSTILE_F32 {
351            let m = PageMargins::uniform(v);
352            assert!(same_f32(m.top, v), "top != {v:?}");
353            assert!(same_f32(m.right, v), "right != {v:?}");
354            assert!(same_f32(m.bottom, v), "bottom != {v:?}");
355            assert!(same_f32(m.left, v), "left != {v:?}");
356        }
357    }
358
359    #[test]
360    fn uniform_negative_margins_are_kept_not_clamped() {
361        // Negative margins are legal CSS (`margin: -10pt`); nothing here may clamp.
362        let m = PageMargins::uniform(-10.0);
363        assert_eq!(m.top, -10.0);
364        assert_eq!(m.horizontal(), -20.0);
365        assert_eq!(m.vertical(), -20.0);
366    }
367
368    #[test]
369    fn uniform_min_max_do_not_panic_and_overflow_to_infinity_not_wrap() {
370        // f32::MAX + f32::MAX overflows. IEEE-754 says +inf; it must not panic,
371        // wrap to a negative, or saturate back to f32::MAX.
372        let hi = PageMargins::uniform(f32::MAX);
373        assert_eq!(hi.horizontal(), f32::INFINITY);
374        assert_eq!(hi.vertical(), f32::INFINITY);
375
376        let lo = PageMargins::uniform(f32::MIN);
377        assert_eq!(lo.horizontal(), f32::NEG_INFINITY);
378        assert_eq!(lo.vertical(), f32::NEG_INFINITY);
379    }
380
381    #[test]
382    fn uniform_exactly_at_the_overflow_boundary_stays_finite() {
383        // MAX/2 is exactly representable, so doubling it must land on MAX exactly
384        // — the last finite step before the overflow tested above.
385        let edge = PageMargins::uniform(f32::MAX / 2.0);
386        assert_eq!(edge.horizontal(), f32::MAX);
387        assert!(edge.horizontal().is_finite());
388    }
389
390    #[test]
391    fn uniform_smallest_normal_doubles_exactly_without_flushing_to_zero() {
392        let tiny = PageMargins::uniform(f32::MIN_POSITIVE);
393        assert_eq!(tiny.horizontal(), f32::MIN_POSITIVE * 2.0);
394        assert!(tiny.horizontal() > 0.0, "tiny margin flushed to zero");
395    }
396
397    #[test]
398    fn uniform_nan_and_inf_propagate_without_panicking() {
399        let nan = PageMargins::uniform(f32::NAN);
400        assert!(nan.horizontal().is_nan());
401        assert!(nan.vertical().is_nan());
402
403        let inf = PageMargins::uniform(f32::INFINITY);
404        assert_eq!(inf.horizontal(), f32::INFINITY);
405        assert_eq!(inf.vertical(), f32::INFINITY);
406
407        let neg_inf = PageMargins::uniform(f32::NEG_INFINITY);
408        assert_eq!(neg_inf.horizontal(), f32::NEG_INFINITY);
409        assert_eq!(neg_inf.vertical(), f32::NEG_INFINITY);
410    }
411
412    #[test]
413    fn uniform_agrees_with_new_given_the_same_value() {
414        for v in HOSTILE_F32 {
415            let u = PageMargins::uniform(v);
416            let n = PageMargins::new(v, v, v, v);
417            assert!(same_f32(u.top, n.top));
418            assert!(same_f32(u.right, n.right));
419            assert!(same_f32(u.bottom, n.bottom));
420            assert!(same_f32(u.left, n.left));
421            assert!(same_f32(u.horizontal(), n.horizontal()));
422            assert!(same_f32(u.vertical(), n.vertical()));
423        }
424    }
425
426    // --- PageMargins::horizontal / vertical (getters) ------------------------
427
428    #[test]
429    fn horizontal_sums_left_and_right_vertical_sums_top_and_bottom() {
430        // new(top, right, bottom, left) = (1, 2, 3, 4)
431        //   horizontal = left + right   = 4 + 2 = 6
432        //   vertical   = top  + bottom  = 1 + 3 = 4
433        // Distinct values, so picking the wrong pair cannot produce these sums.
434        let m = PageMargins::new(1.0, 2.0, 3.0, 4.0);
435        assert_eq!(m.horizontal(), 6.0);
436        assert_eq!(m.vertical(), 4.0);
437    }
438
439    #[test]
440    fn horizontal_and_vertical_are_axis_independent() {
441        // Loading one axis must not move the other.
442        let h_only = PageMargins::new(0.0, 7.0, 0.0, 11.0);
443        assert_eq!(h_only.horizontal(), 18.0);
444        assert_eq!(h_only.vertical(), 0.0);
445
446        let v_only = PageMargins::new(7.0, 0.0, 11.0, 0.0);
447        assert_eq!(v_only.vertical(), 18.0);
448        assert_eq!(v_only.horizontal(), 0.0);
449    }
450
451    #[test]
452    fn opposing_infinities_yield_nan_not_a_panic() {
453        // +inf + -inf is NaN by IEEE-754. The getter must return it, not trap.
454        let m = PageMargins::new(f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
455        assert!(m.vertical().is_nan(), "inf + -inf should be NaN");
456        assert!(m.horizontal().is_nan(), "-inf + inf should be NaN");
457    }
458
459    #[test]
460    fn opposing_extremes_cancel_to_zero_rather_than_overflowing() {
461        // MAX + MIN == MAX + (-MAX) == 0.0, exactly. No overflow, no panic.
462        let m = PageMargins::new(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
463        assert_eq!(m.vertical(), 0.0);
464        assert_eq!(m.horizontal(), 0.0);
465    }
466
467    #[test]
468    fn getters_absorb_a_tiny_operand_next_to_a_huge_one_without_error() {
469        // Classic float-precision trap: 1e30 + 1.0 == 1e30. Documented, not a bug —
470        // pin it so nobody "fixes" the sum into something lossier.
471        // horizontal() = left + right, vertical() = top + bottom -- so each axis has
472        // to MIX magnitudes for absorption to happen at all. (top, right, bottom, left)
473        let m = PageMargins::new(1.0e30, 1.0, 1.0, 1.0e30);
474        assert_eq!(m.horizontal(), 1.0e30);
475        assert_eq!(m.vertical(), 1.0e30);
476    }
477
478    #[test]
479    fn getters_do_not_panic_on_any_hostile_field_combination() {
480        for a in HOSTILE_F32 {
481            for b in HOSTILE_F32 {
482                // new(top=a, right=b, bottom=b, left=a): both axes sum the same pair,
483                // so horizontal() and vertical() must agree bit-for-bit for every one
484                // of the 144 hostile combinations — and neither may panic.
485                let m = PageMargins::new(a, b, b, a);
486                assert!(
487                    same_f32(m.horizontal(), m.vertical()),
488                    "axes disagree for {a:?}/{b:?}: h={:?} v={:?}",
489                    m.horizontal(),
490                    m.vertical()
491                );
492            }
493        }
494    }
495
496    #[test]
497    fn getters_are_pure_and_do_not_mutate_the_receiver() {
498        let m = PageMargins::new(1.0, 2.0, 3.0, 4.0);
499        let first_h = m.horizontal();
500        let first_v = m.vertical();
501        // Repeat calls: same answer, fields untouched.
502        assert_eq!(m.horizontal(), first_h);
503        assert_eq!(m.vertical(), first_v);
504        assert_eq!(m.top, 1.0);
505        assert_eq!(m.right, 2.0);
506        assert_eq!(m.bottom, 3.0);
507        assert_eq!(m.left, 4.0);
508    }
509
510    #[test]
511    fn page_margins_copy_does_not_alias_the_original() {
512        let original = PageMargins::uniform(5.0);
513        let mut copy = original; // Copy, not a move
514        copy.top = 99.0;
515        assert_eq!(original.top, 5.0, "mutating a copy wrote through to the original");
516        assert_eq!(original.vertical(), 10.0);
517        assert_eq!(copy.vertical(), 104.0);
518    }
519
520    // --- round-trip ----------------------------------------------------------
521
522    #[test]
523    fn round_trip_new_fields_reconstruct_an_identical_margin() {
524        for v in HOSTILE_F32 {
525            let a = PageMargins::new(v, 1.0, 2.0, 3.0);
526            let b = PageMargins::new(a.top, a.right, a.bottom, a.left);
527            assert!(same_f32(a.top, b.top));
528            assert!(same_f32(a.right, b.right));
529            assert!(same_f32(a.bottom, b.bottom));
530            assert!(same_f32(a.left, b.left));
531        }
532    }
533
534    #[test]
535    fn round_trip_paged_context_returns_the_height_it_was_built_from() {
536        for h in [0.0_f32, 1.0, 842.0, -842.0, f32::MAX, f32::MIN, f32::MIN_POSITIVE] {
537            let ctx = FragmentationContext::new_paged(LogicalSize::new(595.0, h));
538            assert!(same_f32(ctx.page_content_height(), h));
539            assert!(ctx.is_paged());
540        }
541    }
542}