1
//! Page-break analysis as a standalone, pure computation.
2
//!
3
//! Extracted from `display_list.rs::calculate_page_break_positions` so that
4
//! embedders (document editors, printpdf) can compute pagination *without*
5
//! generating any per-page display list, and so the slicer becomes a consumer
6
//! of the same analysis it used to inline.
7
//!
8
//! Three latent defects were fixed in the extraction (each pinned by a test):
9
//!
10
//! 1. the sort now uses `f32::total_cmp` — the old `partial_cmp().unwrap()`
11
//!    was a panic path if a NaN ever survived the input filter;
12
//! 2. when a forced break and an interval break land within the 1px merge
13
//!    window, the FORCED break survives (CSS Fragmentation: forced breaks
14
//!    win). The old positional dedup kept whichever sorted first, so an
15
//!    author's `break-before: always` could be silently replaced by the
16
//!    interval break up to 1px above it;
17
//! 3. `normal_page_content_height <= 0` (header + footer at least as tall as
18
//!    the page, with `skip_first_page` making the first page valid) made the
19
//!    old interval loop `y += normal` never terminate. Interval generation now
20
//!    stops after the first-page break when the normal height is not positive.
21

            
22
use azul_core::dom::NodeId;
23

            
24
use crate::solver3::display_list::{
25
    calculate_display_list_height, DisplayList, SlicerConfig,
26
};
27

            
28
/// Why a page ends where it does.
29
#[derive(Debug, Clone, Copy, PartialEq)]
30
#[repr(C, u8)]
31
pub enum BreakKind {
32
    /// A CSS `break-before/after: always` (or legacy `page-break-*`) forced
33
    /// this break.
34
    Forced,
35
    /// The page was simply full (regular interval break).
36
    Interval,
37
    /// The page was full at `pushed_from`, but the break moved UP to honor an
38
    /// avoid-rule (`break-inside: avoid`, line atomicity, widows/orphans).
39
    Avoided { pushed_from: f32 },
40
}
41

            
42
/// One page boundary in document space.
43
#[derive(Debug, Clone, Copy, PartialEq)]
44
pub struct PageBreakPosition {
45
    /// Document-space Y where the page ENDS (content at or below `y` belongs
46
    /// to the next page).
47
    pub y: f32,
48
    pub kind: BreakKind,
49
    /// For [`BreakKind::Forced`]: the node whose break property caused it,
50
    /// when known. `None` in the display-list-only path — the display list
51
    /// records only the Y positions of forced breaks.
52
    pub causing_node: Option<NodeId>,
53
}
54

            
55
/// Page geometry the break computation needs.
56
///
57
/// [`PageConstraints::from_slicer_config`] performs the header/footer
58
/// subtraction that used to be inlined in
59
/// `paginate_display_list_with_slicer_and_breaks`.
60
#[derive(Debug, Clone, Copy, PartialEq)]
61
pub struct PageConstraints {
62
    /// Content height available on the first page (differs from
63
    /// `normal_page_content_height` when headers/footers skip the first page).
64
    pub first_page_content_height: f32,
65
    /// Content height available on every page after the first.
66
    pub normal_page_content_height: f32,
67
}
68

            
69
impl PageConstraints {
70
    /// Derive the per-page content heights from a slicer config: subtract
71
    /// header/footer space, and give the first page the full height when
72
    /// `skip_first_page` is set.
73
    #[must_use]
74
1412
    pub fn from_slicer_config(cfg: &SlicerConfig) -> Self {
75
1412
        let base_header_space = if cfg.header_footer.show_header {
76
4
            cfg.header_footer.header_height
77
        } else {
78
1408
            0.0
79
        };
80
1412
        let base_footer_space = if cfg.header_footer.show_footer {
81
6
            cfg.header_footer.footer_height
82
        } else {
83
1406
            0.0
84
        };
85
1412
        let normal_page_content_height =
86
1412
            cfg.page_content_height - base_header_space - base_footer_space;
87
1412
        let first_page_content_height = if cfg.header_footer.skip_first_page {
88
            // First page has full height when skipping headers/footers
89
2
            cfg.page_content_height
90
        } else {
91
1410
            normal_page_content_height
92
        };
93
1412
        Self {
94
1412
            first_page_content_height,
95
1412
            normal_page_content_height,
96
1412
        }
97
1412
    }
98
}
99

            
100
/// Breaks within this distance are the same boundary and are merged — a
101
/// duplicate would produce a zero-height page.
102
const MERGE_WINDOW_PX: f32 = 1.0;
103

            
104
/// Break-awareness policy.
105
///
106
/// ALL flags off (the default) reproduces the plain forced ∪ interval
107
/// algorithm exactly — that is how this type can ship ahead of the behaviors
108
/// it gates (each lands in its own stage and flips on in printpdf with a
109
/// changelog entry, never silently).
110
#[derive(Debug, Clone, Copy, PartialEq)]
111
#[allow(clippy::struct_excessive_bools)] // independent feature flags; mirrors the C-ABI struct layout
112
pub struct BreakPolicy {
113
    /// Honor `break-inside: avoid` (push boxes below the break intact).
114
    pub honor_break_inside: bool,
115
    /// Honor `widows` / `orphans` line constraints.
116
    pub widows_orphans: bool,
117
    /// Never tear a line box across pages (snap to line boundaries).
118
    pub atomic_lines: bool,
119
    /// Never tear a table row across pages.
120
    pub atomic_table_rows: bool,
121
    /// Repeat `<thead>` on continuation pages.
122
    pub repeat_table_headers: bool,
123
    /// Upper bound on how far a break may be pushed UP to satisfy
124
    /// avoid-rules, as a fraction of the page height (guards pathological
125
    /// cascades; beyond it the plain candidate snap applies).
126
    pub max_push_distance: f32,
127
}
128

            
129
impl Default for BreakPolicy {
130
253
    fn default() -> Self {
131
253
        Self {
132
253
            honor_break_inside: false,
133
253
            widows_orphans: false,
134
253
            atomic_lines: false,
135
253
            atomic_table_rows: false,
136
253
            repeat_table_headers: false,
137
253
            max_push_distance: 0.33,
138
253
        }
139
253
    }
140
}
141

            
142
/// The richer inputs break-awareness needs (geometry beyond the display
143
/// list: box rects and break properties). The display-list-only path stays
144
/// available via [`compute_page_breaks_from_display_list`].
145
#[derive(Debug)]
146
pub struct PageBreakInput<'a> {
147
    /// Item geometry + forced break positions.
148
    pub display_list: &'a DisplayList,
149
    /// Box rects + line boxes for future break-candidate stages. v1
150
    /// break-awareness derives all geometry from the display list, so this
151
    /// may be `None`.
152
    pub layout_tree: Option<&'a crate::solver3::layout_tree::LayoutTree>,
153
    /// Break properties (`break-inside`, `widows`, `orphans`, …).
154
    pub styled_dom: &'a azul_core::styled_dom::StyledDom,
155
    /// Registered table headers (`collect_table_headers`) — continuation
156
    /// pages that start inside a table reserve the repeated thead's height.
157
    /// `None` when `repeat_table_headers` is off.
158
    pub table_headers: Option<&'a crate::solver3::pagination::TableHeaderTracker>,
159
}
160

            
161
/// A vertical range a break may not enter, with the Y to snap up to
162
/// (`top`) when one lands inside.
163
#[derive(Debug, Clone, Copy)]
164
struct AvoidRange {
165
    top: f32,
166
    bottom: f32,
167
    /// The node that owns the range (diagnostics).
168
    node: Option<NodeId>,
169
}
170

            
171
/// The line boxes of one paragraph (for widows/orphans), sorted by top.
172
#[derive(Debug, Clone)]
173
struct ParagraphLines {
174
    node: NodeId,
175
    /// `(top, bottom)` of each line, sorted by top, deduplicated.
176
    lines: Vec<(f32, f32)>,
177
    widows: u32,
178
    orphans: u32,
179
}
180

            
181
/// Compute page breaks with break-awareness `policy`. With the default
182
/// (all-off) policy this is exactly [`compute_page_breaks_from_display_list`].
183
///
184
/// With flags on, interval breaks run as a single FORWARD pass (matching the
185
/// slicer's "breaks only move up, content never moves" model): the naive
186
/// break `prev + page_height` snaps UP out of avoid-ranges
187
/// (`break-inside: avoid` boxes, atomic line boxes) and back for
188
/// widows/orphans, bounded by `policy.max_push_distance` (beyond it the
189
/// naive break stands — a bounded fallback, never a loop). Forced breaks
190
/// never move. Boxes taller than the page are torn regardless (the monolith
191
/// rule — an unbreakable box that cannot fit must still paginate).
192
#[must_use]
193
1419
pub fn compute_page_breaks(
194
1419
    input: &PageBreakInput<'_>,
195
1419
    constraints: &PageConstraints,
196
1419
    policy: &BreakPolicy,
197
1419
) -> Vec<PageBreakPosition> {
198
1419
    compute_page_breaks_impl(input, *constraints, policy, None, None)
199
1419
}
200

            
201
/// Why an element could not be kept intact across a page boundary (E21).
202
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203
pub enum MonolithReason {
204
    /// A `break-inside: avoid` box taller than the page content area —
205
    /// no break position can satisfy it, so it tears.
206
    AvoidBoxTallerThanPage,
207
    /// An atomic table row taller than the page content area.
208
    RowTallerThanPage,
209
}
210

            
211
/// E21: one element the break pass had to TEAR despite a keep-intact rule.
212
/// The exporter surfaces these as "element X could not be kept intact".
213
#[derive(Debug, Clone, Copy, PartialEq)]
214
pub struct MonolithWarning {
215
    /// The node whose rule was violated (None = anonymous, e.g. a row range
216
    /// derived without a node mapping).
217
    pub node: Option<NodeId>,
218
    /// Document-space extent of the torn box.
219
    pub top: f32,
220
    pub bottom: f32,
221
    pub reason: MonolithReason,
222
}
223

            
224
/// [`compute_page_breaks`] + the E21 monolith report: elements whose
225
/// keep-intact rules (`break-inside: avoid`, atomic rows) could not be
226
/// honored because they are taller than the page. The breaks are identical
227
/// to [`compute_page_breaks`] — the report only ADDS information.
228
#[must_use]
229
2
pub fn compute_page_breaks_with_report(
230
2
    input: &PageBreakInput<'_>,
231
2
    constraints: &PageConstraints,
232
2
    policy: &BreakPolicy,
233
2
) -> (Vec<PageBreakPosition>, Vec<MonolithWarning>) {
234
2
    let mut warnings = Vec::new();
235
2
    let breaks =
236
2
        compute_page_breaks_impl(input, *constraints, policy, None, Some(&mut warnings));
237
2
    (breaks, warnings)
238
2
}
239

            
240
/// [`compute_page_breaks`] with an office-suite-style [`PageSequence`].
241
///
242
/// Every page's content HEIGHT comes from `setup_for_page(index)` (default /
243
/// explicit override / different-first / odd-even parity), so "page 345 is
244
/// landscape-height with a 2cm footer" flows straight into the break
245
/// positions. Content WIDTH must be uniform for now (the fragmentainer
246
/// re-wrap stage) — a non-uniform sequence announces once and lays out at
247
/// the default width.
248
#[must_use]
249
7
pub fn compute_page_breaks_with_sequence(
250
7
    input: &PageBreakInput<'_>,
251
7
    sequence: &crate::solver3::pagination::PageSequence,
252
7
    policy: &BreakPolicy,
253
7
) -> Vec<PageBreakPosition> {
254
7
    let _ = sequence.has_uniform_width(); // announce the width degradation once
255
7
    let default_h = sequence.default.content_height();
256
7
    let constraints = PageConstraints {
257
7
        first_page_content_height: sequence.setup_for_page(0).content_height(),
258
7
        normal_page_content_height: default_h,
259
7
    };
260
7
    compute_page_breaks_impl(input, constraints, policy, Some(sequence), None)
261
7
}
262

            
263
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
264
1428
fn compute_page_breaks_impl(
265
1428
    input: &PageBreakInput<'_>,
266
1428
    constraints: PageConstraints,
267
1428
    policy: &BreakPolicy,
268
1428
    sequence: Option<&crate::solver3::pagination::PageSequence>,
269
1428
    mut monolith_report: Option<&mut Vec<MonolithWarning>>,
270
1428
) -> Vec<PageBreakPosition> {
271
1428
    let any_awareness = policy.honor_break_inside
272
1422
        || policy.atomic_lines
273
1421
        || policy.widows_orphans
274
1419
        || policy.atomic_table_rows
275
1417
        || policy.repeat_table_headers
276
        // A page sequence varies heights per page — the forward pass is the
277
        // only correct evaluator even with all avoid-rules off.
278
1416
        || sequence.is_some();
279
1428
    if !any_awareness {
280
1409
        return compute_page_breaks_from_display_list(input.display_list, &constraints);
281
19
    }
282

            
283
19
    let total_height = calculate_display_list_height(input.display_list);
284
19
    let first = constraints.first_page_content_height;
285
19
    let normal = constraints.normal_page_content_height;
286
19
    if total_height <= 0.0 || first <= 0.0 {
287
        return Vec::new();
288
19
    }
289

            
290
19
    let mut avoid_ranges =
291
19
        collect_avoid_ranges(input, constraints, policy, monolith_report.as_deref_mut());
292
19
    if policy.atomic_table_rows {
293
        // A break may not slice a table row (monolith rule still applies:
294
        // a row taller than the page tears).
295
2
        let page_h = constraints.normal_page_content_height.max(1.0);
296
10
        for (top, bottom) in crate::solver3::pagination::collect_table_row_ranges(
297
2
            input.display_list,
298
2
            input.styled_dom,
299
        ) {
300
10
            if bottom - top <= page_h {
301
8
                avoid_ranges.push(AvoidRange {
302
8
                    top,
303
8
                    bottom,
304
8
                    node: None,
305
8
                });
306
8
            } else if let Some(report) = monolith_report.as_deref_mut() {
307
                report.push(MonolithWarning {
308
                    node: None,
309
                    top,
310
                    bottom,
311
                    reason: MonolithReason::RowTallerThanPage,
312
                });
313
2
            }
314
        }
315
6
        avoid_ranges.sort_by(|a, b| a.top.total_cmp(&b.top));
316
17
    }
317

            
318
    // Tables whose continuation pages must reserve repeated-thead height.
319
19
    let reserved_tables: Vec<(f32, f32, f32)> = if policy.repeat_table_headers {
320
1
        input
321
1
            .table_headers
322
1
            .map(|t| {
323
1
                t.tables
324
1
                    .iter()
325
1
                    .map(|info| (info.table_start_y, info.table_end_y, info.thead_height))
326
1
                    .collect()
327
1
            })
328
1
            .unwrap_or_default()
329
    } else {
330
18
        Vec::new()
331
    };
332
19
    let paragraphs = if policy.widows_orphans {
333
2
        collect_paragraph_lines(input)
334
    } else {
335
17
        Vec::new()
336
    };
337

            
338
    // Forced breaks, ascending (they are hard walls the forward pass emits
339
    // verbatim — CSS Fragmentation: forced always wins over avoid).
340
19
    let mut forced: Vec<crate::solver3::display_list::ForcedBreak> = input
341
19
        .display_list
342
19
        .forced_page_breaks
343
19
        .iter()
344
19
        .copied()
345
19
        .filter(|b| b.y > 0.0 && b.y < total_height)
346
19
        .collect();
347
19
    forced.sort_by(|a, b| a.y.total_cmp(&b.y));
348

            
349
19
    let mut breaks: Vec<PageBreakPosition> = Vec::new();
350
19
    let mut prev_end = 0.0_f32;
351
19
    let mut page_height = first;
352
19
    let mut page_index: usize = 0;
353
19
    let mut forced_iter = forced.into_iter().peekable();
354

            
355
    loop {
356
        // Per-page setup (classic office suites model): the sequence overrides the height
357
        // for THIS page index (explicit / first / parity / default).
358
77
        if let Some(seq) = sequence {
359
33
            page_height = seq.setup_for_page(page_index).content_height();
360
44
        }
361
        // A page STARTING inside a table shows that table's repeated thead —
362
        // its content area shrinks by the header stack's height.
363
77
        let reserve: f32 = reserved_tables
364
77
            .iter()
365
77
            .filter(|(start, end, _)| *start < prev_end && *end > prev_end)
366
77
            .map(|(_, _, h)| *h)
367
77
            .sum();
368
77
        let effective_height = (page_height - reserve).max(MERGE_WINDOW_PX);
369
77
        let naive = prev_end + effective_height;
370

            
371
        // A forced break before (or at) the naive position ends the page there.
372
77
        if let Some(&fb) = forced_iter.peek() {
373
1
            if fb.y <= naive + MERGE_WINDOW_PX {
374
1
                forced_iter.next();
375
1
                if fb.y > prev_end + MERGE_WINDOW_PX {
376
1
                    breaks.push(PageBreakPosition {
377
1
                        y: fb.y,
378
1
                        kind: BreakKind::Forced,
379
1
                        causing_node: fb.causing_node,
380
1
                    });
381
1
                    prev_end = fb.y;
382
1
                    page_height = normal;
383
1
                    page_index += 1;
384
1
                }
385
1
                continue;
386
            }
387
76
        }
388

            
389
76
        if naive >= total_height {
390
19
            break;
391
57
        }
392

            
393
57
        let max_push = (policy.max_push_distance.max(0.0)) * page_height;
394
57
        let floor = (naive - max_push).max(prev_end + MERGE_WINDOW_PX);
395
57
        let adjusted = snap_break_up(naive, floor, &avoid_ranges, &paragraphs, policy);
396

            
397
57
        let kind = if (adjusted - naive).abs() < f32::EPSILON {
398
51
            BreakKind::Interval
399
        } else {
400
6
            BreakKind::Avoided { pushed_from: naive }
401
        };
402
57
        breaks.push(PageBreakPosition {
403
57
            y: adjusted,
404
57
            kind,
405
57
            causing_node: None,
406
57
        });
407
57
        prev_end = adjusted;
408
57
        page_height = normal;
409
57
        page_index += 1;
410
        // `!(x > 0.0)` semantics kept explicitly: a NaN height must also stop
411
        // the loop, so test `<= 0.0 || is_nan` instead of the negation.
412
57
        if sequence.is_none() && (normal <= 0.0 || normal.is_nan()) {
413
            break;
414
57
        }
415
57
        if sequence.is_some() {
416
26
            let next_height = page_height_floor(sequence, page_index, normal);
417
26
            if next_height <= 0.0 || next_height.is_nan() {
418
                break;
419
26
            }
420
31
        }
421
    }
422

            
423
    // Any forced breaks past the last interval position still apply.
424
19
    for fb in forced_iter {
425
        if fb.y > prev_end + MERGE_WINDOW_PX {
426
            breaks.push(PageBreakPosition {
427
                y: fb.y,
428
                kind: BreakKind::Forced,
429
                causing_node: fb.causing_node,
430
            });
431
            prev_end = fb.y;
432
        }
433
    }
434

            
435
19
    breaks
436
1428
}
437

            
438
/// Termination guard: the NEXT page's height (sequence-aware). A sequence
439
/// page with non-positive content height would loop forever, same as the
440
/// plain `normal <= 0` case.
441
26
fn page_height_floor(
442
26
    sequence: Option<&crate::solver3::pagination::PageSequence>,
443
26
    page_index: usize,
444
26
    normal: f32,
445
26
) -> f32 {
446
26
    sequence.map_or(normal, |s| s.setup_for_page(page_index).content_height())
447
26
}
448

            
449
/// Collect the vertical ranges a break may not enter.
450
19
fn collect_avoid_ranges(
451
19
    input: &PageBreakInput<'_>,
452
19
    constraints: PageConstraints,
453
19
    policy: &BreakPolicy,
454
19
    mut monolith_report: Option<&mut Vec<MonolithWarning>>,
455
19
) -> Vec<AvoidRange> {
456
    use crate::solver3::getters::get_break_inside;
457
    use azul_css::props::layout::fragmentation::BreakInside;
458

            
459
19
    let mut ranges: Vec<AvoidRange> = Vec::new();
460
19
    let page_height = constraints.normal_page_content_height.max(1.0);
461

            
462
19
    if policy.honor_break_inside {
463
        // Union the bounds of every display item a break-inside:avoid node
464
        // produced (document-space Ys come from the DL — no positions map
465
        // needed). One range per node.
466
6
        let mut per_node: std::collections::BTreeMap<NodeId, (f32, f32)> =
467
6
            std::collections::BTreeMap::new();
468
12
        for (idx, item) in input.display_list.items.iter().enumerate() {
469
12
            let Some(node) = input.display_list.node_mapping.get(idx).copied().flatten() else {
470
6
                continue;
471
            };
472
6
            let Some(bounds) = item.bounds() else { continue };
473
6
            if get_break_inside(input.styled_dom, Some(node)) != BreakInside::Avoid {
474
                continue;
475
6
            }
476
6
            let top = bounds.origin.y;
477
6
            let bottom = bounds.origin.y + bounds.size.height;
478
6
            per_node
479
6
                .entry(node)
480
6
                .and_modify(|(t, b)| {
481
                    *t = t.min(top);
482
                    *b = b.max(bottom);
483
                })
484
6
                .or_insert((top, bottom));
485
        }
486
12
        for (node, (top, bottom)) in per_node {
487
            // Monolith rule: a box taller than the page may be torn — an
488
            // avoid-range that can never be satisfied would push forever.
489
            // E21: the tear is no longer SILENT — it lands in the report.
490
6
            if bottom - top > page_height {
491
3
                if let Some(report) = monolith_report.as_deref_mut() {
492
1
                    report.push(MonolithWarning {
493
1
                        node: Some(node),
494
1
                        top,
495
1
                        bottom,
496
1
                        reason: MonolithReason::AvoidBoxTallerThanPage,
497
1
                    });
498
2
                }
499
3
                continue;
500
3
            }
501
3
            ranges.push(AvoidRange {
502
3
                top,
503
3
                bottom,
504
3
                node: Some(node),
505
3
            });
506
        }
507
13
    }
508

            
509
19
    if policy.atomic_lines {
510
        // Every text item's rect is a line-box fragment; a break through one
511
        // slices a line (the "baseline-sliced line" artifact). Snap to its top.
512
4
        for item in &input.display_list.items {
513
            if let crate::solver3::display_list::DisplayListItem::Text {
514
2
                clip_rect, ..
515
3
            } = item
516
            {
517
2
                let r = clip_rect.inner();
518
2
                if r.size.height > 0.0 {
519
2
                    ranges.push(AvoidRange {
520
2
                        top: r.origin.y,
521
2
                        bottom: r.origin.y + r.size.height,
522
2
                        node: None,
523
2
                    });
524
2
                }
525
1
            }
526
        }
527
18
    }
528

            
529
19
    ranges.sort_by(|a, b| a.top.total_cmp(&b.top));
530
19
    ranges
531
19
}
532

            
533
/// Group text-item rects per source node into line boxes for widows/orphans.
534
2
fn collect_paragraph_lines(input: &PageBreakInput<'_>) -> Vec<ParagraphLines> {
535
    use crate::solver3::getters::{get_orphans, get_widows};
536

            
537
2
    let mut per_node: std::collections::BTreeMap<NodeId, Vec<(f32, f32)>> =
538
2
        std::collections::BTreeMap::new();
539
8
    for (idx, item) in input.display_list.items.iter().enumerate() {
540
8
        let crate::solver3::display_list::DisplayListItem::Text { clip_rect, .. } = item else {
541
2
            continue;
542
        };
543
6
        let Some(node) = input.display_list.node_mapping.get(idx).copied().flatten() else {
544
            continue;
545
        };
546
6
        let r = clip_rect.inner();
547
6
        if r.size.height <= 0.0 {
548
            continue;
549
6
        }
550
6
        per_node
551
6
            .entry(node)
552
6
            .or_default()
553
6
            .push((r.origin.y, r.origin.y + r.size.height));
554
    }
555

            
556
2
    per_node
557
2
        .into_iter()
558
2
        .filter_map(|(node, mut rects)| {
559
4
            rects.sort_by(|a, b| a.0.total_cmp(&b.0));
560
            // Merge run-rects sharing a line (tops within 0.5px).
561
2
            let mut lines: Vec<(f32, f32)> = Vec::new();
562
8
            for (top, bottom) in rects {
563
6
                match lines.last_mut() {
564
4
                    Some((lt, lb)) if (top - *lt).abs() < 0.5 => *lb = lb.max(bottom),
565
6
                    _ => lines.push((top, bottom)),
566
                }
567
            }
568
2
            if lines.len() < 2 {
569
                return None; // single-line paragraphs have no widow/orphan case
570
2
            }
571
2
            Some(ParagraphLines {
572
2
                node,
573
2
                widows: get_widows(input.styled_dom, Some(node)).max(1),
574
2
                orphans: get_orphans(input.styled_dom, Some(node)).max(1),
575
2
                lines,
576
2
            })
577
2
        })
578
2
        .collect()
579
2
}
580

            
581
/// Snap a naive break Y up out of avoid-ranges and widow/orphan violations.
582
/// Iterates to a fixpoint (a snap can land inside ANOTHER range) but never
583
/// below `floor` — beyond the push budget the current candidate stands.
584
57
fn snap_break_up(
585
57
    naive: f32,
586
57
    floor: f32,
587
57
    avoid_ranges: &[AvoidRange],
588
57
    paragraphs: &[ParagraphLines],
589
57
    policy: &BreakPolicy,
590
57
) -> f32 {
591
57
    let mut y = naive;
592
    // Bounded iterations: each snap strictly decreases y and range/paragraph
593
    // counts are finite; 32 covers any sane nesting without risking a loop.
594
63
    for _ in 0..32 {
595
63
        let mut moved = false;
596

            
597
110
        for range in avoid_ranges {
598
            // STRICTLY inside (a break AT an edge is fine).
599
47
            if y > range.top + f32::EPSILON && y < range.bottom - f32::EPSILON {
600
4
                let _ = range.node;
601
4
                if range.top >= floor {
602
4
                    y = range.top;
603
4
                    moved = true;
604
4
                } // else: budget exceeded — the naive break stands mid-range
605
43
            }
606
        }
607

            
608
63
        if policy.widows_orphans {
609
12
            for para in paragraphs {
610
6
                let first_top = para.lines[0].0;
611
6
                let last_bottom = para.lines[para.lines.len() - 1].1;
612
6
                if y <= first_top || y >= last_bottom {
613
3
                    continue;
614
3
                }
615
                // Lines fully above the break stay; the rest move to the next page.
616
3
                let total_lines = u32::try_from(para.lines.len()).unwrap_or(u32::MAX);
617
9
                let before_count = para.lines.iter().filter(|(_, b)| *b <= y + 0.5).count();
618
3
                let before = u32::try_from(before_count).unwrap_or(u32::MAX);
619
3
                let after = total_lines - before;
620
3
                if before > 0 && before < para.orphans {
621
                    // Too few lines kept: the whole paragraph moves.
622
1
                    if first_top >= floor {
623
1
                        y = first_top;
624
1
                        moved = true;
625
1
                    }
626
2
                } else if after > 0 && after < para.widows {
627
                    // Too few lines moved: push more lines over the break.
628
1
                    let needed = total_lines - para.widows;
629
1
                    let target = para
630
1
                        .lines
631
1
                        .get(needed as usize)
632
1
                        .map_or(first_top, |(t, _)| *t);
633
1
                    if target < y && target >= floor {
634
1
                        y = target;
635
1
                        moved = true;
636
1
                    }
637
1
                }
638
            }
639
57
        }
640

            
641
63
        if !moved {
642
57
            break;
643
6
        }
644
    }
645
57
    y
646
57
}
647

            
648
/// Incremental re-break after an edit.
649
///
650
/// Recompute (always — correctness under
651
/// ANY input change, and the break pass is cheap next to the relayout that
652
/// preceded it), then NORMALIZE against the previous run — every fresh break
653
/// that matches a previous break within the merge window returns the
654
/// PREVIOUS value bit-for-bit. Unchanged pages therefore compare value-equal
655
/// against the old spans, which is the signal a paged viewer uses to keep
656
/// its cached page surfaces.
657
///
658
/// `dirty_y_start` (the topmost document-space Y whose content changed — the
659
/// editing IFC root's top, see `LayoutWindow::take_pagination_dirty_from`)
660
/// is a DEBUG contract: breaks above it must come out identical, and a
661
/// mismatch there means the caller under-reported the dirty region — the
662
/// fresh value wins (correctness over reuse).
663
#[must_use]
664
2
pub fn recompute_page_breaks_from(
665
2
    prev: &[PageBreakPosition],
666
2
    input: &PageBreakInput<'_>,
667
2
    constraints: &PageConstraints,
668
2
    policy: &BreakPolicy,
669
2
    dirty_y_start: f32,
670
2
) -> Vec<PageBreakPosition> {
671
2
    let fresh = compute_page_breaks(input, constraints, policy);
672

            
673
2
    let mut out: Vec<PageBreakPosition> = Vec::with_capacity(fresh.len());
674
2
    let mut prev_iter = prev.iter().peekable();
675
21
    for fb in fresh {
676
19
        while prev_iter
677
19
            .peek()
678
19
            .is_some_and(|pb| pb.y < fb.y - MERGE_WINDOW_PX)
679
        {
680
            prev_iter.next();
681
        }
682
19
        match prev_iter.peek() {
683
19
            Some(pb) if (pb.y - fb.y).abs() < MERGE_WINDOW_PX => {
684
18
                // Value-identical reuse — above the dirty bound this is the
685
18
                // guaranteed case; below it, it means the page converged.
686
18
                out.push(**pb);
687
18
                prev_iter.next();
688
18
            }
689
            _ => {
690
1
                debug_assert!(
691
                    fb.y >= dirty_y_start - MERGE_WINDOW_PX,
692
                    "a break above dirty_y_start changed ({} < {dirty_y_start}) — \
693
                     the caller under-reported the dirty region",
694
                    fb.y
695
                );
696
1
                out.push(fb);
697
            }
698
        }
699
    }
700
2
    out
701
2
}
702

            
703
/// Which page a document-space Y coordinate lands on.
704
///
705
/// Given the break list (page 0 = before the first break). The
706
/// document-editor query: "what page is this node on?" WITHOUT materializing
707
/// any per-page display list.
708
#[must_use]
709
7
pub fn page_of_y(breaks: &[PageBreakPosition], y: f32) -> usize {
710
12
    breaks.iter().take_while(|b| b.y <= y).count()
711
7
}
712

            
713
/// Precomputed pagination facts for a document — everything a viewer needs
714
/// to draw page chrome and schedule lazy page materialization, with NO
715
/// per-page display list generated.
716
#[derive(Debug, Clone, PartialEq)]
717
pub struct PaginationInfo {
718
    pub breaks: Vec<PageBreakPosition>,
719
    pub page_count: usize,
720
    pub total_content_height: f32,
721
}
722

            
723
/// Compute page breaks for a display list: CSS-forced breaks
724
/// (`DisplayList::forced_page_breaks`) plus regular interval breaks wherever
725
/// the page runs full.
726
///
727
/// Returns an empty vector when the document has no content or the first-page
728
/// height is not positive — [`page_spans`] then yields the single-page result.
729
#[must_use]
730
1420
pub fn compute_page_breaks_from_display_list(
731
1420
    display_list: &DisplayList,
732
1420
    constraints: &PageConstraints,
733
1420
) -> Vec<PageBreakPosition> {
734
1420
    compute_page_breaks_from_forced(
735
1420
        &display_list.forced_page_breaks,
736
1420
        constraints,
737
1420
        calculate_display_list_height(display_list),
738
    )
739
1420
}
740

            
741
/// The display-list-independent pagination core.
742
///
743
/// Consumes forced break Y positions plus page heights plus total content
744
/// height. Also the seat for later break-awareness stages, which add richer
745
/// inputs without touching this contract.
746
#[must_use]
747
17
pub fn compute_page_breaks_from_positions(
748
17
    forced_breaks: &[f32],
749
17
    constraints: &PageConstraints,
750
17
    total_height: f32,
751
17
) -> Vec<PageBreakPosition> {
752
17
    let typed: Vec<crate::solver3::display_list::ForcedBreak> = forced_breaks
753
17
        .iter()
754
17
        .map(|&y| crate::solver3::display_list::ForcedBreak { y, causing_node: None })
755
17
        .collect();
756
17
    compute_page_breaks_from_forced(&typed, constraints, total_height)
757
17
}
758

            
759
/// [`compute_page_breaks_from_positions`] with the causing node carried
760
/// through to [`PageBreakPosition::causing_node`].
761
#[must_use]
762
1437
pub fn compute_page_breaks_from_forced(
763
1437
    forced_breaks: &[crate::solver3::display_list::ForcedBreak],
764
1437
    constraints: &PageConstraints,
765
1437
    total_height: f32,
766
1437
) -> Vec<PageBreakPosition> {
767
1437
    let first = constraints.first_page_content_height;
768
1437
    let normal = constraints.normal_page_content_height;
769

            
770
1437
    if total_height <= 0.0 || first <= 0.0 {
771
5
        return Vec::new();
772
1432
    }
773

            
774
1432
    let mut breaks: Vec<PageBreakPosition> = Vec::new();
775

            
776
    // Forced breaks from CSS break-before/after: always.
777
    // The range check also filters NaN (both comparisons are false for NaN).
778
1483
    for fb in forced_breaks {
779
51
        if fb.y > 0.0 && fb.y < total_height {
780
39
            breaks.push(PageBreakPosition {
781
39
                y: fb.y,
782
39
                kind: BreakKind::Forced,
783
39
                causing_node: fb.causing_node,
784
39
            });
785
39
        }
786
    }
787

            
788
    // Regular interval breaks. A non-positive normal height cannot advance the
789
    // cursor — emit the first-page break once and stop (defect 3: the old loop
790
    // never terminated here).
791
1432
    let mut y = first;
792
    #[allow(clippy::while_float)] // intentional bounded float loop; an integer counter would be artificial
793
2161
    while y < total_height {
794
732
        breaks.push(PageBreakPosition {
795
732
            y,
796
732
            kind: BreakKind::Interval,
797
732
            causing_node: None,
798
732
        });
799
        // `!(normal > 0.0)` semantics kept explicitly: NaN must also stop.
800
732
        if normal <= 0.0 || normal.is_nan() {
801
3
            break;
802
729
        }
803
729
        y += normal;
804
    }
805

            
806
1541
    breaks.sort_by(|a, b| a.y.total_cmp(&b.y));
807

            
808
    // Merge breaks within the 1px window. A forced break replaces an interval
809
    // break in the same window (defect 2: forced breaks win); otherwise the
810
    // first break of a run is kept, matching the old positional dedup.
811
1432
    let mut merged: Vec<PageBreakPosition> = Vec::with_capacity(breaks.len());
812
2203
    for b in breaks {
813
771
        match merged.last_mut() {
814
456
            Some(last) if (b.y - last.y).abs() < MERGE_WINDOW_PX => {
815
13
                if last.kind == BreakKind::Interval && b.kind == BreakKind::Forced {
816
2
                    *last = b;
817
11
                }
818
            }
819
758
            _ => merged.push(b),
820
        }
821
    }
822
1432
    merged
823
1437
}
824

            
825
/// Convert break positions into per-page `(start_y, end_y)` spans — what the
826
/// slicer consumes. Breaks at or below a previous break (and at Y=0) are
827
/// skipped rather than producing empty pages.
828
///
829
/// May return an empty vector when `total_height <= 0` and there are no
830
/// breaks; pagination entry points map that to their single-page fallback.
831
#[must_use]
832
1443
pub fn page_spans(breaks: &[PageBreakPosition], total_height: f32) -> Vec<(f32, f32)> {
833
1443
    let mut spans: Vec<(f32, f32)> = Vec::with_capacity(breaks.len() + 1);
834
1443
    let mut page_start = 0.0f32;
835

            
836
2208
    for b in breaks {
837
765
        if b.y > page_start {
838
763
            spans.push((page_start, b.y));
839
763
            page_start = b.y;
840
763
        }
841
    }
842

            
843
1443
    if page_start < total_height {
844
1441
        spans.push((page_start, total_height));
845
1441
    }
846

            
847
1443
    spans
848
1443
}
849

            
850
#[cfg(test)]
851
mod tests {
852
    use super::*;
853

            
854
33
    fn constraints(first: f32, normal: f32) -> PageConstraints {
855
33
        PageConstraints {
856
33
            first_page_content_height: first,
857
33
            normal_page_content_height: normal,
858
33
        }
859
33
    }
860

            
861
10
    fn ys(breaks: &[PageBreakPosition]) -> Vec<f32> {
862
10
        breaks.iter().map(|b| b.y).collect()
863
10
    }
864

            
865
    /// The pre-extraction algorithm, verbatim (minus the NaN-panic sort), as
866
    /// the golden reference. Differences are allowed ONLY where the named
867
    /// defects fire.
868
17
    fn reference_spans(
869
17
        forced: &[f32],
870
17
        first_page_height: f32,
871
17
        normal_page_height: f32,
872
17
        total_height: f32,
873
17
    ) -> Vec<(f32, f32)> {
874
17
        if total_height <= 0.0 || first_page_height <= 0.0 {
875
4
            return vec![(0.0, total_height.max(first_page_height))];
876
13
        }
877
13
        let mut break_points: Vec<f32> = Vec::new();
878
26
        for &forced_break_y in forced {
879
13
            if forced_break_y > 0.0 && forced_break_y < total_height {
880
7
                break_points.push(forced_break_y);
881
7
            }
882
        }
883
13
        let mut y = first_page_height;
884
55
        while y < total_height {
885
42
            break_points.push(y);
886
            // Guard added ONLY to keep the reference terminating; the shipped
887
            // code hung here (defect 3), which the corpus below avoids by
888
            // never combining normal<=0 with the reference.
889
            // `!(x > 0.0)` semantics kept explicitly: NaN must also stop.
890
42
            if normal_page_height <= 0.0 || normal_page_height.is_nan() {
891
                break;
892
42
            }
893
42
            y += normal_page_height;
894
        }
895
13
        break_points.sort_by(f32::total_cmp);
896
40
        break_points.dedup_by(|a, b| (*a - *b).abs() < 1.0);
897
13
        let mut page_breaks: Vec<(f32, f32)> = Vec::new();
898
13
        let mut page_start = 0.0f32;
899
60
        for break_y in break_points {
900
47
            if break_y > page_start {
901
47
                page_breaks.push((page_start, break_y));
902
47
                page_start = break_y;
903
47
            }
904
        }
905
13
        if page_start < total_height {
906
13
            page_breaks.push((page_start, total_height));
907
13
        }
908
13
        if page_breaks.is_empty() {
909
            page_breaks.push((0.0, total_height.max(first_page_height)));
910
13
        }
911
13
        page_breaks
912
17
    }
913

            
914
    /// New-path spans including the entry-point fallback, so the comparison is
915
    /// against what callers actually observe.
916
16
    fn new_spans(forced: &[f32], first: f32, normal: f32, total: f32) -> Vec<(f32, f32)> {
917
16
        if total <= 0.0 || first <= 0.0 {
918
4
            return vec![(0.0, total.max(first))];
919
12
        }
920
12
        let breaks = compute_page_breaks_from_positions(forced, &constraints(first, normal), total);
921
12
        let mut spans = page_spans(&breaks, total);
922
12
        if spans.is_empty() {
923
            spans.push((0.0, total.max(first)));
924
12
        }
925
12
        spans
926
16
    }
927

            
928
    #[test]
929
1
    fn golden_corpus_matches_the_old_algorithm_where_no_defect_fires() {
930
        // (forced, first, normal, total) — no forced-vs-interval collisions
931
        // within 1px, normal > 0: behavior must be IDENTICAL.
932
1
        let corpus: &[(&[f32], f32, f32, f32)] = &[
933
1
            (&[], 100.0, 100.0, 250.0),
934
1
            (&[], 100.0, 100.0, 100.0),
935
1
            (&[], 100.0, 100.0, 99.0),
936
1
            (&[], 100.0, 50.0, 1000.0),
937
1
            (&[], 50.0, 100.0, 1000.0),
938
1
            (&[50.0], 100.0, 100.0, 250.0),
939
1
            (&[50.0, 150.0], 100.0, 100.0, 250.0),
940
1
            (&[-10.0, 0.0, 250.0, 9999.0, f32::NAN, f32::INFINITY], 100.0, 100.0, 250.0),
941
1
            (&[50.0, 50.4], 100.0, 100.0, 250.0), // forced-forced merge: first wins in both
942
1
            (&[249.5], 100.0, 100.0, 250.0),
943
1
            (&[], 0.0, 100.0, 250.0),   // degenerate: single page
944
1
            (&[], -50.0, 100.0, 250.0), // degenerate: single page
945
1
            (&[], 100.0, 100.0, 0.0),   // empty document
946
1
            (&[], 100.0, 100.0, -5.0),  // negative height
947
1
            (&[], f32::NAN, 100.0, 250.0), // NaN first: one unsplit page in both
948
1
            (&[], 100.0, 100.0, 50.0),  // total < first
949
1
        ];
950
17
        for &(forced, first, normal, total) in corpus {
951
16
            assert_eq!(
952
16
                new_spans(forced, first, normal, total),
953
16
                reference_spans(forced, first, normal, total),
954
                "case: forced={forced:?} first={first} normal={normal} total={total}"
955
            );
956
        }
957
1
    }
958

            
959
    #[test]
960
1
    fn defect_2_forced_break_within_1px_of_interval_break_survives() {
961
        // Old behavior: sorted [100.0 (interval), 100.5 (forced)] → dedup kept
962
        // 100.0 and the author's forced break vanished. New behavior: the
963
        // forced break replaces the interval break in the merge window.
964
1
        let breaks =
965
1
            compute_page_breaks_from_positions(&[100.5], &constraints(100.0, 100.0), 250.0);
966
1
        assert_eq!(ys(&breaks), vec![100.5, 200.0]);
967
1
        assert_eq!(breaks[0].kind, BreakKind::Forced);
968
1
        assert_eq!(breaks[1].kind, BreakKind::Interval);
969
1
        assert_eq!(
970
1
            page_spans(&breaks, 250.0),
971
1
            vec![(0.0, 100.5), (100.5, 200.0), (200.0, 250.0)]
972
        );
973

            
974
        // …and the old behavior really was the swallow (regression witness):
975
1
        let old = reference_spans(&[100.5], 100.0, 100.0, 250.0);
976
1
        assert_eq!(old, vec![(0.0, 100.0), (100.0, 200.0), (200.0, 250.0)]);
977

            
978
        // Forced break BELOW the interval break in the window: same outcome.
979
1
        let breaks =
980
1
            compute_page_breaks_from_positions(&[99.6], &constraints(100.0, 100.0), 250.0);
981
1
        assert_eq!(ys(&breaks), vec![99.6, 200.0]);
982
1
        assert_eq!(breaks[0].kind, BreakKind::Forced);
983
1
    }
984

            
985
    #[test]
986
1
    fn defect_3_non_positive_normal_height_terminates() {
987
        // Old behavior: `y += normal` with normal <= 0 never terminated when
988
        // skip_first_page made the first page valid. New behavior: the
989
        // first-page break is emitted once, everything else lands on page 2.
990
4
        for normal in [0.0, -20.0, f32::NAN] {
991
3
            let breaks =
992
3
                compute_page_breaks_from_positions(&[], &constraints(100.0, normal), 250.0);
993
3
            assert_eq!(ys(&breaks), vec![100.0], "normal={normal}");
994
3
            assert_eq!(
995
3
                page_spans(&breaks, 250.0),
996
3
                vec![(0.0, 100.0), (100.0, 250.0)],
997
                "normal={normal}"
998
            );
999
        }
1
    }
    #[test]
1
    fn from_slicer_config_reproduces_the_inlined_subtraction() {
        use crate::solver3::pagination::HeaderFooterConfig;
1
        let mut cfg = SlicerConfig::simple(800.0);
1
        let c = PageConstraints::from_slicer_config(&cfg);
1
        assert_eq!(c.first_page_content_height, 800.0);
1
        assert_eq!(c.normal_page_content_height, 800.0);
1
        cfg.header_footer = HeaderFooterConfig {
1
            show_header: true,
1
            header_height: 50.0,
1
            show_footer: true,
1
            footer_height: 30.0,
1
            ..Default::default()
1
        };
1
        let c = PageConstraints::from_slicer_config(&cfg);
1
        assert_eq!(c.first_page_content_height, 720.0);
1
        assert_eq!(c.normal_page_content_height, 720.0);
1
        cfg.header_footer.skip_first_page = true;
1
        let c = PageConstraints::from_slicer_config(&cfg);
1
        assert_eq!(c.first_page_content_height, 800.0);
1
        assert_eq!(c.normal_page_content_height, 720.0);
1
    }
    // ==================================================================
    // B3: break-awareness (policy-gated)
    // ==================================================================
    use crate::solver3::display_list::{DisplayList, DisplayListItem};
    use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
    use azul_core::styled_dom::StyledDom;
    use crate::solver3::display_list::BorderRadius as DlBorderRadius;
    use azul_css::props::basic::ColorU;
50
    fn rect(y: f32, h: f32) -> LogicalRect {
50
        LogicalRect {
50
            origin: LogicalPosition { x: 0.0, y },
50
            size: LogicalSize {
50
                width: 100.0,
50
                height: h,
50
            },
50
        }
50
    }
42
    fn rect_item(y: f32, h: f32) -> DisplayListItem {
42
        DisplayListItem::Rect {
42
            bounds: rect(y, h).into(),
42
            color: ColorU {
42
                r: 0,
42
                g: 0,
42
                b: 0,
42
                a: 255,
42
            },
42
            border_radius: DlBorderRadius::default(),
42
        }
42
    }
8
    fn text_item(y: f32, h: f32) -> DisplayListItem {
8
        DisplayListItem::Text {
8
            glyphs: Vec::new(),
8
            font_hash: crate::font_traits::FontHash::from_hash(1),
8
            font_size_px: 12.0,
8
            color: ColorU {
8
                r: 0,
8
                g: 0,
8
                b: 0,
8
                a: 255,
8
            },
8
            clip_rect: rect(y, h).into(),
8
            source_node_index: None,
8
        }
8
    }
    /// A DOM whose node 1 is `.avoid { break-inside: avoid; }`, plus a
    /// display list of `(item, source node)` pairs.
9
    fn avoid_fixture(items: Vec<(DisplayListItem, Option<usize>)>) -> (StyledDom, DisplayList) {
9
        let mut dom = azul_core::dom::Dom::create_div();
9
        dom.add_child(
9
            azul_core::dom::Dom::create_div()
9
                .with_ids_and_classes(vec![azul_core::dom::IdOrClass::Class("avoid".into())].into()),
        );
9
        let (css, _warnings) = azul_css::parser2::new_from_str(
9
            ".avoid { break-inside: avoid; } p { widows: 2; orphans: 2; }",
9
        );
9
        let styled = StyledDom::create(&mut dom, css);
9
        let mut dl = DisplayList::default();
26
        for (item, node) in items {
17
            dl.items.push(item);
17
            dl.node_mapping.push(node.map(NodeId::new));
17
        }
9
        (styled, dl)
9
    }
5
    fn breaks_with(
5
        styled: &StyledDom,
5
        dl: &DisplayList,
5
        policy: &BreakPolicy,
5
        first: f32,
5
        normal: f32,
5
    ) -> Vec<PageBreakPosition> {
5
        compute_page_breaks(
5
            &PageBreakInput {
5
                display_list: dl,
5
                layout_tree: None,
5
                styled_dom: styled,
5
                table_headers: None,
5
            },
5
            &constraints(first, normal),
5
            policy,
        )
5
    }
    #[test]
1
    fn policy_off_is_byte_identical_to_the_plain_algorithm() {
1
        let (styled, dl) = avoid_fixture(vec![
1
            (rect_item(0.0, 250.0), None),
1
            (rect_item(80.0, 60.0), Some(1)), // avoid-box straddling y=100
1
        ]);
1
        let plain = compute_page_breaks_from_display_list(&dl, &constraints(100.0, 100.0));
1
        let off = breaks_with(&styled, &dl, &BreakPolicy::default(), 100.0, 100.0);
1
        assert_eq!(plain, off);
1
    }
    #[test]
1
    fn break_inside_avoid_box_is_pushed_intact() {
        // Page 100; the avoid-box spans 80..140 — the naive break at 100 cuts
        // it, so the break snaps UP to the box top (80).
1
        let (styled, dl) = avoid_fixture(vec![
1
            (rect_item(0.0, 250.0), None),
1
            (rect_item(80.0, 60.0), Some(1)),
1
        ]);
1
        let policy = BreakPolicy {
1
            honor_break_inside: true,
1
            ..Default::default()
1
        };
1
        let breaks = breaks_with(&styled, &dl, &policy, 100.0, 100.0);
1
        assert_eq!(
1
            breaks[0].y, 80.0,
            "the break must land at the avoid-box top, got {breaks:?}"
        );
1
        assert!(matches!(breaks[0].kind, BreakKind::Avoided { pushed_from } if (pushed_from - 100.0).abs() < 0.01));
        // Following pages re-flow from the moved break.
1
        assert_eq!(breaks[1].y, 180.0);
1
    }
    #[test]
1
    fn taller_than_page_avoid_box_is_torn() {
        // The avoid-box spans 0..180 with a 100 page: satisfying it is
        // impossible (monolith rule) — the naive interval stands.
1
        let (styled, dl) = avoid_fixture(vec![
1
            (rect_item(0.0, 250.0), None),
1
            (rect_item(0.0, 180.0), Some(1)),
1
        ]);
1
        let policy = BreakPolicy {
1
            honor_break_inside: true,
1
            ..Default::default()
1
        };
1
        let breaks = breaks_with(&styled, &dl, &policy, 100.0, 100.0);
1
        assert_eq!(breaks[0].y, 100.0);
1
        assert!(matches!(breaks[0].kind, BreakKind::Interval));
1
    }
    #[test]
1
    fn forced_break_wins_over_avoid() {
        // Forced break at 90 INSIDE the avoid-box: forced always applies
        // (CSS Fragmentation §resolution), the avoid-range cannot move it.
1
        let (styled, mut dl) = avoid_fixture(vec![
1
            (rect_item(0.0, 250.0), None),
1
            (rect_item(80.0, 60.0), Some(1)),
1
        ]);
1
        dl.forced_page_breaks = vec![crate::solver3::display_list::ForcedBreak { y: 90.0, causing_node: None }];
1
        let policy = BreakPolicy {
1
            honor_break_inside: true,
1
            ..Default::default()
1
        };
1
        let breaks = breaks_with(&styled, &dl, &policy, 100.0, 100.0);
1
        assert_eq!(breaks[0].y, 90.0);
1
        assert!(matches!(breaks[0].kind, BreakKind::Forced));
1
    }
    #[test]
1
    fn atomic_lines_never_slice_a_text_rect() {
        // Lines at 90..106 and 106..122; page 100 cuts the first line — the
        // break snaps to its top (90).
1
        let (styled, dl) = avoid_fixture(vec![
1
            (rect_item(0.0, 250.0), None),
1
            (text_item(90.0, 16.0), None),
1
            (text_item(106.0, 16.0), None),
1
        ]);
1
        let policy = BreakPolicy {
1
            atomic_lines: true,
1
            ..Default::default()
1
        };
1
        let breaks = breaks_with(&styled, &dl, &policy, 100.0, 100.0);
1
        assert_eq!(breaks[0].y, 90.0, "{breaks:?}");
1
        assert!(matches!(breaks[0].kind, BreakKind::Avoided { .. }));
1
    }
    #[test]
1
    fn orphans_move_the_whole_paragraph() {
        // Paragraph (node 1) with 3 lines at 84..100, 100..116, 116..132;
        // orphans: 2 (from the p rule — attach the class to make it node 1).
        // The naive break at 100 keeps ONE line — fewer than orphans — so the
        // whole paragraph moves (break at its top, 84).
1
        let mut dom = azul_core::dom::Dom::create_div();
1
        dom.add_child(azul_core::dom::Dom::create_p());
1
        let (css, _warnings) = azul_css::parser2::new_from_str("p { widows: 2; orphans: 2; }");
1
        let styled = StyledDom::create(&mut dom, css);
1
        let mut dl = DisplayList::default();
4
        for (item, node) in [
1
            (rect_item(0.0, 250.0), None),
1
            (text_item(84.0, 16.0), Some(1)),
1
            (text_item(100.0, 16.0), Some(1)),
1
            (text_item(116.0, 16.0), Some(1)),
4
        ] {
4
            dl.items.push(item);
4
            dl.node_mapping.push(node.map(NodeId::new));
4
        }
1
        let policy = BreakPolicy {
1
            widows_orphans: true,
1
            ..Default::default()
1
        };
1
        let breaks = compute_page_breaks(
1
            &PageBreakInput {
1
                display_list: &dl,
1
                layout_tree: None,
1
                styled_dom: &styled,
1
                table_headers: None,
1
            },
1
            &constraints(100.0, 100.0),
1
            &policy,
        );
1
        assert_eq!(breaks[0].y, 84.0, "{breaks:?}");
1
        assert!(matches!(breaks[0].kind, BreakKind::Avoided { .. }));
1
    }
    #[test]
1
    fn widows_pull_enough_lines_onto_the_next_page() {
        // E20 counterpart to the orphans test. Paragraph (node 1) with 3
        // lines at 68..84, 84..100, 100..116; widows: 2, orphans: 1.
        // The naive break at 100 carries ONE line to the next page — fewer
        // than widows — so the break moves UP one line (84): 1 line stays
        // (orphans: 1 satisfied), 2 lines carry over (widows: 2 satisfied).
1
        let mut dom = azul_core::dom::Dom::create_div();
1
        dom.add_child(azul_core::dom::Dom::create_p());
1
        let (css, _warnings) =
1
            azul_css::parser2::new_from_str("p { widows: 2; orphans: 1; }");
1
        let styled = StyledDom::create(&mut dom, css);
1
        let mut dl = DisplayList::default();
4
        for (item, node) in [
1
            (rect_item(0.0, 250.0), None),
1
            (text_item(68.0, 16.0), Some(1)),
1
            (text_item(84.0, 16.0), Some(1)),
1
            (text_item(100.0, 16.0), Some(1)),
4
        ] {
4
            dl.items.push(item);
4
            dl.node_mapping.push(node.map(NodeId::new));
4
        }
1
        let policy = BreakPolicy {
1
            widows_orphans: true,
1
            ..Default::default()
1
        };
1
        let breaks = compute_page_breaks(
1
            &PageBreakInput {
1
                display_list: &dl,
1
                layout_tree: None,
1
                styled_dom: &styled,
1
                table_headers: None,
1
            },
1
            &constraints(100.0, 100.0),
1
            &policy,
        );
1
        assert_eq!(breaks[0].y, 84.0, "{breaks:?}");
1
        assert!(matches!(breaks[0].kind, BreakKind::Avoided { .. }));
1
    }
    #[test]
1
    fn taller_than_page_avoid_box_lands_in_the_monolith_report() {
        // E21: the tear itself is unchanged (monolith rule), but it is no
        // longer silent. Avoid-box node 1 spans 250px on 100px pages.
1
        let (styled, dl) = avoid_fixture(vec![
1
            (rect_item(0.0, 400.0), None),
1
            (rect_item(20.0, 250.0), Some(1)),
1
        ]);
1
        let policy = BreakPolicy {
1
            honor_break_inside: true,
1
            ..Default::default()
1
        };
1
        let input = PageBreakInput {
1
            display_list: &dl,
1
            layout_tree: None,
1
            styled_dom: &styled,
1
            table_headers: None,
1
        };
1
        let (breaks, warnings) =
1
            compute_page_breaks_with_report(&input, &constraints(100.0, 100.0), &policy);
1
        assert_eq!(
            breaks,
1
            compute_page_breaks(&input, &constraints(100.0, 100.0), &policy),
            "the report only ADDS information, breaks are identical"
        );
1
        assert_eq!(warnings.len(), 1, "{warnings:?}");
1
        assert_eq!(warnings[0].node, Some(NodeId::new(1)));
1
        assert_eq!(warnings[0].reason, MonolithReason::AvoidBoxTallerThanPage);
1
        assert_eq!((warnings[0].top, warnings[0].bottom), (20.0, 270.0));
        // A box that FITS the page produces no warning.
1
        let (styled2, dl2) = avoid_fixture(vec![
1
            (rect_item(0.0, 400.0), None),
1
            (rect_item(20.0, 60.0), Some(1)),
1
        ]);
1
        let input2 = PageBreakInput {
1
            display_list: &dl2,
1
            layout_tree: None,
1
            styled_dom: &styled2,
1
            table_headers: None,
1
        };
1
        let (_, warnings2) =
1
            compute_page_breaks_with_report(&input2, &constraints(100.0, 100.0), &policy);
1
        assert!(warnings2.is_empty(), "{warnings2:?}");
1
    }
    /// A synthetic table DOM + display list: div > table > (thead > tr,
    /// tbody > tr×3), with one rect item per row (and one for the thead),
    /// mapped to the right nodes.
4
    fn table_fixture(
4
        thead_h: f32,
4
        row_h: f32,
4
        rows: usize,
4
    ) -> (StyledDom, DisplayList) {
        use azul_core::dom::Dom;
4
        let tag = azul_core::xml::tag_to_node_type;
4
        let mut table = Dom::create_node(tag("table"));
4
        let mut thead = Dom::create_node(tag("thead"));
4
        thead.add_child(Dom::create_node(tag("tr")));
4
        table.add_child(thead);
4
        let mut tbody = Dom::create_node(tag("tbody"));
21
        for _ in 0..rows {
21
            tbody.add_child(Dom::create_node(tag("tr")));
21
        }
4
        table.add_child(tbody);
4
        let mut root = Dom::create_div();
4
        root.add_child(table);
4
        let styled = StyledDom::create_from_dom(root);
        // Locate node ids structurally: thead's tr + tbody's trs.
4
        let container = styled.node_data.as_container();
4
        let trs: Vec<NodeId> = (0..container.len())
4
            .map(NodeId::new)
41
            .filter(|n| {
16
                matches!(
41
                    container[*n].get_node_type(),
                    azul_core::dom::NodeType::Tr
                )
41
            })
4
            .collect();
4
        assert_eq!(trs.len(), rows + 1, "thead tr + body trs");
4
        let mut dl = DisplayList::default();
        // thead row rect at the table top.
4
        dl.items.push(rect_item(0.0, thead_h));
4
        dl.node_mapping.push(Some(trs[0]));
        // body rows stacked below.
21
        for (i, tr) in trs[1..].iter().enumerate() {
21
            dl.items
21
                .push(rect_item(thead_h + i as f32 * row_h, row_h));
21
            dl.node_mapping.push(Some(*tr));
21
        }
4
        (styled, dl)
4
    }
    #[test]
1
    fn collect_table_headers_registers_the_thead_rebased() {
1
        let (styled, dl) = table_fixture(20.0, 30.0, 3);
1
        let tracker =
1
            crate::solver3::pagination::collect_table_headers(&dl, &styled);
1
        assert_eq!(tracker.tables.len(), 1, "one table registered");
1
        let info = &tracker.tables[0];
1
        assert_eq!(info.thead_height, 20.0);
1
        assert_eq!(info.table_start_y, 0.0);
1
        assert_eq!(info.table_end_y, 20.0 + 3.0 * 30.0);
        // Items stored REBASED to thead-local Y.
1
        let first = info.thead_items[0].bounds().expect("bounds");
1
        assert_eq!(first.origin.y, 0.0);
1
    }
    #[test]
1
    fn continuation_pages_inside_a_table_reserve_the_thead_height() {
        // Table spans 0..320 (thead 20 + 10 rows × 30); page height 100.
1
        let (styled, dl) = table_fixture(20.0, 30.0, 10);
1
        let tracker =
1
            crate::solver3::pagination::collect_table_headers(&dl, &styled);
1
        let policy = BreakPolicy {
1
            repeat_table_headers: true,
1
            ..Default::default()
1
        };
1
        let breaks = compute_page_breaks(
1
            &PageBreakInput {
1
                display_list: &dl,
1
                layout_tree: None,
1
                styled_dom: &styled,
1
                table_headers: Some(&tracker),
1
            },
1
            &constraints(100.0, 100.0),
1
            &policy,
        );
        // Page 1 ends at 100 (starts OUTSIDE any table continuation). Every
        // page STARTING inside the table reserves the 20px repeated thead:
        // 100 + 80 = 180, then 260 (the table ends at 320; the page starting
        // at 260 is still inside → 260 + 80 = 340 > 320 → last page).
1
        assert_eq!(ys(&breaks), vec![100.0, 180.0, 260.0], "{breaks:?}");
        // Control: flag off → plain 100/200/300.
1
        let plain = compute_page_breaks(
1
            &PageBreakInput {
1
                display_list: &dl,
1
                layout_tree: None,
1
                styled_dom: &styled,
1
                table_headers: Some(&tracker),
1
            },
1
            &constraints(100.0, 100.0),
1
            &BreakPolicy::default(),
        );
1
        assert_eq!(ys(&plain), vec![100.0, 200.0, 300.0]);
1
    }
    #[test]
1
    fn atomic_table_rows_snap_the_break_to_the_row_top() {
        // Rows at 20..50, 50..80, 80..110, 110..140…: the naive break at 100
        // slices the 80..110 row → snaps to 80.
1
        let (styled, dl) = table_fixture(20.0, 30.0, 6);
1
        let policy = BreakPolicy {
1
            atomic_table_rows: true,
1
            ..Default::default()
1
        };
1
        let breaks = compute_page_breaks(
1
            &PageBreakInput {
1
                display_list: &dl,
1
                layout_tree: None,
1
                styled_dom: &styled,
1
                table_headers: None,
1
            },
1
            &constraints(100.0, 100.0),
1
            &policy,
        );
1
        assert_eq!(breaks[0].y, 80.0, "{breaks:?}");
1
        assert!(matches!(breaks[0].kind, BreakKind::Avoided { .. }));
        // Monolith rule: a row TALLER than the page still tears.
1
        let (styled, dl) = table_fixture(20.0, 300.0, 2);
1
        let breaks = compute_page_breaks(
1
            &PageBreakInput {
1
                display_list: &dl,
1
                layout_tree: None,
1
                styled_dom: &styled,
1
                table_headers: None,
1
            },
1
            &constraints(100.0, 100.0),
1
            &policy,
        );
1
        assert_eq!(breaks[0].y, 100.0);
1
        assert!(matches!(breaks[0].kind, BreakKind::Interval));
1
    }
    #[test]
1
    fn page_sequence_heights_flow_into_break_positions() {
        use crate::solver3::pagination::{PageMargins, PageSequence, PageSetup};
        use azul_core::geom::LogicalSize;
1
        let setup = |h: f32| PageSetup {
9
            page_size: LogicalSize::new(200.0, h),
9
            margins: PageMargins::default(),
9
            header_footer: Default::default(),
9
        };
1
        let (styled, dl) = avoid_fixture(vec![(rect_item(0.0, 500.0), None)]);
1
        let input = PageBreakInput {
1
            display_list: &dl,
1
            layout_tree: None,
1
            styled_dom: &styled,
1
            table_headers: None,
1
        };
        // Default 100-high pages, but PAGE 2 (0-based index 1) is 150 high:
        // breaks at 100, 250, 350, 450 (the classic office-suite "page 345 is different").
1
        let mut seq = PageSequence::uniform(setup(100.0));
1
        seq.overrides.insert(1, setup(150.0));
1
        let breaks = compute_page_breaks_with_sequence(&input, &seq, &BreakPolicy::default());
1
        assert_eq!(ys(&breaks), vec![100.0, 250.0, 350.0, 450.0], "{breaks:?}");
        // Odd/even parity: 1-based odd pages 100 high, even pages 60 high:
        // breaks 100, 160, 260, 320, 420, 480 (alternating).
1
        let mut seq = PageSequence::uniform(setup(100.0));
1
        seq.even_pages = Some(setup(60.0));
1
        let breaks = compute_page_breaks_with_sequence(&input, &seq, &BreakPolicy::default());
1
        assert_eq!(
1
            ys(&breaks),
1
            vec![100.0, 160.0, 260.0, 320.0, 420.0, 480.0],
            "{breaks:?}"
        );
        // Different-first-page: first 40 high, rest 100: 40, 140, 240, …
1
        let mut seq = PageSequence::uniform(setup(100.0));
1
        seq.first_page = Some(setup(40.0));
1
        let breaks = compute_page_breaks_with_sequence(&input, &seq, &BreakPolicy::default());
1
        assert_eq!(ys(&breaks), vec![40.0, 140.0, 240.0, 340.0, 440.0], "{breaks:?}");
        // Precedence: explicit override BEATS parity on the same index.
1
        let mut seq = PageSequence::uniform(setup(100.0));
1
        seq.even_pages = Some(setup(60.0));
1
        seq.overrides.insert(1, setup(150.0)); // page 2 (even) overridden
1
        let breaks = compute_page_breaks_with_sequence(&input, &seq, &BreakPolicy::default());
1
        assert_eq!(breaks[1].y, 250.0, "override wins over parity: {breaks:?}");
1
    }
    #[test]
1
    fn page_setup_content_height_subtracts_margins_and_decoration() {
        use crate::solver3::pagination::{HeaderFooterConfig, PageMargins, PageSetup};
        use azul_core::geom::LogicalSize;
1
        let setup = PageSetup {
1
            page_size: LogicalSize::new(210.0, 297.0),
1
            margins: PageMargins {
1
                top: 20.0,
1
                right: 15.0,
1
                bottom: 20.0,
1
                left: 15.0,
1
            },
1
            header_footer: HeaderFooterConfig {
1
                show_footer: true,
1
                footer_height: 20.0,
1
                ..Default::default()
1
            },
1
        };
1
        assert_eq!(setup.content_height(), 297.0 - 40.0 - 20.0);
1
        assert_eq!(setup.content_width(), 210.0 - 30.0);
1
    }
    #[test]
1
    fn recompute_reuses_previous_values_where_pages_converge() {
        // A 1000-tall document, page 100: breaks at 100..900.
1
        let (styled, dl) = avoid_fixture(vec![(rect_item(0.0, 1000.0), None)]);
1
        let input = PageBreakInput {
1
            display_list: &dl,
1
            layout_tree: None,
1
            styled_dom: &styled,
1
            table_headers: None,
1
        };
1
        let c = constraints(100.0, 100.0);
1
        let policy = BreakPolicy::default();
1
        let prev = compute_page_breaks(&input, &c, &policy);
1
        assert_eq!(prev.len(), 9);
        // Edit on "page 3" (dirty from 250) that does NOT move any break:
        // the result must be VALUE-identical to prev — every span reusable.
1
        let again = recompute_page_breaks_from(&prev, &input, &c, &policy, 250.0);
1
        assert_eq!(again, prev, "no break moved → full value reuse");
        // A forced break appears at 450 (content change at 450): breaks
        // above stay value-identical, the region re-flows from the forced
        // break, and positions that happen to coincide again converge.
1
        let mut dl2 = dl.clone();
1
        dl2.forced_page_breaks = vec![crate::solver3::display_list::ForcedBreak { y: 450.0, causing_node: None }];
1
        let input2 = PageBreakInput {
1
            display_list: &dl2,
1
            layout_tree: None,
1
            styled_dom: &styled,
1
            table_headers: None,
1
        };
1
        let re = recompute_page_breaks_from(&prev, &input2, &c, &policy, 450.0);
1
        assert_eq!(&re[..4], &prev[..4], "breaks above the change are reused");
1
        assert_eq!(re[4].y, 450.0, "{re:?}");
1
        assert!(matches!(re[4].kind, BreakKind::Forced));
        // The PLAIN algorithm keeps interval positions fixed (500, 600, …),
        // so every break below the insertion converges with prev and comes
        // back as prev's VALUES — the reuse signal a paged viewer caches on.
1
        assert_eq!(&re[5..], &prev[4..], "converged tail is value-reused");
1
    }
    #[test]
1
    fn page_of_y_counts_breaks_at_or_below_y() {
1
        let b = |y: f32, kind: BreakKind| PageBreakPosition {
3
            y,
3
            kind,
3
            causing_node: None,
3
        };
1
        let breaks = [
1
            b(100.0, BreakKind::Interval),
1
            b(180.0, BreakKind::Forced),
1
            b(280.0, BreakKind::Interval),
1
        ];
1
        assert_eq!(page_of_y(&breaks, 0.0), 0);
1
        assert_eq!(page_of_y(&breaks, 99.9), 0);
        // A break at EXACTLY y sends the content to the next page
        // ("content at or below y belongs to the next page").
1
        assert_eq!(page_of_y(&breaks, 100.0), 1);
1
        assert_eq!(page_of_y(&breaks, 179.0), 1);
1
        assert_eq!(page_of_y(&breaks, 200.0), 2);
1
        assert_eq!(page_of_y(&breaks, 9999.0), 3);
1
        assert_eq!(page_of_y(&[], 50.0), 0, "no breaks: everything is page 0");
1
    }
    #[test]
1
    fn default_break_policy_is_all_off() {
1
        let p = BreakPolicy::default();
1
        assert!(
1
            !p.honor_break_inside
1
                && !p.widows_orphans
1
                && !p.atomic_lines
1
                && !p.atomic_table_rows
1
                && !p.repeat_table_headers,
            "defaults-off is the bug-compat contract B2 ships under"
        );
1
    }
    #[test]
1
    fn page_spans_skips_zero_height_pages_and_may_be_empty() {
1
        let b = |y: f32| PageBreakPosition {
4
            y,
4
            kind: BreakKind::Interval,
4
            causing_node: None,
4
        };
        // Break at 0 and duplicate breaks produce no empty page.
1
        assert_eq!(
1
            page_spans(&[b(0.0), b(100.0), b(100.0)], 250.0),
1
            vec![(0.0, 100.0), (100.0, 250.0)]
        );
        // Break beyond the end: final span still lands on total_height only
        // if content remains.
1
        assert_eq!(page_spans(&[b(250.0)], 250.0), vec![(0.0, 250.0)]);
        // No content, no breaks: empty (callers add the single-page fallback).
1
        assert_eq!(page_spans(&[], 0.0), Vec::<(f32, f32)>::new());
1
    }
}