use lopdf::content::Operation;
use lopdf::{Document, Object, ObjectId};
use tracing::{debug, instrument, trace};
mod constants;
mod drawing;
mod drawing_utils;
pub mod error;
pub mod font;
pub mod layout;
pub mod style;
pub mod table;
mod text;
pub use constants::*;
pub use error::{Result, TableError};
pub use font::FontMetrics;
#[cfg(feature = "ttf-parser")]
pub use font::TtfFontMetrics;
pub use style::{
Alignment, BorderStyle, CellStyle, Color, RowStyle, TableStyle, VerticalAlignment,
};
pub use table::{Cell, CellImage, ColumnWidth, ImageFit, ImageOverlay, Row, Table};
pub trait TaggedCellHook {
fn begin_cell(&mut self, row: usize, col: usize, is_header: bool) -> Vec<Operation>;
fn end_cell(&mut self, row: usize, col: usize, is_header: bool) -> Vec<Operation>;
}
#[derive(Debug, Clone)]
pub struct PagedTableResult {
pub page_ids: Vec<ObjectId>,
pub total_pages: usize,
pub final_position: (f32, f32),
}
pub trait TableDrawing {
fn draw_table(&mut self, page_id: ObjectId, table: Table, position: (f32, f32)) -> Result<()>;
fn add_table_to_page(&mut self, page_id: ObjectId, table: Table) -> Result<()>;
fn create_table_content(&self, table: &Table, position: (f32, f32)) -> Result<Vec<Object>>;
fn draw_table_with_pagination(
&mut self,
page_id: ObjectId,
table: Table,
position: (f32, f32),
) -> Result<PagedTableResult>;
fn draw_table_with_hook(
&mut self,
page_id: ObjectId,
table: Table,
position: (f32, f32),
hook: Option<&mut dyn TaggedCellHook>,
) -> Result<()>;
fn draw_table_with_pagination_and_hook(
&mut self,
page_id: ObjectId,
table: Table,
position: (f32, f32),
hook: Option<&mut dyn TaggedCellHook>,
) -> Result<PagedTableResult>;
}
impl TableDrawing for Document {
#[instrument(skip(self, table), fields(table_rows = table.rows.len()))]
fn draw_table(&mut self, page_id: ObjectId, table: Table, position: (f32, f32)) -> Result<()> {
debug!("Drawing table at position {:?}", position);
let layout = layout::calculate_layout(&table)?;
trace!("Calculated layout: {:?}", layout);
let image_reg = if drawing::table_has_images(&table) {
Some(drawing::register_all_images(self, &table))
} else {
None
};
let operations = drawing::generate_table_operations(
&table,
&layout,
position,
None,
image_reg.as_ref(),
)?;
if let Some(ref reg) = image_reg {
reg.register_on_page(self, page_id)?;
}
drawing::add_operations_to_page(self, page_id, operations)?;
Ok(())
}
#[instrument(skip(self, table))]
fn add_table_to_page(&mut self, page_id: ObjectId, table: Table) -> Result<()> {
let position = (DEFAULT_MARGIN, A4_HEIGHT - DEFAULT_MARGIN - 50.0);
self.draw_table(page_id, table, position)
}
fn create_table_content(&self, table: &Table, position: (f32, f32)) -> Result<Vec<Object>> {
if drawing::table_has_images(table) {
return Err(TableError::DrawingError(
"Image cells require document-backed drawing (use draw_table or draw_table_with_pagination instead)".to_string(),
));
}
let layout = layout::calculate_layout(table)?;
drawing::generate_table_operations(table, &layout, position, None, None)
}
#[instrument(skip(self, table), fields(table_rows = table.rows.len()))]
fn draw_table_with_pagination(
&mut self,
page_id: ObjectId,
table: Table,
position: (f32, f32),
) -> Result<PagedTableResult> {
debug!("Drawing paginated table at position {:?}", position);
let layout = layout::calculate_layout(&table)?;
trace!("Calculated layout: {:?}", layout);
let image_reg = if drawing::table_has_images(&table) {
let reg = drawing::register_all_images(self, &table);
reg.register_on_page(self, page_id)?;
Some(reg)
} else {
None
};
let result = drawing::draw_table_paginated(
self,
page_id,
&table,
&layout,
position,
None,
image_reg.as_ref(),
)?;
Ok(result)
}
fn draw_table_with_hook(
&mut self,
page_id: ObjectId,
table: Table,
position: (f32, f32),
hook: Option<&mut dyn TaggedCellHook>,
) -> Result<()> {
debug!("Drawing table with hook at position {:?}", position);
let layout = layout::calculate_layout(&table)?;
let image_reg = if drawing::table_has_images(&table) {
Some(drawing::register_all_images(self, &table))
} else {
None
};
let operations = drawing::generate_table_operations(
&table,
&layout,
position,
hook,
image_reg.as_ref(),
)?;
if let Some(ref reg) = image_reg {
reg.register_on_page(self, page_id)?;
}
drawing::add_operations_to_page(self, page_id, operations)?;
Ok(())
}
fn draw_table_with_pagination_and_hook(
&mut self,
page_id: ObjectId,
table: Table,
position: (f32, f32),
hook: Option<&mut dyn TaggedCellHook>,
) -> Result<PagedTableResult> {
debug!(
"Drawing paginated table with hook at position {:?}",
position
);
let layout = layout::calculate_layout(&table)?;
let image_reg = if drawing::table_has_images(&table) {
let reg = drawing::register_all_images(self, &table);
reg.register_on_page(self, page_id)?;
Some(reg)
} else {
None
};
drawing::draw_table_paginated(
self,
page_id,
&table,
&layout,
position,
hook,
image_reg.as_ref(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use lopdf::content::{Content, Operation};
use lopdf::{Document, Object, dictionary};
#[test]
fn test_basic_table_creation() {
let table = Table::new()
.add_row(Row::new(vec![Cell::new("Header 1"), Cell::new("Header 2")]))
.add_row(Row::new(vec![Cell::new("Data 1"), Cell::new("Data 2")]));
assert_eq!(table.rows.len(), 2);
assert_eq!(table.rows[0].cells.len(), 2);
}
#[test]
fn test_backward_compat_no_metrics() {
let mut doc = Document::with_version("1.5");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let table = Table::new()
.add_row(Row::new(vec![Cell::new("A"), Cell::new("B")]))
.add_row(Row::new(vec![Cell::new("C"), Cell::new("D")]))
.with_border(1.0);
assert!(table.font_metrics.is_none());
let result = doc.draw_table(page_id, table, (50.0, 750.0));
assert!(result.is_ok());
}
#[test]
fn test_unicode_table_no_metrics() {
let table = Table::new()
.add_row(Row::new(vec![
Cell::new("caf\u{00e9}"),
Cell::new("\u{00fc}ber"),
]))
.add_row(Row::new(vec![
Cell::new("\u{4f60}\u{597d}"),
Cell::new("\u{00a9} 2025"),
]));
assert_eq!(table.rows.len(), 2);
let layout = layout::calculate_layout(&table);
assert!(layout.is_ok());
}
#[derive(Clone)]
struct MockMetrics {
char_width_pts: f32,
}
impl FontMetrics for MockMetrics {
fn char_width(&self, _ch: char, _font_size: f32) -> f32 {
self.char_width_pts
}
fn text_width(&self, text: &str, _font_size: f32) -> f32 {
text.chars().count() as f32 * self.char_width_pts
}
fn encode_text(&self, text: &str) -> Vec<u8> {
vec![0; text.chars().count() * 2]
}
}
fn extract_tf_font_names(objects: &[Object]) -> Vec<String> {
let mut names = Vec::new();
let mut i = 0usize;
while i + 1 < objects.len() {
if let Object::Name(op) = &objects[i] {
if op.as_slice() == b"Tf" {
if let Object::Name(font_name) = &objects[i + 1] {
names.push(String::from_utf8_lossy(font_name).to_string());
}
}
}
i += 1;
}
names
}
#[derive(Debug, Clone, Copy)]
struct RectExtents {
max_top: f32,
min_bottom: f32,
}
fn object_to_f32(object: &Object) -> Option<f32> {
match object {
Object::Integer(v) => Some(*v as f32),
Object::Real(v) => Some(*v),
_ => None,
}
}
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() <= 0.001
}
fn op_has_rgb(op: &Operation, operator: &str, color: Color) -> bool {
op.operator == operator
&& op.operands.len() == 3
&& object_to_f32(&op.operands[0]).map_or(false, |v| approx_eq(v, color.r))
&& object_to_f32(&op.operands[1]).map_or(false, |v| approx_eq(v, color.g))
&& object_to_f32(&op.operands[2]).map_or(false, |v| approx_eq(v, color.b))
}
fn op_has_line_width(op: &Operation, width: f32) -> bool {
op.operator == "w"
&& op.operands.len() == 1
&& object_to_f32(&op.operands[0]).map_or(false, |v| approx_eq(v, width))
}
fn has_stroke_style(operations: &[Operation], color: Color, width: f32) -> bool {
operations.iter().any(|op| op_has_rgb(op, "RG", color))
&& operations.iter().any(|op| op_has_line_width(op, width))
}
fn page_content_operations(doc: &Document, page_id: ObjectId) -> Vec<Operation> {
let bytes = doc
.get_page_content(page_id)
.expect("page content should be readable");
Content::decode(&bytes)
.expect("page content should decode")
.operations
}
fn page_rect_extents(doc: &Document, page_id: ObjectId) -> Option<RectExtents> {
let bytes = doc.get_page_content(page_id).ok()?;
let content = Content::decode(&bytes).ok()?;
let mut max_top = f32::NEG_INFINITY;
let mut min_bottom = f32::INFINITY;
let mut found = false;
for op in content.operations {
if op.operator != "re" || op.operands.len() != 4 {
continue;
}
let y = object_to_f32(&op.operands[1])?;
let h = object_to_f32(&op.operands[3])?;
max_top = max_top.max(y + h);
min_bottom = min_bottom.min(y);
found = true;
}
if found {
Some(RectExtents {
max_top,
min_bottom,
})
} else {
None
}
}
#[test]
fn test_cell_border_overrides_emit_custom_stroke_ops() {
let custom_color = Color::rgb(0.11, 0.22, 0.33);
let custom_width = 2.75;
let header_style = CellStyle {
border_top: Some((BorderStyle::Solid, custom_width, custom_color)),
..Default::default()
};
let table = Table::new()
.with_pixel_widths(vec![180.0])
.add_row(Row::new(vec![Cell::new("Header").with_style(header_style)]));
let objects = Document::with_version("1.7")
.create_table_content(&table, (50.0, 750.0))
.expect("table content should be generated");
let operations = crate::drawing_utils::objects_to_operations(&objects);
assert!(
has_stroke_style(&operations, custom_color, custom_width),
"expected custom border stroke ops (color + width) to be emitted"
);
}
#[test]
fn test_cell_background_fill_ops_still_emitted_with_styled_cells() {
let bg_color = Color::rgb(0.13, 0.27, 0.71);
let style = CellStyle {
background_color: Some(bg_color),
..Default::default()
};
let table = Table::new()
.with_pixel_widths(vec![180.0])
.add_row(Row::new(vec![Cell::new("Header").with_style(style)]));
let objects = Document::with_version("1.7")
.create_table_content(&table, (50.0, 750.0))
.expect("table content should be generated");
let operations = crate::drawing_utils::objects_to_operations(&objects);
let has_bg_color = operations.iter().any(|op| op_has_rgb(op, "rg", bg_color));
let has_fill = operations.iter().any(|op| op.operator == "f");
assert!(
has_bg_color && has_fill,
"expected background color fill ops to be present for styled cells"
);
}
#[test]
fn test_cell_border_overrides_are_emitted_after_table_grid_borders() {
let table_border_color = Color::rgb(0.7, 0.7, 0.7);
let custom_border_color = Color::rgb(0.11, 0.22, 0.33);
let border_width = 0.5;
let mut table_style = TableStyle::default();
table_style.border_color = table_border_color;
table_style.border_width = border_width;
let header_style = CellStyle {
border_top: Some((BorderStyle::Solid, border_width, custom_border_color)),
..Default::default()
};
let table = Table::new()
.with_style(table_style)
.with_pixel_widths(vec![180.0])
.add_row(Row::new(vec![Cell::new("Header").with_style(header_style)]));
let objects = Document::with_version("1.7")
.create_table_content(&table, (50.0, 750.0))
.expect("table content should be generated");
let operations = crate::drawing_utils::objects_to_operations(&objects);
let last_table_border_idx = operations
.iter()
.enumerate()
.filter(|(_, op)| op_has_rgb(op, "RG", table_border_color))
.map(|(idx, _)| idx)
.last()
.expect("expected table border stroke color op");
let last_custom_border_idx = operations
.iter()
.enumerate()
.filter(|(_, op)| op_has_rgb(op, "RG", custom_border_color))
.map(|(idx, _)| idx)
.last()
.expect("expected custom border stroke color op");
assert!(
last_custom_border_idx > last_table_border_idx,
"expected custom border stroke ops to be emitted after table grid borders"
);
}
#[test]
fn test_embedded_bold_resource_selected_for_bold_cells() {
let mut style = TableStyle::default();
style.embedded_font_resource_name = Some("EF0".to_string());
style.embedded_font_resource_name_bold = Some("EF0B".to_string());
let table = Table::new()
.with_style(style)
.add_row(Row::new(vec![Cell::new("Header").bold()]))
.with_font_metrics(MockMetrics {
char_width_pts: 5.0,
})
.with_bold_font_metrics(MockMetrics {
char_width_pts: 9.0,
});
let ops = Document::with_version("1.5")
.create_table_content(&table, (50.0, 750.0))
.expect("table content should be generated");
let font_names = extract_tf_font_names(&ops);
assert!(
font_names.iter().any(|name| name == "EF0B"),
"expected bold embedded font resource EF0B, got: {:?}",
font_names
);
}
#[test]
fn test_embedded_regular_resource_used_as_bold_fallback() {
let mut style = TableStyle::default();
style.embedded_font_resource_name = Some("EF0".to_string());
style.embedded_font_resource_name_bold = None;
let table = Table::new()
.with_style(style)
.add_row(Row::new(vec![Cell::new("Header").bold()]))
.with_font_metrics(MockMetrics {
char_width_pts: 5.0,
});
let ops = Document::with_version("1.5")
.create_table_content(&table, (50.0, 750.0))
.expect("table content should be generated");
let font_names = extract_tf_font_names(&ops);
assert!(
font_names.iter().any(|name| name == "EF0"),
"expected embedded font fallback EF0, got: {:?}",
font_names
);
}
#[test]
fn test_layout_uses_bold_metrics_when_available() {
let bold_cell = Cell::new("WWWWWW").bold();
let table_regular_only = Table::new()
.add_row(Row::new(vec![bold_cell.clone()]))
.with_font_metrics(MockMetrics {
char_width_pts: 2.0,
});
let table_with_bold_metrics = Table::new()
.add_row(Row::new(vec![bold_cell]))
.with_font_metrics(MockMetrics {
char_width_pts: 2.0,
})
.with_bold_font_metrics(MockMetrics {
char_width_pts: 8.0,
});
let regular_layout = layout::calculate_layout(&table_regular_only)
.expect("layout should succeed with regular metrics only");
let bold_layout = layout::calculate_layout(&table_with_bold_metrics)
.expect("layout should succeed with bold metrics");
assert!(
bold_layout.total_width > regular_layout.total_width,
"expected bold metrics to increase width: regular={} bold={}",
regular_layout.total_width,
bold_layout.total_width
);
}
#[test]
fn test_tagged_cell_hook_is_invoked() {
struct Hook {
begin_calls: usize,
end_calls: usize,
}
impl TaggedCellHook for Hook {
fn begin_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
self.begin_calls += 1;
vec![]
}
fn end_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
self.end_calls += 1;
vec![]
}
}
let mut doc = Document::with_version("1.7");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let table = Table::new()
.add_row(Row::new(vec![Cell::new("H1"), Cell::new("H2")]))
.add_row(Row::new(vec![Cell::new("A1"), Cell::new("A2")]))
.with_header_rows(1);
let mut hook = Hook {
begin_calls: 0,
end_calls: 0,
};
doc.draw_table_with_hook(page_id, table, (50.0, 750.0), Some(&mut hook))
.expect("table draw with hook should succeed");
assert_eq!(hook.begin_calls, 4);
assert_eq!(hook.end_calls, 4);
}
#[test]
fn test_marked_content_tokens_parse_as_operators() {
let objects = vec![
Object::Name(b"BDC".to_vec()),
Object::Name(b"TH".to_vec()),
Object::Dictionary(dictionary! { "MCID" => 0 }),
Object::Name(b"BT".to_vec()),
Object::Name(b"ET".to_vec()),
Object::Name(b"EMC".to_vec()),
];
let operations = crate::drawing_utils::objects_to_operations(&objects);
assert_eq!(operations.len(), 4);
assert_eq!(operations[0].operator, "BDC");
assert_eq!(operations[0].operands.len(), 2);
assert_eq!(operations[1].operator, "BT");
assert_eq!(operations[2].operator, "ET");
assert_eq!(operations[3].operator, "EMC");
}
#[test]
fn test_hook_generated_bdc_emc_appear_in_page_content() {
struct MarkedHook;
impl TaggedCellHook for MarkedHook {
fn begin_cell(&mut self, _row: usize, _col: usize, is_header: bool) -> Vec<Operation> {
vec![Operation::new(
"BDC",
vec![
Object::Name(if is_header {
b"TH".to_vec()
} else {
b"TD".to_vec()
}),
Object::Dictionary(dictionary! { "MCID" => 0 }),
],
)]
}
fn end_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
vec![Operation::new("EMC", vec![])]
}
}
let mut doc = Document::with_version("1.7");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let table = Table::new()
.add_row(Row::new(vec![Cell::new("H1"), Cell::new("H2")]))
.add_row(Row::new(vec![Cell::new("A1"), Cell::new("A2")]))
.with_header_rows(1);
let mut hook = MarkedHook;
doc.draw_table_with_hook(page_id, table, (50.0, 750.0), Some(&mut hook))
.expect("table draw with hook should succeed");
let bytes = doc
.get_page_content(page_id)
.expect("page content should be readable");
let decoded = Content::decode(&bytes).expect("content should decode");
let bdc_count = decoded
.operations
.iter()
.filter(|op| op.operator == "BDC")
.count();
let emc_count = decoded
.operations
.iter()
.filter(|op| op.operator == "EMC")
.count();
assert!(bdc_count >= 4);
assert_eq!(bdc_count, emc_count);
}
#[test]
fn test_hook_mode_wraps_non_semantic_ops_as_artifact() {
struct NoopHook;
impl TaggedCellHook for NoopHook {
fn begin_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
vec![]
}
fn end_cell(&mut self, _row: usize, _col: usize, _is_header: bool) -> Vec<Operation> {
vec![]
}
}
let mut doc = Document::with_version("1.7");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let table = Table::new()
.with_border(0.5)
.add_row(Row::new(vec![Cell::new("H1"), Cell::new("H2")]))
.add_row(Row::new(vec![Cell::new("A1"), Cell::new("A2")]))
.with_header_rows(1);
let mut hook = NoopHook;
doc.draw_table_with_hook(page_id, table, (50.0, 750.0), Some(&mut hook))
.expect("table draw with hook should succeed");
let bytes = doc
.get_page_content(page_id)
.expect("page content should be readable");
let decoded = Content::decode(&bytes).expect("content should decode");
let has_artifact_bdc = decoded.operations.iter().any(|op| {
op.operator == "BDC"
&& op
.operands
.first()
.and_then(|operand| operand.as_name().ok())
== Some(b"Artifact".as_slice())
});
assert!(
has_artifact_bdc,
"expected non-semantic table drawing ops to be wrapped as Artifact"
);
}
#[test]
fn test_paginated_table_continuation_pages_use_top_margin_anchor_with_repeated_headers() {
const PAGE_HEIGHT: f32 = 842.0;
const TOP_MARGIN: f32 = 50.0;
const BOTTOM_MARGIN: f32 = 50.0;
const START_Y: f32 = 500.0;
const EPS: f32 = 0.01;
let mut doc = Document::with_version("1.7");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), PAGE_HEIGHT.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let mut style = TableStyle::default();
style.page_height = Some(PAGE_HEIGHT);
style.top_margin = TOP_MARGIN;
style.bottom_margin = BOTTOM_MARGIN;
style.repeat_headers = true;
let mut table = Table::new()
.with_style(style)
.with_header_rows(1)
.with_pixel_widths(vec![300.0])
.add_row(Row::new(vec![Cell::new("Header")]).with_height(30.0));
for row in 0..120 {
table =
table.add_row(Row::new(vec![Cell::new(format!("row-{row}"))]).with_height(30.0));
}
let result = doc
.draw_table_with_pagination(page_id, table, (50.0, START_Y))
.expect("paginated table draw should succeed");
assert!(
result.page_ids.len() >= 3,
"expected at least 3 pages, got {}",
result.page_ids.len()
);
let first_page_extents =
page_rect_extents(&doc, result.page_ids[0]).expect("first page should have rectangles");
let second_page_extents = page_rect_extents(&doc, result.page_ids[1])
.expect("second page should have rectangles");
assert!(
(first_page_extents.max_top - START_Y).abs() <= EPS,
"expected first page max top ~{START_Y}, got {}",
first_page_extents.max_top
);
assert!(
(second_page_extents.max_top - (PAGE_HEIGHT - TOP_MARGIN)).abs() <= EPS,
"expected second page max top ~{}, got {}",
PAGE_HEIGHT - TOP_MARGIN,
second_page_extents.max_top
);
assert!(
second_page_extents.min_bottom >= BOTTOM_MARGIN - EPS,
"expected second page min bottom >= {}, got {}",
BOTTOM_MARGIN - EPS,
second_page_extents.min_bottom
);
}
#[test]
fn test_paginated_repeated_header_border_overrides_render_on_continuation_pages() {
const PAGE_HEIGHT: f32 = 842.0;
const TOP_MARGIN: f32 = 50.0;
const BOTTOM_MARGIN: f32 = 50.0;
const START_Y: f32 = 500.0;
let header_border_color = Color::rgb(0.07, 0.16, 0.29);
let header_border_width = 2.5;
let mut doc = Document::with_version("1.7");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), PAGE_HEIGHT.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let mut table_style = TableStyle::default();
table_style.page_height = Some(PAGE_HEIGHT);
table_style.top_margin = TOP_MARGIN;
table_style.bottom_margin = BOTTOM_MARGIN;
table_style.repeat_headers = true;
let header_style = CellStyle {
border_top: Some((BorderStyle::Solid, header_border_width, header_border_color)),
border_bottom: Some((BorderStyle::Solid, header_border_width, header_border_color)),
..Default::default()
};
let mut table = Table::new()
.with_style(table_style)
.with_header_rows(1)
.with_pixel_widths(vec![300.0])
.add_row(
Row::new(vec![Cell::new("Header").with_style(header_style)]).with_height(30.0),
);
for row in 0..120 {
table =
table.add_row(Row::new(vec![Cell::new(format!("row-{row}"))]).with_height(30.0));
}
let result = doc
.draw_table_with_pagination(page_id, table, (50.0, START_Y))
.expect("paginated table draw should succeed");
assert!(
result.page_ids.len() >= 2,
"expected at least 2 pages, got {}",
result.page_ids.len()
);
let second_page_ops = page_content_operations(&doc, result.page_ids[1]);
assert!(
has_stroke_style(&second_page_ops, header_border_color, header_border_width),
"expected repeated header border override stroke ops on continuation page"
);
}
fn tiny_jpeg_bytes() -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_fn(2, 2, |_, _| Rgb([255, 0, 0]));
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg)
.expect("JPEG encoding should succeed");
buf.into_inner()
}
fn tiny_png_bytes() -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_fn(4, 3, |_, _| Rgb([0, 128, 255]));
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.expect("PNG encoding should succeed");
buf.into_inner()
}
fn make_test_doc() -> (Document, ObjectId) {
let mut doc = Document::with_version("1.7");
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Kids" => vec![],
"Count" => 0,
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
});
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
if let Ok(Object::Array(kids)) = pages.get_mut(b"Kids") {
kids.push(page_id.into());
}
pages.set("Count", Object::Integer(1));
}
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
page.set("Resources", resources_id);
}
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
(doc, page_id)
}
#[test]
fn test_cell_image_jpeg_construction() {
let img = CellImage::new(tiny_jpeg_bytes()).expect("JPEG should parse");
assert_eq!(img.width_px(), 2);
assert_eq!(img.height_px(), 2);
}
#[test]
fn test_cell_image_png_construction() {
let img = CellImage::new(tiny_png_bytes()).expect("PNG should parse");
assert_eq!(img.width_px(), 4);
assert_eq!(img.height_px(), 3);
}
#[test]
fn test_cell_image_invalid_bytes() {
let result = CellImage::new(vec![0, 1, 2, 3]);
assert!(result.is_err(), "invalid bytes should produce an error");
}
#[test]
fn test_image_cell_draw_emits_do_and_cm() {
let (mut doc, page_id) = make_test_doc();
let img = CellImage::new(tiny_jpeg_bytes())
.unwrap()
.with_max_height(120.0);
let table = Table::new()
.with_pixel_widths(vec![200.0])
.add_row(Row::new(vec![Cell::from_image(img)]));
doc.draw_table(page_id, table, (50.0, 750.0))
.expect("image table draw should succeed");
let ops = page_content_operations(&doc, page_id);
let has_cm = ops.iter().any(|op| op.operator == "cm");
let has_do = ops.iter().any(|op| op.operator == "Do");
assert!(has_cm, "expected cm operator for image transform");
assert!(has_do, "expected Do operator for image rendering");
}
#[test]
fn test_create_table_content_rejects_image_cells() {
let img = CellImage::new(tiny_jpeg_bytes()).unwrap();
let table = Table::new()
.with_pixel_widths(vec![200.0])
.add_row(Row::new(vec![Cell::from_image(img)]));
let result = Document::with_version("1.7").create_table_content(&table, (50.0, 750.0));
assert!(
result.is_err(),
"create_table_content should reject image cells"
);
}
#[test]
fn test_text_only_tables_still_work_with_image_support() {
let (mut doc, page_id) = make_test_doc();
let table = Table::new()
.add_row(Row::new(vec![Cell::new("A"), Cell::new("B")]))
.add_row(Row::new(vec![Cell::new("C"), Cell::new("D")]))
.with_border(1.0);
doc.draw_table(page_id, table, (50.0, 750.0))
.expect("text-only table should still work");
}
#[test]
fn test_paginated_image_table_renders_on_continuation_pages() {
const PAGE_HEIGHT: f32 = 842.0;
const TOP_MARGIN: f32 = 50.0;
const BOTTOM_MARGIN: f32 = 50.0;
let (mut doc, page_id) = make_test_doc();
let jpeg = tiny_jpeg_bytes();
let mut style = TableStyle::default();
style.page_height = Some(PAGE_HEIGHT);
style.top_margin = TOP_MARGIN;
style.bottom_margin = BOTTOM_MARGIN;
style.repeat_headers = true;
let mut table = Table::new()
.with_style(style)
.with_header_rows(1)
.with_pixel_widths(vec![100.0, 200.0])
.add_row(Row::new(vec![
Cell::new("Header").bold(),
Cell::new("Photo").bold(),
]));
for _ in 0..30 {
let img = CellImage::new(jpeg.clone()).unwrap().with_max_height(80.0);
table = table.add_row(Row::new(vec![Cell::new("data"), Cell::from_image(img)]));
}
let result = doc
.draw_table_with_pagination(page_id, table, (50.0, 500.0))
.expect("paginated image table should succeed");
assert!(
result.page_ids.len() >= 2,
"expected at least 2 pages, got {}",
result.page_ids.len()
);
let second_page_ops = page_content_operations(&doc, result.page_ids[1]);
let has_do = second_page_ops.iter().any(|op| op.operator == "Do");
assert!(
has_do,
"expected Do operator on continuation page for image rendering"
);
}
#[test]
fn test_image_overlay_emits_gs_and_text_ops() {
let (mut doc, page_id) = make_test_doc();
let img = CellImage::new(tiny_jpeg_bytes())
.unwrap()
.with_max_height(120.0)
.with_overlay(table::ImageOverlay::new("01/01/2026 12:00"));
let table = Table::new()
.with_pixel_widths(vec![200.0])
.add_row(Row::new(vec![Cell::from_image(img)]));
doc.draw_table(page_id, table, (50.0, 750.0))
.expect("image table with overlay should succeed");
let ops = page_content_operations(&doc, page_id);
let has_gs = ops.iter().any(|op| op.operator == "gs");
let has_do = ops.iter().any(|op| op.operator == "Do");
let gs_idx = ops.iter().position(|op| op.operator == "gs").unwrap();
let has_white_text_after_gs = ops[gs_idx..].iter().any(|op| {
op.operator == "rg"
&& op.operands.len() == 3
&& object_to_f32(&op.operands[0]).map_or(false, |v| approx_eq(v, 1.0))
&& object_to_f32(&op.operands[1]).map_or(false, |v| approx_eq(v, 1.0))
&& object_to_f32(&op.operands[2]).map_or(false, |v| approx_eq(v, 1.0))
});
assert!(has_gs, "expected gs operator for overlay transparency");
assert!(has_do, "expected Do operator for image rendering");
assert!(
has_white_text_after_gs,
"expected white text color after gs for overlay"
);
}
#[test]
fn test_image_without_overlay_has_no_gs_ops() {
let (mut doc, page_id) = make_test_doc();
let img = CellImage::new(tiny_jpeg_bytes())
.unwrap()
.with_max_height(120.0);
let table = Table::new()
.with_pixel_widths(vec![200.0])
.add_row(Row::new(vec![Cell::from_image(img)]));
doc.draw_table(page_id, table, (50.0, 750.0))
.expect("image table without overlay should succeed");
let ops = page_content_operations(&doc, page_id);
let has_gs = ops.iter().any(|op| op.operator == "gs");
assert!(
!has_gs,
"expected no gs operator when no overlay is present"
);
}
#[test]
fn test_overlay_extgstate_registered_on_page() {
let (mut doc, page_id) = make_test_doc();
let img = CellImage::new(tiny_jpeg_bytes())
.unwrap()
.with_max_height(120.0)
.with_overlay(table::ImageOverlay::new("test date"));
let table = Table::new()
.with_pixel_widths(vec![200.0])
.add_row(Row::new(vec![Cell::from_image(img)]));
doc.draw_table(page_id, table, (50.0, 750.0))
.expect("overlay table draw should succeed");
let has_gstate = if let Ok(Object::Dictionary(page_dict)) = doc.get_object(page_id) {
if let Ok(Object::Reference(res_ref)) = page_dict.get(b"Resources") {
if let Ok(Object::Dictionary(res_dict)) = doc.get_object(*res_ref) {
if let Ok(Object::Dictionary(gs_dict)) = res_dict.get(b"ExtGState") {
gs_dict.has(b"GSTblOvl")
} else {
false
}
} else {
false
}
} else {
false
}
} else {
false
};
assert!(
has_gstate,
"expected GSTblOvl ExtGState to be registered in page Resources"
);
}
#[test]
fn test_paginated_overlay_images_register_gstate_on_all_pages() {
const PAGE_HEIGHT: f32 = 842.0;
const TOP_MARGIN: f32 = 50.0;
const BOTTOM_MARGIN: f32 = 50.0;
let (mut doc, page_id) = make_test_doc();
let jpeg = tiny_jpeg_bytes();
let mut style = TableStyle::default();
style.page_height = Some(PAGE_HEIGHT);
style.top_margin = TOP_MARGIN;
style.bottom_margin = BOTTOM_MARGIN;
style.repeat_headers = true;
let mut table = Table::new()
.with_style(style)
.with_header_rows(1)
.with_pixel_widths(vec![100.0, 200.0])
.add_row(Row::new(vec![
Cell::new("Header").bold(),
Cell::new("Photo").bold(),
]));
for i in 0..30 {
let img = CellImage::new(jpeg.clone())
.unwrap()
.with_max_height(80.0)
.with_overlay(table::ImageOverlay::new(format!("date-{i}")));
table = table.add_row(Row::new(vec![Cell::new("data"), Cell::from_image(img)]));
}
let result = doc
.draw_table_with_pagination(page_id, table, (50.0, 500.0))
.expect("paginated overlay table should succeed");
assert!(result.page_ids.len() >= 2);
let second_ops = page_content_operations(&doc, result.page_ids[1]);
let has_gs = second_ops.iter().any(|op| op.operator == "gs");
assert!(
has_gs,
"expected gs operator on continuation page for overlay rendering"
);
}
}