use pdf_extract::TextBlock;
const LINE_Y_TOLERANCE: f64 = 2.0;
const PARAGRAPH_GAP_FACTOR: f64 = 1.5;
const TABLE_X_TOLERANCE: f64 = 5.0;
#[derive(Debug, Clone)]
pub struct Run {
pub text: String,
pub font_name: String,
pub font_size: f64,
pub bold: bool,
pub italic: bool,
}
#[derive(Debug, Clone)]
pub struct Paragraph {
pub runs: Vec<Run>,
}
#[derive(Debug, Clone)]
pub struct Table {
pub rows: Vec<Vec<String>>,
pub col_count: usize,
}
#[derive(Debug, Clone)]
pub struct DocxImage {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub content_type: String,
pub id: String,
}
#[derive(Debug, Clone)]
pub enum PageElement {
Para(Paragraph),
Tbl(Table),
Img(DocxImage),
}
#[derive(Debug)]
struct Line {
y: f64,
font_size: f64,
blocks: Vec<TextBlock>,
}
pub fn analyze_page(blocks: &[TextBlock]) -> Vec<PageElement> {
if blocks.is_empty() {
return Vec::new();
}
let lines = group_into_lines(blocks);
let table = try_detect_table(&lines);
if let Some(tbl) = table {
return vec![PageElement::Tbl(tbl)];
}
group_into_paragraphs(&lines)
}
fn group_into_lines(blocks: &[TextBlock]) -> Vec<Line> {
let mut sorted: Vec<&TextBlock> = blocks.iter().collect();
sorted.sort_by(|a, b| {
let y_cmp = b.bbox[1]
.partial_cmp(&a.bbox[1])
.unwrap_or(std::cmp::Ordering::Equal);
if y_cmp == std::cmp::Ordering::Equal {
a.bbox[0]
.partial_cmp(&b.bbox[0])
.unwrap_or(std::cmp::Ordering::Equal)
} else {
y_cmp
}
});
let mut lines: Vec<Line> = Vec::new();
for block in sorted {
let y = block.bbox[1];
let matched = lines
.iter_mut()
.find(|line| (line.y - y).abs() < LINE_Y_TOLERANCE);
if let Some(line) = matched {
line.blocks.push(block.clone());
} else {
lines.push(Line {
y,
font_size: block.font_size,
blocks: vec![block.clone()],
});
}
}
for line in &mut lines {
line.blocks.sort_by(|a, b| {
a.bbox[0]
.partial_cmp(&b.bbox[0])
.unwrap_or(std::cmp::Ordering::Equal)
});
}
lines
}
const MIN_COLUMN_LINES: usize = 2;
const MIN_COLUMN_SUPPORT_PERCENT: usize = 75;
fn column_positions(lines: &[Line]) -> Vec<f64> {
let mut candidates: Vec<(f64, usize)> = Vec::new();
for line in lines {
let mut counted: Vec<usize> = Vec::new();
for block in &line.blocks {
let x = block.bbox[0];
match candidates
.iter()
.position(|&(px, _)| (px - x).abs() < TABLE_X_TOLERANCE)
{
Some(i) => {
if !counted.contains(&i) {
candidates[i].1 += 1;
counted.push(i);
}
}
None => {
candidates.push((x, 1));
counted.push(candidates.len() - 1);
}
}
}
}
let required = (lines.len() * MIN_COLUMN_SUPPORT_PERCENT).div_ceil(100);
let threshold = std::cmp::max(MIN_COLUMN_LINES, required);
let mut positions: Vec<f64> = candidates
.into_iter()
.filter(|&(_, support)| support >= threshold)
.map(|(x, _)| x)
.collect();
positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
positions
}
fn column_for(x: f64, positions: &[f64]) -> usize {
let mut best = 0;
for (i, &px) in positions.iter().enumerate() {
if px <= x + TABLE_X_TOLERANCE {
best = i;
} else {
break;
}
}
best
}
fn try_detect_table(lines: &[Line]) -> Option<Table> {
if lines.len() < 2 {
return None;
}
let x_positions = column_positions(lines);
if x_positions.len() < 2 {
return None;
}
let multi_col_lines = lines
.iter()
.filter(|line| {
line.blocks
.iter()
.map(|b| column_for(b.bbox[0], &x_positions))
.collect::<std::collections::HashSet<_>>()
.len()
>= 2
})
.count();
if multi_col_lines * 100 / lines.len() < 60 {
return None;
}
let col_count = x_positions.len();
let mut rows = Vec::new();
for line in lines {
let mut row = vec![String::new(); col_count];
for block in &line.blocks {
let col_idx = column_for(block.bbox[0], &x_positions);
if !row[col_idx].is_empty() {
row[col_idx].push(' ');
}
row[col_idx].push_str(&block.text);
}
rows.push(row);
}
Some(Table { rows, col_count })
}
fn group_into_paragraphs(lines: &[Line]) -> Vec<PageElement> {
let mut elements = Vec::new();
let mut current_runs: Vec<Run> = Vec::new();
let mut prev_y: Option<f64> = None;
let mut prev_font_size: f64 = 12.0;
for line in lines {
let line_text: String = line
.blocks
.iter()
.map(|b| b.text.as_str())
.collect::<Vec<_>>()
.join(" ");
if line_text.trim().is_empty() {
continue;
}
let is_new_paragraph = if let Some(py) = prev_y {
let gap = (py - line.y).abs();
gap > prev_font_size * PARAGRAPH_GAP_FACTOR
} else {
false
};
if is_new_paragraph && !current_runs.is_empty() {
elements.push(PageElement::Para(Paragraph {
runs: std::mem::take(&mut current_runs),
}));
}
let font_name = line
.blocks
.first()
.map(|b| b.font_name.clone())
.unwrap_or_default();
let font_size = line.font_size;
let bold = font_name.contains("Bold") || font_name.contains("bold");
let italic = font_name.contains("Italic")
|| font_name.contains("italic")
|| font_name.contains("Oblique");
current_runs.push(Run {
text: line_text,
font_name,
font_size,
bold,
italic,
});
prev_y = Some(line.y);
prev_font_size = font_size;
}
if !current_runs.is_empty() {
elements.push(PageElement::Para(Paragraph { runs: current_runs }));
}
elements
}
pub fn map_font_name(pdf_font: &str) -> &str {
let name = if let Some(pos) = pdf_font.find('+') {
&pdf_font[pos + 1..]
} else {
pdf_font
};
if name.contains("Times") || name.contains("Serif") {
"Times New Roman"
} else if name.contains("Arial") || name.contains("Helvetica") || name.contains("Sans") {
"Arial"
} else if name.contains("Courier") || name.contains("Mono") {
"Courier New"
} else if name.contains("Symbol") {
"Symbol"
} else {
"Calibri"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_block(text: &str, x: f64, y: f64, font_size: f64) -> TextBlock {
TextBlock {
text: text.to_string(),
page: 1,
bbox: [x, y, x + text.len() as f64 * font_size * 0.5, y + font_size],
font_name: "F1".to_string(),
font_size,
actual_text: None,
base_font: None,
is_bold: false,
is_italic: false,
color: None,
width_source: Default::default(),
char_bounds: vec![],
}
}
#[test]
fn single_line_becomes_paragraph() {
let blocks = vec![make_block("Hello World", 72.0, 720.0, 12.0)];
let elements = analyze_page(&blocks);
assert_eq!(elements.len(), 1);
assert!(matches!(elements[0], PageElement::Para(_)));
}
#[test]
fn two_close_lines_same_paragraph() {
let blocks = vec![
make_block("Line 1", 72.0, 720.0, 12.0),
make_block("Line 2", 72.0, 706.0, 12.0), ];
let elements = analyze_page(&blocks);
assert_eq!(elements.len(), 1);
}
#[test]
fn two_distant_lines_different_paragraphs() {
let blocks = vec![
make_block("Para 1", 72.0, 720.0, 12.0),
make_block("Para 2", 72.0, 680.0, 12.0), ];
let elements = analyze_page(&blocks);
assert_eq!(elements.len(), 2);
}
#[test]
fn table_detection() {
let blocks = vec![
make_block("Name", 72.0, 700.0, 12.0),
make_block("Age", 200.0, 700.0, 12.0),
make_block("Alice", 72.0, 684.0, 12.0),
make_block("30", 200.0, 684.0, 12.0),
make_block("Bob", 72.0, 668.0, 12.0),
make_block("25", 200.0, 668.0, 12.0),
];
let elements = analyze_page(&blocks);
assert_eq!(elements.len(), 1);
assert!(matches!(elements[0], PageElement::Tbl(_)));
if let PageElement::Tbl(ref tbl) = elements[0] {
assert_eq!(tbl.rows.len(), 3);
assert_eq!(tbl.col_count, 2);
}
}
#[test]
fn empty_blocks_returns_empty() {
let elements = analyze_page(&[]);
assert!(elements.is_empty());
}
#[test]
fn a_sentence_does_not_become_a_table_with_a_column_per_word() {
let mut blocks = Vec::new();
for (row, y) in [(0, 700.0), (1, 684.0), (2, 668.0)] {
for w in 0..8 {
let x = 72.0 + (w as f64) * 37.0 + (row as f64) * 11.0;
blocks.push(make_block("word", x, y, 12.0));
}
}
let elements = analyze_page(&blocks);
for el in &elements {
if let PageElement::Tbl(t) = el {
assert!(
t.col_count <= 4,
"flowing text became a table of {} columns; that is the \
symptom from #161",
t.col_count
);
}
}
}
#[test]
fn a_real_table_keeps_its_columns() {
let mut blocks = Vec::new();
for y in [700.0_f64, 684.0, 668.0, 652.0] {
blocks.push(make_block("left", 72.0, y, 12.0));
blocks.push(make_block("middle", 200.0, y, 12.0));
blocks.push(make_block("right", 340.0, y, 12.0));
}
let elements = analyze_page(&blocks);
let table = elements.iter().find_map(|e| match e {
PageElement::Tbl(t) => Some(t),
_ => None,
});
let t = table.expect("an aligned three-column table was not detected");
assert_eq!(t.col_count, 3, "columns: {:?}", t.rows.first());
assert_eq!(t.rows.len(), 4);
}
#[test]
fn text_without_a_column_of_its_own_does_not_land_in_the_first_cell() {
let mut blocks = Vec::new();
for y in [700.0_f64, 684.0, 668.0] {
blocks.push(make_block("A", 72.0, y, 12.0));
blocks.push(make_block("B", 300.0, y, 12.0));
}
blocks.push(make_block("loose", 310.0, 652.0, 12.0));
blocks.push(make_block("A", 72.0, 652.0, 12.0));
let elements = analyze_page(&blocks);
if let Some(PageElement::Tbl(t)) =
elements.iter().find(|e| matches!(e, PageElement::Tbl(_)))
{
let last = t.rows.last().expect("no rows");
assert!(
!last[0].contains("loose"),
"unaligned text was dumped into the first cell: {last:?}"
);
}
}
}