use super::engine::ParseRule;
use crate::config::{ElementClusteringConfig, ParsingConfig};
use crate::types::BoundingBox;
use crate::types::*;
use anyhow::Result;
pub struct SpatialClusteringRule<'a> {
config: &'a ParsingConfig,
}
impl<'a> SpatialClusteringRule<'a> {
pub fn new(config: &'a ParsingConfig) -> Self {
Self { config }
}
}
impl<'a> ParseRule for SpatialClusteringRule<'a> {
fn apply(&self, elements: Vec<ParsedPdfElement>) -> Result<Vec<ParsedPdfElement>> {
println!(
"🧩 SpatialClustering rule applied - clustering {} elements by adjacency",
elements.len()
);
if elements.is_empty() {
return Ok(elements);
}
let original_count = elements.len();
let mut clustered_elements = elements;
if self.config.spatial_clustering.enable_paragraph_merging {
println!(" 📝 Step 1: Paragraph merging enabled");
clustered_elements = self.cluster_paragraphs_elements(clustered_elements)?;
} else {
println!(" ⏭️ Step 1: Paragraph merging disabled");
}
if self.config.spatial_clustering.enable_spatial_adjacency {
println!(" 🧩 Step 2: Spatial adjacency clustering enabled");
clustered_elements = self.cluster_adjacent_elements(clustered_elements)?;
} else {
println!(" ⏭️ Step 2: Spatial adjacency clustering disabled");
}
println!(
" ✅ Clustered into {} elements (reduced from {})",
clustered_elements.len(),
original_count
);
Ok(clustered_elements)
}
fn name(&self) -> &str {
"SpatialClustering"
}
}
impl<'a> SpatialClusteringRule<'a> {
fn cluster_paragraphs_elements(
&self,
elements: Vec<ParsedPdfElement>,
) -> Result<Vec<ParsedPdfElement>> {
println!("🔗 Clustering paragraph segments by paragraph_number and page...");
if elements.is_empty() {
return Ok(elements);
}
let mut paragraph_groups: std::collections::HashMap<(u32, u32), Vec<ParsedPdfElement>> =
std::collections::HashMap::new();
for element in elements {
let key = (element.page_number, element.paragraph_number);
paragraph_groups
.entry(key)
.or_default()
.push(element);
}
let original_count = paragraph_groups.values().map(|v| v.len()).sum::<usize>();
let mut clustered_elements = Vec::new();
for ((_page_num, _para_num), mut group) in paragraph_groups {
if group.len() == 1 {
clustered_elements.push(group.into_iter().next().unwrap());
} else {
group.sort_by_key(|e| e.reading_order);
let _group_len = group.len();
let mut group_iter = group.into_iter();
let mut merged_element = group_iter.next().unwrap();
for element in group_iter {
merged_element.text = format!("{} {}", merged_element.text, element.text);
merged_element.bounding_box = self
.merge_bounding_boxes(&merged_element.bounding_box, &element.bounding_box);
merged_element.token_count += element.token_count;
}
clustered_elements.push(merged_element);
}
}
clustered_elements.sort_by(|a, b| {
a.page_number
.cmp(&b.page_number)
.then(a.reading_order.cmp(&b.reading_order))
});
println!(
" ✅ Clustered {} segments into {} paragraphs",
original_count,
clustered_elements.len()
);
Ok(clustered_elements)
}
fn cluster_adjacent_elements(
&self,
elements: Vec<ParsedPdfElement>,
) -> Result<Vec<ParsedPdfElement>> {
let mut clustered = Vec::new();
let mut current_cluster: Option<ParsedPdfElement> = None;
for element in elements {
match &mut current_cluster {
None => {
current_cluster = Some(element);
}
Some(cluster) => {
if self.can_merge_elements(cluster, &element) {
self.merge_elements(cluster, element);
} else {
clustered.push(current_cluster.take().unwrap());
current_cluster = Some(element);
}
}
}
}
if let Some(cluster) = current_cluster {
clustered.push(cluster);
}
Ok(clustered)
}
fn can_merge_elements(&self, cluster: &ParsedPdfElement, element: &ParsedPdfElement) -> bool {
if cluster.element_type != element.element_type {
return false;
}
if cluster.hierarchy_level != element.hierarchy_level {
return false;
}
if cluster.page_number != element.page_number {
return false;
}
let config = self.get_clustering_config_for_type(&cluster.element_type);
let combined_length = cluster.text.len() + element.text.len() + 1;
if combined_length > config.max_segment_size {
return false;
}
if !self.are_spatially_adjacent(cluster, element) {
return false;
}
true
}
fn merge_elements(&self, cluster: &mut ParsedPdfElement, element: ParsedPdfElement) {
cluster.text = format!("{} {}", cluster.text, element.text);
cluster.bounding_box =
self.merge_bounding_boxes(&cluster.bounding_box, &element.bounding_box);
cluster.token_count += element.token_count;
}
fn get_clustering_config_for_type(
&self,
element_type: &ParsedElementType,
) -> &ElementClusteringConfig {
match element_type {
ParsedElementType::Section => &self.config.spatial_clustering.sections,
ParsedElementType::Paragraph
| ParsedElementType::List
| ParsedElementType::ListItem => &self.config.spatial_clustering.paragraphs,
}
}
fn merge_bounding_boxes(&self, bbox1: &BoundingBox, bbox2: &BoundingBox) -> BoundingBox {
let min_x = bbox1.x.min(bbox2.x); let min_y = bbox1.y.min(bbox2.y); let max_x = (bbox1.x + bbox1.width).max(bbox2.x + bbox2.width); let max_y = (bbox1.y + bbox1.height).max(bbox2.y + bbox2.height);
BoundingBox {
x: min_x,
y: min_y,
width: max_x - min_x, height: max_y - min_y, }
}
fn are_spatially_adjacent(
&self,
cluster: &ParsedPdfElement,
element: &ParsedPdfElement,
) -> bool {
let cluster_bbox = &cluster.bounding_box;
let element_bbox = &element.bounding_box;
let cluster_bottom = cluster_bbox.y + cluster_bbox.height;
let element_top = element_bbox.y;
let element_bottom = element_bbox.y + element_bbox.height;
let cluster_top = cluster_bbox.y;
let vertical_gap = if cluster_bottom <= element_top {
element_top - cluster_bottom
} else if element_bottom <= cluster_top {
cluster_top - element_bottom
} else {
0.0
};
let min_line_height = self.config.spatial_clustering.min_line_height;
let gap_multiplier = self
.config
.spatial_clustering
.vertical_gap_threshold_multiplier;
let max_vertical_gap = min_line_height * gap_multiplier;
if vertical_gap > max_vertical_gap {
return false;
}
let cluster_left = cluster_bbox.x;
let cluster_right = cluster_bbox.x + cluster_bbox.width;
let element_left = element_bbox.x;
let element_right = element_bbox.x + element_bbox.width;
let horizontal_tolerance = self
.config
.spatial_clustering
.horizontal_alignment_tolerance;
let horizontal_overlap = cluster_right.max(element_right) - cluster_left.min(element_left)
< (cluster_bbox.width + element_bbox.width + horizontal_tolerance);
if !horizontal_overlap {
return false;
}
true
}
}