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}