Skip to main content

oxidize_pdf/
page_tables.rs

1//! Page extension for table rendering
2//!
3//! This module provides traits and implementations to easily add tables to PDF pages.
4
5use crate::document::Document;
6use crate::error::{ensure_finite, PdfError};
7use crate::graphics::Color;
8use crate::page::Page;
9use crate::text::{Font, HeaderStyle, Table, TableOptions};
10
11/// Extension trait for adding tables to pages
12pub trait PageTables {
13    /// Add a simple table to the page
14    fn add_simple_table(&mut self, table: &Table, x: f64, y: f64) -> Result<&mut Self, PdfError>;
15
16    /// Create and add a quick table with equal columns
17    fn add_quick_table(
18        &mut self,
19        data: Vec<Vec<String>>,
20        x: f64,
21        y: f64,
22        width: f64,
23        options: Option<TableOptions>,
24    ) -> Result<&mut Self, PdfError>;
25
26    /// Create and add an advanced table with custom styling
27    fn add_styled_table(
28        &mut self,
29        headers: Vec<String>,
30        data: Vec<Vec<String>>,
31        x: f64,
32        y: f64,
33        width: f64,
34        style: TableStyle,
35    ) -> Result<&mut Self, PdfError>;
36}
37
38/// Predefined table styles
39#[derive(Debug, Clone)]
40pub struct TableStyle {
41    /// Header background color
42    pub header_background: Option<Color>,
43    /// Header text color
44    pub header_text_color: Option<Color>,
45    /// Default font size
46    pub font_size: f64,
47    /// Header font override. `None` keeps the legacy default (`Font::Helvetica`).
48    /// See [issue #217](https://github.com/bzsanti/oxidizePdf/issues/217).
49    pub header_font: Option<Font>,
50    /// Header bold override. `None` keeps the legacy default (`true`).
51    /// Combined with `header_font`, the rendering layer maps non-oblique
52    /// builtin fonts to their `*Bold` variant (e.g. `TimesRoman` + `bold=true`
53    /// → `TimesBold`); oblique fonts and custom fonts are passed through
54    /// unchanged.
55    pub header_bold: Option<bool>,
56}
57
58impl TableStyle {
59    /// Create a minimal table style (no borders)
60    pub fn minimal() -> Self {
61        Self {
62            header_background: None,
63            header_text_color: None,
64            font_size: 10.0,
65            header_font: None,
66            header_bold: None,
67        }
68    }
69
70    /// Create a simple table style with borders
71    pub fn simple() -> Self {
72        Self {
73            header_background: None,
74            header_text_color: None,
75            font_size: 10.0,
76            header_font: None,
77            header_bold: None,
78        }
79    }
80
81    /// Create a professional table style
82    pub fn professional() -> Self {
83        Self {
84            header_background: Some(Color::gray(0.1)),
85            header_text_color: Some(Color::white()),
86            font_size: 10.0,
87            header_font: None,
88            header_bold: None,
89        }
90    }
91
92    /// Create a colorful table style
93    pub fn colorful() -> Self {
94        Self {
95            header_background: Some(Color::rgb(0.2, 0.4, 0.8)),
96            header_text_color: Some(Color::white()),
97            font_size: 10.0,
98            header_font: None,
99            header_bold: None,
100        }
101    }
102
103    /// Override the header font. Chainable on presets.
104    ///
105    /// ```
106    /// use oxidize_pdf::page_tables::TableStyle;
107    /// use oxidize_pdf::text::Font;
108    /// let style = TableStyle::professional().with_header_font(Font::TimesRoman);
109    /// assert_eq!(style.header_font, Some(Font::TimesRoman));
110    /// ```
111    pub fn with_header_font(mut self, font: Font) -> Self {
112        self.header_font = Some(font);
113        self
114    }
115
116    /// Override the header bold flag. Chainable on presets.
117    ///
118    /// ```
119    /// use oxidize_pdf::page_tables::TableStyle;
120    /// let style = TableStyle::simple().with_header_bold(false);
121    /// assert_eq!(style.header_bold, Some(false));
122    /// ```
123    pub fn with_header_bold(mut self, bold: bool) -> Self {
124        self.header_bold = Some(bold);
125        self
126    }
127}
128
129impl PageTables for Page {
130    fn add_simple_table(&mut self, table: &Table, x: f64, y: f64) -> Result<&mut Self, PdfError> {
131        let mut table_clone = table.clone();
132        table_clone.set_position(x, y);
133        table_clone.render(self.graphics())?;
134        Ok(self)
135    }
136
137    fn add_quick_table(
138        &mut self,
139        data: Vec<Vec<String>>,
140        x: f64,
141        y: f64,
142        width: f64,
143        options: Option<TableOptions>,
144    ) -> Result<&mut Self, PdfError> {
145        if data.is_empty() {
146            return Ok(self);
147        }
148
149        let num_columns = data[0].len();
150        let mut table = Table::with_equal_columns(num_columns, width);
151
152        if let Some(opts) = options {
153            table.set_options(opts);
154        }
155
156        for row in data {
157            table.add_row(row)?;
158        }
159
160        self.add_simple_table(&table, x, y)
161    }
162
163    fn add_styled_table(
164        &mut self,
165        headers: Vec<String>,
166        data: Vec<Vec<String>>,
167        x: f64,
168        y: f64,
169        width: f64,
170        style: TableStyle,
171    ) -> Result<&mut Self, PdfError> {
172        let num_columns = headers.len();
173        if num_columns == 0 {
174            return Ok(self);
175        }
176
177        // Create a simple table with the given style
178        let mut table = Table::with_equal_columns(num_columns, width);
179
180        // Create table options based on style.
181        //
182        // The header gate now also fires on `header_font` / `header_bold`
183        // overrides — without this, a caller picking `TableStyle::minimal()`
184        // (where both colour fields are `None`) and overriding only the font
185        // would have their request silently ignored.
186        let header_style = if style.header_background.is_some()
187            || style.header_text_color.is_some()
188            || style.header_font.is_some()
189            || style.header_bold.is_some()
190        {
191            Some(HeaderStyle {
192                background_color: style.header_background.unwrap_or(Color::white()),
193                text_color: style.header_text_color.unwrap_or(Color::black()),
194                font: style.header_font.clone().unwrap_or(Font::Helvetica),
195                bold: style.header_bold.unwrap_or(true),
196            })
197        } else {
198            None
199        };
200
201        let options = TableOptions {
202            font_size: style.font_size,
203            header_style,
204            ..Default::default()
205        };
206
207        table.set_options(options);
208
209        // Add header row — `add_header_row` (not `add_row`) sets
210        // `is_header: true`. Without it the row is treated as data and the
211        // configured `HeaderStyle` is never applied at render time
212        // (see `Table::render`'s `use_header_style = row.is_header && …`
213        // guard). This was a pre-existing bug surfaced while fixing #217.
214        table.add_header_row(headers)?;
215
216        // Add data rows
217        for row_data in data {
218            table.add_row(row_data)?;
219        }
220
221        self.add_simple_table(&table, x, y)
222    }
223}
224
225/// Document-level extension for table rendering with automatic pagination.
226///
227/// Where [`PageTables`] writes a table on a single page, this trait will
228/// allocate continuation pages as needed when the table doesn't fit, and
229/// (by default) repeat header rows on each new page.
230///
231/// See [issue #218](https://github.com/bzsanti/oxidizePdf/issues/218) for the
232/// motivating use case.
233pub trait DocumentTables {
234    /// Render `table` starting at `(x, y)` on the page at `starting_page_index`.
235    /// If the table doesn't fit above `bottom_y`, allocate new pages of the
236    /// same dimensions and continue rendering each remaining slice at
237    /// `(x, next_page_y)`.
238    ///
239    /// All `y` values are absolute coordinates in the page's PDF coordinate
240    /// system (origin at the bottom-left). `bottom_y` is the floor below
241    /// which no row may be drawn; `next_page_y` is the top of the table on
242    /// every continuation page.
243    ///
244    /// When `table.options().repeat_header_on_split` is `true` (the default),
245    /// the leading header rows are repeated at the top of every continuation
246    /// page.
247    ///
248    /// # Returns
249    ///
250    /// `(final_page_index, final_y)` — where the layout cursor ended up after
251    /// the table was fully rendered. Callers can resume layout from there.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`PdfError::TableOverflow`] when a single row is taller than
256    /// the available vertical space on a fresh page (the table cannot make
257    /// progress and would loop forever).
258    fn add_paginated_table(
259        &mut self,
260        starting_page_index: usize,
261        table: &Table,
262        x: f64,
263        y: f64,
264        bottom_y: f64,
265        next_page_y: f64,
266    ) -> Result<(usize, f64), PdfError>;
267}
268
269impl DocumentTables for Document {
270    fn add_paginated_table(
271        &mut self,
272        starting_page_index: usize,
273        table: &Table,
274        x: f64,
275        y: f64,
276        bottom_y: f64,
277        next_page_y: f64,
278    ) -> Result<(usize, f64), PdfError> {
279        // Reject non-finite floats at the API boundary. NaN comparisons silently
280        // return `false`, which would bypass `fit_count`'s overflow guard and
281        // silently render off-page — exactly the failure mode #218 prevents.
282        ensure_finite("x", x)?;
283        ensure_finite("y", y)?;
284        ensure_finite("bottom_y", bottom_y)?;
285        ensure_finite("next_page_y", next_page_y)?;
286
287        let repeat_headers = table.options().repeat_header_on_split;
288
289        let mut current_table = table.clone();
290        current_table.set_position(x, y);
291
292        let mut current_page_idx = starting_page_index;
293
294        loop {
295            // Capture the current page's dims (needed both for the floor check
296            // and for allocating a same-sized continuation page).
297            let (page_width, page_height) = match self.page(current_page_idx) {
298                Some(p) => (p.width(), p.height()),
299                None => {
300                    return Err(PdfError::InvalidStructure(format!(
301                        "page index {current_page_idx} out of bounds (page_count={})",
302                        self.page_count()
303                    )))
304                }
305            };
306
307            // Snapshot the data-row count BEFORE rendering. The progress check
308            // must compare *data* rows, not raw row counts: a header-heavy
309            // table where the page only fits headers would render some rows
310            // but advance zero data rows, then re-prepend headers for the next
311            // page — unbounded memory growth (DoS).
312            let current_data_rows = current_table.row_count() - current_table.header_count();
313
314            let tail = {
315                let page = self.page_mut(current_page_idx).expect("checked above");
316                current_table.render_with_split(page.graphics(), bottom_y)?
317            };
318
319            match tail {
320                None => {
321                    let final_y = current_table.position().1 - current_table.get_height();
322                    return Ok((current_page_idx, final_y));
323                }
324                Some(mut tail) => {
325                    // Forward progress: at least one *data* row must have been
326                    // drawn on this page. Comparing raw `row_count()` is wrong
327                    // because headers prepended on the next iteration inflate
328                    // the count without making progress.
329                    let tail_data_rows = tail.row_count() - tail.header_count();
330                    let data_rows_drawn = current_data_rows.saturating_sub(tail_data_rows);
331                    if data_rows_drawn == 0 {
332                        return Err(PdfError::TableOverflow {
333                            rendered: current_table.row_count() - tail.row_count(),
334                            dropped: tail.row_count(),
335                            bottom_y,
336                        });
337                    }
338
339                    self.add_page(Page::new(page_width, page_height));
340                    current_page_idx = self.page_count() - 1;
341
342                    if repeat_headers {
343                        tail.prepend_headers_from(table);
344                    }
345                    tail.set_position(x, next_page_y);
346                    current_table = tail;
347                }
348            }
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::page::Page;
357
358    // ==================== TableStyle Tests ====================
359
360    #[test]
361    fn test_table_style_minimal() {
362        let style = TableStyle::minimal();
363        assert_eq!(style.header_background, None);
364        assert_eq!(style.header_text_color, None);
365        assert_eq!(style.font_size, 10.0);
366    }
367
368    #[test]
369    fn test_table_style_simple() {
370        let style = TableStyle::simple();
371        assert_eq!(style.header_background, None);
372        assert_eq!(style.header_text_color, None);
373        assert_eq!(style.font_size, 10.0);
374    }
375
376    #[test]
377    fn test_table_style_professional() {
378        let style = TableStyle::professional();
379        assert!(style.header_background.is_some());
380        assert!(style.header_text_color.is_some());
381        assert_eq!(style.font_size, 10.0);
382
383        // Verify dark header background
384        if let Some(bg) = style.header_background {
385            assert!(bg.r() < 0.2, "Professional header should be dark");
386        }
387
388        // Verify white text
389        if let Some(text) = style.header_text_color {
390            assert_eq!(text, Color::white());
391        }
392    }
393
394    #[test]
395    fn test_table_style_colorful() {
396        let style = TableStyle::colorful();
397        assert!(style.header_background.is_some());
398        assert!(style.header_text_color.is_some());
399        assert_eq!(style.font_size, 10.0);
400
401        // Verify blue-ish header background (0.2, 0.4, 0.8)
402        if let Some(bg) = style.header_background {
403            assert!(bg.b() > bg.r(), "Colorful header should be blue-ish");
404            assert!(bg.b() > bg.g(), "Colorful header should be blue-ish");
405        }
406
407        // Verify white text
408        if let Some(text) = style.header_text_color {
409            assert_eq!(text, Color::white());
410        }
411    }
412
413    #[test]
414    fn test_table_style_clone() {
415        let original = TableStyle::professional();
416        let cloned = original.clone();
417
418        assert_eq!(cloned.header_background, original.header_background);
419        assert_eq!(cloned.header_text_color, original.header_text_color);
420        assert_eq!(cloned.font_size, original.font_size);
421    }
422
423    #[test]
424    fn test_table_style_debug() {
425        let style = TableStyle::minimal();
426        let debug_str = format!("{:?}", style);
427        assert!(debug_str.contains("TableStyle"));
428    }
429
430    #[test]
431    fn test_table_style_mutability() {
432        let mut style = TableStyle::minimal();
433
434        style.header_background = Some(Color::red());
435        style.header_text_color = Some(Color::blue());
436        style.font_size = 14.0;
437
438        assert_eq!(style.header_background, Some(Color::red()));
439        assert_eq!(style.header_text_color, Some(Color::blue()));
440        assert_eq!(style.font_size, 14.0);
441    }
442
443    #[test]
444    fn test_table_styles() {
445        let minimal = TableStyle::minimal();
446        assert_eq!(minimal.font_size, 10.0);
447
448        let simple = TableStyle::simple();
449        assert_eq!(simple.font_size, 10.0);
450
451        let professional = TableStyle::professional();
452        assert!(professional.header_background.is_some());
453
454        let colorful = TableStyle::colorful();
455        assert!(colorful.header_background.is_some());
456    }
457
458    // ==================== Page Integration Tests ====================
459
460    #[test]
461    fn test_page_tables_trait() {
462        let mut page = Page::a4();
463
464        // Test quick table
465        let data = vec![
466            vec!["Name".to_string(), "Age".to_string()],
467            vec!["John".to_string(), "30".to_string()],
468        ];
469
470        let result = page.add_quick_table(data, 50.0, 700.0, 400.0, None);
471        assert!(result.is_ok());
472    }
473
474    #[test]
475    fn test_quick_table_with_options() {
476        let mut page = Page::a4();
477
478        let data = vec![
479            vec!["A".to_string(), "B".to_string()],
480            vec!["C".to_string(), "D".to_string()],
481        ];
482
483        let options = TableOptions {
484            font_size: 12.0,
485            ..Default::default()
486        };
487
488        let result = page.add_quick_table(data, 50.0, 700.0, 400.0, Some(options));
489        assert!(result.is_ok());
490    }
491
492    #[test]
493    fn test_styled_table() {
494        let mut page = Page::a4();
495
496        let headers = vec!["Column 1".to_string(), "Column 2".to_string()];
497        let data = vec![
498            vec!["Data 1".to_string(), "Data 2".to_string()],
499            vec!["Data 3".to_string(), "Data 4".to_string()],
500        ];
501
502        let result = page.add_styled_table(
503            headers,
504            data,
505            50.0,
506            700.0,
507            500.0,
508            TableStyle::professional(),
509        );
510
511        assert!(result.is_ok());
512    }
513
514    #[test]
515    fn test_styled_table_minimal() {
516        let mut page = Page::a4();
517
518        let headers = vec!["H1".to_string(), "H2".to_string()];
519        let data = vec![vec!["V1".to_string(), "V2".to_string()]];
520
521        let result =
522            page.add_styled_table(headers, data, 50.0, 700.0, 400.0, TableStyle::minimal());
523        assert!(result.is_ok());
524    }
525
526    #[test]
527    fn test_styled_table_colorful() {
528        let mut page = Page::a4();
529
530        let headers = vec!["Header".to_string()];
531        let data = vec![vec!["Value".to_string()]];
532
533        let result =
534            page.add_styled_table(headers, data, 50.0, 700.0, 300.0, TableStyle::colorful());
535        assert!(result.is_ok());
536    }
537
538    #[test]
539    fn test_styled_table_empty_headers() {
540        let mut page = Page::a4();
541
542        let headers: Vec<String> = vec![];
543        let data = vec![vec!["Data".to_string()]];
544
545        // Empty headers should return Ok (early return)
546        let result = page.add_styled_table(headers, data, 50.0, 700.0, 400.0, TableStyle::simple());
547        assert!(result.is_ok());
548    }
549
550    #[test]
551    fn test_styled_table_empty_data() {
552        let mut page = Page::a4();
553
554        let headers = vec!["H1".to_string(), "H2".to_string()];
555        let data: Vec<Vec<String>> = vec![];
556
557        // Headers only, no data rows
558        let result = page.add_styled_table(
559            headers,
560            data,
561            50.0,
562            700.0,
563            400.0,
564            TableStyle::professional(),
565        );
566        assert!(result.is_ok());
567    }
568
569    #[test]
570    fn test_empty_table() {
571        let mut page = Page::a4();
572
573        let data: Vec<Vec<String>> = vec![];
574        let result = page.add_quick_table(data, 50.0, 700.0, 400.0, None);
575        assert!(result.is_ok());
576    }
577
578    #[test]
579    fn test_single_cell_table() {
580        let mut page = Page::a4();
581
582        let data = vec![vec!["Single".to_string()]];
583        let result = page.add_quick_table(data, 50.0, 700.0, 200.0, None);
584        assert!(result.is_ok());
585    }
586
587    #[test]
588    fn test_single_row_table() {
589        let mut page = Page::a4();
590
591        let data = vec![vec![
592            "A".to_string(),
593            "B".to_string(),
594            "C".to_string(),
595            "D".to_string(),
596        ]];
597        let result = page.add_quick_table(data, 50.0, 700.0, 500.0, None);
598        assert!(result.is_ok());
599    }
600
601    #[test]
602    fn test_single_column_table() {
603        let mut page = Page::a4();
604
605        let data = vec![
606            vec!["Row 1".to_string()],
607            vec!["Row 2".to_string()],
608            vec!["Row 3".to_string()],
609        ];
610        let result = page.add_quick_table(data, 50.0, 700.0, 150.0, None);
611        assert!(result.is_ok());
612    }
613
614    #[test]
615    fn test_many_rows_table() {
616        let mut page = Page::a4();
617
618        let data: Vec<Vec<String>> = (0..50)
619            .map(|i| vec![format!("Row {}", i), format!("Value {}", i)])
620            .collect();
621
622        let result = page.add_quick_table(data, 50.0, 700.0, 400.0, None);
623        assert!(result.is_ok());
624    }
625
626    #[test]
627    fn test_many_columns_table() {
628        let mut page = Page::a4();
629
630        let headers: Vec<String> = (0..10).map(|i| format!("Col {}", i)).collect();
631        let data = vec![(0..10).map(|i| format!("V{}", i)).collect()];
632
633        let result = page.add_styled_table(headers, data, 50.0, 700.0, 550.0, TableStyle::simple());
634        assert!(result.is_ok());
635    }
636
637    #[test]
638    fn test_table_at_different_positions() {
639        let mut page = Page::a4();
640
641        let data = vec![vec!["Test".to_string()]];
642
643        // Top-left
644        let result = page.add_quick_table(data.clone(), 0.0, 800.0, 100.0, None);
645        assert!(result.is_ok());
646
647        // Center-ish
648        let result = page.add_quick_table(data.clone(), 200.0, 400.0, 100.0, None);
649        assert!(result.is_ok());
650
651        // Bottom-right area
652        let result = page.add_quick_table(data, 400.0, 100.0, 100.0, None);
653        assert!(result.is_ok());
654    }
655
656    #[test]
657    fn test_styled_table_with_only_header_background() {
658        let mut page = Page::a4();
659
660        let mut style = TableStyle::minimal();
661        style.header_background = Some(Color::green());
662        // header_text_color remains None
663
664        let headers = vec!["Test".to_string()];
665        let data = vec![vec!["Data".to_string()]];
666
667        let result = page.add_styled_table(headers, data, 50.0, 700.0, 200.0, style);
668        assert!(result.is_ok());
669    }
670
671    #[test]
672    fn test_styled_table_with_only_header_text_color() {
673        let mut page = Page::a4();
674
675        let mut style = TableStyle::minimal();
676        style.header_text_color = Some(Color::red());
677        // header_background remains None
678
679        let headers = vec!["Test".to_string()];
680        let data = vec![vec!["Data".to_string()]];
681
682        let result = page.add_styled_table(headers, data, 50.0, 700.0, 200.0, style);
683        assert!(result.is_ok());
684    }
685
686    #[test]
687    fn test_styled_table_custom_font_size() {
688        let mut page = Page::a4();
689
690        let mut style = TableStyle::professional();
691        style.font_size = 16.0;
692
693        let headers = vec!["Big".to_string(), "Text".to_string()];
694        let data = vec![vec!["Large".to_string(), "Font".to_string()]];
695
696        let result = page.add_styled_table(headers, data, 50.0, 700.0, 300.0, style);
697        assert!(result.is_ok());
698    }
699
700    #[test]
701    fn test_all_styles_integration() {
702        let mut page = Page::a4();
703
704        let headers = vec!["A".to_string(), "B".to_string()];
705        let data = vec![vec!["1".to_string(), "2".to_string()]];
706
707        let styles = vec![
708            TableStyle::minimal(),
709            TableStyle::simple(),
710            TableStyle::professional(),
711            TableStyle::colorful(),
712        ];
713
714        for (i, style) in styles.into_iter().enumerate() {
715            let y = 700.0 - (i as f64 * 100.0);
716            let result =
717                page.add_styled_table(headers.clone(), data.clone(), 50.0, y, 200.0, style);
718            assert!(result.is_ok(), "Failed for style index {}", i);
719        }
720    }
721}