oxipdf-ir 0.1.0

Intermediate representation types for the oxipdf PDF engine
Documentation
//! Page template: margins, headers, and footers for paginated output.
//!
//! `PageTemplate` configures page margins and optional header/footer regions.
//! `PageDecorations` holds pre-computed layout data that the renderer consumes
//! to offset body content, render headers/footers, and substitute page numbers.

use crate::layout_result::ComputedLayout;
use crate::tree::StyledTree;
use crate::units::Pt;

/// Page margins in PDF points.
///
/// All values must be non-negative. The sum of top+bottom must be less
/// than the page height, and left+right less than the page width.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageMargins {
    /// Distance from page top edge to content area.
    pub top: Pt,
    /// Distance from page right edge to content area.
    pub right: Pt,
    /// Distance from page bottom edge to content area.
    pub bottom: Pt,
    /// Distance from page left edge to content area.
    pub left: Pt,
}

impl PageMargins {
    /// No margins (all zero). Default value.
    pub const ZERO: Self = Self {
        top: Pt::ZERO,
        right: Pt::ZERO,
        bottom: Pt::ZERO,
        left: Pt::ZERO,
    };

    /// Standard 1-inch (72pt) margins on all sides.
    pub const STANDARD: Self = Self {
        top: Pt::new(72.0),
        right: Pt::new(72.0),
        bottom: Pt::new(72.0),
        left: Pt::new(72.0),
    };

    /// Create uniform margins on all sides.
    #[must_use]
    pub const fn uniform(size: Pt) -> Self {
        Self {
            top: size,
            right: size,
            bottom: size,
            left: size,
        }
    }

    /// Create symmetric margins (same vertical, same horizontal).
    #[must_use]
    pub const fn symmetric(vertical: Pt, horizontal: Pt) -> Self {
        Self {
            top: vertical,
            right: horizontal,
            bottom: vertical,
            left: horizontal,
        }
    }

    /// Total horizontal margins (left + right).
    #[must_use]
    pub fn horizontal(&self) -> Pt {
        self.left + self.right
    }

    /// Total vertical margins (top + bottom).
    #[must_use]
    pub fn vertical(&self) -> Pt {
        self.top + self.bottom
    }

    /// Validate margins against page dimensions.
    ///
    /// Returns `Some(error_message)` if margins are negative or exceed
    /// the available page dimensions.
    pub fn validate(&self, page_width: Pt, page_height: Pt) -> Option<String> {
        if self.top.get() < 0.0
            || self.right.get() < 0.0
            || self.bottom.get() < 0.0
            || self.left.get() < 0.0
        {
            return Some("page margins must be non-negative".into());
        }
        if self.horizontal().get() >= page_width.get() {
            return Some(format!(
                "horizontal margins ({:.1}pt) exceed page width ({:.1}pt)",
                self.horizontal().get(),
                page_width.get()
            ));
        }
        if self.vertical().get() >= page_height.get() {
            return Some(format!(
                "vertical margins ({:.1}pt) exceed page height ({:.1}pt)",
                self.vertical().get(),
                page_height.get()
            ));
        }
        None
    }
}

impl Default for PageMargins {
    fn default() -> Self {
        Self::ZERO
    }
}

/// Controls first-page header/footer behavior.
#[derive(Debug, Clone)]
pub enum FirstPageContent {
    /// Use a different styled tree for the first page.
    Custom(StyledTree),
    /// Suppress the header/footer on the first page entirely.
    Suppress,
}

/// Page template controlling margins, headers, and footers.
///
/// When set on `RenderConfig`, the paginated render functions use the
/// content area height (page height minus margins and header/footer heights)
/// for page splitting, and offset body content by margins.
#[derive(Debug, Clone, Default)]
pub struct PageTemplate {
    /// Page margins.
    pub margins: PageMargins,
    /// Header content rendered below the top margin on each page.
    /// Text nodes may contain `{page}` and `{pages}` placeholders
    /// for page number substitution.
    pub header: Option<StyledTree>,
    /// Footer content rendered above the bottom margin on each page.
    /// Supports the same `{page}` and `{pages}` placeholders.
    pub footer: Option<StyledTree>,
    /// First-page header override. `Some(Custom(...))` uses different
    /// content; `Some(Suppress)` hides the header on page 1.
    /// `None` uses the regular header.
    pub first_page_header: Option<FirstPageContent>,
    /// First-page footer override, same semantics as `first_page_header`.
    pub first_page_footer: Option<FirstPageContent>,
    /// Left-page (even, 1-indexed = verso) header override. Used for
    /// book-style alternating headers (e.g., book title on left pages).
    pub left_page_header: Option<StyledTree>,
    /// Left-page (even/verso) footer override.
    pub left_page_footer: Option<StyledTree>,
    /// Right-page (odd, 1-indexed = recto) header override. Used for
    /// book-style alternating headers (e.g., chapter name on right pages).
    pub right_page_header: Option<StyledTree>,
    /// Right-page (odd/recto) footer override.
    pub right_page_footer: Option<StyledTree>,
}

/// Pre-computed header or footer layout data for the renderer.
#[derive(Debug, Clone)]
pub struct HeaderFooterLayout {
    /// The styled tree for this header/footer region.
    pub tree: StyledTree,
    /// Computed layout positions within the header/footer area.
    pub layout: ComputedLayout,
    /// Total height of the header/footer content.
    pub height: Pt,
}

/// Pre-computed page decoration data consumed by the renderer.
///
/// Created by the facade crate from a `PageTemplate` after computing
/// header/footer layouts. The renderer uses this to offset body content,
/// render headers/footers, and substitute page numbers.
#[derive(Debug, Clone)]
pub struct PageDecorations {
    /// Page margins.
    pub margins: PageMargins,
    /// Y offset from page top to body content area start
    /// (margin_top + header_height).
    pub content_offset_y: Pt,
    /// X offset from page left to body content area start (margin_left).
    pub content_offset_x: Pt,
    /// Header rendered on pages 2+ (or all pages if no first-page override).
    pub header: Option<HeaderFooterLayout>,
    /// Footer rendered on pages 2+ (or all pages if no first-page override).
    pub footer: Option<HeaderFooterLayout>,
    /// First-page header: `Some(Some(..))` = custom content,
    /// `Some(None)` = suppressed, `None` = use regular header.
    pub first_page_header: Option<Option<HeaderFooterLayout>>,
    /// First-page footer: same semantics.
    pub first_page_footer: Option<Option<HeaderFooterLayout>>,
    /// Left-page (odd, 1-indexed) header layout.
    pub left_page_header: Option<HeaderFooterLayout>,
    /// Left-page footer layout.
    pub left_page_footer: Option<HeaderFooterLayout>,
    /// Right-page (even, 1-indexed) header layout.
    pub right_page_header: Option<HeaderFooterLayout>,
    /// Right-page footer layout.
    pub right_page_footer: Option<HeaderFooterLayout>,
    /// Total page count (set after pagination for `{pages}` substitution).
    pub total_pages: u32,
}

impl PageDecorations {
    /// Content area height available for body content per page.
    pub fn content_area_height(&self, page_height: Pt) -> Pt {
        let footer_h = self.footer.as_ref().map_or(Pt::ZERO, |f| f.height);
        page_height - self.content_offset_y - footer_h - self.margins.bottom
    }

    /// Get the header layout to use for a specific page (0-indexed).
    ///
    /// Priority: first-page override → left/right override → regular header.
    pub fn header_for_page(&self, page_idx: usize) -> Option<&HeaderFooterLayout> {
        if page_idx == 0 {
            match &self.first_page_header {
                Some(Some(hf)) => return Some(hf),
                Some(None) => return None,
                None => {} // Fall through to left/right or regular.
            }
        }
        // Left/right page differentiation (1-indexed: page 1 = right/recto).
        let display_page = page_idx + 1;
        if display_page % 2 == 1 {
            // Odd (right/recto) page.
            if let Some(ref hf) = self.right_page_header {
                return Some(hf);
            }
        } else {
            // Even (left/verso) page.
            if let Some(ref hf) = self.left_page_header {
                return Some(hf);
            }
        }
        self.header.as_ref()
    }

    /// Get the footer layout to use for a specific page (0-indexed).
    ///
    /// Priority: first-page override → left/right override → regular footer.
    pub fn footer_for_page(&self, page_idx: usize) -> Option<&HeaderFooterLayout> {
        if page_idx == 0 {
            match &self.first_page_footer {
                Some(Some(hf)) => return Some(hf),
                Some(None) => return None,
                None => {}
            }
        }
        let display_page = page_idx + 1;
        if display_page % 2 == 1 {
            // Odd (right/recto) page.
            if let Some(ref hf) = self.right_page_footer {
                return Some(hf);
            }
        } else {
            // Even (left/verso) page.
            if let Some(ref hf) = self.left_page_footer {
                return Some(hf);
            }
        }
        self.footer.as_ref()
    }

    /// Footer Y position in taffy coordinates (top-left, Y-down).
    pub fn footer_y(&self, page_height: Pt, footer_height: Pt) -> Pt {
        page_height - self.margins.bottom - footer_height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn margins_zero_default() {
        assert_eq!(PageMargins::default(), PageMargins::ZERO);
    }

    #[test]
    fn margins_horizontal_vertical() {
        let m = PageMargins {
            top: Pt::new(10.0),
            right: Pt::new(20.0),
            bottom: Pt::new(30.0),
            left: Pt::new(40.0),
        };
        assert_eq!(m.horizontal().get(), 60.0);
        assert_eq!(m.vertical().get(), 40.0);
    }

    #[test]
    fn margins_uniform() {
        let m = PageMargins::uniform(Pt::new(50.0));
        assert_eq!(m.top, m.right);
        assert_eq!(m.right, m.bottom);
        assert_eq!(m.bottom, m.left);
        assert_eq!(m.top.get(), 50.0);
    }

    #[test]
    fn margins_symmetric() {
        let m = PageMargins::symmetric(Pt::new(72.0), Pt::new(54.0));
        assert_eq!(m.top, m.bottom);
        assert_eq!(m.left, m.right);
        assert_eq!(m.top.get(), 72.0);
        assert_eq!(m.left.get(), 54.0);
    }

    #[test]
    fn margins_standard_is_one_inch() {
        let m = PageMargins::STANDARD;
        assert_eq!(m.top.get(), 72.0);
        assert_eq!(m.right.get(), 72.0);
        assert_eq!(m.bottom.get(), 72.0);
        assert_eq!(m.left.get(), 72.0);
    }

    #[test]
    fn margins_validation_valid() {
        let m = PageMargins::uniform(Pt::new(72.0));
        assert!(m.validate(Pt::new(595.0), Pt::new(842.0)).is_none());
    }

    #[test]
    fn margins_validation_exceeds_width() {
        let m = PageMargins::uniform(Pt::new(300.0));
        let err = m.validate(Pt::new(595.0), Pt::new(842.0));
        assert!(err.is_some());
        assert!(err.unwrap().contains("horizontal"));
    }

    #[test]
    fn margins_validation_negative() {
        let m = PageMargins {
            top: Pt::new(-1.0),
            ..PageMargins::ZERO
        };
        assert!(m.validate(Pt::new(595.0), Pt::new(842.0)).is_some());
    }

    #[test]
    fn page_template_default_has_no_margins() {
        let t = PageTemplate::default();
        assert_eq!(t.margins, PageMargins::ZERO);
        assert!(t.header.is_none());
        assert!(t.footer.is_none());
    }

    #[test]
    fn content_area_height_with_margins() {
        let dec = PageDecorations {
            margins: PageMargins {
                top: Pt::new(72.0),
                right: Pt::new(72.0),
                bottom: Pt::new(72.0),
                left: Pt::new(72.0),
            },
            content_offset_y: Pt::new(72.0),
            content_offset_x: Pt::new(72.0),
            header: None,
            footer: None,
            first_page_header: None,
            first_page_footer: None,
            left_page_header: None,
            left_page_footer: None,
            right_page_header: None,
            right_page_footer: None,
            total_pages: 0,
        };
        let cah = dec.content_area_height(Pt::new(842.0));
        assert!((cah.get() - 698.0).abs() < 0.01);
    }

    #[test]
    fn header_for_page_suppressed_first() {
        let dec = PageDecorations {
            margins: PageMargins::ZERO,
            content_offset_y: Pt::ZERO,
            content_offset_x: Pt::ZERO,
            header: None,
            footer: None,
            first_page_header: Some(None),
            first_page_footer: None,
            left_page_header: None,
            left_page_footer: None,
            right_page_header: None,
            right_page_footer: None,
            total_pages: 3,
        };
        assert!(dec.header_for_page(0).is_none());
        assert!(dec.header_for_page(1).is_none());
    }
}