use oxidize_pdf::advanced_tables::{
AdvancedTableBuilder, AdvancedTableExt, CellData, TableRenderer,
};
use oxidize_pdf::text::{Table, TableCell};
use oxidize_pdf::writer::WriterConfig;
use oxidize_pdf::{Color, Document, Page, Result};
use regex::Regex;
fn render_table_to_bytes(table: &Table) -> Result<Vec<u8>> {
let mut doc = Document::new();
let mut page = Page::a4();
page.add_table(table)?;
doc.add_page(page);
let config = WriterConfig {
compress_streams: false,
..WriterConfig::default()
};
doc.to_bytes_with_config(config)
}
fn extract_text_positions(pdf_bytes: &[u8]) -> Vec<(f64, f64)> {
let content = String::from_utf8_lossy(pdf_bytes);
let mut positions = Vec::new();
let re_td = Regex::new(r"(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s+Td").unwrap();
for cap in re_td.captures_iter(&content) {
positions.push((cap[1].parse().unwrap(), cap[2].parse().unwrap()));
}
let re_tm =
Regex::new(r"[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s+Tm")
.unwrap();
for cap in re_tm.captures_iter(&content) {
positions.push((cap[1].parse().unwrap(), cap[2].parse().unwrap()));
}
positions
}
fn extract_rectangles(pdf_bytes: &[u8]) -> Vec<(f64, f64, f64, f64)> {
let content = String::from_utf8_lossy(pdf_bytes);
let re =
Regex::new(r"(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s+re").unwrap();
re.captures_iter(&content)
.map(|cap| {
let x: f64 = cap[1].parse().unwrap();
let y: f64 = cap[2].parse().unwrap();
let w: f64 = cap[3].parse().unwrap();
let h: f64 = cap[4].parse().unwrap();
(x, y, w, h)
})
.collect()
}
fn extract_text_strings(pdf_bytes: &[u8]) -> Vec<String> {
let content = String::from_utf8_lossy(pdf_bytes);
let re = Regex::new(r"\(([^)]*)\)\s+Tj").unwrap();
re.captures_iter(&content)
.map(|cap| cap[1].to_string())
.collect()
}
#[test]
fn test_row_order_top_to_bottom() -> Result<()> {
let mut table = Table::new(vec![200.0]);
table.set_position(50.0, 700.0);
table.add_row(vec!["Row A".to_string()])?;
table.add_row(vec!["Row B".to_string()])?;
table.add_row(vec!["Row C".to_string()])?;
let pdf_bytes = render_table_to_bytes(&table)?;
let positions = extract_text_positions(&pdf_bytes);
let texts = extract_text_strings(&pdf_bytes);
assert!(
texts.contains(&"Row A".to_string()),
"PDF should contain 'Row A', found: {:?}",
texts
);
assert!(
texts.contains(&"Row B".to_string()),
"PDF should contain 'Row B'"
);
assert!(
texts.contains(&"Row C".to_string()),
"PDF should contain 'Row C'"
);
let row_a_idx = texts.iter().position(|t| t == "Row A").unwrap();
let row_b_idx = texts.iter().position(|t| t == "Row B").unwrap();
let row_c_idx = texts.iter().position(|t| t == "Row C").unwrap();
let y_a = positions[row_a_idx].1;
let y_b = positions[row_b_idx].1;
let y_c = positions[row_c_idx].1;
assert!(
y_a > y_b,
"Row A (y={y_a}) should be ABOVE Row B (y={y_b}) — first row added should be at top"
);
assert!(
y_b > y_c,
"Row B (y={y_b}) should be ABOVE Row C (y={y_c}) — rows should go top to bottom"
);
Ok(())
}
#[test]
fn test_row_order_cell_backgrounds_match_text_order() -> Result<()> {
use oxidize_pdf::text::table::{GridStyle, TableOptions};
let mut options = TableOptions::default();
options.grid_style = GridStyle::None;
let mut table = Table::new(vec![200.0]);
table.set_options(options);
table.set_position(50.0, 700.0);
let mut cell_a = TableCell::new("Row A".to_string());
cell_a.set_background_color(Color::rgb(1.0, 0.0, 0.0));
let mut cell_b = TableCell::new("Row B".to_string());
cell_b.set_background_color(Color::rgb(0.0, 1.0, 0.0));
table.add_custom_row(vec![cell_a])?;
table.add_custom_row(vec![cell_b])?;
let pdf_bytes = render_table_to_bytes(&table)?;
let rects = extract_rectangles(&pdf_bytes);
assert_eq!(
rects.len(),
2,
"Should have exactly 2 rectangles for cell backgrounds, found {}",
rects.len()
);
let rect_a_bottom_y = rects[0].1;
let rect_b_bottom_y = rects[1].1;
assert!(
rect_a_bottom_y > rect_b_bottom_y,
"Row A rect bottom (y={rect_a_bottom_y}) should be above Row B rect bottom (y={rect_b_bottom_y})"
);
Ok(())
}
#[test]
fn test_basic_table_multiline_text() -> Result<()> {
let mut table = Table::new(vec![200.0]);
table.set_position(50.0, 700.0);
table.add_row(vec!["Line1\nLine2".to_string()])?;
let pdf_bytes = render_table_to_bytes(&table)?;
let texts = extract_text_strings(&pdf_bytes);
let positions = extract_text_positions(&pdf_bytes);
assert!(
texts.contains(&"Line1".to_string()),
"PDF should contain 'Line1', found: {:?}",
texts
);
assert!(
texts.contains(&"Line2".to_string()),
"PDF should contain 'Line2', found: {:?}",
texts
);
let idx1 = texts.iter().position(|t| t == "Line1").unwrap();
let idx2 = texts.iter().position(|t| t == "Line2").unwrap();
assert!(
positions[idx1].1 > positions[idx2].1,
"Line1 (y={}) should be above Line2 (y={})",
positions[idx1].1,
positions[idx2].1
);
Ok(())
}
#[test]
fn test_advanced_table_multiline_text() -> Result<()> {
let table = AdvancedTableBuilder::new()
.add_column("Col1", 200.0)
.add_row_cells(vec![CellData::new("Line1\nLine2")])
.build()
.map_err(|e| oxidize_pdf::error::PdfError::InvalidOperation(e.to_string()))?;
let mut doc = Document::new();
let mut page = Page::a4();
page.add_advanced_table(&table, 50.0, 700.0)?;
doc.add_page(page);
let config = WriterConfig {
compress_streams: false,
..WriterConfig::default()
};
let pdf_bytes = doc.to_bytes_with_config(config)?;
let texts = extract_text_strings(&pdf_bytes);
assert!(
texts.contains(&"Line1".to_string()),
"AdvancedTable PDF should contain 'Line1', found: {:?}",
texts
);
assert!(
texts.contains(&"Line2".to_string()),
"AdvancedTable PDF should contain 'Line2', found: {:?}",
texts
);
Ok(())
}
#[test]
fn test_per_row_height() -> Result<()> {
use oxidize_pdf::text::table::{GridStyle, TableOptions};
let mut options = TableOptions::default();
options.grid_style = GridStyle::None;
options.row_height = 0.0;
let mut table = Table::new(vec![200.0]);
table.set_options(options);
table.set_position(50.0, 700.0);
let cell_a = TableCell::new("Row A".to_string());
table.add_custom_row(vec![cell_a])?;
table.set_last_row_height(30.0);
let cell_b = TableCell::new("Row B".to_string());
table.add_custom_row(vec![cell_b])?;
table.set_last_row_height(50.0);
let cell_c = TableCell::new("Row C".to_string());
table.add_custom_row(vec![cell_c])?;
table.set_last_row_height(20.0);
let pdf_bytes = render_table_to_bytes(&table)?;
let positions = extract_text_positions(&pdf_bytes);
let texts = extract_text_strings(&pdf_bytes);
let idx_a = texts.iter().position(|t| t == "Row A").unwrap();
let idx_b = texts.iter().position(|t| t == "Row B").unwrap();
let idx_c = texts.iter().position(|t| t == "Row C").unwrap();
let y_a = positions[idx_a].1;
let y_b = positions[idx_b].1;
let y_c = positions[idx_c].1;
let gap_ab = y_a - y_b;
let gap_bc = y_b - y_c;
assert!(
(gap_ab - 30.0).abs() < 1.0,
"Gap between Row A and B should be ~30pt (Row A height), got {gap_ab}"
);
assert!(
(gap_bc - 50.0).abs() < 1.0,
"Gap between Row B and C should be ~50pt (Row B height), got {gap_bc}"
);
assert!(
(table.get_height() - 100.0).abs() < 0.01,
"Total height should be 100pt, got {}",
table.get_height()
);
Ok(())
}
#[test]
fn test_advanced_table_colspan_cell_positions() -> Result<()> {
let table = AdvancedTableBuilder::new()
.add_column("A", 100.0)
.add_column("B", 100.0)
.add_column("C", 100.0)
.add_column("D", 100.0)
.add_row_cells(vec![
CellData::new("Span2").colspan(2),
CellData::new("CellC"),
CellData::new("CellD"),
])
.build()
.map_err(|e| oxidize_pdf::error::PdfError::InvalidOperation(e.to_string()))?;
let mut doc = Document::new();
let mut page = Page::a4();
page.add_advanced_table(&table, 50.0, 700.0)?;
doc.add_page(page);
let config = WriterConfig {
compress_streams: false,
..WriterConfig::default()
};
let pdf_bytes = doc.to_bytes_with_config(config)?;
let texts = extract_text_strings(&pdf_bytes);
let positions = extract_text_positions(&pdf_bytes);
assert!(
texts.contains(&"Span2".to_string()),
"Should contain 'Span2', found: {:?}",
texts
);
assert!(
texts.contains(&"CellC".to_string()),
"Should contain 'CellC', found: {:?}",
texts
);
assert!(
texts.contains(&"CellD".to_string()),
"Should contain 'CellD', found: {:?}",
texts
);
let idx_span = texts.iter().position(|t| t == "Span2").unwrap();
let idx_c = texts.iter().position(|t| t == "CellC").unwrap();
let idx_d = texts.iter().position(|t| t == "CellD").unwrap();
let x_span = positions[idx_span].0;
let x_c = positions[idx_c].0;
let x_d = positions[idx_d].0;
assert!(
x_c > x_span + 150.0,
"CellC (x={x_c}) should start at col 2 (~250), not col 1 (~150). Span2 at x={x_span}"
);
assert!(
x_d > x_c + 50.0,
"CellD (x={x_d}) should be after CellC (x={x_c})"
);
Ok(())
}
#[test]
fn test_advanced_table_rowspan_no_overlap() -> Result<()> {
let table = AdvancedTableBuilder::new()
.add_column("A", 150.0)
.add_column("B", 150.0)
.add_row_cells(vec![
CellData::new("SpanDown").rowspan(2),
CellData::new("R0B"),
])
.add_row_cells(vec![
CellData::new("R1B"), ])
.build()
.map_err(|e| oxidize_pdf::error::PdfError::InvalidOperation(e.to_string()))?;
let mut doc = Document::new();
let mut page = Page::a4();
page.add_advanced_table(&table, 50.0, 700.0)?;
doc.add_page(page);
let config = WriterConfig {
compress_streams: false,
..WriterConfig::default()
};
let pdf_bytes = doc.to_bytes_with_config(config)?;
let texts = extract_text_strings(&pdf_bytes);
assert!(
texts.contains(&"SpanDown".to_string()),
"Should contain 'SpanDown'"
);
assert!(texts.contains(&"R0B".to_string()), "Should contain 'R0B'");
assert!(texts.contains(&"R1B".to_string()), "Should contain 'R1B'");
let positions = extract_text_positions(&pdf_bytes);
let idx_r1b = texts.iter().position(|t| t == "R1B").unwrap();
let x_r1b = positions[idx_r1b].0;
assert!(
x_r1b > 180.0,
"R1B (x={x_r1b}) should be in column B (x~200), not column A (x~50)"
);
Ok(())
}
#[test]
fn test_cjk_center_alignment_in_table() -> Result<()> {
use oxidize_pdf::text::TextAlign;
let mut table = Table::new(vec![200.0]);
table.set_position(50.0, 700.0);
table.add_row_with_alignment(vec!["测试中文".to_string()], TextAlign::Center)?;
table.add_row_with_alignment(vec!["Test".to_string()], TextAlign::Center)?;
let pdf_bytes = render_table_to_bytes(&table)?;
let texts = extract_text_strings(&pdf_bytes);
let positions = extract_text_positions(&pdf_bytes);
assert!(
texts.contains(&"Test".to_string()),
"Should contain 'Test', found: {:?}",
texts
);
let test_idx = texts.iter().position(|t| t == "Test").unwrap();
let test_x = positions[test_idx].0;
assert!(
test_x > 80.0 && test_x < 200.0,
"Centered 'Test' (x={test_x}) should be roughly centered in 200pt cell starting at x=50"
);
Ok(())
}
#[test]
fn test_extract_text_positions_is_non_empty() -> Result<()> {
let mut table = Table::new(vec![200.0]);
table.set_position(50.0, 700.0);
table.add_row(vec!["Guard".to_string()])?;
let pdf_bytes = render_table_to_bytes(&table)?;
let positions = extract_text_positions(&pdf_bytes);
assert!(
!positions.is_empty(),
"extract_text_positions returned empty — regex may not match the PDF operator format"
);
Ok(())
}
#[test]
fn test_advanced_table_auto_height_multiline() -> Result<()> {
let table = AdvancedTableBuilder::new()
.add_column("Col1", 200.0)
.add_column("Col2", 200.0)
.add_row_cells(vec![
CellData::new("Line1\nLine2\nLine3"),
CellData::new("Single"),
])
.build()
.map_err(|e| oxidize_pdf::error::PdfError::InvalidOperation(e.to_string()))?;
let renderer = TableRenderer::new();
let calc_height = renderer.calculate_table_height(&table);
assert!(
calc_height > 60.0,
"calculate_table_height should expand for multiline content, got {calc_height} \
(expected > 60, default without expansion would be ~55)"
);
Ok(())
}
#[test]
fn test_advanced_table_auto_height_renders_all_lines() -> Result<()> {
let table = AdvancedTableBuilder::new()
.add_column("Content", 300.0)
.add_row_cells(vec![CellData::new("Alpha\nBravo\nCharlie")])
.build()
.map_err(|e| oxidize_pdf::error::PdfError::InvalidOperation(e.to_string()))?;
let mut doc = Document::new();
let mut page = Page::a4();
page.add_advanced_table(&table, 50.0, 700.0)?;
doc.add_page(page);
let config = WriterConfig {
compress_streams: false,
..WriterConfig::default()
};
let pdf_bytes = doc.to_bytes_with_config(config)?;
let texts = extract_text_strings(&pdf_bytes);
assert!(
texts.contains(&"Alpha".to_string()),
"should contain 'Alpha', found: {:?}",
texts
);
assert!(
texts.contains(&"Bravo".to_string()),
"should contain 'Bravo', found: {:?}",
texts
);
assert!(
texts.contains(&"Charlie".to_string()),
"should contain 'Charlie', found: {:?}",
texts
);
let positions = extract_text_positions(&pdf_bytes);
let idx_a = texts.iter().position(|t| t == "Alpha").unwrap();
let idx_b = texts.iter().position(|t| t == "Bravo").unwrap();
let idx_c = texts.iter().position(|t| t == "Charlie").unwrap();
assert!(
positions[idx_a].1 > positions[idx_b].1,
"Alpha (y={}) should be above Bravo (y={})",
positions[idx_a].1,
positions[idx_b].1
);
assert!(
positions[idx_b].1 > positions[idx_c].1,
"Bravo (y={}) should be above Charlie (y={})",
positions[idx_b].1,
positions[idx_c].1
);
Ok(())
}
#[test]
fn test_advanced_table_height_matches_render() -> Result<()> {
let table = AdvancedTableBuilder::new()
.add_column("A", 200.0)
.add_column("B", 200.0)
.add_row_cells(vec![
CellData::new("One\nTwo\nThree\nFour"),
CellData::new("Short"),
])
.add_row_cells(vec![
CellData::new("Single"),
CellData::new("Also\nTwo lines"),
])
.build()
.map_err(|e| oxidize_pdf::error::PdfError::InvalidOperation(e.to_string()))?;
let renderer = TableRenderer::new();
let predicted = renderer.calculate_table_height(&table);
let mut doc = Document::new();
let mut page = Page::a4();
let start_y = 700.0;
let final_y = page.add_advanced_table(&table, 50.0, start_y)?;
doc.add_page(page);
let rendered_height = start_y - final_y;
let diff = (predicted - rendered_height).abs();
assert!(
diff < 3.0,
"calculate_table_height ({predicted:.1}) and actual render height ({rendered_height:.1}) \
disagree by {diff:.1}pt (tolerance 3pt)"
);
Ok(())
}