Skip to main content

azul_layout/solver3/
pagination.rs

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
24use std::sync::Arc;
25
26use 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)]
33pub 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
54impl 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)]
75pub enum CounterFormat {
76    Decimal,
77    DecimalLeadingZero,
78    LowerRoman,
79    UpperRoman,
80    LowerAlpha,
81    UpperAlpha,
82    LowerGreek,
83}
84
85impl Default for CounterFormat {
86    fn default() -> Self {
87        Self::Decimal
88    }
89}
90
91impl CounterFormat {
92    /// Format a number according to this counter style.
93    #[must_use] pub fn format(&self, n: usize) -> String {
94        use super::counters::{to_alphabetic, to_greek, to_roman};
95        match self {
96            Self::Decimal => n.to_string(),
97            Self::DecimalLeadingZero => format!("{n:02}"),
98            Self::LowerRoman => to_roman(n, false),
99            Self::UpperRoman => to_roman(n, true),
100            Self::LowerAlpha => to_alphabetic(n, false),
101            Self::UpperAlpha => to_alphabetic(n, true),
102            Self::LowerGreek => to_greek(n, false),
103        }
104    }
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)
110pub 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
127impl PageInfo {
128    /// Create `PageInfo` for a specific page.
129    #[must_use] pub const fn new(page_number: usize, total_pages: usize) -> Self {
130        Self {
131            page_number,
132            total_pages,
133            is_first: page_number == 1,
134            is_last: total_pages > 0 && page_number == total_pages,
135            is_left: page_number.is_multiple_of(2), // Even pages are left (verso)
136            is_right: page_number % 2 == 1, // Odd pages are right (recto)
137            is_blank: false,
138        }
139    }
140}
141
142/// Default height for page headers and footers (in points).
143const DEFAULT_HEADER_FOOTER_HEIGHT: f32 = 30.0;
144
145/// Default font size for header/footer text (in points).
146const 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)]
153pub 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
174impl Default for HeaderFooterConfig {
175    fn default() -> Self {
176        Self {
177            show_header: false,
178            show_footer: false,
179            header_height: DEFAULT_HEADER_FOOTER_HEIGHT,
180            footer_height: DEFAULT_HEADER_FOOTER_HEIGHT,
181            header_content: MarginBoxContent::None,
182            footer_content: MarginBoxContent::None,
183            font_size: DEFAULT_HEADER_FOOTER_FONT_SIZE,
184            text_color: ColorU {
185                r: 0,
186                g: 0,
187                b: 0,
188                a: 255,
189            },
190            skip_first_page: false,
191        }
192    }
193}
194
195impl HeaderFooterConfig {
196    /// Create a config with page numbers in the footer.
197    #[must_use] pub fn with_page_numbers() -> Self {
198        Self {
199            show_footer: true,
200            footer_content: MarginBoxContent::Combined(vec![
201                MarginBoxContent::Text("Page ".to_string()),
202                MarginBoxContent::PageCounter,
203                MarginBoxContent::Text(" of ".to_string()),
204                MarginBoxContent::PagesCounter,
205            ]),
206            ..Default::default()
207        }
208    }
209
210    /// Create a config with page numbers in both header and footer.
211    #[must_use] pub fn with_header_and_footer_page_numbers() -> Self {
212        Self {
213            show_header: true,
214            show_footer: true,
215            header_content: MarginBoxContent::Combined(vec![
216                MarginBoxContent::Text("Page ".to_string()),
217                MarginBoxContent::PageCounter,
218            ]),
219            footer_content: MarginBoxContent::Combined(vec![
220                MarginBoxContent::Text("Page ".to_string()),
221                MarginBoxContent::PageCounter,
222                MarginBoxContent::Text(" of ".to_string()),
223                MarginBoxContent::PagesCounter,
224            ]),
225            ..Default::default()
226        }
227    }
228
229    /// Set custom header text.
230    #[must_use]
231    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
232        self.show_header = true;
233        self.header_content = MarginBoxContent::Text(text.into());
234        self
235    }
236
237    /// Set custom footer text.
238    #[must_use]
239    pub fn with_footer_text(mut self, text: impl Into<String>) -> Self {
240        self.show_footer = true;
241        self.footer_content = MarginBoxContent::Text(text.into());
242        self
243    }
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    #[must_use] pub fn generate_content(&self, content: &MarginBoxContent, info: PageInfo) -> String {
250        match content {
251            MarginBoxContent::None => String::new(),
252            MarginBoxContent::Text(s) => s.clone(),
253            MarginBoxContent::PageCounter => info.page_number.to_string(),
254            MarginBoxContent::PagesCounter => {
255                if info.total_pages > 0 {
256                    info.total_pages.to_string()
257                } else {
258                    "?".to_string()
259                }
260            }
261            MarginBoxContent::PageCounterFormatted { format } => format.format(info.page_number),
262            MarginBoxContent::Combined(parts) => parts
263                .iter()
264                .map(|p| self.generate_content(p, info))
265                .collect(),
266            MarginBoxContent::NamedString(name) => {
267                // TODO: Look up named string from document context
268                format!("[string:{name}]")
269            }
270            MarginBoxContent::RunningElement(name) => {
271                // Running elements are rendered as display items, not text
272                format!("[element:{name}]")
273            }
274            MarginBoxContent::Custom(f) => f(info),
275        }
276    }
277
278    /// Get the header text for a specific page.
279    #[must_use] pub fn header_text(&self, info: PageInfo) -> String {
280        if !self.show_header {
281            return String::new();
282        }
283        if self.skip_first_page && info.is_first {
284            return String::new();
285        }
286        self.generate_content(&self.header_content, info)
287    }
288
289    /// Get the footer text for a specific page.
290    #[must_use] pub fn footer_text(&self, info: PageInfo) -> String {
291        if !self.show_footer {
292            return String::new();
293        }
294        if self.skip_first_page && info.is_first {
295            return String::new();
296        }
297        self.generate_content(&self.footer_content, info)
298    }
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
327pub 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}
357
358impl Default for FakePageConfig {
359    fn default() -> Self {
360        Self {
361            show_header: false,
362            show_footer: false,
363            header_text: None,
364            footer_text: None,
365            header_page_number: false,
366            footer_page_number: false,
367            header_total_pages: false,
368            footer_total_pages: false,
369            number_format: CounterFormat::Decimal,
370            skip_first_page: false,
371            header_height: DEFAULT_HEADER_FOOTER_HEIGHT,
372            footer_height: DEFAULT_HEADER_FOOTER_HEIGHT,
373            font_size: DEFAULT_HEADER_FOOTER_FONT_SIZE,
374            text_color: ColorU {
375                r: 0,
376                g: 0,
377                b: 0,
378                a: 255,
379            },
380        }
381    }
382}
383
384impl FakePageConfig {
385    /// Create a new empty configuration (no headers/footers).
386    #[must_use] pub fn new() -> Self {
387        Self::default()
388    }
389
390    /// Enable footer with "Page X of Y" format.
391    #[must_use] pub const fn with_footer_page_numbers(mut self) -> Self {
392        self.show_footer = true;
393        self.footer_page_number = true;
394        self.footer_total_pages = true;
395        self
396    }
397
398    /// Enable header with "Page X" format.
399    #[must_use] pub const fn with_header_page_numbers(mut self) -> Self {
400        self.show_header = true;
401        self.header_page_number = true;
402        self
403    }
404
405    /// Enable both header and footer with page numbers.
406    #[must_use] pub const fn with_header_and_footer_page_numbers(mut self) -> Self {
407        self.show_header = true;
408        self.show_footer = true;
409        self.header_page_number = true;
410        self.footer_page_number = true;
411        self.footer_total_pages = true;
412        self
413    }
414
415    /// Set custom header text.
416    #[must_use]
417    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
418        self.show_header = true;
419        self.header_text = Some(text.into());
420        self
421    }
422
423    /// Set custom footer text.
424    #[must_use]
425    pub fn with_footer_text(mut self, text: impl Into<String>) -> Self {
426        self.show_footer = true;
427        self.footer_text = Some(text.into());
428        self
429    }
430
431    /// Set the number format for page counters.
432    #[must_use] pub const fn with_number_format(mut self, format: CounterFormat) -> Self {
433        self.number_format = format;
434        self
435    }
436
437    /// Skip header/footer on the first page.
438    #[must_use] pub const fn skip_first_page(mut self, skip: bool) -> Self {
439        self.skip_first_page = skip;
440        self
441    }
442
443    /// Set header height.
444    #[must_use] pub const fn with_header_height(mut self, height: f32) -> Self {
445        self.header_height = height;
446        self
447    }
448
449    /// Set footer height.
450    #[must_use] pub const fn with_footer_height(mut self, height: f32) -> Self {
451        self.footer_height = height;
452        self
453    }
454
455    /// Set font size for header/footer text.
456    #[must_use] pub const fn with_font_size(mut self, size: f32) -> Self {
457        self.font_size = size;
458        self
459    }
460
461    /// Set text color for header/footer.
462    #[must_use] pub const fn with_text_color(mut self, color: ColorU) -> Self {
463        self.text_color = color;
464        self
465    }
466
467    /// Convert this fake config to the internal `HeaderFooterConfig`.
468    ///
469    /// This is the bridge between the user-facing API and the internal
470    /// pagination engine.
471    #[must_use] pub fn to_header_footer_config(&self) -> HeaderFooterConfig {
472        HeaderFooterConfig {
473            show_header: self.show_header,
474            show_footer: self.show_footer,
475            header_height: self.header_height,
476            footer_height: self.footer_height,
477            header_content: self.build_header_content(),
478            footer_content: self.build_footer_content(),
479            skip_first_page: self.skip_first_page,
480            font_size: self.font_size,
481            text_color: self.text_color,
482        }
483    }
484
485    /// Build the `MarginBoxContent` for the header.
486    fn build_header_content(&self) -> MarginBoxContent {
487        Self::build_margin_content(
488            self.header_text.as_deref(),
489            self.header_page_number,
490            self.header_total_pages,
491            self.number_format,
492        )
493    }
494
495    /// Build the `MarginBoxContent` for the footer.
496    fn build_footer_content(&self) -> MarginBoxContent {
497        Self::build_margin_content(
498            self.footer_text.as_deref(),
499            self.footer_page_number,
500            self.footer_total_pages,
501            self.number_format,
502        )
503    }
504
505    /// Shared helper for building header/footer margin box content.
506    fn build_margin_content(
507        text: Option<&str>,
508        page_number: bool,
509        total_pages: bool,
510        number_format: CounterFormat,
511    ) -> MarginBoxContent {
512        let mut parts = Vec::new();
513
514        if let Some(text) = text {
515            parts.push(MarginBoxContent::Text(text.to_string()));
516            if page_number {
517                parts.push(MarginBoxContent::Text(" - ".to_string()));
518            }
519        }
520
521        if page_number {
522            parts.push(MarginBoxContent::Text("Page ".to_string()));
523            if number_format == CounterFormat::Decimal {
524                parts.push(MarginBoxContent::PageCounter);
525            } else {
526                parts.push(MarginBoxContent::PageCounterFormatted {
527                    format: number_format,
528                });
529            }
530
531            if total_pages {
532                parts.push(MarginBoxContent::Text(" of ".to_string()));
533                parts.push(MarginBoxContent::PagesCounter);
534            }
535        }
536
537        if parts.is_empty() {
538            MarginBoxContent::None
539        } else if parts.len() == 1 {
540            parts.pop().unwrap()
541        } else {
542            MarginBoxContent::Combined(parts)
543        }
544    }
545}
546
547/// Information about a table that may need header repetition.
548#[derive(Debug, Clone)]
549pub struct TableHeaderInfo {
550    /// The table's node index in the layout tree
551    pub table_node_index: usize,
552    /// The Y position where the table starts
553    pub table_start_y: f32,
554    /// The Y position where the table ends
555    pub table_end_y: f32,
556    /// The thead's display list items (captured during initial render)
557    pub thead_items: Vec<super::display_list::DisplayListItem>,
558    /// Height of the thead
559    pub thead_height: f32,
560    /// The Y position of the thead relative to table start
561    pub thead_offset_y: f32,
562}
563
564/// Context for tracking table headers across pages.
565#[derive(Debug, Default, Clone)]
566pub struct TableHeaderTracker {
567    /// All tables with theads that might need repetition
568    pub tables: Vec<TableHeaderInfo>,
569}
570
571impl TableHeaderTracker {
572    #[must_use] pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Register a table's thead for potential repetition.
577    pub fn register_table_header(&mut self, info: TableHeaderInfo) {
578        self.tables.push(info);
579    }
580
581    /// Get theads that should be repeated on a specific page.
582    ///
583    /// Returns the thead items that need to be injected at the top of the page,
584    /// along with the Y offset where they should appear.
585    #[must_use] pub fn get_repeated_headers_for_page(
586        &self,
587        page_index: usize,
588        page_top_y: f32,
589        page_bottom_y: f32,
590    ) -> Vec<(f32, &[super::display_list::DisplayListItem], f32)> {
591        let mut headers = Vec::new();
592
593        for table in &self.tables {
594            // Check if this table spans into this page (but didn't start on this page)
595            let table_starts_before_page = table.table_start_y < page_top_y;
596            let table_continues_on_page = table.table_end_y > page_top_y;
597
598            if table_starts_before_page && table_continues_on_page {
599                // This table needs its header repeated on this page
600                // The header should appear at the top of the page content area
601                headers.push((
602                    0.0, // Y offset from page top (header goes at very top)
603                    table.thead_items.as_slice(),
604                    table.thead_height,
605                ));
606            }
607        }
608
609        headers
610    }
611}
612
613#[cfg(test)]
614mod autotest_generated {
615    use std::sync::atomic::{AtomicUsize, Ordering};
616
617    use super::super::display_list::DisplayListItem;
618    use super::*;
619
620    // ------------------------------------------------------------------
621    // Independent decoders — used to round-trip `CounterFormat::format`
622    // without reusing the encoder's own arithmetic.
623    // ------------------------------------------------------------------
624
625    /// Decode a lowercase roman numeral. Uses `i64` so the subtractive
626    /// prefix ("iv") cannot underflow the accumulator.
627    fn decode_roman(s: &str) -> Option<i64> {
628        let mut vals = Vec::new();
629        for c in s.chars() {
630            vals.push(match c {
631                'i' => 1_i64,
632                'v' => 5,
633                'x' => 10,
634                'l' => 50,
635                'c' => 100,
636                'd' => 500,
637                'm' => 1000,
638                _ => return None,
639            });
640        }
641        let mut total = 0_i64;
642        for i in 0..vals.len() {
643            if i + 1 < vals.len() && vals[i] < vals[i + 1] {
644                total -= vals[i];
645            } else {
646                total += vals[i];
647            }
648        }
649        Some(total)
650    }
651
652    /// Decode a lowercase bijective base-26 string ("a" == 1, "z" == 26, "aa" == 27).
653    fn decode_alpha(s: &str) -> Option<usize> {
654        if s.is_empty() {
655            return None;
656        }
657        let mut n = 0_usize;
658        for c in s.chars() {
659            let digit = match c {
660                'a'..='z' => c as usize - 'a' as usize + 1,
661                _ => return None,
662            };
663            n = n.checked_mul(26)?.checked_add(digit)?;
664        }
665        Some(n)
666    }
667
668    /// Decode a lowercase bijective base-24 greek string ("α" == 1, "ω" == 24, "αα" == 25).
669    fn decode_greek(s: &str) -> Option<usize> {
670        const LOWER: &[char] = &[
671            'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ',
672            'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
673        ];
674        if s.is_empty() {
675            return None;
676        }
677        let mut n = 0_usize;
678        for c in s.chars() {
679            let digit = LOWER.iter().position(|l| *l == c)? + 1;
680            n = n.checked_mul(LOWER.len())?.checked_add(digit)?;
681        }
682        Some(n)
683    }
684
685    fn table(start_y: f32, end_y: f32, thead_height: f32) -> TableHeaderInfo {
686        TableHeaderInfo {
687            table_node_index: 0,
688            table_start_y: start_y,
689            table_end_y: end_y,
690            thead_items: vec![DisplayListItem::PopClip],
691            thead_height,
692            thead_offset_y: 0.0,
693        }
694    }
695
696    // ==================================================================
697    // CounterFormat::format — serializer: edge values, huge n, round-trip
698    // ==================================================================
699
700    #[test]
701    fn counter_format_decimal_handles_zero_and_usize_max() {
702        assert_eq!(CounterFormat::Decimal.format(0), "0");
703        assert_eq!(CounterFormat::Decimal.format(1), "1");
704        assert_eq!(
705            CounterFormat::Decimal.format(usize::MAX),
706            usize::MAX.to_string()
707        );
708    }
709
710    #[test]
711    fn counter_format_default_is_decimal_and_does_not_panic_on_zero() {
712        let default = CounterFormat::default();
713        assert_eq!(default, CounterFormat::Decimal);
714        assert_eq!(default.format(0), "0");
715    }
716
717    #[test]
718    fn counter_format_leading_zero_pads_to_two_but_never_truncates() {
719        assert_eq!(CounterFormat::DecimalLeadingZero.format(0), "00");
720        assert_eq!(CounterFormat::DecimalLeadingZero.format(7), "07");
721        assert_eq!(CounterFormat::DecimalLeadingZero.format(9), "09");
722        assert_eq!(CounterFormat::DecimalLeadingZero.format(10), "10");
723        // A width-2 pad must not *clip* wider numbers.
724        assert_eq!(CounterFormat::DecimalLeadingZero.format(12345), "12345");
725        assert_eq!(
726            CounterFormat::DecimalLeadingZero.format(usize::MAX),
727            usize::MAX.to_string()
728        );
729    }
730
731    #[test]
732    fn counter_format_every_variant_survives_zero_one_and_usize_max() {
733        // The contract we are pinning: no panic, no hang, output is valid UTF-8
734        // with all char boundaries intact for every (variant, extreme) pair.
735        let variants = [
736            CounterFormat::Decimal,
737            CounterFormat::DecimalLeadingZero,
738            CounterFormat::LowerRoman,
739            CounterFormat::UpperRoman,
740            CounterFormat::LowerAlpha,
741            CounterFormat::UpperAlpha,
742            CounterFormat::LowerGreek,
743        ];
744        for v in variants {
745            for n in [0_usize, 1, 25, 26, 27, 3999, 4000, usize::MAX] {
746                let s = v.format(n);
747                assert!(
748                    s.is_char_boundary(0) && s.is_char_boundary(s.len()),
749                    "{v:?}.format({n}) produced a malformed string"
750                );
751            }
752        }
753    }
754
755    #[test]
756    fn counter_format_alphabetic_and_greek_return_empty_at_zero() {
757        // KNOWN DIVERGENCE (asserted, not papered over): `format_counter` in
758        // `super::counters` applies a CSS decimal fallback when an alphabetic or
759        // greek style cannot represent a value, but `CounterFormat::format` does
760        // not — so a page counter at 0 renders as a *blank* margin box here while
761        // roman renders "0". Page numbers are 1-indexed so this is unreachable in
762        // the current pipeline; the test exists to catch it becoming reachable.
763        assert_eq!(CounterFormat::LowerAlpha.format(0), "");
764        assert_eq!(CounterFormat::UpperAlpha.format(0), "");
765        assert_eq!(CounterFormat::LowerGreek.format(0), "");
766        assert_eq!(CounterFormat::LowerRoman.format(0), "0");
767        assert_eq!(CounterFormat::UpperRoman.format(0), "0");
768    }
769
770    #[test]
771    fn counter_format_roman_falls_back_to_decimal_past_3999() {
772        assert_eq!(CounterFormat::LowerRoman.format(3999), "mmmcmxcix");
773        assert_eq!(CounterFormat::UpperRoman.format(3999), "MMMCMXCIX");
774        // 4000 is not representable -> decimal, in *both* cases (no stray casing).
775        assert_eq!(CounterFormat::LowerRoman.format(4000), "4000");
776        assert_eq!(CounterFormat::UpperRoman.format(4000), "4000");
777        assert_eq!(
778            CounterFormat::UpperRoman.format(usize::MAX),
779            usize::MAX.to_string()
780        );
781    }
782
783    #[test]
784    fn counter_format_roman_round_trips_over_its_whole_representable_range() {
785        for n in 1..=3999_usize {
786            let lower = CounterFormat::LowerRoman.format(n);
787            let upper = CounterFormat::UpperRoman.format(n);
788            assert_eq!(
789                decode_roman(&lower),
790                Some(n as i64),
791                "lower-roman round-trip failed for {n} -> {lower}"
792            );
793            assert_eq!(upper, lower.to_uppercase(), "casing mismatch for {n}");
794        }
795    }
796
797    #[test]
798    fn counter_format_alphabetic_round_trips_and_is_injective() {
799        let mut seen = std::collections::HashSet::new();
800        for n in 1..=3000_usize {
801            let lower = CounterFormat::LowerAlpha.format(n);
802            let upper = CounterFormat::UpperAlpha.format(n);
803            assert_eq!(
804                decode_alpha(&lower),
805                Some(n),
806                "lower-alpha round-trip failed for {n} -> {lower}"
807            );
808            assert_eq!(upper, lower.to_uppercase(), "casing mismatch for {n}");
809            assert!(seen.insert(lower), "two page numbers collided at {n}");
810        }
811        // Bijective base-26 boundaries — the classic off-by-one zone.
812        assert_eq!(CounterFormat::LowerAlpha.format(26), "z");
813        assert_eq!(CounterFormat::LowerAlpha.format(27), "aa");
814        assert_eq!(CounterFormat::LowerAlpha.format(52), "az");
815        assert_eq!(CounterFormat::LowerAlpha.format(53), "ba");
816    }
817
818    #[test]
819    fn counter_format_greek_round_trips_and_emits_whole_code_points() {
820        for n in 1..=2000_usize {
821            let s = CounterFormat::LowerGreek.format(n);
822            assert_eq!(
823                decode_greek(&s),
824                Some(n),
825                "lower-greek round-trip failed for {n} -> {s}"
826            );
827            // Every greek letter is 2 bytes: byte len must be exactly 2x char count,
828            // i.e. the encoder's `insert(0, ..)` never split a code point.
829            assert_eq!(s.len(), s.chars().count() * 2, "sliced a code point at {n}");
830        }
831        assert_eq!(CounterFormat::LowerGreek.format(24), "ω");
832        assert_eq!(CounterFormat::LowerGreek.format(25), "αα");
833    }
834
835    #[test]
836    fn counter_format_usize_max_terminates_for_the_positional_styles() {
837        // `(n - 1) / base` strictly decreases, so these must halt rather than hang.
838        let alpha = CounterFormat::LowerAlpha.format(usize::MAX);
839        let greek = CounterFormat::LowerGreek.format(usize::MAX);
840        assert!(!alpha.is_empty() && alpha.chars().all(|c| c.is_ascii_lowercase()));
841        assert!(!greek.is_empty());
842        assert_eq!(greek.len(), greek.chars().count() * 2);
843    }
844
845    // ==================================================================
846    // PageInfo::new — constructor invariants
847    // ==================================================================
848
849    #[test]
850    fn page_info_new_sets_flags_for_a_representative_page() {
851        let info = PageInfo::new(1, 3);
852        assert_eq!(info.page_number, 1);
853        assert_eq!(info.total_pages, 3);
854        assert!(info.is_first);
855        assert!(!info.is_last);
856        assert!(info.is_right, "page 1 (odd) is a recto page");
857        assert!(!info.is_left);
858        assert!(!info.is_blank);
859    }
860
861    #[test]
862    fn page_info_left_and_right_are_always_mutually_exclusive() {
863        for n in [0_usize, 1, 2, 3, 100, 101, usize::MAX - 1, usize::MAX] {
864            let info = PageInfo::new(n, 0);
865            assert!(
866                info.is_left != info.is_right,
867                "page {n} claimed to be both/neither verso and recto"
868            );
869            assert_eq!(info.is_left, n % 2 == 0);
870            assert!(!info.is_blank, "new() must never fabricate a blank page");
871        }
872    }
873
874    #[test]
875    fn page_info_is_last_is_false_when_the_total_is_unknown() {
876        // total_pages == 0 means "unknown during the first pass" — nothing is last.
877        for n in [0_usize, 1, 7, usize::MAX] {
878            assert!(!PageInfo::new(n, 0).is_last, "page {n} of 0 claimed is_last");
879        }
880    }
881
882    #[test]
883    fn page_info_page_zero_is_degenerate_but_does_not_panic() {
884        let info = PageInfo::new(0, 0);
885        assert!(!info.is_first, "1-indexed: page 0 is not the first page");
886        assert!(!info.is_last);
887        assert!(info.is_left, "0 is even, so it lands on the verso branch");
888    }
889
890    #[test]
891    fn page_info_out_of_range_page_number_does_not_claim_to_be_last() {
892        // page_number > total_pages is nonsense input; it must not silently
893        // become `is_last` (which would duplicate the last-page decoration).
894        let info = PageInfo::new(9, 3);
895        assert!(!info.is_last);
896        assert!(!info.is_first);
897    }
898
899    #[test]
900    fn page_info_usize_max_extremes_do_not_overflow() {
901        let info = PageInfo::new(usize::MAX, usize::MAX);
902        assert!(info.is_last, "the final page of a MAX-page document is last");
903        assert!(!info.is_first);
904        assert!(info.is_right, "usize::MAX is odd");
905        let single = PageInfo::new(1, 1);
906        assert!(single.is_first && single.is_last);
907    }
908
909    // ==================================================================
910    // HeaderFooterConfig — constructors + content generation
911    // ==================================================================
912
913    #[test]
914    fn header_footer_default_renders_nothing_on_any_page() {
915        let cfg = HeaderFooterConfig::default();
916        assert!(!cfg.show_header && !cfg.show_footer);
917        assert!(matches!(cfg.header_content, MarginBoxContent::None));
918        assert!(matches!(cfg.footer_content, MarginBoxContent::None));
919        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "");
920        assert_eq!(cfg.footer_text(PageInfo::new(usize::MAX, usize::MAX)), "");
921        assert_eq!(cfg.text_color.a, 255);
922    }
923
924    #[test]
925    fn header_footer_with_page_numbers_only_enables_the_footer() {
926        let cfg = HeaderFooterConfig::with_page_numbers();
927        assert!(cfg.show_footer);
928        assert!(!cfg.show_header, "with_page_numbers must not enable a header");
929        assert_eq!(cfg.footer_text(PageInfo::new(2, 7)), "Page 2 of 7");
930        assert_eq!(cfg.header_text(PageInfo::new(2, 7)), "");
931    }
932
933    #[test]
934    fn header_footer_unknown_total_renders_a_question_mark_not_a_zero() {
935        let cfg = HeaderFooterConfig::with_page_numbers();
936        assert_eq!(cfg.footer_text(PageInfo::new(1, 0)), "Page 1 of ?");
937    }
938
939    #[test]
940    fn header_footer_with_header_and_footer_page_numbers_fills_both() {
941        let cfg = HeaderFooterConfig::with_header_and_footer_page_numbers();
942        assert!(cfg.show_header && cfg.show_footer);
943        let info = PageInfo::new(3, 10);
944        assert_eq!(cfg.header_text(info), "Page 3");
945        assert_eq!(cfg.footer_text(info), "Page 3 of 10");
946        // Extremes must not panic or produce truncated numbers.
947        let extreme = PageInfo::new(usize::MAX, usize::MAX);
948        assert_eq!(
949            cfg.header_text(extreme),
950            format!("Page {}", usize::MAX)
951        );
952    }
953
954    #[test]
955    fn header_footer_with_text_enables_the_box_and_preserves_unicode_exactly() {
956        let text = "Ünïcödé — 日本語 🎉\u{200b}\u{0}";
957        let cfg = HeaderFooterConfig::default()
958            .with_header_text(text)
959            .with_footer_text(text);
960        assert!(cfg.show_header && cfg.show_footer);
961        let info = PageInfo::new(1, 1);
962        // Byte-for-byte: no normalization, no NUL truncation, no BOM stripping.
963        assert_eq!(cfg.header_text(info), text);
964        assert_eq!(cfg.footer_text(info), text);
965        assert_eq!(cfg.header_text(info).len(), text.len());
966    }
967
968    #[test]
969    fn header_footer_empty_text_still_switches_the_box_on() {
970        // Quirk worth pinning: an empty string is indistinguishable from "no header"
971        // in the rendered output, yet it *does* flip `show_header` — so the slicer
972        // will still reserve `header_height` for a blank box.
973        let cfg = HeaderFooterConfig::default().with_header_text("");
974        assert!(cfg.show_header);
975        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "");
976        assert!(cfg.header_height > 0.0);
977    }
978
979    #[test]
980    fn header_footer_text_of_huge_length_round_trips_without_truncation() {
981        let huge = "x".repeat(200_000);
982        let cfg = HeaderFooterConfig::default().with_footer_text(huge.clone());
983        assert_eq!(cfg.footer_text(PageInfo::new(1, 1)).len(), huge.len());
984    }
985
986    #[test]
987    fn header_footer_last_builder_call_wins() {
988        let cfg = HeaderFooterConfig::default()
989            .with_header_text("first")
990            .with_header_text("second");
991        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "second");
992    }
993
994    #[test]
995    fn header_footer_skip_first_page_blanks_only_page_one() {
996        let mut cfg = HeaderFooterConfig::with_header_and_footer_page_numbers();
997        cfg.skip_first_page = true;
998        assert_eq!(cfg.header_text(PageInfo::new(1, 5)), "");
999        assert_eq!(cfg.footer_text(PageInfo::new(1, 5)), "");
1000        assert_eq!(cfg.header_text(PageInfo::new(2, 5)), "Page 2");
1001        assert_eq!(cfg.footer_text(PageInfo::new(2, 5)), "Page 2 of 5");
1002        // The gate keys off `is_first`, not off `page_number == 1`: a hand-built
1003        // PageInfo with is_first forced on is skipped regardless of its number.
1004        let mut forged = PageInfo::new(4, 5);
1005        forged.is_first = true;
1006        assert_eq!(cfg.header_text(forged), "");
1007    }
1008
1009    #[test]
1010    fn header_footer_generate_content_covers_every_margin_box_variant() {
1011        let cfg = HeaderFooterConfig::default();
1012        let info = PageInfo::new(4, 9);
1013        assert_eq!(cfg.generate_content(&MarginBoxContent::None, info), "");
1014        assert_eq!(
1015            cfg.generate_content(&MarginBoxContent::Text(String::new()), info),
1016            ""
1017        );
1018        assert_eq!(cfg.generate_content(&MarginBoxContent::PageCounter, info), "4");
1019        assert_eq!(
1020            cfg.generate_content(&MarginBoxContent::PagesCounter, info),
1021            "9"
1022        );
1023        assert_eq!(
1024            cfg.generate_content(
1025                &MarginBoxContent::PageCounterFormatted {
1026                    format: CounterFormat::LowerRoman
1027                },
1028                info
1029            ),
1030            "iv"
1031        );
1032        // Not-yet-implemented variants must degrade to a placeholder, not panic.
1033        assert_eq!(
1034            cfg.generate_content(&MarginBoxContent::NamedString("chapter".into()), info),
1035            "[string:chapter]"
1036        );
1037        assert_eq!(
1038            cfg.generate_content(&MarginBoxContent::RunningElement("hdr".into()), info),
1039            "[element:hdr]"
1040        );
1041    }
1042
1043    #[test]
1044    fn header_footer_generate_content_of_an_empty_combined_is_empty() {
1045        let cfg = HeaderFooterConfig::default();
1046        assert_eq!(
1047            cfg.generate_content(&MarginBoxContent::Combined(Vec::new()), PageInfo::new(1, 1)),
1048            ""
1049        );
1050    }
1051
1052    #[test]
1053    fn header_footer_generate_content_recurses_through_nested_combined() {
1054        // `Combined` recursion is unbounded in the impl; a deeply nested tree (as could
1055        // arrive from a future @page parser) must still resolve. Depth is kept modest
1056        // on purpose — a stack overflow would abort the whole test binary, so this
1057        // pins "reasonable nesting works" rather than probing for the cliff.
1058        let cfg = HeaderFooterConfig::default();
1059        let mut content = MarginBoxContent::PageCounter;
1060        for _ in 0..128 {
1061            content = MarginBoxContent::Combined(vec![content]);
1062        }
1063        assert_eq!(cfg.generate_content(&content, PageInfo::new(42, 99)), "42");
1064    }
1065
1066    #[test]
1067    fn header_footer_generate_content_calls_a_custom_hook_exactly_once() {
1068        let calls = Arc::new(AtomicUsize::new(0));
1069        let seen = Arc::clone(&calls);
1070        let content = MarginBoxContent::Custom(Arc::new(move |info: PageInfo| {
1071            seen.fetch_add(1, Ordering::SeqCst);
1072            format!("{}/{}", info.page_number, info.total_pages)
1073        }));
1074        let cfg = HeaderFooterConfig::default();
1075        assert_eq!(cfg.generate_content(&content, PageInfo::new(2, 5)), "2/5");
1076        assert_eq!(calls.load(Ordering::SeqCst), 1);
1077
1078        // Nested inside a Combined, it is still invoked (once per occurrence).
1079        let combined = MarginBoxContent::Combined(vec![
1080            MarginBoxContent::Text("[".to_string()),
1081            content,
1082            MarginBoxContent::Text("]".to_string()),
1083        ]);
1084        assert_eq!(cfg.generate_content(&combined, PageInfo::new(2, 5)), "[2/5]");
1085        assert_eq!(calls.load(Ordering::SeqCst), 2);
1086    }
1087
1088    #[test]
1089    fn header_footer_hidden_box_short_circuits_before_generating_content() {
1090        // show_header == false must win even when header_content would panic-free
1091        // produce text — otherwise a disabled box still costs a callback call.
1092        let calls = Arc::new(AtomicUsize::new(0));
1093        let seen = Arc::clone(&calls);
1094        let mut cfg = HeaderFooterConfig {
1095            header_content: MarginBoxContent::Custom(Arc::new(move |_| {
1096                seen.fetch_add(1, Ordering::SeqCst);
1097                "leaked".to_string()
1098            })),
1099            ..Default::default()
1100        };
1101        assert_eq!(cfg.header_text(PageInfo::new(1, 1)), "");
1102        assert_eq!(calls.load(Ordering::SeqCst), 0);
1103    }
1104
1105    // ==================================================================
1106    // FakePageConfig — builders, defaults, and the HeaderFooterConfig bridge
1107    // ==================================================================
1108
1109    #[test]
1110    fn fake_page_new_is_inert_and_matches_default() {
1111        let cfg = FakePageConfig::new();
1112        assert!(!cfg.show_header && !cfg.show_footer);
1113        assert!(cfg.header_text.is_none() && cfg.footer_text.is_none());
1114        assert!(!cfg.header_page_number && !cfg.footer_page_number);
1115        assert!(!cfg.header_total_pages && !cfg.footer_total_pages);
1116        assert!(!cfg.skip_first_page);
1117        assert_eq!(cfg.number_format, CounterFormat::Decimal);
1118
1119        let hf = cfg.to_header_footer_config();
1120        assert!(matches!(hf.header_content, MarginBoxContent::None));
1121        assert!(matches!(hf.footer_content, MarginBoxContent::None));
1122        assert_eq!(hf.header_text(PageInfo::new(1, 1)), "");
1123        assert_eq!(hf.footer_text(PageInfo::new(1, 1)), "");
1124    }
1125
1126    #[test]
1127    fn fake_page_footer_page_numbers_render_page_x_of_y() {
1128        let hf = FakePageConfig::new()
1129            .with_footer_page_numbers()
1130            .to_header_footer_config();
1131        assert!(hf.show_footer && !hf.show_header);
1132        assert_eq!(hf.footer_text(PageInfo::new(2, 7)), "Page 2 of 7");
1133        assert_eq!(hf.footer_text(PageInfo::new(2, 0)), "Page 2 of ?");
1134    }
1135
1136    #[test]
1137    fn fake_page_header_page_numbers_omit_the_total() {
1138        let hf = FakePageConfig::new()
1139            .with_header_page_numbers()
1140            .to_header_footer_config();
1141        assert_eq!(hf.header_text(PageInfo::new(3, 7)), "Page 3");
1142        assert_eq!(hf.footer_text(PageInfo::new(3, 7)), "");
1143    }
1144
1145    #[test]
1146    fn fake_page_header_and_footer_page_numbers_agree_with_the_pair_of_setters() {
1147        let both = FakePageConfig::new()
1148            .with_header_and_footer_page_numbers()
1149            .to_header_footer_config();
1150        let info = PageInfo::new(5, 11);
1151        assert_eq!(both.header_text(info), "Page 5");
1152        assert_eq!(both.footer_text(info), "Page 5 of 11");
1153    }
1154
1155    #[test]
1156    fn fake_page_text_and_page_number_are_joined_by_a_separator() {
1157        let mut cfg = FakePageConfig::new()
1158            .with_header_text("My Document")
1159            .with_header_page_numbers();
1160        cfg.header_total_pages = true;
1161        let hf = cfg.to_header_footer_config();
1162        assert_eq!(
1163            hf.header_text(PageInfo::new(4, 9)),
1164            "My Document - Page 4 of 9"
1165        );
1166    }
1167
1168    #[test]
1169    fn fake_page_number_format_flows_into_the_rendered_counter() {
1170        let hf = FakePageConfig::new()
1171            .with_footer_page_numbers()
1172            .with_number_format(CounterFormat::LowerRoman)
1173            .to_header_footer_config();
1174        // Only the page counter is formatted; the *total* stays decimal — pin that,
1175        // because a mismatched pair ("Page iv of 9") is easy to regress into.
1176        assert_eq!(hf.footer_text(PageInfo::new(4, 9)), "Page iv of 9");
1177
1178        let greek = FakePageConfig::new()
1179            .with_header_page_numbers()
1180            .with_number_format(CounterFormat::LowerGreek)
1181            .to_header_footer_config();
1182        assert_eq!(greek.header_text(PageInfo::new(2, 9)), "Page β");
1183    }
1184
1185    #[test]
1186    fn fake_page_decimal_format_takes_the_unformatted_counter_branch() {
1187        let decimal = FakePageConfig::new().with_header_page_numbers();
1188        match decimal.to_header_footer_config().header_content {
1189            MarginBoxContent::Combined(parts) => {
1190                assert_eq!(parts.len(), 2);
1191                assert!(matches!(parts[1], MarginBoxContent::PageCounter));
1192            }
1193            other => panic!("expected Combined, got {other:?}"),
1194        }
1195        let roman = FakePageConfig::new()
1196            .with_header_page_numbers()
1197            .with_number_format(CounterFormat::UpperRoman);
1198        match roman.to_header_footer_config().header_content {
1199            MarginBoxContent::Combined(parts) => {
1200                assert!(matches!(
1201                    parts[1],
1202                    MarginBoxContent::PageCounterFormatted {
1203                        format: CounterFormat::UpperRoman
1204                    }
1205                ));
1206            }
1207            other => panic!("expected Combined, got {other:?}"),
1208        }
1209    }
1210
1211    #[test]
1212    fn fake_page_total_pages_without_page_number_is_silently_dropped() {
1213        // Adversarial: `footer_total_pages` is only honoured *inside* the
1214        // `page_number` branch of `build_margin_content`. Setting the total flag
1215        // alone yields an empty box rather than "of Y" — assert the real behavior
1216        // so a future fix has to update this test deliberately.
1217        let mut cfg = FakePageConfig::new();
1218        cfg.show_footer = true;
1219        cfg.footer_total_pages = true;
1220        let hf = cfg.to_header_footer_config();
1221        assert!(matches!(hf.footer_content, MarginBoxContent::None));
1222        assert_eq!(hf.footer_text(PageInfo::new(1, 5)), "");
1223    }
1224
1225    #[test]
1226    fn fake_page_single_text_part_is_not_wrapped_in_a_combined() {
1227        let hf = FakePageConfig::new()
1228            .with_footer_text("plain")
1229            .to_header_footer_config();
1230        assert!(matches!(hf.footer_content, MarginBoxContent::Text(ref s) if s == "plain"));
1231        assert_eq!(hf.footer_text(PageInfo::new(1, 1)), "plain");
1232    }
1233
1234    #[test]
1235    fn fake_page_empty_text_plus_page_number_leaks_a_leading_separator() {
1236        // Adversarial edge: an empty custom text is still pushed as a `Text("")`
1237        // part, so the " - " joiner is emitted with nothing before it.
1238        let hf = FakePageConfig::new()
1239            .with_header_text("")
1240            .with_header_page_numbers()
1241            .to_header_footer_config();
1242        assert_eq!(hf.header_text(PageInfo::new(1, 1)), " - Page 1");
1243    }
1244
1245    #[test]
1246    fn fake_page_unicode_text_survives_the_bridge_byte_for_byte() {
1247        let text = "Ünïcödé — 日本語 🎉";
1248        let hf = FakePageConfig::new()
1249            .with_header_text(text)
1250            .with_footer_text(text)
1251            .to_header_footer_config();
1252        let info = PageInfo::new(1, 1);
1253        assert_eq!(hf.header_text(info), text);
1254        assert_eq!(hf.footer_text(info), text);
1255    }
1256
1257    #[test]
1258    fn fake_page_huge_text_survives_the_bridge() {
1259        let huge = "λ".repeat(100_000);
1260        let hf = FakePageConfig::new()
1261            .with_footer_text(huge.clone())
1262            .to_header_footer_config();
1263        let out = hf.footer_text(PageInfo::new(1, 1));
1264        assert_eq!(out.len(), huge.len());
1265        assert_eq!(out.chars().count(), 100_000);
1266    }
1267
1268    #[test]
1269    fn fake_page_skip_first_page_toggles_both_ways() {
1270        let on = FakePageConfig::new()
1271            .with_footer_page_numbers()
1272            .skip_first_page(true)
1273            .to_header_footer_config();
1274        assert!(on.skip_first_page);
1275        assert_eq!(on.footer_text(PageInfo::new(1, 3)), "");
1276        assert_eq!(on.footer_text(PageInfo::new(2, 3)), "Page 2 of 3");
1277
1278        let off = FakePageConfig::new()
1279            .with_footer_page_numbers()
1280            .skip_first_page(true)
1281            .skip_first_page(false)
1282            .to_header_footer_config();
1283        assert!(!off.skip_first_page);
1284        assert_eq!(off.footer_text(PageInfo::new(1, 3)), "Page 1 of 3");
1285    }
1286
1287    #[test]
1288    fn fake_page_non_finite_geometry_is_stored_and_forwarded_verbatim() {
1289        // There is no validation/clamping anywhere on this path: NaN and infinite
1290        // heights reach the slicer unchanged. Pin it so any future clamp is a
1291        // deliberate, reviewed change rather than a silent behavior swap.
1292        let cfg = FakePageConfig::new()
1293            .with_header_height(f32::NAN)
1294            .with_footer_height(f32::INFINITY)
1295            .with_font_size(-0.0);
1296        assert!(cfg.header_height.is_nan());
1297        let hf = cfg.to_header_footer_config();
1298        assert!(hf.header_height.is_nan(), "NaN header height was swallowed");
1299        assert_eq!(hf.footer_height, f32::INFINITY);
1300        assert_eq!(hf.font_size, -0.0);
1301    }
1302
1303    #[test]
1304    fn fake_page_extreme_but_finite_geometry_is_preserved_exactly() {
1305        let hf = FakePageConfig::new()
1306            .with_header_height(f32::MAX)
1307            .with_footer_height(f32::MIN)
1308            .with_font_size(f32::MIN_POSITIVE)
1309            .to_header_footer_config();
1310        assert_eq!(hf.header_height, f32::MAX);
1311        assert_eq!(hf.footer_height, f32::MIN);
1312        assert_eq!(hf.font_size, f32::MIN_POSITIVE);
1313
1314        let negative = FakePageConfig::new()
1315            .with_header_height(-100.0)
1316            .to_header_footer_config();
1317        assert_eq!(negative.header_height, -100.0);
1318    }
1319
1320    #[test]
1321    fn fake_page_text_color_crosses_the_bridge_unchanged() {
1322        let color = ColorU {
1323            r: 1,
1324            g: 2,
1325            b: 3,
1326            a: 0,
1327        };
1328        let hf = FakePageConfig::new()
1329            .with_text_color(color)
1330            .to_header_footer_config();
1331        assert_eq!(hf.text_color, color);
1332        assert_eq!(hf.text_color.a, 0, "a fully transparent color must survive");
1333    }
1334
1335    #[test]
1336    fn fake_page_to_header_footer_config_is_a_pure_read() {
1337        // The bridge is a getter: calling it repeatedly must be stable and must not
1338        // mutate the source config.
1339        let cfg = FakePageConfig::new()
1340            .with_header_and_footer_page_numbers()
1341            .with_number_format(CounterFormat::UpperAlpha)
1342            .skip_first_page(true);
1343        let info = PageInfo::new(3, 4);
1344        let first = cfg.to_header_footer_config();
1345        let second = cfg.to_header_footer_config();
1346        assert_eq!(first.header_text(info), second.header_text(info));
1347        assert_eq!(first.footer_text(info), second.footer_text(info));
1348        assert_eq!(first.header_text(info), "Page C");
1349        assert!(cfg.show_header && cfg.show_footer && cfg.skip_first_page);
1350    }
1351
1352    #[test]
1353    fn fake_page_build_margin_content_shapes_match_the_part_count() {
1354        // Private helper, exercised directly: 0 parts -> None, 1 part -> that part,
1355        // >1 -> Combined. The `parts.pop().unwrap()` in the 1-part arm is the risk.
1356        assert!(matches!(
1357            FakePageConfig::build_margin_content(None, false, false, CounterFormat::Decimal),
1358            MarginBoxContent::None
1359        ));
1360        assert!(matches!(
1361            FakePageConfig::build_margin_content(None, false, true, CounterFormat::Decimal),
1362            MarginBoxContent::None
1363        ));
1364        assert!(matches!(
1365            FakePageConfig::build_margin_content(Some("t"), false, false, CounterFormat::Decimal),
1366            MarginBoxContent::Text(ref s) if s == "t"
1367        ));
1368        assert!(matches!(
1369            FakePageConfig::build_margin_content(Some(""), false, true, CounterFormat::Decimal),
1370            MarginBoxContent::Text(ref s) if s.is_empty()
1371        ));
1372        match FakePageConfig::build_margin_content(
1373            Some("Doc"),
1374            true,
1375            true,
1376            CounterFormat::LowerRoman,
1377        ) {
1378            MarginBoxContent::Combined(parts) => {
1379                assert_eq!(parts.len(), 6, "text + sep + label + counter + of + total");
1380                assert!(matches!(parts[1], MarginBoxContent::Text(ref s) if s == " - "));
1381                assert!(matches!(
1382                    parts[3],
1383                    MarginBoxContent::PageCounterFormatted { .. }
1384                ));
1385                assert!(matches!(parts[5], MarginBoxContent::PagesCounter));
1386            }
1387            other => panic!("expected Combined, got {other:?}"),
1388        }
1389    }
1390
1391    #[test]
1392    fn fake_page_build_header_and_footer_content_read_their_own_fields() {
1393        // Guards against the classic copy-paste bug: header building from the
1394        // footer's flags (or vice versa).
1395        let mut cfg = FakePageConfig::new();
1396        cfg.header_text = Some("H".to_string());
1397        cfg.footer_text = Some("F".to_string());
1398        cfg.header_page_number = true;
1399        cfg.footer_page_number = false;
1400        let hf = HeaderFooterConfig::default();
1401        let info = PageInfo::new(2, 4);
1402        assert_eq!(
1403            hf.generate_content(&cfg.build_header_content(), info),
1404            "H - Page 2"
1405        );
1406        assert_eq!(hf.generate_content(&cfg.build_footer_content(), info), "F");
1407    }
1408
1409    // ==================================================================
1410    // TableHeaderTracker — numeric edges on the page-overlap arithmetic
1411    // ==================================================================
1412
1413    #[test]
1414    fn tracker_new_is_empty_and_matches_default() {
1415        let tracker = TableHeaderTracker::new();
1416        assert!(tracker.tables.is_empty());
1417        assert!(TableHeaderTracker::default().tables.is_empty());
1418        assert!(tracker
1419            .get_repeated_headers_for_page(0, 0.0, 0.0)
1420            .is_empty());
1421    }
1422
1423    #[test]
1424    fn tracker_empty_returns_nothing_for_every_extreme_page_geometry() {
1425        let tracker = TableHeaderTracker::new();
1426        for page_index in [0_usize, 1, usize::MAX] {
1427            for y in [
1428                0.0_f32,
1429                -0.0,
1430                f32::MIN,
1431                f32::MAX,
1432                f32::INFINITY,
1433                f32::NEG_INFINITY,
1434                f32::NAN,
1435            ] {
1436                assert!(
1437                    tracker.get_repeated_headers_for_page(page_index, y, y).is_empty(),
1438                    "empty tracker produced a header at page {page_index}, y={y}"
1439                );
1440            }
1441        }
1442    }
1443
1444    #[test]
1445    fn tracker_register_appends_in_order_and_allows_duplicates() {
1446        let mut tracker = TableHeaderTracker::new();
1447        for i in 0..1000 {
1448            let mut info = table(0.0, 100.0, 10.0);
1449            info.table_node_index = i;
1450            tracker.register_table_header(info);
1451        }
1452        // Same table registered twice must not be deduplicated silently.
1453        tracker.register_table_header(table(0.0, 100.0, 10.0));
1454        tracker.register_table_header(table(0.0, 100.0, 10.0));
1455        assert_eq!(tracker.tables.len(), 1002);
1456        assert_eq!(tracker.tables[0].table_node_index, 0);
1457        assert_eq!(tracker.tables[999].table_node_index, 999);
1458    }
1459
1460    #[test]
1461    fn tracker_repeats_only_tables_that_straddle_the_page_top() {
1462        let mut tracker = TableHeaderTracker::new();
1463        tracker.register_table_header(table(0.0, 500.0, 20.0)); // straddles -> repeat
1464        tracker.register_table_header(table(0.0, 50.0, 20.0)); // ends above -> no
1465        tracker.register_table_header(table(200.0, 500.0, 20.0)); // starts on page -> no
1466        let headers = tracker.get_repeated_headers_for_page(1, 100.0, 900.0);
1467        assert_eq!(headers.len(), 1);
1468        let (offset, items, height) = headers[0];
1469        assert_eq!(offset, 0.0, "a repeated thead sits at the page top");
1470        assert_eq!(height, 20.0);
1471        assert_eq!(items.len(), 1);
1472        assert!(matches!(items[0], DisplayListItem::PopClip));
1473    }
1474
1475    #[test]
1476    fn tracker_page_boundary_comparisons_are_strict() {
1477        let mut tracker = TableHeaderTracker::new();
1478        // start_y == page_top: the table *begins* on this page -> its own thead is
1479        // already there, so no repeat.
1480        tracker.register_table_header(table(100.0, 500.0, 20.0));
1481        assert!(tracker
1482            .get_repeated_headers_for_page(1, 100.0, 900.0)
1483            .is_empty());
1484
1485        // end_y == page_top: the table finished exactly at the boundary -> no repeat.
1486        let mut tracker = TableHeaderTracker::new();
1487        tracker.register_table_header(table(0.0, 100.0, 20.0));
1488        assert!(tracker
1489            .get_repeated_headers_for_page(1, 100.0, 900.0)
1490            .is_empty());
1491
1492        // One representable step past the boundary on both sides -> repeat. (`f32::EPSILON`
1493        // is useless here: at magnitude 100 it is far below one ulp and would round
1494        // straight back to 100.0, silently re-testing the equality case above.)
1495        let below = f32::from_bits(100.0_f32.to_bits() - 1);
1496        let above = f32::from_bits(100.0_f32.to_bits() + 1);
1497        assert!(below < 100.0 && above > 100.0, "ulp step collapsed");
1498        let mut tracker = TableHeaderTracker::new();
1499        tracker.register_table_header(table(below, above, 20.0));
1500        assert_eq!(
1501            tracker.get_repeated_headers_for_page(1, 100.0, 900.0).len(),
1502            1
1503        );
1504    }
1505
1506    #[test]
1507    fn tracker_nan_page_top_yields_no_headers_and_no_panic() {
1508        // Every f32 comparison against NaN is false, so both guards fail: the
1509        // defined result is "no repeated headers" rather than a panic or a
1510        // spurious header at an unrenderable offset.
1511        let mut tracker = TableHeaderTracker::new();
1512        tracker.register_table_header(table(0.0, 500.0, 20.0));
1513        assert!(tracker
1514            .get_repeated_headers_for_page(1, f32::NAN, 900.0)
1515            .is_empty());
1516        // A NaN *bottom* changes nothing, because the bottom is never read.
1517        assert_eq!(
1518            tracker
1519                .get_repeated_headers_for_page(1, 100.0, f32::NAN)
1520                .len(),
1521            1
1522        );
1523    }
1524
1525    #[test]
1526    fn tracker_nan_table_geometry_yields_no_headers() {
1527        let mut tracker = TableHeaderTracker::new();
1528        tracker.register_table_header(table(f32::NAN, 500.0, 20.0));
1529        tracker.register_table_header(table(0.0, f32::NAN, 20.0));
1530        tracker.register_table_header(table(f32::NAN, f32::NAN, 20.0));
1531        assert!(
1532            tracker
1533                .get_repeated_headers_for_page(1, 100.0, 900.0)
1534                .is_empty(),
1535            "a NaN-positioned table must not be repeated"
1536        );
1537    }
1538
1539    #[test]
1540    fn tracker_infinite_page_top_behaves_deterministically() {
1541        let mut tracker = TableHeaderTracker::new();
1542        tracker.register_table_header(table(0.0, 500.0, 20.0));
1543        // +inf page top: the table starts before it, but nothing can extend past
1544        // +inf -> no repeat.
1545        assert!(tracker
1546            .get_repeated_headers_for_page(1, f32::INFINITY, f32::INFINITY)
1547            .is_empty());
1548        // -inf page top: nothing starts before -inf -> no repeat.
1549        assert!(tracker
1550            .get_repeated_headers_for_page(1, f32::NEG_INFINITY, 900.0)
1551            .is_empty());
1552    }
1553
1554    #[test]
1555    fn tracker_infinite_table_extent_repeats_forever_without_overflow() {
1556        let mut tracker = TableHeaderTracker::new();
1557        tracker.register_table_header(table(f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY));
1558        let headers = tracker.get_repeated_headers_for_page(usize::MAX, f32::MAX, f32::MAX);
1559        assert_eq!(headers.len(), 1);
1560        // The thead height is forwarded verbatim — no clamping, no NaN laundering.
1561        assert!(headers[0].2.is_infinite());
1562    }
1563
1564    #[test]
1565    fn tracker_nan_thead_height_is_forwarded_not_sanitized() {
1566        let mut tracker = TableHeaderTracker::new();
1567        tracker.register_table_header(table(0.0, 500.0, f32::NAN));
1568        let headers = tracker.get_repeated_headers_for_page(1, 100.0, 900.0);
1569        assert_eq!(headers.len(), 1);
1570        assert!(headers[0].2.is_nan(), "height NaN was silently rewritten");
1571    }
1572
1573    #[test]
1574    fn tracker_page_index_is_ignored_by_the_current_implementation() {
1575        // Documented reality check: `page_index` never enters the arithmetic, so a
1576        // straddling table repeats identically for page 0 and page usize::MAX. If
1577        // per-page logic is ever added, this test must be revisited.
1578        let mut tracker = TableHeaderTracker::new();
1579        tracker.register_table_header(table(0.0, 500.0, 20.0));
1580        let a = tracker.get_repeated_headers_for_page(0, 100.0, 900.0);
1581        let b = tracker.get_repeated_headers_for_page(usize::MAX, 100.0, 900.0);
1582        assert_eq!(a.len(), b.len());
1583        assert_eq!(a.len(), 1);
1584    }
1585
1586    #[test]
1587    fn tracker_page_bottom_is_ignored_even_when_the_page_is_inverted() {
1588        // `page_bottom_y` is unused: an inverted page (bottom above top) still
1589        // yields a header. Pinned so the parameter's dead status is visible.
1590        let mut tracker = TableHeaderTracker::new();
1591        tracker.register_table_header(table(0.0, 500.0, 20.0));
1592        assert_eq!(
1593            tracker.get_repeated_headers_for_page(1, 100.0, -900.0).len(),
1594            1
1595        );
1596        assert_eq!(
1597            tracker.get_repeated_headers_for_page(1, 100.0, 100.0).len(),
1598            1
1599        );
1600    }
1601
1602    #[test]
1603    fn tracker_preserves_registration_order_across_many_straddling_tables() {
1604        let mut tracker = TableHeaderTracker::new();
1605        for i in 0..64_u32 {
1606            #[allow(clippy::cast_precision_loss)]
1607            tracker.register_table_header(table(0.0, 500.0, i as f32));
1608        }
1609        let headers = tracker.get_repeated_headers_for_page(3, 100.0, 900.0);
1610        assert_eq!(headers.len(), 64);
1611        for (i, (offset, _, height)) in headers.iter().enumerate() {
1612            assert_eq!(*offset, 0.0);
1613            #[allow(clippy::cast_precision_loss)]
1614            let expected = i as f32;
1615            assert_eq!(*height, expected, "headers came back out of order");
1616        }
1617    }
1618
1619    #[test]
1620    fn tracker_zero_height_page_returns_nothing() {
1621        // A degenerate zero-extent page (top == bottom == 0) is the "zero" case:
1622        // no table can both start before 0 and end after 0 unless it is negative.
1623        let mut tracker = TableHeaderTracker::new();
1624        tracker.register_table_header(table(0.0, 500.0, 20.0));
1625        assert!(tracker
1626            .get_repeated_headers_for_page(0, 0.0, 0.0)
1627            .is_empty());
1628        // ...but a table with a negative start does straddle y=0.
1629        let mut tracker = TableHeaderTracker::new();
1630        tracker.register_table_header(table(-10.0, 500.0, 20.0));
1631        assert_eq!(tracker.get_repeated_headers_for_page(0, 0.0, 0.0).len(), 1);
1632    }
1633}