1use 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
20pub 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
32pub 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#[derive(Debug, Clone)]
40pub struct PagedTableResult {
41 pub page_ids: Vec<ObjectId>,
43 pub total_pages: usize,
45 pub final_position: (f32, f32),
47}
48
49pub trait TableDrawing {
51 fn draw_table(&mut self, page_id: ObjectId, table: Table, position: (f32, f32)) -> Result<()>;
61
62 fn add_table_to_page(&mut self, page_id: ObjectId, table: Table) -> Result<()>;
66
67 fn create_table_content(&self, table: &Table, position: (f32, f32)) -> Result<Vec<Object>>;
71
72 fn draw_table_with_pagination(
86 &mut self,
87 page_id: ObjectId,
88 table: Table,
89 position: (f32, f32),
90 ) -> Result<PagedTableResult>;
91
92 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 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 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 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 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 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 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 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 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 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}