Skip to main content

oxidize_pdf/text/
header_footer.rs

1//! Header and footer support for PDF pages.
2//!
3//! This module provides functionality for adding headers and footers to PDF pages,
4//! including support for dynamic placeholders like page numbers.
5
6use crate::text::{Font, TextAlign};
7use chrono::Local;
8use std::collections::HashMap;
9
10/// Position for headers and footers on a page.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum HeaderFooterPosition {
13    /// Header at the top of the page
14    Header,
15    /// Footer at the bottom of the page
16    Footer,
17}
18
19/// Configuration options for headers and footers.
20#[derive(Debug, Clone)]
21pub struct HeaderFooterOptions {
22    /// Font to use for the header/footer text
23    pub font: Font,
24    /// Font size in points
25    pub font_size: f64,
26    /// Text alignment
27    pub alignment: TextAlign,
28    /// Vertical offset from page edge in points
29    pub margin: f64,
30    /// Whether to include page numbers
31    pub show_page_numbers: bool,
32    /// Custom date format (if None, uses default)
33    pub date_format: Option<String>,
34}
35
36impl Default for HeaderFooterOptions {
37    fn default() -> Self {
38        Self {
39            font: Font::Helvetica,
40            font_size: 10.0,
41            alignment: TextAlign::Center,
42            margin: 36.0, // 0.5 inch
43            show_page_numbers: true,
44            date_format: None,
45        }
46    }
47}
48
49/// A header or footer that can be added to PDF pages.
50#[derive(Debug, Clone)]
51pub struct HeaderFooter {
52    /// The position of this header/footer
53    position: HeaderFooterPosition,
54    /// The content template with optional placeholders
55    content: String,
56    /// Configuration options
57    options: HeaderFooterOptions,
58}
59
60impl HeaderFooter {
61    /// Creates a new header with the given content.
62    ///
63    /// # Example
64    ///
65    /// ```rust
66    /// use oxidize_pdf::text::{HeaderFooter, HeaderFooterOptions};
67    ///
68    /// let header = HeaderFooter::new_header("Annual Report {{year}}");
69    /// ```
70    pub fn new_header(content: impl Into<String>) -> Self {
71        Self {
72            position: HeaderFooterPosition::Header,
73            content: content.into(),
74            options: HeaderFooterOptions::default(),
75        }
76    }
77
78    /// Creates a new footer with the given content.
79    ///
80    /// # Example
81    ///
82    /// ```rust
83    /// use oxidize_pdf::text::{HeaderFooter, HeaderFooterOptions};
84    ///
85    /// let footer = HeaderFooter::new_footer("Page {{page_number}} of {{total_pages}}");
86    /// ```
87    pub fn new_footer(content: impl Into<String>) -> Self {
88        Self {
89            position: HeaderFooterPosition::Footer,
90            content: content.into(),
91            options: HeaderFooterOptions::default(),
92        }
93    }
94
95    /// Sets the options for this header/footer.
96    pub fn with_options(mut self, options: HeaderFooterOptions) -> Self {
97        self.options = options;
98        self
99    }
100
101    /// Sets the font for this header/footer.
102    pub fn with_font(mut self, font: Font, size: f64) -> Self {
103        self.options.font = font;
104        self.options.font_size = size;
105        self
106    }
107
108    /// Sets the text alignment.
109    pub fn with_alignment(mut self, alignment: TextAlign) -> Self {
110        self.options.alignment = alignment;
111        self
112    }
113
114    /// Sets the margin from the page edge.
115    pub fn with_margin(mut self, margin: f64) -> Self {
116        self.options.margin = margin;
117        self
118    }
119
120    /// Gets the position of this header/footer.
121    pub fn position(&self) -> HeaderFooterPosition {
122        self.position
123    }
124
125    /// Gets the content template.
126    pub fn content(&self) -> &str {
127        &self.content
128    }
129
130    /// Gets the options.
131    pub fn options(&self) -> &HeaderFooterOptions {
132        &self.options
133    }
134
135    /// Renders the header/footer content with placeholder substitution.
136    ///
137    /// Available placeholders:
138    /// - `{{page_number}}` - Current page number
139    /// - `{{total_pages}}` - Total number of pages
140    /// - `{{date}}` - Current date
141    /// - `{{time}}` - Current time
142    /// - `{{datetime}}` - Current date and time
143    /// - `{{year}}` - Current year
144    /// - `{{month}}` - Current month
145    /// - `{{day}}` - Current day
146    pub fn render(
147        &self,
148        page_number: usize,
149        total_pages: usize,
150        custom_values: Option<&HashMap<String, String>>,
151    ) -> String {
152        let mut result = self.content.clone();
153
154        // Replace standard placeholders
155        result = result.replace("{{page_number}}", &page_number.to_string());
156        result = result.replace("{{total_pages}}", &total_pages.to_string());
157
158        // Date/time placeholders
159        let now = Local::now();
160        result = result.replace("{{year}}", &now.format("%Y").to_string());
161        result = result.replace("{{month}}", &now.format("%B").to_string());
162        result = result.replace("{{day}}", &now.format("%d").to_string());
163
164        if let Some(date_format) = &self.options.date_format {
165            result = result.replace("{{date}}", &now.format(date_format).to_string());
166        } else {
167            result = result.replace("{{date}}", &now.format("%Y-%m-%d").to_string());
168        }
169
170        result = result.replace("{{time}}", &now.format("%H:%M:%S").to_string());
171        result = result.replace("{{datetime}}", &now.format("%Y-%m-%d %H:%M:%S").to_string());
172
173        // Replace custom values if provided
174        if let Some(custom) = custom_values {
175            for (key, value) in custom {
176                result = result.replace(&format!("{{{{{key}}}}}"), value);
177            }
178        }
179
180        result
181    }
182
183    /// Calculates the Y position for rendering based on page height and position.
184    pub fn calculate_y_position(&self, page_height: f64) -> f64 {
185        match self.position {
186            HeaderFooterPosition::Header => page_height - self.options.margin,
187            HeaderFooterPosition::Footer => self.options.margin,
188        }
189    }
190
191    /// Calculates the X position for rendering based on page width and alignment.
192    pub fn calculate_x_position(&self, page_width: f64, text_width: f64) -> f64 {
193        match self.options.alignment {
194            TextAlign::Left => self.options.margin,
195            TextAlign::Center => (page_width - text_width) / 2.0,
196            TextAlign::Right => page_width - self.options.margin - text_width,
197            TextAlign::Justified => self.options.margin, // Justified acts as left for single line
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_header_creation() {
208        let header = HeaderFooter::new_header("Test Header");
209        assert_eq!(header.position(), HeaderFooterPosition::Header);
210        assert_eq!(header.content(), "Test Header");
211        assert_eq!(header.options().font_size, 10.0);
212    }
213
214    #[test]
215    fn test_footer_creation() {
216        let footer = HeaderFooter::new_footer("Test Footer");
217        assert_eq!(footer.position(), HeaderFooterPosition::Footer);
218        assert_eq!(footer.content(), "Test Footer");
219    }
220
221    #[test]
222    fn test_with_options() {
223        let options = HeaderFooterOptions {
224            font: Font::TimesRoman,
225            font_size: 12.0,
226            alignment: TextAlign::Right,
227            margin: 20.0,
228            show_page_numbers: false,
229            date_format: Some("%d/%m/%Y".to_string()),
230        };
231
232        let header = HeaderFooter::new_header("Test").with_options(options);
233        assert_eq!(header.options().font_size, 12.0);
234        assert_eq!(header.options().margin, 20.0);
235    }
236
237    #[test]
238    fn test_render_page_numbers() {
239        let footer = HeaderFooter::new_footer("Page {{page_number}} of {{total_pages}}");
240        let rendered = footer.render(3, 10, None);
241        assert_eq!(rendered, "Page 3 of 10");
242    }
243
244    #[test]
245    fn test_render_date_placeholders() {
246        let header = HeaderFooter::new_header("Report {{year}} - {{month}}");
247        let rendered = header.render(1, 1, None);
248
249        // Check that year is 4 digits and month is replaced
250        assert!(rendered.contains("Report 20"));
251        assert!(!rendered.contains("{{year}}"));
252        assert!(!rendered.contains("{{month}}"));
253    }
254
255    #[test]
256    fn test_render_custom_values() {
257        let mut custom = HashMap::new();
258        custom.insert("title".to_string(), "Annual Report".to_string());
259        custom.insert("company".to_string(), "ACME Corp".to_string());
260
261        let header = HeaderFooter::new_header("{{company}} - {{title}}");
262        let rendered = header.render(1, 1, Some(&custom));
263        assert_eq!(rendered, "ACME Corp - Annual Report");
264    }
265
266    #[test]
267    fn test_calculate_positions() {
268        let header = HeaderFooter::new_header("Test").with_margin(50.0);
269        let footer = HeaderFooter::new_footer("Test").with_margin(50.0);
270
271        assert_eq!(header.calculate_y_position(842.0), 792.0); // A4 height - margin
272        assert_eq!(footer.calculate_y_position(842.0), 50.0); // margin
273    }
274
275    #[test]
276    fn test_alignment_positions() {
277        let page_width = 595.0; // A4 width
278        let text_width = 100.0;
279        let margin = 36.0;
280
281        let left = HeaderFooter::new_header("Test")
282            .with_alignment(TextAlign::Left)
283            .with_margin(margin);
284        assert_eq!(left.calculate_x_position(page_width, text_width), margin);
285
286        let center = HeaderFooter::new_header("Test").with_alignment(TextAlign::Center);
287        assert_eq!(
288            center.calculate_x_position(page_width, text_width),
289            (page_width - text_width) / 2.0
290        );
291
292        let right = HeaderFooter::new_header("Test")
293            .with_alignment(TextAlign::Right)
294            .with_margin(margin);
295        assert_eq!(
296            right.calculate_x_position(page_width, text_width),
297            page_width - margin - text_width
298        );
299    }
300
301    #[test]
302    fn test_no_placeholders() {
303        let header = HeaderFooter::new_header("Static Header Text");
304        let rendered = header.render(1, 10, None);
305        assert_eq!(rendered, "Static Header Text");
306    }
307
308    #[test]
309    fn test_multiple_placeholders() {
310        let footer = HeaderFooter::new_footer(
311            "{{date}} | Page {{page_number}} of {{total_pages}} | {{time}}",
312        );
313        let rendered = footer.render(5, 20, None);
314
315        // Check structure is maintained
316        assert!(rendered.contains(" | Page 5 of 20 | "));
317        assert!(!rendered.contains("{{"));
318    }
319}