use super::engine::{FontSizeAnalysis, ParseRule};
use crate::config::{ParsingConfig, ParagraphClusteringConfig};
use crate::types::{BoundingBox, FontClass, ParsedElementType, ParsedPdfElement};
use anyhow::Result;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum MergeLevel {
Segments,
Lines,
Columns,
Bands,
}
impl MergeLevel {
fn resolve(cfg: &ParagraphClusteringConfig) -> Self {
if cfg.merge_bands {
if !cfg.merge_columns {
eprintln!(
"ParagraphClustering: merge_bands=true but merge_columns=false; \
auto-promoting merge_columns"
);
}
if !cfg.merge_lines {
eprintln!(
"ParagraphClustering: merge_bands=true but merge_lines=false; \
auto-promoting merge_lines"
);
}
if !cfg.merge_segments {
eprintln!(
"ParagraphClustering: merge_bands=true but merge_segments=false; \
auto-promoting merge_segments"
);
}
return MergeLevel::Bands;
}
if cfg.merge_columns {
if !cfg.merge_lines {
eprintln!(
"ParagraphClustering: merge_columns=true but merge_lines=false; \
auto-promoting merge_lines"
);
}
if !cfg.merge_segments {
eprintln!(
"ParagraphClustering: merge_columns=true but merge_segments=false; \
auto-promoting merge_segments"
);
}
return MergeLevel::Columns;
}
if cfg.merge_lines {
if !cfg.merge_segments {
eprintln!(
"ParagraphClustering: merge_lines=true but merge_segments=false; \
auto-promoting merge_segments"
);
}
return MergeLevel::Lines;
}
if cfg.merge_segments {
return MergeLevel::Segments;
}
MergeLevel::Segments
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct PartitionKey {
page: u32,
band: u32,
column: u32,
paragraph_number: u32,
element_type: u8, }
fn element_type_ordinal(t: &ParsedElementType) -> u8 {
match t {
ParsedElementType::Section => 0,
ParsedElementType::Paragraph => 1,
ParsedElementType::List => 2,
ParsedElementType::ListItem => 3,
}
}
fn build_key(el: &ParsedPdfElement, level: MergeLevel) -> PartitionKey {
let p = el.pdf_placement();
match level {
MergeLevel::Segments => PartitionKey {
page: p.page_number,
band: p.band,
column: p.column,
paragraph_number: p.line_number, element_type: element_type_ordinal(&el.element_type),
},
MergeLevel::Lines => PartitionKey {
page: p.page_number,
band: p.band,
column: p.column,
paragraph_number: p.paragraph_number,
element_type: element_type_ordinal(&el.element_type),
},
MergeLevel::Columns => PartitionKey {
page: p.page_number,
band: p.band,
column: 0, paragraph_number: 0, element_type: element_type_ordinal(&el.element_type),
},
MergeLevel::Bands => PartitionKey {
page: p.page_number,
band: 0, column: 0,
paragraph_number: 0,
element_type: element_type_ordinal(&el.element_type),
},
}
}
fn bbox_union(a: &BoundingBox, b: &BoundingBox) -> BoundingBox {
let x = a.x.min(b.x);
let y = a.y.min(b.y);
let right = (a.x + a.width).max(b.x + b.width);
let bottom = (a.y + a.height).max(b.y + b.height);
BoundingBox {
x,
y,
width: right - x,
height: bottom - y,
}
}
fn majority_style(sorted_elements: &[ParsedPdfElement]) -> FontClass {
let mut class_tokens: HashMap<String, usize> = HashMap::new();
let mut first_occurrence: HashMap<String, usize> = HashMap::new();
for (pos, el) in sorted_elements.iter().enumerate() {
let class = &el.style_info.class_name;
*class_tokens.entry(class.clone()).or_insert(0) += el.token_count;
first_occurrence.entry(class.clone()).or_insert(pos);
}
let winning_class = class_tokens
.iter()
.max_by(|(ca, ta), (cb, tb)| {
ta.cmp(tb).then_with(|| {
let ia = first_occurrence[*ca];
let ib = first_occurrence[*cb];
ib.cmp(&ia) })
})
.map(|(class, _)| class.clone())
.unwrap_or_else(|| sorted_elements[0].style_info.class_name.clone());
sorted_elements
.iter()
.find(|el| el.style_info.class_name == winning_class)
.map(|el| el.style_info.clone())
.unwrap_or_else(|| sorted_elements[0].style_info.clone())
}
fn merge_group(
mut sorted_elements: Vec<ParsedPdfElement>,
cfg: &ParagraphClusteringConfig,
) -> Option<ParsedPdfElement> {
if sorted_elements.is_empty() {
return None;
}
if sorted_elements.len() == 1 {
let el = sorted_elements.remove(0);
if el.text.trim().is_empty() {
return None;
}
return Some(el);
}
let mut text = String::new();
let mut prev_line_number: Option<u32> = None;
for el in &sorted_elements {
let line = el.pdf_placement().line_number;
let nr_cols = el.pdf_placement().nr_band_columns;
if !text.is_empty() {
match prev_line_number {
Some(prev) if prev == line => {
}
_ => {
if nr_cols <= 2 {
text.push_str(&cfg.prose_line_separator);
} else {
text.push_str(&cfg.table_line_separator);
}
}
}
}
text.push_str(&el.text);
prev_line_number = Some(line);
}
if text.trim().is_empty() {
return None;
}
let mut union_bbox = sorted_elements[0].pdf_placement().bounding_box.clone();
for el in &sorted_elements[1..] {
union_bbox = bbox_union(&union_bbox, &el.pdf_placement().bounding_box);
}
let winning_style = majority_style(&sorted_elements);
let min_reading_order = sorted_elements
.iter()
.map(|el| el.reading_order)
.min()
.unwrap_or(0);
let sum_tokens: usize = sorted_elements.iter().map(|el| el.token_count).sum();
let token_count = sum_tokens;
let bookmark_match = sorted_elements
.iter()
.find_map(|el| el.bookmark_match.clone());
let element_type = sorted_elements[0].element_type.clone();
let mut merged_placement = sorted_elements[0].pdf_placement().clone();
merged_placement.bounding_box = union_bbox;
let position = sorted_elements[0].position;
Some(ParsedPdfElement {
element_type,
text,
hierarchy_level: sorted_elements[0].hierarchy_level,
position,
style_info: winning_style,
placement: Some(merged_placement),
reading_order: min_reading_order,
bookmark_match,
token_count,
})
}
pub struct ParagraphClusteringRule<'a> {
#[allow(dead_code)]
engine: &'a super::engine::RuleEngine,
#[allow(dead_code)]
text_elements: &'a [crate::types::PdfTextElement],
config: &'a ParsingConfig,
#[allow(dead_code)]
document_analysis: &'a crate::types::DocumentAnalysis,
#[allow(dead_code)]
font_size_analysis: &'a FontSizeAnalysis,
#[allow(dead_code)]
style_data: &'a crate::types::StyleData,
}
impl<'a> ParagraphClusteringRule<'a> {
pub fn new(
engine: &'a super::engine::RuleEngine,
text_elements: &'a [crate::types::PdfTextElement],
config: &'a ParsingConfig,
document_analysis: &'a crate::types::DocumentAnalysis,
font_size_analysis: &'a FontSizeAnalysis,
style_data: &'a crate::types::StyleData,
) -> Self {
Self {
engine,
text_elements,
config,
document_analysis,
font_size_analysis,
style_data,
}
}
pub fn apply(&self, elements: Vec<ParsedPdfElement>) -> Result<Vec<ParsedPdfElement>> {
let cfg = &self.config.paragraph_clustering;
let level = MergeLevel::resolve(cfg);
println!(
" 📦 ParagraphClustering: {} input elements, merge level = {:?}",
elements.len(),
level,
);
if elements.is_empty() {
return Ok(elements);
}
let mut partition_order: Vec<PartitionKey> = Vec::new();
let mut partition_map: HashMap<PartitionKey, Vec<ParsedPdfElement>> = HashMap::new();
for el in elements {
let key = build_key(&el, level);
if !partition_map.contains_key(&key) {
partition_order.push(key.clone());
}
partition_map.entry(key).or_default().push(el);
}
let mut merged: Vec<ParsedPdfElement> = Vec::with_capacity(partition_order.len());
for key in &partition_order {
let mut group = partition_map.remove(key).unwrap_or_default();
group.sort_by_key(|el| el.reading_order);
if let Some(merged_el) = merge_group(group, cfg) {
merged.push(merged_el);
}
}
merged.sort_by_key(|el| el.reading_order);
println!(
" ✅ ParagraphClustering: {} output elements (from {} partitions)",
merged.len(),
partition_order.len(),
);
Ok(merged)
}
}
impl<'a> ParseRule for ParagraphClusteringRule<'a> {
fn apply(&self, elements: Vec<ParsedPdfElement>) -> Result<Vec<ParsedPdfElement>> {
self.apply(elements)
}
fn name(&self) -> &str {
"ParagraphClustering"
}
}