1
//! CSS Paged Media page decoration: headers, footers, margin boxes, and counters.
2
//!
3
//! This module is the canonical home for paged-media page *decoration*. It provides:
4
//!
5
//! - `FakePageConfig` / `HeaderFooterConfig` — programmatic header/footer setup
6
//!   (a temporary interface until full CSS `@page` rule parsing exists)
7
//! - `MarginBoxContent` / `CounterFormat` — the CSS GCPM margin-box content model and
8
//!   page-counter number formatting (formatting delegates to `super::counters`)
9
//! - `PageInfo` — per-page metadata passed to content generators
10
//! - `TableHeaderInfo` / `TableHeaderTracker` — repeated table headers across pages
11
//!
12
//! The actual page *splitting* is performed by the display-list slicer
13
//! (`paginate_display_list_with_slicer_and_breaks` in `super::display_list`), which
14
//! consumes `HeaderFooterConfig` via its `SlicerConfig`. The continuous-vs-paged media
15
//! decision and page geometry are carried by `crate::paged::FragmentationContext`, and
16
//! CSS break properties are read via `super::getters` (`get_break_before`,
17
//! `get_break_after`, `is_forced_page_break`).
18
//!
19
//! **Note:** Running elements, named strings, and per-page `@page` selectors are not
20
//! yet implemented; only page counters and header/footer configuration are functional.
21
//!
22
//! See: <https://www.w3.org/TR/css-gcpm-3>/
23

            
24
use std::sync::Arc;
25

            
26
use azul_css::props::basic::ColorU;
27

            
28
/// Content that can appear in a page margin box.
29
///
30
/// This enum represents the various types of content that CSS GCPM
31
/// allows in margin boxes.
32
#[derive(Clone)]
33
pub enum MarginBoxContent {
34
    /// Empty margin box
35
    None,
36
    /// A running element referenced by name: `content: element(header)`
37
    RunningElement(String),
38
    /// A named string: `content: string(chapter)`
39
    NamedString(String),
40
    /// Page counter: `content: counter(page)`
41
    PageCounter,
42
    /// Total pages counter: `content: counter(pages)`
43
    PagesCounter,
44
    /// Page counter with format: `content: counter(page, lower-roman)`
45
    PageCounterFormatted { format: CounterFormat },
46
    /// Combined content (e.g., "Page " counter(page) " of " counter(pages))
47
    Combined(Vec<MarginBoxContent>),
48
    /// Literal text
49
    Text(String),
50
    /// Custom callback for dynamic content generation
51
    Custom(Arc<dyn Fn(PageInfo) -> String + Send + Sync>),
52
}
53

            
54
impl std::fmt::Debug for MarginBoxContent {
55
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56
        match self {
57
            Self::None => write!(f, "None"),
58
            Self::RunningElement(s) => f.debug_tuple("RunningElement").field(s).finish(),
59
            Self::NamedString(s) => f.debug_tuple("NamedString").field(s).finish(),
60
            Self::PageCounter => write!(f, "PageCounter"),
61
            Self::PagesCounter => write!(f, "PagesCounter"),
62
            Self::PageCounterFormatted { format } => f
63
                .debug_struct("PageCounterFormatted")
64
                .field("format", format)
65
                .finish(),
66
            Self::Combined(v) => f.debug_tuple("Combined").field(v).finish(),
67
            Self::Text(s) => f.debug_tuple("Text").field(s).finish(),
68
            Self::Custom(_) => write!(f, "Custom(<fn>)"),
69
        }
70
    }
71
}
72

            
73
/// Counter formatting styles (subset of CSS list-style-type).
74
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75
pub enum CounterFormat {
76
    Decimal,
77
    DecimalLeadingZero,
78
    LowerRoman,
79
    UpperRoman,
80
    LowerAlpha,
81
    UpperAlpha,
82
    LowerGreek,
83
}
84

            
85
impl Default for CounterFormat {
86
1
    fn default() -> Self {
87
1
        Self::Decimal
88
1
    }
89
}
90

            
91
impl CounterFormat {
92
    /// Format a number according to this counter style.
93
16090
    #[must_use] pub fn format(&self, n: usize) -> String {
94
        use super::counters::{to_alphabetic, to_greek, to_roman};
95
16090
        match self {
96
12
            Self::Decimal => n.to_string(),
97
14
            Self::DecimalLeadingZero => format!("{n:02}"),
98
4012
            Self::LowerRoman => to_roman(n, false),
99
4011
            Self::UpperRoman => to_roman(n, true),
100
3014
            Self::LowerAlpha => to_alphabetic(n, false),
101
3014
            Self::UpperAlpha => to_alphabetic(n, true),
102
2013
            Self::LowerGreek => to_greek(n, false),
103
        }
104
16090
    }
105
}
106

            
107
/// Information about the current page, passed to content generators.
108
#[derive(Debug, Clone, Copy)]
109
#[allow(clippy::struct_excessive_bools)] // independent page-position flags (first/last/left/right)
110
pub struct PageInfo {
111
    /// Current page number (1-indexed for display)
112
    pub page_number: usize,
113
    /// Total number of pages (may be 0 if unknown during first pass)
114
    pub total_pages: usize,
115
    /// Whether this is the first page
116
    pub is_first: bool,
117
    /// Whether this is the last page
118
    pub is_last: bool,
119
    /// Whether this is a left (verso) page (for duplex printing)
120
    pub is_left: bool,
121
    /// Whether this is a right (recto) page
122
    pub is_right: bool,
123
    /// Whether this is a blank page (inserted for left/right alignment)
124
    pub is_blank: bool,
125
}
126

            
127
impl PageInfo {
128
    /// Create `PageInfo` for a specific page.
129
1371
    #[must_use] pub const fn new(page_number: usize, total_pages: usize) -> Self {
130
        Self {
131
1371
            page_number,
132
1371
            total_pages,
133
1371
            is_first: page_number == 1,
134
1371
            is_last: total_pages > 0 && page_number == total_pages,
135
1371
            is_left: page_number.is_multiple_of(2), // Even pages are left (verso)
136
1371
            is_right: page_number % 2 == 1, // Odd pages are right (recto)
137
            is_blank: false,
138
        }
139
1371
    }
140
}
141

            
142
/// Default height for page headers and footers (in points).
143
const DEFAULT_HEADER_FOOTER_HEIGHT: f32 = 30.0;
144

            
145
/// Default font size for header/footer text (in points).
146
const DEFAULT_HEADER_FOOTER_FONT_SIZE: f32 = 10.0;
147

            
148
/// Configuration for page headers and footers.
149
///
150
/// This is a simplified interface for the common case of adding
151
/// headers and footers, consumed by the display-list slicer via its `SlicerConfig`.
152
#[derive(Debug, Clone)]
153
pub struct HeaderFooterConfig {
154
    /// Whether to show a header on each page
155
    pub show_header: bool,
156
    /// Whether to show a footer on each page
157
    pub show_footer: bool,
158
    /// Height of the header area (if shown)
159
    pub header_height: f32,
160
    /// Height of the footer area (if shown)  
161
    pub footer_height: f32,
162
    /// Content generator for the header
163
    pub header_content: MarginBoxContent,
164
    /// Content generator for the footer
165
    pub footer_content: MarginBoxContent,
166
    /// Font size for header/footer text
167
    pub font_size: f32,
168
    /// Text color for header/footer
169
    pub text_color: ColorU,
170
    /// Whether to skip header/footer on first page
171
    pub skip_first_page: bool,
172
}
173

            
174
impl Default for HeaderFooterConfig {
175
63
    fn default() -> Self {
176
63
        Self {
177
63
            show_header: false,
178
63
            show_footer: false,
179
63
            header_height: DEFAULT_HEADER_FOOTER_HEIGHT,
180
63
            footer_height: DEFAULT_HEADER_FOOTER_HEIGHT,
181
63
            header_content: MarginBoxContent::None,
182
63
            footer_content: MarginBoxContent::None,
183
63
            font_size: DEFAULT_HEADER_FOOTER_FONT_SIZE,
184
63
            text_color: ColorU {
185
63
                r: 0,
186
63
                g: 0,
187
63
                b: 0,
188
63
                a: 255,
189
63
            },
190
63
            skip_first_page: false,
191
63
        }
192
63
    }
193
}
194

            
195
impl HeaderFooterConfig {
196
    /// Create a config with page numbers in the footer.
197
2
    #[must_use] pub fn with_page_numbers() -> Self {
198
2
        Self {
199
2
            show_footer: true,
200
2
            footer_content: MarginBoxContent::Combined(vec![
201
2
                MarginBoxContent::Text("Page ".to_string()),
202
2
                MarginBoxContent::PageCounter,
203
2
                MarginBoxContent::Text(" of ".to_string()),
204
2
                MarginBoxContent::PagesCounter,
205
2
            ]),
206
2
            ..Default::default()
207
2
        }
208
2
    }
209

            
210
    /// Create a config with page numbers in both header and footer.
211
2
    #[must_use] pub fn with_header_and_footer_page_numbers() -> Self {
212
2
        Self {
213
2
            show_header: true,
214
2
            show_footer: true,
215
2
            header_content: MarginBoxContent::Combined(vec![
216
2
                MarginBoxContent::Text("Page ".to_string()),
217
2
                MarginBoxContent::PageCounter,
218
2
            ]),
219
2
            footer_content: MarginBoxContent::Combined(vec![
220
2
                MarginBoxContent::Text("Page ".to_string()),
221
2
                MarginBoxContent::PageCounter,
222
2
                MarginBoxContent::Text(" of ".to_string()),
223
2
                MarginBoxContent::PagesCounter,
224
2
            ]),
225
2
            ..Default::default()
226
2
        }
227
2
    }
228

            
229
    /// Set custom header text.
230
    #[must_use]
231
4
    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
232
4
        self.show_header = true;
233
4
        self.header_content = MarginBoxContent::Text(text.into());
234
4
        self
235
4
    }
236

            
237
    /// Set custom footer text.
238
    #[must_use]
239
2
    pub fn with_footer_text(mut self, text: impl Into<String>) -> Self {
240
2
        self.show_footer = true;
241
2
        self.footer_content = MarginBoxContent::Text(text.into());
242
2
        self
243
2
    }
244

            
245
    /// Generate the text content for a margin box given page info.
246
    // `&self` is only reached via the recursive Combined arm; it is kept because this is a
247
    // public method and converting to an associated fn would break the `x.generate_content(..)` API.
248
    #[allow(clippy::only_used_in_recursion)]
249
350
    #[must_use] pub fn generate_content(&self, content: &MarginBoxContent, info: PageInfo) -> String {
250
350
        match content {
251
2
            MarginBoxContent::None => String::new(),
252
93
            MarginBoxContent::Text(s) => s.clone(),
253
39
            MarginBoxContent::PageCounter => info.page_number.to_string(),
254
            MarginBoxContent::PagesCounter => {
255
30
                if info.total_pages > 0 {
256
28
                    info.total_pages.to_string()
257
                } else {
258
2
                    "?".to_string()
259
                }
260
            }
261
8
            MarginBoxContent::PageCounterFormatted { format } => format.format(info.page_number),
262
174
            MarginBoxContent::Combined(parts) => parts
263
174
                .iter()
264
283
                .map(|p| self.generate_content(p, info))
265
174
                .collect(),
266
1
            MarginBoxContent::NamedString(name) => {
267
                // TODO: Look up named string from document context
268
1
                format!("[string:{name}]")
269
            }
270
1
            MarginBoxContent::RunningElement(name) => {
271
                // Running elements are rendered as display items, not text
272
1
                format!("[element:{name}]")
273
            }
274
2
            MarginBoxContent::Custom(f) => f(info),
275
        }
276
350
    }
277

            
278
    /// Get the header text for a specific page.
279
26
    #[must_use] pub fn header_text(&self, info: PageInfo) -> String {
280
26
        if !self.show_header {
281
4
            return String::new();
282
22
        }
283
22
        if self.skip_first_page && info.is_first {
284
2
            return String::new();
285
20
        }
286
20
        self.generate_content(&self.header_content, info)
287
26
    }
288

            
289
    /// Get the footer text for a specific page.
290
39
    #[must_use] pub fn footer_text(&self, info: PageInfo) -> String {
291
39
        if !self.show_footer {
292
3
            return String::new();
293
36
        }
294
36
        if self.skip_first_page && info.is_first {
295
2
            return String::new();
296
34
        }
297
34
        self.generate_content(&self.footer_content, info)
298
39
    }
299
}
300

            
301
/// Temporary configuration for page headers/footers without CSS `@page` parsing.
302
///
303
/// Provides programmatic control over page decoration until full CSS `@page`
304
/// rule support is implemented.
305
///
306
/// ## Supported Features
307
///
308
/// - Page numbers in header and/or footer
309
/// - Custom text in header and/or footer
310
/// - Number format (decimal, roman numerals, alphabetic, greek)
311
/// - Skip first page option
312
///
313
/// ## Example
314
///
315
/// ```rust
316
/// use azul_layout::solver3::pagination::FakePageConfig;
317
///
318
/// let config = FakePageConfig::new()
319
///     .with_footer_page_numbers()
320
///     .with_header_text("My Document")
321
///     .skip_first_page(true);
322
///
323
/// let header_footer = config.to_header_footer_config();
324
/// ```
325
#[derive(Debug, Clone)]
326
#[allow(clippy::struct_excessive_bools)] // independent header/footer toggle flags
327
pub struct FakePageConfig {
328
    /// Show header on pages
329
    pub show_header: bool,
330
    /// Show footer on pages
331
    pub show_footer: bool,
332
    /// Header text (static text, or None for page numbers only)
333
    pub header_text: Option<String>,
334
    /// Footer text (static text, or None for page numbers only)
335
    pub footer_text: Option<String>,
336
    /// Include page number in header
337
    pub header_page_number: bool,
338
    /// Include page number in footer
339
    pub footer_page_number: bool,
340
    /// Include total pages count ("of Y") in header
341
    pub header_total_pages: bool,
342
    /// Include total pages count ("of Y") in footer
343
    pub footer_total_pages: bool,
344
    /// Number format for page counters
345
    pub number_format: CounterFormat,
346
    /// Skip header/footer on first page
347
    pub skip_first_page: bool,
348
    /// Header height in points
349
    pub header_height: f32,
350
    /// Footer height in points
351
    pub footer_height: f32,
352
    /// Font size for header/footer text
353
    pub font_size: f32,
354
    /// Text color for header/footer
355
    pub text_color: ColorU,
356
    /// Break-awareness policy for pagination (all-off default = plain
357
    /// interval slicing; printpdf flips the flags on with a changelog entry).
358
    pub break_policy: crate::solver3::page_breaks::BreakPolicy,
359
    /// office-suite-style per-page setup sequence (`None` = uniform pages from
360
    /// the fields above).
361
    pub page_sequence: Option<PageSequence>,
362
}
363

            
364
impl Default for FakePageConfig {
365
205
    fn default() -> Self {
366
205
        Self {
367
205
            show_header: false,
368
205
            show_footer: false,
369
205
            header_text: None,
370
205
            footer_text: None,
371
205
            header_page_number: false,
372
205
            footer_page_number: false,
373
205
            header_total_pages: false,
374
205
            footer_total_pages: false,
375
205
            number_format: CounterFormat::Decimal,
376
205
            skip_first_page: false,
377
205
            header_height: DEFAULT_HEADER_FOOTER_HEIGHT,
378
205
            footer_height: DEFAULT_HEADER_FOOTER_HEIGHT,
379
205
            font_size: DEFAULT_HEADER_FOOTER_FONT_SIZE,
380
205
            text_color: ColorU {
381
205
                r: 0,
382
205
                g: 0,
383
205
                b: 0,
384
205
                a: 255,
385
205
            },
386
205
            break_policy: crate::solver3::page_breaks::BreakPolicy::default(),
387
205
            page_sequence: None,
388
205
        }
389
205
    }
390
}
391

            
392
impl FakePageConfig {
393
    /// Create a new empty configuration (no headers/footers).
394
205
    #[must_use] pub fn new() -> Self {
395
205
        Self::default()
396
205
    }
397

            
398
    /// Enable footer with "Page X of Y" format.
399
7
    #[must_use] pub const fn with_footer_page_numbers(mut self) -> Self {
400
7
        self.show_footer = true;
401
7
        self.footer_page_number = true;
402
7
        self.footer_total_pages = true;
403
7
        self
404
7
    }
405

            
406
    /// Enable header with "Page X" format.
407
7
    #[must_use] pub const fn with_header_page_numbers(mut self) -> Self {
408
7
        self.show_header = true;
409
7
        self.header_page_number = true;
410
7
        self
411
7
    }
412

            
413
    /// Enable both header and footer with page numbers.
414
3
    #[must_use] pub const fn with_header_and_footer_page_numbers(mut self) -> Self {
415
3
        self.show_header = true;
416
3
        self.show_footer = true;
417
3
        self.header_page_number = true;
418
3
        self.footer_page_number = true;
419
3
        self.footer_total_pages = true;
420
3
        self
421
3
    }
422

            
423
    /// Set custom header text.
424
    #[must_use]
425
3
    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
426
3
        self.show_header = true;
427
3
        self.header_text = Some(text.into());
428
3
        self
429
3
    }
430

            
431
    /// Set custom footer text.
432
    #[must_use]
433
3
    pub fn with_footer_text(mut self, text: impl Into<String>) -> Self {
434
3
        self.show_footer = true;
435
3
        self.footer_text = Some(text.into());
436
3
        self
437
3
    }
438

            
439
    /// Set the number format for page counters.
440
4
    #[must_use] pub const fn with_number_format(mut self, format: CounterFormat) -> Self {
441
4
        self.number_format = format;
442
4
        self
443
4
    }
444

            
445
    /// Skip header/footer on the first page.
446
5
    #[must_use] pub const fn skip_first_page(mut self, skip: bool) -> Self {
447
5
        self.skip_first_page = skip;
448
5
        self
449
5
    }
450

            
451
    /// Set header height.
452
5
    #[must_use] pub const fn with_header_height(mut self, height: f32) -> Self {
453
5
        self.header_height = height;
454
5
        self
455
5
    }
456

            
457
    /// Set footer height.
458
4
    #[must_use] pub const fn with_footer_height(mut self, height: f32) -> Self {
459
4
        self.footer_height = height;
460
4
        self
461
4
    }
462

            
463
    /// Set font size for header/footer text.
464
2
    #[must_use] pub const fn with_font_size(mut self, size: f32) -> Self {
465
2
        self.font_size = size;
466
2
        self
467
2
    }
468

            
469
    /// Set text color for header/footer.
470
1
    #[must_use] pub const fn with_text_color(mut self, color: ColorU) -> Self {
471
1
        self.text_color = color;
472
1
        self
473
1
    }
474

            
475
    /// Convert this fake config to the internal `HeaderFooterConfig`.
476
    ///
477
    /// This is the bridge between the user-facing API and the internal
478
    /// pagination engine.
479
1429
    #[must_use] pub fn to_header_footer_config(&self) -> HeaderFooterConfig {
480
1429
        HeaderFooterConfig {
481
1429
            show_header: self.show_header,
482
1429
            show_footer: self.show_footer,
483
1429
            header_height: self.header_height,
484
1429
            footer_height: self.footer_height,
485
1429
            header_content: self.build_header_content(),
486
1429
            footer_content: self.build_footer_content(),
487
1429
            skip_first_page: self.skip_first_page,
488
1429
            font_size: self.font_size,
489
1429
            text_color: self.text_color,
490
1429
        }
491
1429
    }
492

            
493
    /// Build the `MarginBoxContent` for the header.
494
1430
    fn build_header_content(&self) -> MarginBoxContent {
495
1430
        Self::build_margin_content(
496
1430
            self.header_text.as_deref(),
497
1430
            self.header_page_number,
498
1430
            self.header_total_pages,
499
1430
            self.number_format,
500
        )
501
1430
    }
502

            
503
    /// Build the `MarginBoxContent` for the footer.
504
1430
    fn build_footer_content(&self) -> MarginBoxContent {
505
1430
        Self::build_margin_content(
506
1430
            self.footer_text.as_deref(),
507
1430
            self.footer_page_number,
508
1430
            self.footer_total_pages,
509
1430
            self.number_format,
510
        )
511
1430
    }
512

            
513
    /// Shared helper for building header/footer margin box content.
514
2865
    fn build_margin_content(
515
2865
        text: Option<&str>,
516
2865
        page_number: bool,
517
2865
        total_pages: bool,
518
2865
        number_format: CounterFormat,
519
2865
    ) -> MarginBoxContent {
520
2865
        let mut parts = Vec::new();
521

            
522
2865
        if let Some(text) = text {
523
11
            parts.push(MarginBoxContent::Text(text.to_string()));
524
11
            if page_number {
525
4
                parts.push(MarginBoxContent::Text(" - ".to_string()));
526
7
            }
527
2854
        }
528

            
529
2865
        if page_number {
530
24
            parts.push(MarginBoxContent::Text("Page ".to_string()));
531
24
            if number_format == CounterFormat::Decimal {
532
16
                parts.push(MarginBoxContent::PageCounter);
533
16
            } else {
534
8
                parts.push(MarginBoxContent::PageCounterFormatted {
535
8
                    format: number_format,
536
8
                });
537
8
            }
538

            
539
24
            if total_pages {
540
13
                parts.push(MarginBoxContent::Text(" of ".to_string()));
541
13
                parts.push(MarginBoxContent::PagesCounter);
542
13
            }
543
2841
        }
544

            
545
2865
        if parts.is_empty() {
546
2834
            MarginBoxContent::None
547
31
        } else if parts.len() == 1 {
548
7
            parts.pop().unwrap()
549
        } else {
550
24
            MarginBoxContent::Combined(parts)
551
        }
552
2865
    }
553
}
554

            
555
/// Information about a table that may need header repetition.
556
#[derive(Debug, Clone)]
557
pub struct TableHeaderInfo {
558
    /// The table's node index in the layout tree
559
    pub table_node_index: usize,
560
    /// The Y position where the table starts
561
    pub table_start_y: f32,
562
    /// The Y position where the table ends
563
    pub table_end_y: f32,
564
    /// The thead's display list items (captured during initial render)
565
    pub thead_items: Vec<super::display_list::DisplayListItem>,
566
    /// Height of the thead
567
    pub thead_height: f32,
568
    /// The Y position of the thead relative to table start
569
    pub thead_offset_y: f32,
570
}
571

            
572
/// Context for tracking table headers across pages.
573
#[derive(Debug, Default, Clone)]
574
pub struct TableHeaderTracker {
575
    /// All tables with theads that might need repetition
576
    pub tables: Vec<TableHeaderInfo>,
577
}
578

            
579
impl TableHeaderTracker {
580
19
    #[must_use] pub fn new() -> Self {
581
19
        Self::default()
582
19
    }
583

            
584
    /// Register a table's thead for potential repetition.
585
1088
    pub fn register_table_header(&mut self, info: TableHeaderInfo) {
586
1088
        self.tables.push(info);
587
1088
    }
588

            
589
    /// Get theads that should be repeated on a specific page.
590
    ///
591
    /// Returns the thead items that need to be injected at the top of the page,
592
    /// along with the Y offset where they should appear.
593
    ///
594
    /// Kept for compatibility: offsets are the LEGACY vertical stack (each
595
    /// thead below the previous). The slicer now uses
596
    /// [`Self::straddling_tables_for_page`] and places theads X-aware
597
    /// (side-by-side tables keep their own column, nested ones stack).
598
41
    #[must_use] pub fn get_repeated_headers_for_page(
599
41
        &self,
600
41
        page_index: usize,
601
41
        page_top_y: f32,
602
41
        page_bottom_y: f32,
603
41
    ) -> Vec<(f32, &[super::display_list::DisplayListItem], f32)> {
604
41
        let mut stack_offset = 0.0_f32;
605
41
        self.straddling_tables_for_page(page_index, page_top_y, page_bottom_y)
606
41
            .into_iter()
607
71
            .map(|t| {
608
71
                let entry = (stack_offset, t.thead_items.as_slice(), t.thead_height);
609
71
                stack_offset += t.thead_height;
610
71
                entry
611
71
            })
612
41
            .collect()
613
41
    }
614

            
615
    /// The tables whose content STRADDLES this page top (started strictly
616
    /// above, still continuing) — the ones whose thead must repeat here.
617
    /// Two SIBLING tables can only both straddle when they sit side by side
618
    /// (or nested); the slicer decides placement from their x-extents.
619
1353
    #[must_use] pub fn straddling_tables_for_page(
620
1353
        &self,
621
1353
        page_index: usize,
622
1353
        page_top_y: f32,
623
1353
        page_bottom_y: f32,
624
1353
    ) -> Vec<&TableHeaderInfo> {
625
        // Page 0 never repeats anything (the original thead is on it), and a
626
        // degenerate/inverted page has no room for a header.
627
1353
        if page_index == 0 || page_bottom_y <= page_top_y {
628
1159
            return Vec::new();
629
194
        }
630
194
        self.tables
631
194
            .iter()
632
194
            .filter(|table| {
633
83
                table.table_start_y < page_top_y && table.table_end_y > page_top_y
634
83
            })
635
194
            .collect()
636
1353
    }
637
}
638

            
639
/// Page margins in logical px (CSS order).
640
#[derive(Debug, Clone, Copy, PartialEq, Default)]
641
pub struct PageMargins {
642
    pub top: f32,
643
    pub right: f32,
644
    pub bottom: f32,
645
    pub left: f32,
646
}
647

            
648
/// ONE page's complete setup — size (orientation = which side is longer),
649
/// margins, and header/footer decoration. The classic office suites "page setup" unit.
650
#[derive(Debug, Clone)]
651
pub struct PageSetup {
652
    /// Full page size INCLUDING margins (swap the sides for landscape).
653
    pub page_size: azul_core::geom::LogicalSize,
654
    pub margins: PageMargins,
655
    /// Header/footer decoration for pages using this setup (heights count
656
    /// against the content area; text/numbering render per page).
657
    pub header_footer: HeaderFooterConfig,
658
}
659

            
660
impl PageSetup {
661
    /// Content height = page height − vertical margins − active header/footer.
662
    #[must_use]
663
2122
    pub fn content_height(&self) -> f32 {
664
2122
        let header = if self.header_footer.show_header {
665
            self.header_footer.header_height
666
        } else {
667
2122
            0.0
668
        };
669
2122
        let footer = if self.header_footer.show_footer {
670
1
            self.header_footer.footer_height
671
        } else {
672
2121
            0.0
673
        };
674
2122
        (self.page_size.height - self.margins.top - self.margins.bottom - header - footer)
675
2122
            .max(0.0)
676
2122
    }
677

            
678
    /// Content width = page width − horizontal margins.
679
    #[must_use]
680
2076
    pub fn content_width(&self) -> f32 {
681
2076
        (self.page_size.width - self.margins.left - self.margins.right).max(0.0)
682
2076
    }
683
}
684

            
685
/// The document's page-setup SEQUENCE: one default + sparse overrides.
686
///
687
/// The classic office suites model ("default = A4 portrait, 2cm footer, 1.5cm margins;
688
/// page 345 is landscape"). Resolution precedence per page:
689
/// explicit override > first-page setup > odd/even parity > default.
690
///
691
/// pdf2html maps its HTML dataset annotations (e.g. `data-az-page-*` on
692
/// section elements) + CSS onto this structure and hands it to the paged
693
/// pipeline.
694
///
695
/// FRAGMENTAINER-FLOW STAGING: per-page HEIGHT differences are fully live
696
/// (the break forward-pass consumes per-index content heights — same
697
/// mechanism as the repeated-thead reserve). Per-page WIDTH differences
698
/// (true landscape re-wrap) require laying out INTO the fragmentainer
699
/// sequence with re-measure at boundaries — an engine stage; until then a
700
/// non-uniform width falls back to the default width and says so once.
701
#[derive(Debug, Clone)]
702
pub struct PageSequence {
703
    pub default: PageSetup,
704
    /// Explicit per-page overrides (0-based page index). Strongest.
705
    pub overrides: std::collections::BTreeMap<usize, PageSetup>,
706
    /// the classic office-suite "different first page".
707
    pub first_page: Option<PageSetup>,
708
    /// the classic office-suite "different odd & even": odd = 0-based EVEN indices (page 1,
709
    /// 3, … in 1-based speech) — stored by the 1-based convention users
710
    /// think in: `odd_pages` applies to 1-based odd page numbers.
711
    pub odd_pages: Option<PageSetup>,
712
    pub even_pages: Option<PageSetup>,
713
}
714

            
715
impl PageSequence {
716
    /// A uniform sequence (every page identical).
717
    #[must_use]
718
11
    pub const fn uniform(default: PageSetup) -> Self {
719
11
        Self {
720
11
            default,
721
11
            overrides: std::collections::BTreeMap::new(),
722
11
            first_page: None,
723
11
            odd_pages: None,
724
11
            even_pages: None,
725
11
        }
726
11
    }
727

            
728
    /// The setup for 0-based page `index`:
729
    /// override > first > odd/even (1-based parity) > default.
730
    #[must_use]
731
1099
    pub fn setup_for_page(&self, index: usize) -> &PageSetup {
732
1099
        if let Some(explicit) = self.overrides.get(&index) {
733
9
            return explicit;
734
1090
        }
735
1090
        if index == 0 {
736
15
            if let Some(first) = &self.first_page {
737
2
                return first;
738
13
            }
739
1075
        }
740
1088
        let one_based = index + 1;
741
1088
        if one_based % 2 == 1 {
742
548
            if let Some(odd) = &self.odd_pages {
743
                return odd;
744
548
            }
745
540
        } else if let Some(even) = &self.even_pages {
746
8
            return even;
747
532
        }
748
1080
        &self.default
749
1099
    }
750

            
751
    /// Whether every page shares the default's content WIDTH (the
752
    /// fragmentainer-flow precondition for the current single-measure
753
    /// layout). Announces the degradation once when violated.
754
    #[must_use]
755
7
    pub fn has_uniform_width(&self) -> bool {
756
7
        let w = self.default.content_width();
757
7
        let all = self
758
7
            .overrides
759
7
            .values()
760
7
            .chain(self.first_page.iter())
761
7
            .chain(self.odd_pages.iter())
762
7
            .chain(self.even_pages.iter())
763
7
            .all(|s| (s.content_width() - w).abs() < 0.5);
764
7
        if !all {
765
            static ANNOUNCE: std::sync::Once = std::sync::Once::new();
766
1
            ANNOUNCE.call_once(|| {
767
1
                eprintln!(
768
1
                    "[azul][pagination] this PageSequence varies the CONTENT WIDTH                      between pages (landscape override / different margins).                      Re-wrapping text per fragmentainer is not implemented yet —                      content lays out at the DEFAULT width on every page; page                      heights, margins and headers/footers still apply per page                      (announced once)."
769
                );
770
1
            });
771
6
        }
772
7
        all
773
7
    }
774

            
775
    /// Partition the page sequence into WIDTH SECTIONS — maximal runs of
776
    /// consecutive pages sharing a content width. This is the fragmentainer
777
    /// unit for width re-wrap: content lays out ONCE per section at that
778
    /// section's width and is cut at the section boundary (the classic office suites model,
779
    /// where page setup changes at section breaks).
780
    ///
781
    /// The last section is open-ended (`page_count: None`): once every
782
    /// per-page override / parity variation has been passed, the width is
783
    /// constant forever. `max_scan` bounds the scan (parity alternation
784
    /// yields one section per page up to the bound — such sequences degrade
785
    /// to per-page sections and remain correct, just not cheap).
786
    #[must_use]
787
4
    pub fn width_sections(&self, max_scan: usize) -> Vec<WidthSection> {
788
4
        let mut out: Vec<WidthSection> = Vec::new();
789
        // Past the largest explicit override AND the first page AND parity
790
        // variation, the width can still alternate (odd/even) — only treat
791
        // the run as final when parity widths agree with the default.
792
4
        let parity_uniform = {
793
4
            let w = self.default.content_width();
794
4
            self.odd_pages
795
4
                .iter()
796
4
                .chain(self.even_pages.iter())
797
4
                .all(|s| (s.content_width() - w).abs() < 0.5)
798
        };
799
4
        let last_override = self.overrides.keys().next_back().copied().unwrap_or(0);
800
8
        for page in 0..max_scan.max(1) {
801
8
            let w = self.setup_for_page(page).content_width();
802
8
            match out.last_mut() {
803
4
                Some(sec) if (sec.content_width - w).abs() < 0.5 => {
804
2
                    if let Some(n) = sec.page_count.as_mut() {
805
2
                        *n += 1;
806
2
                    }
807
                }
808
6
                _ => out.push(WidthSection {
809
6
                    first_page: page,
810
6
                    page_count: Some(1),
811
6
                    content_width: w,
812
6
                }),
813
            }
814
            // Stable tail: no more overrides ahead, first-page passed, parity
815
            // constant — the current section runs forever.
816
8
            if page > last_override && page > 0 && parity_uniform {
817
4
                if let Some(sec) = out.last_mut() {
818
4
                    sec.page_count = None;
819
4
                }
820
4
                break;
821
4
            }
822
        }
823
4
        out
824
4
    }
825
}
826

            
827
/// A maximal run of consecutive pages sharing a content width — the
828
/// fragmentainer unit for width re-wrap. See [`PageSequence::width_sections`].
829
#[derive(Debug, Clone, Copy, PartialEq)]
830
pub struct WidthSection {
831
    /// 0-based index of the first page of the run.
832
    pub first_page: usize,
833
    /// Number of pages in the run; `None` = open-ended (runs to the end of
834
    /// the document).
835
    pub page_count: Option<usize>,
836
    pub content_width: f32,
837
}
838

            
839
/// Capture every table's `<thead>` from the master display list so the
840
/// slicer can repeat it on continuation pages.
841
///
842
/// The REGISTRATION side the tracker always lacked (its
843
/// `register_table_header` had zero production callers, so
844
/// `repeat_table_headers` could never do anything).
845
///
846
/// Detection is structural: `NodeType::THead` nodes and their owning
847
/// `NodeType::Table` ancestor; geometry comes from the display list via
848
/// `node_mapping` (thead items are stored REBASED to thead-local Y, which is
849
/// what the paginate-time `offset_display_item_y(+page offset)` expects).
850
#[must_use]
851
2
pub fn collect_table_headers(
852
2
    display_list: &super::display_list::DisplayList,
853
2
    styled_dom: &azul_core::styled_dom::StyledDom,
854
2
) -> TableHeaderTracker {
855
    use azul_core::dom::{NodeId, NodeType};
856
    use std::collections::BTreeMap;
857

            
858
    // Per table: bounds union; per (table with thead): thead item indices +
859
    // thead bounds union.
860
    struct Acc {
861
        table_top: f32,
862
        table_bottom: f32,
863
        thead_top: f32,
864
        thead_bottom: f32,
865
        thead_items: Vec<usize>,
866
    }
867

            
868
2
    let node_data = styled_dom.node_data.as_container();
869
2
    let hierarchy = styled_dom.node_hierarchy.as_container();
870

            
871
    // Memoized classification: which table / thead subtree (if any) a node
872
    // belongs to.
873
2
    let mut owner_cache: BTreeMap<NodeId, (Option<NodeId>, Option<NodeId>)> = BTreeMap::new();
874
15
    let mut classify = |node: NodeId| -> (Option<NodeId>, Option<NodeId>) {
875
15
        if let Some(hit) = owner_cache.get(&node) {
876
            return *hit;
877
15
        }
878
15
        let mut table = None;
879
15
        let mut thead = None;
880
15
        let mut current = Some(node);
881
45
        while let Some(n) = current {
882
45
            match node_data.get(n).map(azul_core::dom::NodeData::get_node_type) {
883
2
                Some(NodeType::THead) if thead.is_none() => thead = Some(n),
884
                Some(NodeType::Table) => {
885
15
                    table = Some(n);
886
15
                    break; // the NEAREST table owns; don't escape nested tables
887
                }
888
28
                _ => {}
889
            }
890
30
            current = hierarchy.get(n).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
891
        }
892
15
        let result = (table, thead);
893
15
        owner_cache.insert(node, result);
894
15
        result
895
15
    };
896

            
897
2
    let mut per_table: BTreeMap<NodeId, Acc> = BTreeMap::new();
898

            
899
15
    for (idx, item) in display_list.items.iter().enumerate() {
900
15
        let Some(node) = display_list.node_mapping.get(idx).copied().flatten() else {
901
            continue;
902
        };
903
15
        let Some(bounds) = item.bounds() else { continue };
904
15
        let (table, thead) = classify(node);
905
15
        let Some(table) = table else { continue };
906
15
        let acc = per_table.entry(table).or_insert(Acc {
907
15
            table_top: f32::MAX,
908
15
            table_bottom: f32::MIN,
909
15
            thead_top: f32::MAX,
910
15
            thead_bottom: f32::MIN,
911
15
            thead_items: Vec::new(),
912
15
        });
913
15
        let top = bounds.origin.y;
914
15
        let bottom = bounds.origin.y + bounds.size.height;
915
15
        acc.table_top = acc.table_top.min(top);
916
15
        acc.table_bottom = acc.table_bottom.max(bottom);
917
15
        if thead.is_some() {
918
2
            acc.thead_top = acc.thead_top.min(top);
919
2
            acc.thead_bottom = acc.thead_bottom.max(bottom);
920
2
            acc.thead_items.push(idx);
921
13
        }
922
    }
923

            
924
2
    let mut tracker = TableHeaderTracker::default();
925
4
    for (table_node, acc) in per_table {
926
2
        if acc.thead_items.is_empty() || acc.table_bottom <= acc.table_top {
927
            continue;
928
2
        }
929
        // Rebase the thead's items to thead-local Y (paginate re-offsets
930
        // them to each continuation page's top).
931
2
        let items: Vec<super::display_list::DisplayListItem> = acc
932
2
            .thead_items
933
2
            .iter()
934
2
            .map(|&i| {
935
2
                super::display_list::offset_display_item_y(
936
2
                    &display_list.items[i],
937
2
                    -acc.thead_top,
938
                )
939
2
            })
940
2
            .collect();
941
2
        tracker.register_table_header(TableHeaderInfo {
942
2
            table_node_index: table_node.index(),
943
2
            table_start_y: acc.table_top,
944
2
            table_end_y: acc.table_bottom,
945
2
            thead_height: (acc.thead_bottom - acc.thead_top).max(0.0),
946
2
            thead_offset_y: acc.thead_top - acc.table_top,
947
2
            thead_items: items,
948
2
        });
949
    }
950
2
    tracker
951
2
}
952

            
953
/// The vertical ranges of every table ROW (`<tr>`), for
954
/// `BreakPolicy::atomic_table_rows` (a break may not slice a row).
955
#[must_use]
956
2
pub fn collect_table_row_ranges(
957
2
    display_list: &super::display_list::DisplayList,
958
2
    styled_dom: &azul_core::styled_dom::StyledDom,
959
2
) -> Vec<(f32, f32)> {
960
    use azul_core::dom::{NodeId, NodeType};
961
    use std::collections::BTreeMap;
962

            
963
2
    let node_data = styled_dom.node_data.as_container();
964
2
    let hierarchy = styled_dom.node_hierarchy.as_container();
965

            
966
2
    let mut owner_cache: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
967
10
    let mut owning_row = |node: NodeId| -> Option<NodeId> {
968
10
        if let Some(hit) = owner_cache.get(&node) {
969
            return *hit;
970
10
        }
971
10
        let mut row = None;
972
10
        let mut current = Some(node);
973
10
        while let Some(n) = current {
974
10
            match node_data.get(n).map(azul_core::dom::NodeData::get_node_type) {
975
                Some(NodeType::Tr) => {
976
10
                    row = Some(n);
977
10
                    break;
978
                }
979
                Some(NodeType::Table) => break, // rows don't escape their table
980
                _ => {}
981
            }
982
            current = hierarchy.get(n).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
983
        }
984
10
        owner_cache.insert(node, row);
985
10
        row
986
10
    };
987

            
988
2
    let mut per_row: BTreeMap<NodeId, (f32, f32)> = BTreeMap::new();
989
10
    for (idx, item) in display_list.items.iter().enumerate() {
990
10
        let Some(node) = display_list.node_mapping.get(idx).copied().flatten() else {
991
            continue;
992
        };
993
10
        let Some(bounds) = item.bounds() else { continue };
994
10
        let Some(row) = owning_row(node) else { continue };
995
10
        let top = bounds.origin.y;
996
10
        let bottom = bounds.origin.y + bounds.size.height;
997
10
        per_row
998
10
            .entry(row)
999
10
            .and_modify(|(t, b)| {
                *t = t.min(top);
                *b = b.max(bottom);
            })
10
            .or_insert((top, bottom));
    }
10
    per_row.into_values().filter(|(t, b)| b > t).collect()
2
}
#[cfg(test)]
mod autotest_generated {
    use std::sync::atomic::{AtomicUsize, Ordering};
    use super::super::display_list::DisplayListItem;
    use super::*;
    // ------------------------------------------------------------------
    // Independent decoders — used to round-trip `CounterFormat::format`
    // without reusing the encoder's own arithmetic.
    // ------------------------------------------------------------------
    /// Decode a lowercase roman numeral. Uses `i64` so the subtractive
    /// prefix ("iv") cannot underflow the accumulator.
    fn decode_roman(s: &str) -> Option<i64> {
        let mut vals = Vec::new();
        for c in s.chars() {
            vals.push(match c {
                'i' => 1_i64,
                'v' => 5,
                'x' => 10,
                'l' => 50,
                'c' => 100,
                'd' => 500,
                'm' => 1000,
                _ => return None,
            });
        }
        let mut total = 0_i64;
        for i in 0..vals.len() {
            if i + 1 < vals.len() && vals[i] < vals[i + 1] {
                total -= vals[i];
            } else {
                total += vals[i];
            }
        }
        Some(total)
    }
    /// Decode a lowercase bijective base-26 string ("a" == 1, "z" == 26, "aa" == 27).
    fn decode_alpha(s: &str) -> Option<usize> {
        if s.is_empty() {
            return None;
        }
        let mut n = 0_usize;
        for c in s.chars() {
            let digit = match c {
                'a'..='z' => c as usize - 'a' as usize + 1,
                _ => return None,
            };
            n = n.checked_mul(26)?.checked_add(digit)?;
        }
        Some(n)
    }
    /// Decode a lowercase bijective base-24 greek string ("α" == 1, "ω" == 24, "αα" == 25).
    fn decode_greek(s: &str) -> Option<usize> {
        const LOWER: &[char] = &[
            'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ',
            'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
        ];
        if s.is_empty() {
            return None;
        }
        let mut n = 0_usize;
        for c in s.chars() {
            let digit = LOWER.iter().position(|l| *l == c)? + 1;
            n = n.checked_mul(LOWER.len())?.checked_add(digit)?;
        }
        Some(n)
    }
    fn table(start_y: f32, end_y: f32, thead_height: f32) -> TableHeaderInfo {
        TableHeaderInfo {
            table_node_index: 0,
            table_start_y: start_y,
            table_end_y: end_y,
            thead_items: vec![DisplayListItem::PopClip],
            thead_height,
            thead_offset_y: 0.0,
        }
    }
    // ==================================================================
    // CounterFormat::format — serializer: edge values, huge n, round-trip
    // ==================================================================
    #[test]
    fn counter_format_decimal_handles_zero_and_usize_max() {
        assert_eq!(CounterFormat::Decimal.format(0), "0");
        assert_eq!(CounterFormat::Decimal.format(1), "1");
        assert_eq!(
            CounterFormat::Decimal.format(usize::MAX),
            usize::MAX.to_string()
        );
    }
    #[test]
    fn counter_format_default_is_decimal_and_does_not_panic_on_zero() {
        let default = CounterFormat::default();
        assert_eq!(default, CounterFormat::Decimal);
        assert_eq!(default.format(0), "0");
    }
    #[test]
    fn counter_format_leading_zero_pads_to_two_but_never_truncates() {
        assert_eq!(CounterFormat::DecimalLeadingZero.format(0), "00");
        assert_eq!(CounterFormat::DecimalLeadingZero.format(7), "07");
        assert_eq!(CounterFormat::DecimalLeadingZero.format(9), "09");
        assert_eq!(CounterFormat::DecimalLeadingZero.format(10), "10");
        // A width-2 pad must not *clip* wider numbers.
        assert_eq!(CounterFormat::DecimalLeadingZero.format(12345), "12345");
        assert_eq!(
            CounterFormat::DecimalLeadingZero.format(usize::MAX),
            usize::MAX.to_string()
        );
    }
    #[test]
    fn counter_format_every_variant_survives_zero_one_and_usize_max() {
        // The contract we are pinning: no panic, no hang, output is valid UTF-8
        // with all char boundaries intact for every (variant, extreme) pair.
        let variants = [
            CounterFormat::Decimal,
            CounterFormat::DecimalLeadingZero,
            CounterFormat::LowerRoman,
            CounterFormat::UpperRoman,
            CounterFormat::LowerAlpha,
            CounterFormat::UpperAlpha,
            CounterFormat::LowerGreek,
        ];
        for v in variants {
            for n in [0_usize, 1, 25, 26, 27, 3999, 4000, usize::MAX] {
                let s = v.format(n);
                assert!(
                    s.is_char_boundary(0) && s.is_char_boundary(s.len()),
                    "{v:?}.format({n}) produced a malformed string"
                );
            }
        }
    }
    #[test]
    fn counter_format_alphabetic_and_greek_return_empty_at_zero() {
        // KNOWN DIVERGENCE (asserted, not papered over): `format_counter` in
        // `super::counters` applies a CSS decimal fallback when an alphabetic or
        // greek style cannot represent a value, but `CounterFormat::format` does
        // not — so a page counter at 0 renders as a *blank* margin box here while
        // roman renders "0". Page numbers are 1-indexed so this is unreachable in
        // the current pipeline; the test exists to catch it becoming reachable.
        assert_eq!(CounterFormat::LowerAlpha.format(0), "");
        assert_eq!(CounterFormat::UpperAlpha.format(0), "");
        assert_eq!(CounterFormat::LowerGreek.format(0), "");
        assert_eq!(CounterFormat::LowerRoman.format(0), "0");
        assert_eq!(CounterFormat::UpperRoman.format(0), "0");
    }
    #[test]
    fn counter_format_roman_falls_back_to_decimal_past_3999() {
        assert_eq!(CounterFormat::LowerRoman.format(3999), "mmmcmxcix");
        assert_eq!(CounterFormat::UpperRoman.format(3999), "MMMCMXCIX");
        // 4000 is not representable -> decimal, in *both* cases (no stray casing).
        assert_eq!(CounterFormat::LowerRoman.format(4000), "4000");
        assert_eq!(CounterFormat::UpperRoman.format(4000), "4000");
        assert_eq!(
            CounterFormat::UpperRoman.format(usize::MAX),
            usize::MAX.to_string()
        );
    }
    #[test]
    fn counter_format_roman_round_trips_over_its_whole_representable_range() {
        for n in 1..=3999_usize {
            let lower = CounterFormat::LowerRoman.format(n);
            let upper = CounterFormat::UpperRoman.format(n);
            assert_eq!(
                decode_roman(&lower),
                Some(n as i64),
                "lower-roman round-trip failed for {n} -> {lower}"
            );
            assert_eq!(upper, lower.to_uppercase(), "casing mismatch for {n}");
        }
    }
    #[test]
    fn counter_format_alphabetic_round_trips_and_is_injective() {
        let mut seen = std::collections::HashSet::new();
        for n in 1..=3000_usize {
            let lower = CounterFormat::LowerAlpha.format(n);
            let upper = CounterFormat::UpperAlpha.format(n);
            assert_eq!(
                decode_alpha(&lower),
                Some(n),
                "lower-alpha round-trip failed for {n} -> {lower}"
            );
            assert_eq!(upper, lower.to_uppercase(), "casing mismatch for {n}");
            assert!(seen.insert(lower), "two page numbers collided at {n}");
        }
        // Bijective base-26 boundaries — the classic off-by-one zone.
        assert_eq!(CounterFormat::LowerAlpha.format(26), "z");
        assert_eq!(CounterFormat::LowerAlpha.format(27), "aa");
        assert_eq!(CounterFormat::LowerAlpha.format(52), "az");
        assert_eq!(CounterFormat::LowerAlpha.format(53), "ba");
    }
    #[test]
    fn counter_format_greek_round_trips_and_emits_whole_code_points() {
        for n in 1..=2000_usize {
            let s = CounterFormat::LowerGreek.format(n);
            assert_eq!(
                decode_greek(&s),
                Some(n),
                "lower-greek round-trip failed for {n} -> {s}"
            );
            // Every greek letter is 2 bytes: byte len must be exactly 2x char count,
            // i.e. the encoder's `insert(0, ..)` never split a code point.
            assert_eq!(s.len(), s.chars().count() * 2, "sliced a code point at {n}");
        }
        assert_eq!(CounterFormat::LowerGreek.format(24), "ω");
        assert_eq!(CounterFormat::LowerGreek.format(25), "αα");
    }
    #[test]
    fn counter_format_usize_max_terminates_for_the_positional_styles() {
        // `(n - 1) / base` strictly decreases, so these must halt rather than hang.
        let alpha = CounterFormat::LowerAlpha.format(usize::MAX);
        let greek = CounterFormat::LowerGreek.format(usize::MAX);
        assert!(!alpha.is_empty() && alpha.chars().all(|c| c.is_ascii_lowercase()));
        assert!(!greek.is_empty());
        assert_eq!(greek.len(), greek.chars().count() * 2);
    }
    // ==================================================================
    // PageInfo::new — constructor invariants
    // ==================================================================
    #[test]
    fn page_info_new_sets_flags_for_a_representative_page() {
        let info = PageInfo::new(1, 3);
        assert_eq!(info.page_number, 1);
        assert_eq!(info.total_pages, 3);
        assert!(info.is_first);
        assert!(!info.is_last);
        assert!(info.is_right, "page 1 (odd) is a recto page");
        assert!(!info.is_left);
        assert!(!info.is_blank);
    }
    #[test]
    fn page_info_left_and_right_are_always_mutually_exclusive() {
        for n in [0_usize, 1, 2, 3, 100, 101, usize::MAX - 1, usize::MAX] {
            let info = PageInfo::new(n, 0);
            assert!(
                info.is_left != info.is_right,
                "page {n} claimed to be both/neither verso and recto"
            );
            assert_eq!(info.is_left, n % 2 == 0);
            assert!(!info.is_blank, "new() must never fabricate a blank page");
        }
    }
    #[test]
    fn page_info_is_last_is_false_when_the_total_is_unknown() {
        // total_pages == 0 means "unknown during the first pass" — nothing is last.
        for n in [0_usize, 1, 7, usize::MAX] {
            assert!(!PageInfo::new(n, 0).is_last, "page {n} of 0 claimed is_last");
        }
    }
    #[test]
    fn page_info_page_zero_is_degenerate_but_does_not_panic() {
        let info = PageInfo::new(0, 0);
        assert!(!info.is_first, "1-indexed: page 0 is not the first page");
        assert!(!info.is_last);
        assert!(info.is_left, "0 is even, so it lands on the verso branch");
    }
    #[test]
    fn page_info_out_of_range_page_number_does_not_claim_to_be_last() {
        // page_number > total_pages is nonsense input; it must not silently
        // become `is_last` (which would duplicate the last-page decoration).
        let info = PageInfo::new(9, 3);
        assert!(!info.is_last);
        assert!(!info.is_first);
    }
    #[test]
    fn page_info_usize_max_extremes_do_not_overflow() {
        let info = PageInfo::new(usize::MAX, usize::MAX);
        assert!(info.is_last, "the final page of a MAX-page document is last");
        assert!(!info.is_first);
        assert!(info.is_right, "usize::MAX is odd");
        let single = PageInfo::new(1, 1);
        assert!(single.is_first && single.is_last);
    }
    // ==================================================================
    // HeaderFooterConfig — constructors + content generation
    // ==================================================================
    #[test]
    fn header_footer_default_renders_nothing_on_any_page() {
        let cfg = HeaderFooterConfig::default();
        assert!(!cfg.show_header && !cfg.show_footer);
        assert!(matches!(cfg.header_content, MarginBoxContent::None));
        assert!(matches!(cfg.footer_content, MarginBoxContent::None));
        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "");
        assert_eq!(cfg.footer_text(PageInfo::new(usize::MAX, usize::MAX)), "");
        assert_eq!(cfg.text_color.a, 255);
    }
    #[test]
    fn header_footer_with_page_numbers_only_enables_the_footer() {
        let cfg = HeaderFooterConfig::with_page_numbers();
        assert!(cfg.show_footer);
        assert!(!cfg.show_header, "with_page_numbers must not enable a header");
        assert_eq!(cfg.footer_text(PageInfo::new(2, 7)), "Page 2 of 7");
        assert_eq!(cfg.header_text(PageInfo::new(2, 7)), "");
    }
    #[test]
    fn header_footer_unknown_total_renders_a_question_mark_not_a_zero() {
        let cfg = HeaderFooterConfig::with_page_numbers();
        assert_eq!(cfg.footer_text(PageInfo::new(1, 0)), "Page 1 of ?");
    }
    #[test]
    fn header_footer_with_header_and_footer_page_numbers_fills_both() {
        let cfg = HeaderFooterConfig::with_header_and_footer_page_numbers();
        assert!(cfg.show_header && cfg.show_footer);
        let info = PageInfo::new(3, 10);
        assert_eq!(cfg.header_text(info), "Page 3");
        assert_eq!(cfg.footer_text(info), "Page 3 of 10");
        // Extremes must not panic or produce truncated numbers.
        let extreme = PageInfo::new(usize::MAX, usize::MAX);
        assert_eq!(
            cfg.header_text(extreme),
            format!("Page {}", usize::MAX)
        );
    }
    #[test]
    fn header_footer_with_text_enables_the_box_and_preserves_unicode_exactly() {
        let text = "Ünïcödé — 日本語 🎉\u{200b}\u{0}";
        let cfg = HeaderFooterConfig::default()
            .with_header_text(text)
            .with_footer_text(text);
        assert!(cfg.show_header && cfg.show_footer);
        let info = PageInfo::new(1, 1);
        // Byte-for-byte: no normalization, no NUL truncation, no BOM stripping.
        assert_eq!(cfg.header_text(info), text);
        assert_eq!(cfg.footer_text(info), text);
        assert_eq!(cfg.header_text(info).len(), text.len());
    }
    #[test]
    fn header_footer_empty_text_still_switches_the_box_on() {
        // Quirk worth pinning: an empty string is indistinguishable from "no header"
        // in the rendered output, yet it *does* flip `show_header` — so the slicer
        // will still reserve `header_height` for a blank box.
        let cfg = HeaderFooterConfig::default().with_header_text("");
        assert!(cfg.show_header);
        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "");
        assert!(cfg.header_height > 0.0);
    }
    #[test]
    fn header_footer_text_of_huge_length_round_trips_without_truncation() {
        let huge = "x".repeat(200_000);
        let cfg = HeaderFooterConfig::default().with_footer_text(huge.clone());
        assert_eq!(cfg.footer_text(PageInfo::new(1, 1)).len(), huge.len());
    }
    #[test]
    fn header_footer_last_builder_call_wins() {
        let cfg = HeaderFooterConfig::default()
            .with_header_text("first")
            .with_header_text("second");
        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "second");
    }
    #[test]
    fn header_footer_skip_first_page_blanks_only_page_one() {
        let mut cfg = HeaderFooterConfig::with_header_and_footer_page_numbers();
        cfg.skip_first_page = true;
        assert_eq!(cfg.header_text(PageInfo::new(1, 5)), "");
        assert_eq!(cfg.footer_text(PageInfo::new(1, 5)), "");
        assert_eq!(cfg.header_text(PageInfo::new(2, 5)), "Page 2");
        assert_eq!(cfg.footer_text(PageInfo::new(2, 5)), "Page 2 of 5");
        // The gate keys off `is_first`, not off `page_number == 1`: a hand-built
        // PageInfo with is_first forced on is skipped regardless of its number.
        let mut forged = PageInfo::new(4, 5);
        forged.is_first = true;
        assert_eq!(cfg.header_text(forged), "");
    }
    #[test]
    fn header_footer_generate_content_covers_every_margin_box_variant() {
        let cfg = HeaderFooterConfig::default();
        let info = PageInfo::new(4, 9);
        assert_eq!(cfg.generate_content(&MarginBoxContent::None, info), "");
        assert_eq!(
            cfg.generate_content(&MarginBoxContent::Text(String::new()), info),
            ""
        );
        assert_eq!(cfg.generate_content(&MarginBoxContent::PageCounter, info), "4");
        assert_eq!(
            cfg.generate_content(&MarginBoxContent::PagesCounter, info),
            "9"
        );
        assert_eq!(
            cfg.generate_content(
                &MarginBoxContent::PageCounterFormatted {
                    format: CounterFormat::LowerRoman
                },
                info
            ),
            "iv"
        );
        // Not-yet-implemented variants must degrade to a placeholder, not panic.
        assert_eq!(
            cfg.generate_content(&MarginBoxContent::NamedString("chapter".into()), info),
            "[string:chapter]"
        );
        assert_eq!(
            cfg.generate_content(&MarginBoxContent::RunningElement("hdr".into()), info),
            "[element:hdr]"
        );
    }
    #[test]
    fn header_footer_generate_content_of_an_empty_combined_is_empty() {
        let cfg = HeaderFooterConfig::default();
        assert_eq!(
            cfg.generate_content(&MarginBoxContent::Combined(Vec::new()), PageInfo::new(1, 1)),
            ""
        );
    }
    #[test]
    fn header_footer_generate_content_recurses_through_nested_combined() {
        // `Combined` recursion is unbounded in the impl; a deeply nested tree (as could
        // arrive from a future @page parser) must still resolve. Depth is kept modest
        // on purpose — a stack overflow would abort the whole test binary, so this
        // pins "reasonable nesting works" rather than probing for the cliff.
        let cfg = HeaderFooterConfig::default();
        let mut content = MarginBoxContent::PageCounter;
        for _ in 0..128 {
            content = MarginBoxContent::Combined(vec![content]);
        }
        assert_eq!(cfg.generate_content(&content, PageInfo::new(42, 99)), "42");
    }
    #[test]
    fn header_footer_generate_content_calls_a_custom_hook_exactly_once() {
        let calls = Arc::new(AtomicUsize::new(0));
        let seen = Arc::clone(&calls);
        let content = MarginBoxContent::Custom(Arc::new(move |info: PageInfo| {
            seen.fetch_add(1, Ordering::SeqCst);
            format!("{}/{}", info.page_number, info.total_pages)
        }));
        let cfg = HeaderFooterConfig::default();
        assert_eq!(cfg.generate_content(&content, PageInfo::new(2, 5)), "2/5");
        assert_eq!(calls.load(Ordering::SeqCst), 1);
        // Nested inside a Combined, it is still invoked (once per occurrence).
        let combined = MarginBoxContent::Combined(vec![
            MarginBoxContent::Text("[".to_string()),
            content,
            MarginBoxContent::Text("]".to_string()),
        ]);
        assert_eq!(cfg.generate_content(&combined, PageInfo::new(2, 5)), "[2/5]");
        assert_eq!(calls.load(Ordering::SeqCst), 2);
    }
    #[test]
    fn header_footer_hidden_box_short_circuits_before_generating_content() {
        // show_header == false must win even when header_content would panic-free
        // produce text — otherwise a disabled box still costs a callback call.
        let calls = Arc::new(AtomicUsize::new(0));
        let seen = Arc::clone(&calls);
        let mut cfg = HeaderFooterConfig {
            header_content: MarginBoxContent::Custom(Arc::new(move |_| {
                seen.fetch_add(1, Ordering::SeqCst);
                "leaked".to_string()
            })),
            ..Default::default()
        };
        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "");
        assert_eq!(calls.load(Ordering::SeqCst), 0);
    }
    // ==================================================================
    // FakePageConfig — builders, defaults, and the HeaderFooterConfig bridge
    // ==================================================================
    #[test]
    fn fake_page_new_is_inert_and_matches_default() {
        let cfg = FakePageConfig::new();
        assert!(!cfg.show_header && !cfg.show_footer);
        assert!(cfg.header_text.is_none() && cfg.footer_text.is_none());
        assert!(!cfg.header_page_number && !cfg.footer_page_number);
        assert!(!cfg.header_total_pages && !cfg.footer_total_pages);
        assert!(!cfg.skip_first_page);
        assert_eq!(cfg.number_format, CounterFormat::Decimal);
        let hf = cfg.to_header_footer_config();
        assert!(matches!(hf.header_content, MarginBoxContent::None));
        assert!(matches!(hf.footer_content, MarginBoxContent::None));
        assert_eq!(hf.header_text(PageInfo::new(1, 1)), "");
        assert_eq!(hf.footer_text(PageInfo::new(1, 1)), "");
    }
    #[test]
    fn fake_page_footer_page_numbers_render_page_x_of_y() {
        let hf = FakePageConfig::new()
            .with_footer_page_numbers()
            .to_header_footer_config();
        assert!(hf.show_footer && !hf.show_header);
        assert_eq!(hf.footer_text(PageInfo::new(2, 7)), "Page 2 of 7");
        assert_eq!(hf.footer_text(PageInfo::new(2, 0)), "Page 2 of ?");
    }
    #[test]
    fn fake_page_header_page_numbers_omit_the_total() {
        let hf = FakePageConfig::new()
            .with_header_page_numbers()
            .to_header_footer_config();
        assert_eq!(hf.header_text(PageInfo::new(3, 7)), "Page 3");
        assert_eq!(hf.footer_text(PageInfo::new(3, 7)), "");
    }
    #[test]
    fn fake_page_header_and_footer_page_numbers_agree_with_the_pair_of_setters() {
        let both = FakePageConfig::new()
            .with_header_and_footer_page_numbers()
            .to_header_footer_config();
        let info = PageInfo::new(5, 11);
        assert_eq!(both.header_text(info), "Page 5");
        assert_eq!(both.footer_text(info), "Page 5 of 11");
    }
    #[test]
    fn fake_page_text_and_page_number_are_joined_by_a_separator() {
        let mut cfg = FakePageConfig::new()
            .with_header_text("My Document")
            .with_header_page_numbers();
        cfg.header_total_pages = true;
        let hf = cfg.to_header_footer_config();
        assert_eq!(
            hf.header_text(PageInfo::new(4, 9)),
            "My Document - Page 4 of 9"
        );
    }
    #[test]
    fn fake_page_number_format_flows_into_the_rendered_counter() {
        let hf = FakePageConfig::new()
            .with_footer_page_numbers()
            .with_number_format(CounterFormat::LowerRoman)
            .to_header_footer_config();
        // Only the page counter is formatted; the *total* stays decimal — pin that,
        // because a mismatched pair ("Page iv of 9") is easy to regress into.
        assert_eq!(hf.footer_text(PageInfo::new(4, 9)), "Page iv of 9");
        let greek = FakePageConfig::new()
            .with_header_page_numbers()
            .with_number_format(CounterFormat::LowerGreek)
            .to_header_footer_config();
        assert_eq!(greek.header_text(PageInfo::new(2, 9)), "Page β");
    }
    #[test]
    fn fake_page_decimal_format_takes_the_unformatted_counter_branch() {
        let decimal = FakePageConfig::new().with_header_page_numbers();
        match decimal.to_header_footer_config().header_content {
            MarginBoxContent::Combined(parts) => {
                assert_eq!(parts.len(), 2);
                assert!(matches!(parts[1], MarginBoxContent::PageCounter));
            }
            other => panic!("expected Combined, got {other:?}"),
        }
        let roman = FakePageConfig::new()
            .with_header_page_numbers()
            .with_number_format(CounterFormat::UpperRoman);
        match roman.to_header_footer_config().header_content {
            MarginBoxContent::Combined(parts) => {
                assert!(matches!(
                    parts[1],
                    MarginBoxContent::PageCounterFormatted {
                        format: CounterFormat::UpperRoman
                    }
                ));
            }
            other => panic!("expected Combined, got {other:?}"),
        }
    }
    #[test]
    fn fake_page_total_pages_without_page_number_is_silently_dropped() {
        // Adversarial: `footer_total_pages` is only honoured *inside* the
        // `page_number` branch of `build_margin_content`. Setting the total flag
        // alone yields an empty box rather than "of Y" — assert the real behavior
        // so a future fix has to update this test deliberately.
        let mut cfg = FakePageConfig::new();
        cfg.show_footer = true;
        cfg.footer_total_pages = true;
        let hf = cfg.to_header_footer_config();
        assert!(matches!(hf.footer_content, MarginBoxContent::None));
        assert_eq!(hf.footer_text(PageInfo::new(1, 5)), "");
    }
    #[test]
    fn fake_page_single_text_part_is_not_wrapped_in_a_combined() {
        let hf = FakePageConfig::new()
            .with_footer_text("plain")
            .to_header_footer_config();
        assert!(matches!(hf.footer_content, MarginBoxContent::Text(ref s) if s == "plain"));
        assert_eq!(hf.footer_text(PageInfo::new(1, 1)), "plain");
    }
    #[test]
    fn fake_page_empty_text_plus_page_number_leaks_a_leading_separator() {
        // Adversarial edge: an empty custom text is still pushed as a `Text("")`
        // part, so the " - " joiner is emitted with nothing before it.
        let hf = FakePageConfig::new()
            .with_header_text("")
            .with_header_page_numbers()
            .to_header_footer_config();
        assert_eq!(hf.header_text(PageInfo::new(1, 1)), " - Page 1");
    }
    #[test]
    fn fake_page_unicode_text_survives_the_bridge_byte_for_byte() {
        let text = "Ünïcödé — 日本語 🎉";
        let hf = FakePageConfig::new()
            .with_header_text(text)
            .with_footer_text(text)
            .to_header_footer_config();
        let info = PageInfo::new(1, 1);
        assert_eq!(hf.header_text(info), text);
        assert_eq!(hf.footer_text(info), text);
    }
    #[test]
    fn fake_page_huge_text_survives_the_bridge() {
        let huge = "λ".repeat(100_000);
        let hf = FakePageConfig::new()
            .with_footer_text(huge.clone())
            .to_header_footer_config();
        let out = hf.footer_text(PageInfo::new(1, 1));
        assert_eq!(out.len(), huge.len());
        assert_eq!(out.chars().count(), 100_000);
    }
    #[test]
    fn fake_page_skip_first_page_toggles_both_ways() {
        let on = FakePageConfig::new()
            .with_footer_page_numbers()
            .skip_first_page(true)
            .to_header_footer_config();
        assert!(on.skip_first_page);
        assert_eq!(on.footer_text(PageInfo::new(1, 3)), "");
        assert_eq!(on.footer_text(PageInfo::new(2, 3)), "Page 2 of 3");
        let off = FakePageConfig::new()
            .with_footer_page_numbers()
            .skip_first_page(true)
            .skip_first_page(false)
            .to_header_footer_config();
        assert!(!off.skip_first_page);
        assert_eq!(off.footer_text(PageInfo::new(1, 3)), "Page 1 of 3");
    }
    #[test]
    fn fake_page_non_finite_geometry_is_stored_and_forwarded_verbatim() {
        // There is no validation/clamping anywhere on this path: NaN and infinite
        // heights reach the slicer unchanged. Pin it so any future clamp is a
        // deliberate, reviewed change rather than a silent behavior swap.
        let cfg = FakePageConfig::new()
            .with_header_height(f32::NAN)
            .with_footer_height(f32::INFINITY)
            .with_font_size(-0.0);
        assert!(cfg.header_height.is_nan());
        let hf = cfg.to_header_footer_config();
        assert!(hf.header_height.is_nan(), "NaN header height was swallowed");
        assert_eq!(hf.footer_height, f32::INFINITY);
        assert_eq!(hf.font_size, -0.0);
    }
    #[test]
    fn fake_page_extreme_but_finite_geometry_is_preserved_exactly() {
        let hf = FakePageConfig::new()
            .with_header_height(f32::MAX)
            .with_footer_height(f32::MIN)
            .with_font_size(f32::MIN_POSITIVE)
            .to_header_footer_config();
        assert_eq!(hf.header_height, f32::MAX);
        assert_eq!(hf.footer_height, f32::MIN);
        assert_eq!(hf.font_size, f32::MIN_POSITIVE);
        let negative = FakePageConfig::new()
            .with_header_height(-100.0)
            .to_header_footer_config();
        assert_eq!(negative.header_height, -100.0);
    }
    #[test]
    fn fake_page_text_color_crosses_the_bridge_unchanged() {
        let color = ColorU {
            r: 1,
            g: 2,
            b: 3,
            a: 0,
        };
        let hf = FakePageConfig::new()
            .with_text_color(color)
            .to_header_footer_config();
        assert_eq!(hf.text_color, color);
        assert_eq!(hf.text_color.a, 0, "a fully transparent color must survive");
    }
    #[test]
    fn fake_page_to_header_footer_config_is_a_pure_read() {
        // The bridge is a getter: calling it repeatedly must be stable and must not
        // mutate the source config.
        let cfg = FakePageConfig::new()
            .with_header_and_footer_page_numbers()
            .with_number_format(CounterFormat::UpperAlpha)
            .skip_first_page(true);
        let info = PageInfo::new(3, 4);
        let first = cfg.to_header_footer_config();
        let second = cfg.to_header_footer_config();
        assert_eq!(first.header_text(info), second.header_text(info));
        assert_eq!(first.footer_text(info), second.footer_text(info));
        assert_eq!(first.header_text(info), "Page C");
        assert!(cfg.show_header && cfg.show_footer && cfg.skip_first_page);
    }
    #[test]
    fn fake_page_build_margin_content_shapes_match_the_part_count() {
        // Private helper, exercised directly: 0 parts -> None, 1 part -> that part,
        // >1 -> Combined. The `parts.pop().unwrap()` in the 1-part arm is the risk.
        assert!(matches!(
            FakePageConfig::build_margin_content(None, false, false, CounterFormat::Decimal),
            MarginBoxContent::None
        ));
        assert!(matches!(
            FakePageConfig::build_margin_content(None, false, true, CounterFormat::Decimal),
            MarginBoxContent::None
        ));
        assert!(matches!(
            FakePageConfig::build_margin_content(Some("t"), false, false, CounterFormat::Decimal),
            MarginBoxContent::Text(ref s) if s == "t"
        ));
        assert!(matches!(
            FakePageConfig::build_margin_content(Some(""), false, true, CounterFormat::Decimal),
            MarginBoxContent::Text(ref s) if s.is_empty()
        ));
        match FakePageConfig::build_margin_content(
            Some("Doc"),
            true,
            true,
            CounterFormat::LowerRoman,
        ) {
            MarginBoxContent::Combined(parts) => {
                assert_eq!(parts.len(), 6, "text + sep + label + counter + of + total");
                assert!(matches!(parts[1], MarginBoxContent::Text(ref s) if s == " - "));
                assert!(matches!(
                    parts[3],
                    MarginBoxContent::PageCounterFormatted { .. }
                ));
                assert!(matches!(parts[5], MarginBoxContent::PagesCounter));
            }
            other => panic!("expected Combined, got {other:?}"),
        }
    }
    #[test]
    fn fake_page_build_header_and_footer_content_read_their_own_fields() {
        // Guards against the classic copy-paste bug: header building from the
        // footer's flags (or vice versa).
        let mut cfg = FakePageConfig::new();
        cfg.header_text = Some("H".to_string());
        cfg.footer_text = Some("F".to_string());
        cfg.header_page_number = true;
        cfg.footer_page_number = false;
        let hf = HeaderFooterConfig::default();
        let info = PageInfo::new(2, 4);
        assert_eq!(
            hf.generate_content(&cfg.build_header_content(), info),
            "H - Page 2"
        );
        assert_eq!(hf.generate_content(&cfg.build_footer_content(), info), "F");
    }
    // ==================================================================
    // TableHeaderTracker — numeric edges on the page-overlap arithmetic
    // ==================================================================
    #[test]
    fn tracker_new_is_empty_and_matches_default() {
        let tracker = TableHeaderTracker::new();
        assert!(tracker.tables.is_empty());
        assert!(TableHeaderTracker::default().tables.is_empty());
        assert!(tracker
            .get_repeated_headers_for_page(0, 0.0, 0.0)
            .is_empty());
    }
    #[test]
    fn tracker_empty_returns_nothing_for_every_extreme_page_geometry() {
        let tracker = TableHeaderTracker::new();
        for page_index in [0_usize, 1, usize::MAX] {
            for y in [
                0.0_f32,
                -0.0,
                f32::MIN,
                f32::MAX,
                f32::INFINITY,
                f32::NEG_INFINITY,
                f32::NAN,
            ] {
                assert!(
                    tracker.get_repeated_headers_for_page(page_index, y, y).is_empty(),
                    "empty tracker produced a header at page {page_index}, y={y}"
                );
            }
        }
    }
    #[test]
    fn tracker_register_appends_in_order_and_allows_duplicates() {
        let mut tracker = TableHeaderTracker::new();
        for i in 0..1000 {
            let mut info = table(0.0, 100.0, 10.0);
            info.table_node_index = i;
            tracker.register_table_header(info);
        }
        // Same table registered twice must not be deduplicated silently.
        tracker.register_table_header(table(0.0, 100.0, 10.0));
        tracker.register_table_header(table(0.0, 100.0, 10.0));
        assert_eq!(tracker.tables.len(), 1002);
        assert_eq!(tracker.tables[0].table_node_index, 0);
        assert_eq!(tracker.tables[999].table_node_index, 999);
    }
    #[test]
    fn tracker_repeats_only_tables_that_straddle_the_page_top() {
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, 20.0)); // straddles -> repeat
        tracker.register_table_header(table(0.0, 50.0, 20.0)); // ends above -> no
        tracker.register_table_header(table(200.0, 500.0, 20.0)); // starts on page -> no
        let headers = tracker.get_repeated_headers_for_page(1, 100.0, 900.0);
        assert_eq!(headers.len(), 1);
        let (offset, items, height) = headers[0];
        assert_eq!(offset, 0.0, "a repeated thead sits at the page top");
        assert_eq!(height, 20.0);
        assert_eq!(items.len(), 1);
        assert!(matches!(items[0], DisplayListItem::PopClip));
    }
    #[test]
    fn tracker_page_boundary_comparisons_are_strict() {
        let mut tracker = TableHeaderTracker::new();
        // start_y == page_top: the table *begins* on this page -> its own thead is
        // already there, so no repeat.
        tracker.register_table_header(table(100.0, 500.0, 20.0));
        assert!(tracker
            .get_repeated_headers_for_page(1, 100.0, 900.0)
            .is_empty());
        // end_y == page_top: the table finished exactly at the boundary -> no repeat.
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 100.0, 20.0));
        assert!(tracker
            .get_repeated_headers_for_page(1, 100.0, 900.0)
            .is_empty());
        // One representable step past the boundary on both sides -> repeat. (`f32::EPSILON`
        // is useless here: at magnitude 100 it is far below one ulp and would round
        // straight back to 100.0, silently re-testing the equality case above.)
        let below = f32::from_bits(100.0_f32.to_bits() - 1);
        let above = f32::from_bits(100.0_f32.to_bits() + 1);
        assert!(below < 100.0 && above > 100.0, "ulp step collapsed");
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(below, above, 20.0));
        assert_eq!(
            tracker.get_repeated_headers_for_page(1, 100.0, 900.0).len(),
            1
        );
    }
    #[test]
    fn tracker_nan_page_top_yields_no_headers_and_no_panic() {
        // Every f32 comparison against NaN is false, so both guards fail: the
        // defined result is "no repeated headers" rather than a panic or a
        // spurious header at an unrenderable offset.
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, 20.0));
        assert!(tracker
            .get_repeated_headers_for_page(1, f32::NAN, 900.0)
            .is_empty());
        // A NaN *bottom* changes nothing, because the bottom is never read.
        assert_eq!(
            tracker
                .get_repeated_headers_for_page(1, 100.0, f32::NAN)
                .len(),
            1
        );
    }
    #[test]
    fn tracker_nan_table_geometry_yields_no_headers() {
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(f32::NAN, 500.0, 20.0));
        tracker.register_table_header(table(0.0, f32::NAN, 20.0));
        tracker.register_table_header(table(f32::NAN, f32::NAN, 20.0));
        assert!(
            tracker
                .get_repeated_headers_for_page(1, 100.0, 900.0)
                .is_empty(),
            "a NaN-positioned table must not be repeated"
        );
    }
    #[test]
    fn tracker_infinite_page_top_behaves_deterministically() {
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, 20.0));
        // +inf page top: the table starts before it, but nothing can extend past
        // +inf -> no repeat.
        assert!(tracker
            .get_repeated_headers_for_page(1, f32::INFINITY, f32::INFINITY)
            .is_empty());
        // -inf page top: nothing starts before -inf -> no repeat.
        assert!(tracker
            .get_repeated_headers_for_page(1, f32::NEG_INFINITY, 900.0)
            .is_empty());
    }
    #[test]
    fn tracker_infinite_table_extent_repeats_forever_without_overflow() {
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY));
        // A page of positive extent somewhere inside the infinite table.
        let headers = tracker.get_repeated_headers_for_page(usize::MAX, 100.0, 900.0);
        assert_eq!(headers.len(), 1);
        // The thead height is forwarded verbatim — no clamping, no NaN laundering.
        assert!(headers[0].2.is_infinite());
    }
    #[test]
    fn tracker_nan_thead_height_is_forwarded_not_sanitized() {
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, f32::NAN));
        let headers = tracker.get_repeated_headers_for_page(1, 100.0, 900.0);
        assert_eq!(headers.len(), 1);
        assert!(headers[0].2.is_nan(), "height NaN was silently rewritten");
    }
    #[test]
    fn tracker_page_zero_never_repeats_but_continuation_pages_do() {
        // Page 0 carries the ORIGINAL thead — repeating it there would paint
        // it twice. Continuation pages (any index >= 1 whose top the table
        // straddles) repeat it.
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, 20.0));
        assert!(tracker.get_repeated_headers_for_page(0, 100.0, 900.0).is_empty());
        assert_eq!(tracker.get_repeated_headers_for_page(1, 100.0, 900.0).len(), 1);
        assert_eq!(
            tracker
                .get_repeated_headers_for_page(usize::MAX, 100.0, 900.0)
                .len(),
            1
        );
    }
    #[test]
    fn tracker_degenerate_and_inverted_pages_yield_no_headers() {
        // An inverted page (bottom above top) or a zero-extent page has no
        // room for a header — both parameters now gate.
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, 20.0));
        assert!(tracker
            .get_repeated_headers_for_page(1, 100.0, -900.0)
            .is_empty());
        assert!(tracker
            .get_repeated_headers_for_page(1, 100.0, 100.0)
            .is_empty());
    }
    #[test]
    fn tracker_preserves_registration_order_across_many_straddling_tables() {
        let mut tracker = TableHeaderTracker::new();
        for i in 0..64_u32 {
            #[allow(clippy::cast_precision_loss)]
            tracker.register_table_header(table(0.0, 500.0, i as f32));
        }
        let headers = tracker.get_repeated_headers_for_page(3, 100.0, 900.0);
        assert_eq!(headers.len(), 64);
        // Multiple straddling tables STACK: each header's offset is the sum
        // of the previous headers' heights (offset 0 for all painted every
        // header on top of the previous one), in registration order.
        let mut expected_offset = 0.0_f32;
        for (i, (offset, _, height)) in headers.iter().enumerate() {
            assert_eq!(*offset, expected_offset, "header {i} does not stack");
            #[allow(clippy::cast_precision_loss)]
            let expected = i as f32;
            assert_eq!(*height, expected, "headers came back out of order");
            expected_offset += height;
        }
    }
    #[test]
    fn tracker_zero_height_page_returns_nothing() {
        // A degenerate zero-extent page has no room for a header, whether or
        // not a table straddles its top.
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(0.0, 500.0, 20.0));
        assert!(tracker
            .get_repeated_headers_for_page(1, 0.0, 0.0)
            .is_empty());
        let mut tracker = TableHeaderTracker::new();
        tracker.register_table_header(table(-10.0, 500.0, 20.0));
        assert!(tracker
            .get_repeated_headers_for_page(1, 0.0, 0.0)
            .is_empty());
    }
}