Skip to main content

lopdf_table/
lib.rs

1//! A composable table drawing library for PDFs built on lopdf
2//!
3//! This library provides an ergonomic API for creating tables in PDF documents
4//! with support for automatic sizing, custom styling, and flexible layouts.
5
6use lopdf::content::Operation;
7use lopdf::{Document, Object, ObjectId};
8use tracing::{debug, instrument, trace};
9
10mod constants;
11mod drawing;
12mod drawing_utils;
13pub mod error;
14pub mod font;
15pub mod layout;
16pub mod style;
17pub mod table;
18mod text;
19
20// Re-export constants for public use
21pub use constants::*;
22
23pub use error::{Result, TableError};
24pub use font::FontMetrics;
25#[cfg(feature = "ttf-parser")]
26pub use font::TtfFontMetrics;
27pub use style::{
28    Alignment, BorderStyle, CellStyle, Color, RowStyle, TableStyle, VerticalAlignment,
29};
30pub use table::{Cell, CellImage, ColumnWidth, ImageFit, ImageOverlay, Row, Table};
31
32/// Optional hook for injecting tagged content around table cells.
33pub trait TaggedCellHook {
34    fn begin_cell(&mut self, row: usize, col: usize, is_header: bool) -> Vec<Operation>;
35    fn end_cell(&mut self, row: usize, col: usize, is_header: bool) -> Vec<Operation>;
36}
37
38/// Result of drawing a paginated table
39#[derive(Debug, Clone)]
40pub struct PagedTableResult {
41    /// Page IDs where table parts were drawn
42    pub page_ids: Vec<ObjectId>,
43    /// Total number of pages used
44    pub total_pages: usize,
45    /// Final position after drawing (x, y on last page)
46    pub final_position: (f32, f32),
47}
48
49/// Extension trait for lopdf::Document to add table drawing capabilities
50pub trait TableDrawing {
51    /// Draw a table at the specified position on a page
52    ///
53    /// # Arguments
54    /// * `page_id` - The object ID of the page to draw on
55    /// * `table` - The table to draw
56    /// * `position` - The (x, y) position of the table's top-left corner
57    ///
58    /// # Returns
59    /// Returns Ok(()) on success, or an error if the table cannot be drawn
60    fn draw_table(&mut self, page_id: ObjectId, table: Table, position: (f32, f32)) -> Result<()>;
61
62    /// Add a table to a page with automatic positioning
63    ///
64    /// This method will find an appropriate position on the page for the table
65    fn add_table_to_page(&mut self, page_id: ObjectId, table: Table) -> Result<()>;
66
67    /// Create table content operations without adding to document
68    ///
69    /// Useful for custom positioning or combining with other content
70    fn create_table_content(&self, table: &Table, position: (f32, f32)) -> Result<Vec<Object>>;
71
72    /// Draw a table with automatic page wrapping
73    ///
74    /// This method will automatically create new pages as needed when the table
75    /// exceeds the available space on the current page. Header rows will be
76    /// repeated on each new page if configured.
77    ///
78    /// # Arguments
79    /// * `page_id` - The object ID of the starting page
80    /// * `table` - The table to draw
81    /// * `position` - The (x, y) position of the table's top-left corner
82    ///
83    /// # Returns
84    /// Returns a PagedTableResult with information about pages used
85    fn draw_table_with_pagination(
86        &mut self,
87        page_id: ObjectId,
88        table: Table,
89        position: (f32, f32),
90    ) -> Result<PagedTableResult>;
91
92    /// Draw a table with an optional tagged-cell hook.
93    ///
94    /// Existing rendering behavior is unchanged when `hook` is `None`.
95    fn draw_table_with_hook(
96        &mut self,
97        page_id: ObjectId,
98        table: Table,
99        position: (f32, f32),
100        hook: Option<&mut dyn TaggedCellHook>,
101    ) -> Result<()>;
102
103    /// Draw a paginated table with an optional tagged-cell hook.
104    ///
105    /// Existing rendering behavior is unchanged when `hook` is `None`.
106    fn draw_table_with_pagination_and_hook(
107        &mut self,
108        page_id: ObjectId,
109        table: Table,
110        position: (f32, f32),
111        hook: Option<&mut dyn TaggedCellHook>,
112    ) -> Result<PagedTableResult>;
113}
114
115impl TableDrawing for Document {
116    #[instrument(skip(self, table), fields(table_rows = table.rows.len()))]
117    fn draw_table(&mut self, page_id: ObjectId, table: Table, position: (f32, f32)) -> Result<()> {
118        debug!("Drawing table at position {:?}", position);
119
120        let layout = layout::calculate_layout(&table)?;
121        trace!("Calculated layout: {:?}", layout);
122
123        let image_reg = if drawing::table_has_images(&table) {
124            Some(drawing::register_all_images(self, &table))
125        } else {
126            None
127        };
128
129        let operations = drawing::generate_table_operations(
130            &table,
131            &layout,
132            position,
133            None,
134            image_reg.as_ref(),
135        )?;
136
137        if let Some(ref reg) = image_reg {
138            reg.register_on_page(self, page_id)?;
139        }
140
141        drawing::add_operations_to_page(self, page_id, operations)?;
142
143        Ok(())
144    }
145
146    #[instrument(skip(self, table))]
147    fn add_table_to_page(&mut self, page_id: ObjectId, table: Table) -> Result<()> {
148        let position = (DEFAULT_MARGIN, A4_HEIGHT - DEFAULT_MARGIN - 50.0);
149        self.draw_table(page_id, table, position)
150    }
151
152    fn create_table_content(&self, table: &Table, position: (f32, f32)) -> Result<Vec<Object>> {
153        if drawing::table_has_images(table) {
154            return Err(TableError::DrawingError(
155                "Image cells require document-backed drawing (use draw_table or draw_table_with_pagination instead)".to_string(),
156            ));
157        }
158        let layout = layout::calculate_layout(table)?;
159        drawing::generate_table_operations(table, &layout, position, None, None)
160    }
161
162    #[instrument(skip(self, table), fields(table_rows = table.rows.len()))]
163    fn draw_table_with_pagination(
164        &mut self,
165        page_id: ObjectId,
166        table: Table,
167        position: (f32, f32),
168    ) -> Result<PagedTableResult> {
169        debug!("Drawing paginated table at position {:?}", position);
170
171        let layout = layout::calculate_layout(&table)?;
172        trace!("Calculated layout: {:?}", layout);
173
174        let image_reg = if drawing::table_has_images(&table) {
175            let reg = drawing::register_all_images(self, &table);
176            reg.register_on_page(self, page_id)?;
177            Some(reg)
178        } else {
179            None
180        };
181
182        let result = drawing::draw_table_paginated(
183            self,
184            page_id,
185            &table,
186            &layout,
187            position,
188            None,
189            image_reg.as_ref(),
190        )?;
191
192        Ok(result)
193    }
194
195    fn draw_table_with_hook(
196        &mut self,
197        page_id: ObjectId,
198        table: Table,
199        position: (f32, f32),
200        hook: Option<&mut dyn TaggedCellHook>,
201    ) -> Result<()> {
202        debug!("Drawing table with hook at position {:?}", position);
203        let layout = layout::calculate_layout(&table)?;
204
205        let image_reg = if drawing::table_has_images(&table) {
206            Some(drawing::register_all_images(self, &table))
207        } else {
208            None
209        };
210
211        let operations = drawing::generate_table_operations(
212            &table,
213            &layout,
214            position,
215            hook,
216            image_reg.as_ref(),
217        )?;
218
219        if let Some(ref reg) = image_reg {
220            reg.register_on_page(self, page_id)?;
221        }
222
223        drawing::add_operations_to_page(self, page_id, operations)?;
224        Ok(())
225    }
226
227    fn draw_table_with_pagination_and_hook(
228        &mut self,
229        page_id: ObjectId,
230        table: Table,
231        position: (f32, f32),
232        hook: Option<&mut dyn TaggedCellHook>,
233    ) -> Result<PagedTableResult> {
234        debug!(
235            "Drawing paginated table with hook at position {:?}",
236            position
237        );
238        let layout = layout::calculate_layout(&table)?;
239
240        let image_reg = if drawing::table_has_images(&table) {
241            let reg = drawing::register_all_images(self, &table);
242            reg.register_on_page(self, page_id)?;
243            Some(reg)
244        } else {
245            None
246        };
247
248        drawing::draw_table_paginated(
249            self,
250            page_id,
251            &table,
252            &layout,
253            position,
254            hook,
255            image_reg.as_ref(),
256        )
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use lopdf::content::{Content, Operation};
264    use lopdf::{Document, Object, dictionary};
265
266    #[test]
267    fn test_basic_table_creation() {
268        let table = Table::new()
269            .add_row(Row::new(vec![Cell::new("Header 1"), Cell::new("Header 2")]))
270            .add_row(Row::new(vec![Cell::new("Data 1"), Cell::new("Data 2")]));
271
272        assert_eq!(table.rows.len(), 2);
273        assert_eq!(table.rows[0].cells.len(), 2);
274    }
275
276    #[test]
277    fn test_backward_compat_no_metrics() {
278        // Tables without font_metrics should still work identically
279        let mut doc = Document::with_version("1.5");
280        let pages_id = doc.add_object(dictionary! {
281            "Type" => "Pages",
282            "Kids" => vec![],
283            "Count" => 0,
284        });
285        let page_id = doc.add_object(dictionary! {
286            "Type" => "Page",
287            "Parent" => pages_id,
288            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
289        });
290        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
291            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
292                kids.push(page_id.into());
293            }
294            pages.set("Count", Object::Integer(1));
295        }
296        let font_id = doc.add_object(dictionary! {
297            "Type" => "Font",
298            "Subtype" => "Type1",
299            "BaseFont" => "Helvetica",
300        });
301        let resources_id = doc.add_object(dictionary! {
302            "Font" => dictionary! { "F1" => font_id },
303        });
304        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
305            page.set("Resources", resources_id);
306        }
307        let catalog_id = doc.add_object(dictionary! {
308            "Type" => "Catalog",
309            "Pages" => pages_id,
310        });
311        doc.trailer.set("Root", catalog_id);
312
313        let table = Table::new()
314            .add_row(Row::new(vec![Cell::new("A"), Cell::new("B")]))
315            .add_row(Row::new(vec![Cell::new("C"), Cell::new("D")]))
316            .with_border(1.0);
317
318        assert!(table.font_metrics.is_none());
319        let result = doc.draw_table(page_id, table, (50.0, 750.0));
320        assert!(result.is_ok());
321    }
322
323    #[test]
324    fn test_unicode_table_no_metrics() {
325        // Unicode text in a table without metrics should not panic
326        let table = Table::new()
327            .add_row(Row::new(vec![
328                Cell::new("caf\u{00e9}"),
329                Cell::new("\u{00fc}ber"),
330            ]))
331            .add_row(Row::new(vec![
332                Cell::new("\u{4f60}\u{597d}"),
333                Cell::new("\u{00a9} 2025"),
334            ]));
335
336        assert_eq!(table.rows.len(), 2);
337        let layout = layout::calculate_layout(&table);
338        assert!(layout.is_ok());
339    }
340
341    #[derive(Clone)]
342    struct MockMetrics {
343        char_width_pts: f32,
344    }
345
346    impl FontMetrics for MockMetrics {
347        fn char_width(&self, _ch: char, _font_size: f32) -> f32 {
348            self.char_width_pts
349        }
350
351        fn text_width(&self, text: &str, _font_size: f32) -> f32 {
352            text.chars().count() as f32 * self.char_width_pts
353        }
354
355        fn encode_text(&self, text: &str) -> Vec<u8> {
356            vec![0; text.chars().count() * 2]
357        }
358    }
359
360    fn extract_tf_font_names(objects: &[Object]) -> Vec<String> {
361        let mut names = Vec::new();
362        let mut i = 0usize;
363        while i + 1 < objects.len() {
364            if let Object::Name(op) = &objects[i] {
365                if op.as_slice() == b"Tf" {
366                    if let Object::Name(font_name) = &objects[i + 1] {
367                        names.push(String::from_utf8_lossy(font_name).to_string());
368                    }
369                }
370            }
371            i += 1;
372        }
373        names
374    }
375
376    #[derive(Debug, Clone, Copy)]
377    struct RectExtents {
378        max_top: f32,
379        min_bottom: f32,
380    }
381
382    fn object_to_f32(object: &Object) -> Option<f32> {
383        match object {
384            Object::Integer(v) => Some(*v as f32),
385            Object::Real(v) => Some(*v),
386            _ => None,
387        }
388    }
389
390    fn approx_eq(a: f32, b: f32) -> bool {
391        (a - b).abs() <= 0.001
392    }
393
394    fn op_has_rgb(op: &Operation, operator: &str, color: Color) -> bool {
395        op.operator == operator
396            && op.operands.len() == 3
397            && object_to_f32(&op.operands[0]).map_or(false, |v| approx_eq(v, color.r))
398            && object_to_f32(&op.operands[1]).map_or(false, |v| approx_eq(v, color.g))
399            && object_to_f32(&op.operands[2]).map_or(false, |v| approx_eq(v, color.b))
400    }
401
402    fn op_has_line_width(op: &Operation, width: f32) -> bool {
403        op.operator == "w"
404            && op.operands.len() == 1
405            && object_to_f32(&op.operands[0]).map_or(false, |v| approx_eq(v, width))
406    }
407
408    fn has_stroke_style(operations: &[Operation], color: Color, width: f32) -> bool {
409        operations.iter().any(|op| op_has_rgb(op, "RG", color))
410            && operations.iter().any(|op| op_has_line_width(op, width))
411    }
412
413    fn page_content_operations(doc: &Document, page_id: ObjectId) -> Vec<Operation> {
414        let bytes = doc
415            .get_page_content(page_id)
416            .expect("page content should be readable");
417        Content::decode(&bytes)
418            .expect("page content should decode")
419            .operations
420    }
421
422    fn page_rect_extents(doc: &Document, page_id: ObjectId) -> Option<RectExtents> {
423        let bytes = doc.get_page_content(page_id).ok()?;
424        let content = Content::decode(&bytes).ok()?;
425        let mut max_top = f32::NEG_INFINITY;
426        let mut min_bottom = f32::INFINITY;
427        let mut found = false;
428
429        for op in content.operations {
430            if op.operator != "re" || op.operands.len() != 4 {
431                continue;
432            }
433            let y = object_to_f32(&op.operands[1])?;
434            let h = object_to_f32(&op.operands[3])?;
435            max_top = max_top.max(y + h);
436            min_bottom = min_bottom.min(y);
437            found = true;
438        }
439
440        if found {
441            Some(RectExtents {
442                max_top,
443                min_bottom,
444            })
445        } else {
446            None
447        }
448    }
449
450    #[test]
451    fn test_cell_border_overrides_emit_custom_stroke_ops() {
452        let custom_color = Color::rgb(0.11, 0.22, 0.33);
453        let custom_width = 2.75;
454        let header_style = CellStyle {
455            border_top: Some((BorderStyle::Solid, custom_width, custom_color)),
456            ..Default::default()
457        };
458
459        let table = Table::new()
460            .with_pixel_widths(vec![180.0])
461            .add_row(Row::new(vec![Cell::new("Header").with_style(header_style)]));
462
463        let objects = Document::with_version("1.7")
464            .create_table_content(&table, (50.0, 750.0))
465            .expect("table content should be generated");
466        let operations = crate::drawing_utils::objects_to_operations(&objects);
467
468        assert!(
469            has_stroke_style(&operations, custom_color, custom_width),
470            "expected custom border stroke ops (color + width) to be emitted"
471        );
472    }
473
474    #[test]
475    fn test_cell_background_fill_ops_still_emitted_with_styled_cells() {
476        let bg_color = Color::rgb(0.13, 0.27, 0.71);
477        let style = CellStyle {
478            background_color: Some(bg_color),
479            ..Default::default()
480        };
481
482        let table = Table::new()
483            .with_pixel_widths(vec![180.0])
484            .add_row(Row::new(vec![Cell::new("Header").with_style(style)]));
485
486        let objects = Document::with_version("1.7")
487            .create_table_content(&table, (50.0, 750.0))
488            .expect("table content should be generated");
489        let operations = crate::drawing_utils::objects_to_operations(&objects);
490
491        let has_bg_color = operations.iter().any(|op| op_has_rgb(op, "rg", bg_color));
492        let has_fill = operations.iter().any(|op| op.operator == "f");
493
494        assert!(
495            has_bg_color && has_fill,
496            "expected background color fill ops to be present for styled cells"
497        );
498    }
499
500    #[test]
501    fn test_cell_border_overrides_are_emitted_after_table_grid_borders() {
502        let table_border_color = Color::rgb(0.7, 0.7, 0.7);
503        let custom_border_color = Color::rgb(0.11, 0.22, 0.33);
504        let border_width = 0.5;
505
506        let mut table_style = TableStyle::default();
507        table_style.border_color = table_border_color;
508        table_style.border_width = border_width;
509
510        let header_style = CellStyle {
511            border_top: Some((BorderStyle::Solid, border_width, custom_border_color)),
512            ..Default::default()
513        };
514
515        let table = Table::new()
516            .with_style(table_style)
517            .with_pixel_widths(vec![180.0])
518            .add_row(Row::new(vec![Cell::new("Header").with_style(header_style)]));
519
520        let objects = Document::with_version("1.7")
521            .create_table_content(&table, (50.0, 750.0))
522            .expect("table content should be generated");
523        let operations = crate::drawing_utils::objects_to_operations(&objects);
524
525        let last_table_border_idx = operations
526            .iter()
527            .enumerate()
528            .filter(|(_, op)| op_has_rgb(op, "RG", table_border_color))
529            .map(|(idx, _)| idx)
530            .last()
531            .expect("expected table border stroke color op");
532
533        let last_custom_border_idx = operations
534            .iter()
535            .enumerate()
536            .filter(|(_, op)| op_has_rgb(op, "RG", custom_border_color))
537            .map(|(idx, _)| idx)
538            .last()
539            .expect("expected custom border stroke color op");
540
541        assert!(
542            last_custom_border_idx > last_table_border_idx,
543            "expected custom border stroke ops to be emitted after table grid borders"
544        );
545    }
546
547    #[test]
548    fn test_embedded_bold_resource_selected_for_bold_cells() {
549        let mut style = TableStyle::default();
550        style.embedded_font_resource_name = Some("EF0".to_string());
551        style.embedded_font_resource_name_bold = Some("EF0B".to_string());
552
553        let table = Table::new()
554            .with_style(style)
555            .add_row(Row::new(vec![Cell::new("Header").bold()]))
556            .with_font_metrics(MockMetrics {
557                char_width_pts: 5.0,
558            })
559            .with_bold_font_metrics(MockMetrics {
560                char_width_pts: 9.0,
561            });
562
563        let ops = Document::with_version("1.5")
564            .create_table_content(&table, (50.0, 750.0))
565            .expect("table content should be generated");
566        let font_names = extract_tf_font_names(&ops);
567        assert!(
568            font_names.iter().any(|name| name == "EF0B"),
569            "expected bold embedded font resource EF0B, got: {:?}",
570            font_names
571        );
572    }
573
574    #[test]
575    fn test_embedded_regular_resource_used_as_bold_fallback() {
576        let mut style = TableStyle::default();
577        style.embedded_font_resource_name = Some("EF0".to_string());
578        style.embedded_font_resource_name_bold = None;
579
580        let table = Table::new()
581            .with_style(style)
582            .add_row(Row::new(vec![Cell::new("Header").bold()]))
583            .with_font_metrics(MockMetrics {
584                char_width_pts: 5.0,
585            });
586
587        let ops = Document::with_version("1.5")
588            .create_table_content(&table, (50.0, 750.0))
589            .expect("table content should be generated");
590        let font_names = extract_tf_font_names(&ops);
591        assert!(
592            font_names.iter().any(|name| name == "EF0"),
593            "expected embedded font fallback EF0, got: {:?}",
594            font_names
595        );
596    }
597
598    #[test]
599    fn test_layout_uses_bold_metrics_when_available() {
600        let bold_cell = Cell::new("WWWWWW").bold();
601
602        let table_regular_only = Table::new()
603            .add_row(Row::new(vec![bold_cell.clone()]))
604            .with_font_metrics(MockMetrics {
605                char_width_pts: 2.0,
606            });
607
608        let table_with_bold_metrics = Table::new()
609            .add_row(Row::new(vec![bold_cell]))
610            .with_font_metrics(MockMetrics {
611                char_width_pts: 2.0,
612            })
613            .with_bold_font_metrics(MockMetrics {
614                char_width_pts: 8.0,
615            });
616
617        let regular_layout = layout::calculate_layout(&table_regular_only)
618            .expect("layout should succeed with regular metrics only");
619        let bold_layout = layout::calculate_layout(&table_with_bold_metrics)
620            .expect("layout should succeed with bold metrics");
621
622        assert!(
623            bold_layout.total_width > regular_layout.total_width,
624            "expected bold metrics to increase width: regular={} bold={}",
625            regular_layout.total_width,
626            bold_layout.total_width
627        );
628    }
629
630    #[test]
631    fn test_tagged_cell_hook_is_invoked() {
632        struct Hook {
633            begin_calls: usize,
634            end_calls: usize,
635        }
636
637        impl TaggedCellHook for Hook {
638            fn begin_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
639                self.begin_calls += 1;
640                vec![]
641            }
642
643            fn end_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
644                self.end_calls += 1;
645                vec![]
646            }
647        }
648
649        let mut doc = Document::with_version("1.7");
650        let pages_id = doc.add_object(dictionary! {
651            "Type" => "Pages",
652            "Kids" => vec![],
653            "Count" => 0,
654        });
655        let page_id = doc.add_object(dictionary! {
656            "Type" => "Page",
657            "Parent" => pages_id,
658            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
659        });
660        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
661            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
662                kids.push(page_id.into());
663            }
664            pages.set("Count", Object::Integer(1));
665        }
666        let font_id = doc.add_object(dictionary! {
667            "Type" => "Font",
668            "Subtype" => "Type1",
669            "BaseFont" => "Helvetica",
670        });
671        let resources_id = doc.add_object(dictionary! {
672            "Font" => dictionary! { "F1" => font_id },
673        });
674        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
675            page.set("Resources", resources_id);
676        }
677        let catalog_id = doc.add_object(dictionary! {
678            "Type" => "Catalog",
679            "Pages" => pages_id,
680        });
681        doc.trailer.set("Root", catalog_id);
682
683        let table = Table::new()
684            .add_row(Row::new(vec![Cell::new("H1"), Cell::new("H2")]))
685            .add_row(Row::new(vec![Cell::new("A1"), Cell::new("A2")]))
686            .with_header_rows(1);
687
688        let mut hook = Hook {
689            begin_calls: 0,
690            end_calls: 0,
691        };
692        doc.draw_table_with_hook(page_id, table, (50.0, 750.0), Some(&mut hook))
693            .expect("table draw with hook should succeed");
694
695        assert_eq!(hook.begin_calls, 4);
696        assert_eq!(hook.end_calls, 4);
697    }
698
699    #[test]
700    fn test_marked_content_tokens_parse_as_operators() {
701        let objects = vec![
702            Object::Name(b"BDC".to_vec()),
703            Object::Name(b"TH".to_vec()),
704            Object::Dictionary(dictionary! { "MCID" => 0 }),
705            Object::Name(b"BT".to_vec()),
706            Object::Name(b"ET".to_vec()),
707            Object::Name(b"EMC".to_vec()),
708        ];
709
710        let operations = crate::drawing_utils::objects_to_operations(&objects);
711        assert_eq!(operations.len(), 4);
712        assert_eq!(operations[0].operator, "BDC");
713        assert_eq!(operations[0].operands.len(), 2);
714        assert_eq!(operations[1].operator, "BT");
715        assert_eq!(operations[2].operator, "ET");
716        assert_eq!(operations[3].operator, "EMC");
717    }
718
719    #[test]
720    fn test_hook_generated_bdc_emc_appear_in_page_content() {
721        struct MarkedHook;
722
723        impl TaggedCellHook for MarkedHook {
724            fn begin_cell(&mut self, _row: usize, _col: usize, is_header: bool) -> Vec<Operation> {
725                vec![Operation::new(
726                    "BDC",
727                    vec![
728                        Object::Name(if is_header {
729                            b"TH".to_vec()
730                        } else {
731                            b"TD".to_vec()
732                        }),
733                        Object::Dictionary(dictionary! { "MCID" => 0 }),
734                    ],
735                )]
736            }
737
738            fn end_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
739                vec![Operation::new("EMC", vec![])]
740            }
741        }
742
743        let mut doc = Document::with_version("1.7");
744        let pages_id = doc.add_object(dictionary! {
745            "Type" => "Pages",
746            "Kids" => vec![],
747            "Count" => 0,
748        });
749        let page_id = doc.add_object(dictionary! {
750            "Type" => "Page",
751            "Parent" => pages_id,
752            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
753        });
754        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
755            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
756                kids.push(page_id.into());
757            }
758            pages.set("Count", Object::Integer(1));
759        }
760        let font_id = doc.add_object(dictionary! {
761            "Type" => "Font",
762            "Subtype" => "Type1",
763            "BaseFont" => "Helvetica",
764        });
765        let resources_id = doc.add_object(dictionary! {
766            "Font" => dictionary! { "F1" => font_id },
767        });
768        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
769            page.set("Resources", resources_id);
770        }
771        let catalog_id = doc.add_object(dictionary! {
772            "Type" => "Catalog",
773            "Pages" => pages_id,
774        });
775        doc.trailer.set("Root", catalog_id);
776
777        let table = Table::new()
778            .add_row(Row::new(vec![Cell::new("H1"), Cell::new("H2")]))
779            .add_row(Row::new(vec![Cell::new("A1"), Cell::new("A2")]))
780            .with_header_rows(1);
781
782        let mut hook = MarkedHook;
783        doc.draw_table_with_hook(page_id, table, (50.0, 750.0), Some(&mut hook))
784            .expect("table draw with hook should succeed");
785
786        let bytes = doc
787            .get_page_content(page_id)
788            .expect("page content should be readable");
789        let decoded = Content::decode(&bytes).expect("content should decode");
790
791        let bdc_count = decoded
792            .operations
793            .iter()
794            .filter(|op| op.operator == "BDC")
795            .count();
796        let emc_count = decoded
797            .operations
798            .iter()
799            .filter(|op| op.operator == "EMC")
800            .count();
801
802        assert!(bdc_count >= 4);
803        assert_eq!(bdc_count, emc_count);
804    }
805
806    #[test]
807    fn test_hook_mode_wraps_non_semantic_ops_as_artifact() {
808        struct NoopHook;
809
810        impl TaggedCellHook for NoopHook {
811            fn begin_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
812                vec![]
813            }
814
815            fn end_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
816                vec![]
817            }
818        }
819
820        let mut doc = Document::with_version("1.7");
821        let pages_id = doc.add_object(dictionary! {
822            "Type" => "Pages",
823            "Kids" => vec![],
824            "Count" => 0,
825        });
826        let page_id = doc.add_object(dictionary! {
827            "Type" => "Page",
828            "Parent" => pages_id,
829            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
830        });
831        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
832            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
833                kids.push(page_id.into());
834            }
835            pages.set("Count", Object::Integer(1));
836        }
837        let font_id = doc.add_object(dictionary! {
838            "Type" => "Font",
839            "Subtype" => "Type1",
840            "BaseFont" => "Helvetica",
841        });
842        let resources_id = doc.add_object(dictionary! {
843            "Font" => dictionary! { "F1" => font_id },
844        });
845        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
846            page.set("Resources", resources_id);
847        }
848        let catalog_id = doc.add_object(dictionary! {
849            "Type" => "Catalog",
850            "Pages" => pages_id,
851        });
852        doc.trailer.set("Root", catalog_id);
853
854        let table = Table::new()
855            .with_border(0.5)
856            .add_row(Row::new(vec![Cell::new("H1"), Cell::new("H2")]))
857            .add_row(Row::new(vec![Cell::new("A1"), Cell::new("A2")]))
858            .with_header_rows(1);
859
860        let mut hook = NoopHook;
861        doc.draw_table_with_hook(page_id, table, (50.0, 750.0), Some(&mut hook))
862            .expect("table draw with hook should succeed");
863
864        let bytes = doc
865            .get_page_content(page_id)
866            .expect("page content should be readable");
867        let decoded = Content::decode(&bytes).expect("content should decode");
868
869        let has_artifact_bdc = decoded.operations.iter().any(|op| {
870            op.operator == "BDC"
871                && op
872                    .operands
873                    .first()
874                    .and_then(|operand| operand.as_name().ok())
875                    == Some(b"Artifact".as_slice())
876        });
877
878        assert!(
879            has_artifact_bdc,
880            "expected non-semantic table drawing ops to be wrapped as Artifact"
881        );
882    }
883
884    #[test]
885    fn test_paginated_table_continuation_pages_use_top_margin_anchor_with_repeated_headers() {
886        const PAGE_HEIGHT: f32 = 842.0;
887        const TOP_MARGIN: f32 = 50.0;
888        const BOTTOM_MARGIN: f32 = 50.0;
889        const START_Y: f32 = 500.0;
890        const EPS: f32 = 0.01;
891
892        let mut doc = Document::with_version("1.7");
893        let pages_id = doc.add_object(dictionary! {
894            "Type" => "Pages",
895            "Kids" => vec![],
896            "Count" => 0,
897        });
898        let page_id = doc.add_object(dictionary! {
899            "Type" => "Page",
900            "Parent" => pages_id,
901            "MediaBox" => vec![0.into(), 0.into(), 595.into(), PAGE_HEIGHT.into()],
902        });
903        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
904            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
905                kids.push(page_id.into());
906            }
907            pages.set("Count", Object::Integer(1));
908        }
909        let font_id = doc.add_object(dictionary! {
910            "Type" => "Font",
911            "Subtype" => "Type1",
912            "BaseFont" => "Helvetica",
913        });
914        let resources_id = doc.add_object(dictionary! {
915            "Font" => dictionary! { "F1" => font_id },
916        });
917        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
918            page.set("Resources", resources_id);
919        }
920        let catalog_id = doc.add_object(dictionary! {
921            "Type" => "Catalog",
922            "Pages" => pages_id,
923        });
924        doc.trailer.set("Root", catalog_id);
925
926        let mut style = TableStyle::default();
927        style.page_height = Some(PAGE_HEIGHT);
928        style.top_margin = TOP_MARGIN;
929        style.bottom_margin = BOTTOM_MARGIN;
930        style.repeat_headers = true;
931
932        let mut table = Table::new()
933            .with_style(style)
934            .with_header_rows(1)
935            .with_pixel_widths(vec![300.0])
936            .add_row(Row::new(vec![Cell::new("Header")]).with_height(30.0));
937
938        for row in 0..120 {
939            table =
940                table.add_row(Row::new(vec![Cell::new(format!("row-{row}"))]).with_height(30.0));
941        }
942
943        let result = doc
944            .draw_table_with_pagination(page_id, table, (50.0, START_Y))
945            .expect("paginated table draw should succeed");
946
947        assert!(
948            result.page_ids.len() >= 3,
949            "expected at least 3 pages, got {}",
950            result.page_ids.len()
951        );
952
953        let first_page_extents =
954            page_rect_extents(&doc, result.page_ids[0]).expect("first page should have rectangles");
955        let second_page_extents = page_rect_extents(&doc, result.page_ids[1])
956            .expect("second page should have rectangles");
957
958        assert!(
959            (first_page_extents.max_top - START_Y).abs() <= EPS,
960            "expected first page max top ~{START_Y}, got {}",
961            first_page_extents.max_top
962        );
963        assert!(
964            (second_page_extents.max_top - (PAGE_HEIGHT - TOP_MARGIN)).abs() <= EPS,
965            "expected second page max top ~{}, got {}",
966            PAGE_HEIGHT - TOP_MARGIN,
967            second_page_extents.max_top
968        );
969        assert!(
970            second_page_extents.min_bottom >= BOTTOM_MARGIN - EPS,
971            "expected second page min bottom >= {}, got {}",
972            BOTTOM_MARGIN - EPS,
973            second_page_extents.min_bottom
974        );
975    }
976
977    #[test]
978    fn test_paginated_repeated_header_border_overrides_render_on_continuation_pages() {
979        const PAGE_HEIGHT: f32 = 842.0;
980        const TOP_MARGIN: f32 = 50.0;
981        const BOTTOM_MARGIN: f32 = 50.0;
982        const START_Y: f32 = 500.0;
983        let header_border_color = Color::rgb(0.07, 0.16, 0.29);
984        let header_border_width = 2.5;
985
986        let mut doc = Document::with_version("1.7");
987        let pages_id = doc.add_object(dictionary! {
988            "Type" => "Pages",
989            "Kids" => vec![],
990            "Count" => 0,
991        });
992        let page_id = doc.add_object(dictionary! {
993            "Type" => "Page",
994            "Parent" => pages_id,
995            "MediaBox" => vec![0.into(), 0.into(), 595.into(), PAGE_HEIGHT.into()],
996        });
997        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
998            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
999                kids.push(page_id.into());
1000            }
1001            pages.set("Count", Object::Integer(1));
1002        }
1003        let font_id = doc.add_object(dictionary! {
1004            "Type" => "Font",
1005            "Subtype" => "Type1",
1006            "BaseFont" => "Helvetica",
1007        });
1008        let resources_id = doc.add_object(dictionary! {
1009            "Font" => dictionary! { "F1" => font_id },
1010        });
1011        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
1012            page.set("Resources", resources_id);
1013        }
1014        let catalog_id = doc.add_object(dictionary! {
1015            "Type" => "Catalog",
1016            "Pages" => pages_id,
1017        });
1018        doc.trailer.set("Root", catalog_id);
1019
1020        let mut table_style = TableStyle::default();
1021        table_style.page_height = Some(PAGE_HEIGHT);
1022        table_style.top_margin = TOP_MARGIN;
1023        table_style.bottom_margin = BOTTOM_MARGIN;
1024        table_style.repeat_headers = true;
1025
1026        let header_style = CellStyle {
1027            border_top: Some((BorderStyle::Solid, header_border_width, header_border_color)),
1028            border_bottom: Some((BorderStyle::Solid, header_border_width, header_border_color)),
1029            ..Default::default()
1030        };
1031
1032        let mut table = Table::new()
1033            .with_style(table_style)
1034            .with_header_rows(1)
1035            .with_pixel_widths(vec![300.0])
1036            .add_row(
1037                Row::new(vec![Cell::new("Header").with_style(header_style)]).with_height(30.0),
1038            );
1039
1040        for row in 0..120 {
1041            table =
1042                table.add_row(Row::new(vec![Cell::new(format!("row-{row}"))]).with_height(30.0));
1043        }
1044
1045        let result = doc
1046            .draw_table_with_pagination(page_id, table, (50.0, START_Y))
1047            .expect("paginated table draw should succeed");
1048
1049        assert!(
1050            result.page_ids.len() >= 2,
1051            "expected at least 2 pages, got {}",
1052            result.page_ids.len()
1053        );
1054
1055        let second_page_ops = page_content_operations(&doc, result.page_ids[1]);
1056        assert!(
1057            has_stroke_style(&second_page_ops, header_border_color, header_border_width),
1058            "expected repeated header border override stroke ops on continuation page"
1059        );
1060    }
1061
1062    /// Generate a minimal valid 2x2 red JPEG image for testing.
1063    fn tiny_jpeg_bytes() -> Vec<u8> {
1064        use image::{ImageBuffer, Rgb};
1065        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
1066            ImageBuffer::from_fn(2, 2, |_, _| Rgb([255, 0, 0]));
1067        let mut buf = std::io::Cursor::new(Vec::new());
1068        img.write_to(&mut buf, image::ImageFormat::Jpeg)
1069            .expect("JPEG encoding should succeed");
1070        buf.into_inner()
1071    }
1072
1073    /// Generate a minimal valid 4x3 PNG image for testing.
1074    fn tiny_png_bytes() -> Vec<u8> {
1075        use image::{ImageBuffer, Rgb};
1076        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
1077            ImageBuffer::from_fn(4, 3, |_, _| Rgb([0, 128, 255]));
1078        let mut buf = std::io::Cursor::new(Vec::new());
1079        img.write_to(&mut buf, image::ImageFormat::Png)
1080            .expect("PNG encoding should succeed");
1081        buf.into_inner()
1082    }
1083
1084    fn make_test_doc() -> (Document, ObjectId) {
1085        let mut doc = Document::with_version("1.7");
1086        let pages_id = doc.add_object(dictionary! {
1087            "Type" => "Pages",
1088            "Kids" => vec![],
1089            "Count" => 0,
1090        });
1091        let page_id = doc.add_object(dictionary! {
1092            "Type" => "Page",
1093            "Parent" => pages_id,
1094            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1095        });
1096        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
1097            if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
1098                kids.push(page_id.into());
1099            }
1100            pages.set("Count", Object::Integer(1));
1101        }
1102        let font_id = doc.add_object(dictionary! {
1103            "Type" => "Font",
1104            "Subtype" => "Type1",
1105            "BaseFont" => "Helvetica",
1106        });
1107        let resources_id = doc.add_object(dictionary! {
1108            "Font" => dictionary! { "F1" => font_id },
1109        });
1110        if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
1111            page.set("Resources", resources_id);
1112        }
1113        let catalog_id = doc.add_object(dictionary! {
1114            "Type" => "Catalog",
1115            "Pages" => pages_id,
1116        });
1117        doc.trailer.set("Root", catalog_id);
1118        (doc, page_id)
1119    }
1120
1121    #[test]
1122    fn test_cell_image_jpeg_construction() {
1123        let img = CellImage::new(tiny_jpeg_bytes()).expect("JPEG should parse");
1124        assert_eq!(img.width_px(), 2);
1125        assert_eq!(img.height_px(), 2);
1126    }
1127
1128    #[test]
1129    fn test_cell_image_png_construction() {
1130        let img = CellImage::new(tiny_png_bytes()).expect("PNG should parse");
1131        assert_eq!(img.width_px(), 4);
1132        assert_eq!(img.height_px(), 3);
1133    }
1134
1135    #[test]
1136    fn test_cell_image_invalid_bytes() {
1137        let result = CellImage::new(vec![0, 1, 2, 3]);
1138        assert!(result.is_err(), "invalid bytes should produce an error");
1139    }
1140
1141    #[test]
1142    fn test_image_cell_draw_emits_do_and_cm() {
1143        let (mut doc, page_id) = make_test_doc();
1144        let img = CellImage::new(tiny_jpeg_bytes())
1145            .unwrap()
1146            .with_max_height(120.0);
1147
1148        let table = Table::new()
1149            .with_pixel_widths(vec![200.0])
1150            .add_row(Row::new(vec![Cell::from_image(img)]));
1151
1152        doc.draw_table(page_id, table, (50.0, 750.0))
1153            .expect("image table draw should succeed");
1154
1155        let ops = page_content_operations(&doc, page_id);
1156        let has_cm = ops.iter().any(|op| op.operator == "cm");
1157        let has_do = ops.iter().any(|op| op.operator == "Do");
1158        assert!(has_cm, "expected cm operator for image transform");
1159        assert!(has_do, "expected Do operator for image rendering");
1160    }
1161
1162    #[test]
1163    fn test_create_table_content_rejects_image_cells() {
1164        let img = CellImage::new(tiny_jpeg_bytes()).unwrap();
1165        let table = Table::new()
1166            .with_pixel_widths(vec![200.0])
1167            .add_row(Row::new(vec![Cell::from_image(img)]));
1168
1169        let result = Document::with_version("1.7").create_table_content(&table, (50.0, 750.0));
1170        assert!(
1171            result.is_err(),
1172            "create_table_content should reject image cells"
1173        );
1174    }
1175
1176    #[test]
1177    fn test_text_only_tables_still_work_with_image_support() {
1178        let (mut doc, page_id) = make_test_doc();
1179        let table = Table::new()
1180            .add_row(Row::new(vec![Cell::new("A"), Cell::new("B")]))
1181            .add_row(Row::new(vec![Cell::new("C"), Cell::new("D")]))
1182            .with_border(1.0);
1183
1184        doc.draw_table(page_id, table, (50.0, 750.0))
1185            .expect("text-only table should still work");
1186    }
1187
1188    #[test]
1189    fn test_paginated_image_table_renders_on_continuation_pages() {
1190        const PAGE_HEIGHT: f32 = 842.0;
1191        const TOP_MARGIN: f32 = 50.0;
1192        const BOTTOM_MARGIN: f32 = 50.0;
1193
1194        let (mut doc, page_id) = make_test_doc();
1195        let jpeg = tiny_jpeg_bytes();
1196
1197        let mut style = TableStyle::default();
1198        style.page_height = Some(PAGE_HEIGHT);
1199        style.top_margin = TOP_MARGIN;
1200        style.bottom_margin = BOTTOM_MARGIN;
1201        style.repeat_headers = true;
1202
1203        let mut table = Table::new()
1204            .with_style(style)
1205            .with_header_rows(1)
1206            .with_pixel_widths(vec![100.0, 200.0])
1207            .add_row(Row::new(vec![
1208                Cell::new("Header").bold(),
1209                Cell::new("Photo").bold(),
1210            ]));
1211
1212        for _ in 0..30 {
1213            let img = CellImage::new(jpeg.clone()).unwrap().with_max_height(80.0);
1214            table = table.add_row(Row::new(vec![Cell::new("data"), Cell::from_image(img)]));
1215        }
1216
1217        let result = doc
1218            .draw_table_with_pagination(page_id, table, (50.0, 500.0))
1219            .expect("paginated image table should succeed");
1220
1221        assert!(
1222            result.page_ids.len() >= 2,
1223            "expected at least 2 pages, got {}",
1224            result.page_ids.len()
1225        );
1226
1227        // Verify continuation page has Do operator for images
1228        let second_page_ops = page_content_operations(&doc, result.page_ids[1]);
1229        let has_do = second_page_ops.iter().any(|op| op.operator == "Do");
1230        assert!(
1231            has_do,
1232            "expected Do operator on continuation page for image rendering"
1233        );
1234    }
1235
1236    #[test]
1237    fn test_image_overlay_emits_gs_and_text_ops() {
1238        let (mut doc, page_id) = make_test_doc();
1239        let img = CellImage::new(tiny_jpeg_bytes())
1240            .unwrap()
1241            .with_max_height(120.0)
1242            .with_overlay(table::ImageOverlay::new("01/01/2026 12:00"));
1243
1244        let table = Table::new()
1245            .with_pixel_widths(vec![200.0])
1246            .add_row(Row::new(vec![Cell::from_image(img)]));
1247
1248        doc.draw_table(page_id, table, (50.0, 750.0))
1249            .expect("image table with overlay should succeed");
1250
1251        let ops = page_content_operations(&doc, page_id);
1252        let has_gs = ops.iter().any(|op| op.operator == "gs");
1253        let has_do = ops.iter().any(|op| op.operator == "Do");
1254
1255        // Overlay text should render white (rg 1 1 1) after the gs operator
1256        let gs_idx = ops.iter().position(|op| op.operator == "gs").unwrap();
1257        let has_white_text_after_gs = ops[gs_idx..].iter().any(|op| {
1258            op.operator == "rg"
1259                && op.operands.len() == 3
1260                && object_to_f32(&op.operands[0]).map_or(false, |v| approx_eq(v, 1.0))
1261                && object_to_f32(&op.operands[1]).map_or(false, |v| approx_eq(v, 1.0))
1262                && object_to_f32(&op.operands[2]).map_or(false, |v| approx_eq(v, 1.0))
1263        });
1264
1265        assert!(has_gs, "expected gs operator for overlay transparency");
1266        assert!(has_do, "expected Do operator for image rendering");
1267        assert!(
1268            has_white_text_after_gs,
1269            "expected white text color after gs for overlay"
1270        );
1271    }
1272
1273    #[test]
1274    fn test_image_without_overlay_has_no_gs_ops() {
1275        let (mut doc, page_id) = make_test_doc();
1276        let img = CellImage::new(tiny_jpeg_bytes())
1277            .unwrap()
1278            .with_max_height(120.0);
1279
1280        let table = Table::new()
1281            .with_pixel_widths(vec![200.0])
1282            .add_row(Row::new(vec![Cell::from_image(img)]));
1283
1284        doc.draw_table(page_id, table, (50.0, 750.0))
1285            .expect("image table without overlay should succeed");
1286
1287        let ops = page_content_operations(&doc, page_id);
1288        let has_gs = ops.iter().any(|op| op.operator == "gs");
1289        assert!(
1290            !has_gs,
1291            "expected no gs operator when no overlay is present"
1292        );
1293    }
1294
1295    #[test]
1296    fn test_overlay_extgstate_registered_on_page() {
1297        let (mut doc, page_id) = make_test_doc();
1298        let img = CellImage::new(tiny_jpeg_bytes())
1299            .unwrap()
1300            .with_max_height(120.0)
1301            .with_overlay(table::ImageOverlay::new("test date"));
1302
1303        let table = Table::new()
1304            .with_pixel_widths(vec![200.0])
1305            .add_row(Row::new(vec![Cell::from_image(img)]));
1306
1307        doc.draw_table(page_id, table, (50.0, 750.0))
1308            .expect("overlay table draw should succeed");
1309
1310        // Verify the page's Resources has an ExtGState dictionary with GSTblOvl
1311        let has_gstate = if let Ok(Object::Dictionary(page_dict)) = doc.get_object(page_id) {
1312            if let Ok(Object::Reference(res_ref)) = page_dict.get(b"Resources") {
1313                if let Ok(Object::Dictionary(res_dict)) = doc.get_object(*res_ref) {
1314                    if let Ok(Object::Dictionary(gs_dict)) = res_dict.get(b"ExtGState") {
1315                        gs_dict.has(b"GSTblOvl")
1316                    } else {
1317                        false
1318                    }
1319                } else {
1320                    false
1321                }
1322            } else {
1323                false
1324            }
1325        } else {
1326            false
1327        };
1328
1329        assert!(
1330            has_gstate,
1331            "expected GSTblOvl ExtGState to be registered in page Resources"
1332        );
1333    }
1334
1335    #[test]
1336    fn test_paginated_overlay_images_register_gstate_on_all_pages() {
1337        const PAGE_HEIGHT: f32 = 842.0;
1338        const TOP_MARGIN: f32 = 50.0;
1339        const BOTTOM_MARGIN: f32 = 50.0;
1340
1341        let (mut doc, page_id) = make_test_doc();
1342        let jpeg = tiny_jpeg_bytes();
1343
1344        let mut style = TableStyle::default();
1345        style.page_height = Some(PAGE_HEIGHT);
1346        style.top_margin = TOP_MARGIN;
1347        style.bottom_margin = BOTTOM_MARGIN;
1348        style.repeat_headers = true;
1349
1350        let mut table = Table::new()
1351            .with_style(style)
1352            .with_header_rows(1)
1353            .with_pixel_widths(vec![100.0, 200.0])
1354            .add_row(Row::new(vec![
1355                Cell::new("Header").bold(),
1356                Cell::new("Photo").bold(),
1357            ]));
1358
1359        for i in 0..30 {
1360            let img = CellImage::new(jpeg.clone())
1361                .unwrap()
1362                .with_max_height(80.0)
1363                .with_overlay(table::ImageOverlay::new(format!("date-{i}")));
1364            table = table.add_row(Row::new(vec![Cell::new("data"), Cell::from_image(img)]));
1365        }
1366
1367        let result = doc
1368            .draw_table_with_pagination(page_id, table, (50.0, 500.0))
1369            .expect("paginated overlay table should succeed");
1370
1371        assert!(result.page_ids.len() >= 2);
1372
1373        // Check continuation pages have gs operator
1374        let second_ops = page_content_operations(&doc, result.page_ids[1]);
1375        let has_gs = second_ops.iter().any(|op| op.operator == "gs");
1376        assert!(
1377            has_gs,
1378            "expected gs operator on continuation page for overlay rendering"
1379        );
1380    }
1381}