use pdfium_render::prelude::*;
use super::geometry::{
area_ok, cluster_bboxes, containment_ratio, pad_bbox, CONTAINMENT_SUPPRESS, MAX_AREA_FRAC,
};
use super::types::{BBox, RegionKind, RegionProposal, RegionSource};
pub const GRID_RULE_MIN: usize = 2;
#[derive(Clone, Copy)]
struct ObjMeta {
bbox: BBox,
is_image: bool,
is_form: bool,
is_h_rule: bool,
is_v_rule: bool,
}
pub fn propose_object_clusters(page: &PdfPage<'_>, page_num: usize) -> Vec<RegionProposal> {
let page_width = page.width().value.max(1.0);
let page_height = page.height().value.max(1.0);
let page_area = page_width * page_height;
let metas = collect_object_metas(page);
if metas.is_empty() {
return Vec::new();
}
let mut proposals: Vec<RegionProposal> = Vec::new();
for m in metas.iter().filter(|m| m.is_image || m.is_form) {
let bbox = pad_bbox(m.bbox, page_width, page_height);
if !region_area_ok(bbox, page_area, m.is_image) {
continue;
}
proposals.push(placement_proposal(
page_num, bbox, m.is_image, m.is_form, false,
));
}
let path_boxes: Vec<BBox> = metas
.iter()
.filter(|m| !m.is_image && !m.is_form)
.filter(|m| {
!proposals
.iter()
.any(|p| containment_ratio(m.bbox, p.bbox) >= CONTAINMENT_SUPPRESS)
})
.map(|m| m.bbox)
.collect();
for cluster in cluster_bboxes(&path_boxes) {
let members: Vec<&ObjMeta> = metas
.iter()
.filter(|m| !m.is_image && !m.is_form && bbox_intersects(m.bbox, cluster))
.collect();
if members.is_empty() {
continue;
}
let all_rules = members.iter().all(|m| m.is_h_rule || m.is_v_rule);
if all_rules {
continue;
}
let bbox = pad_bbox(cluster, page_width, page_height);
let h_rules = members.iter().filter(|m| m.is_h_rule).count();
let v_rules = members.iter().filter(|m| m.is_v_rule).count();
let has_ruled_grid = h_rules >= GRID_RULE_MIN && v_rules >= GRID_RULE_MIN;
if !region_area_ok(bbox, page_area, false) {
continue;
}
proposals.push(placement_proposal(
page_num,
bbox,
false,
false,
has_ruled_grid,
));
}
proposals
}
fn placement_proposal(
page_num: usize,
bbox: BBox,
has_image: bool,
has_form: bool,
has_ruled_grid: bool,
) -> RegionProposal {
let kind = if has_ruled_grid && !has_image {
RegionKind::Table
} else {
RegionKind::Figure
};
let label = match kind {
RegionKind::Figure => "Figure".to_string(),
RegionKind::Table => "Table".to_string(),
};
RegionProposal {
page_num,
kind,
source: RegionSource::ObjectCluster,
bbox,
label,
has_image,
has_form,
has_ruled_paths: has_ruled_grid,
}
}
fn collect_object_metas(page: &PdfPage<'_>) -> Vec<ObjMeta> {
let mut out = Vec::new();
for object in page.objects().iter() {
let ot = object.object_type();
let is_image = ot == PdfPageObjectType::Image;
let is_form = ot == PdfPageObjectType::XObjectForm;
let is_shading = ot == PdfPageObjectType::Shading;
let is_path = ot == PdfPageObjectType::Path;
if !(is_image || is_form || is_shading || is_path) {
continue;
}
let Ok(bounds) = object.bounds() else {
continue;
};
let r = bounds.to_rect();
let bb = (
r.left().value,
r.bottom().value,
r.right().value,
r.top().value,
);
let w = (bb.2 - bb.0).abs();
let h = (bb.3 - bb.1).abs();
if w < 1.0 && h < 1.0 {
continue;
}
let is_h_rule = is_path && h <= 2.5 && w >= 80.0;
let is_v_rule = is_path && w <= 2.5 && h >= 80.0;
let is_any_rule = is_h_rule || is_v_rule;
if is_path && !is_any_rule && (w < 20.0 || h < 20.0) {
continue;
}
let keep_path = is_path && (is_any_rule || (w >= 40.0 && h >= 40.0));
if is_path && !keep_path {
continue;
}
out.push(ObjMeta {
bbox: bb,
is_image,
is_form,
is_h_rule,
is_v_rule,
});
}
out
}
fn bbox_intersects(a: BBox, b: BBox) -> bool {
a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3
}
fn region_area_ok(bbox: BBox, page_area: f32, has_image: bool) -> bool {
let w = (bbox.2 - bbox.0).abs();
let h = (bbox.3 - bbox.1).abs();
let frac = (w * h) / page_area.max(1.0);
if frac > MAX_AREA_FRAC {
return false;
}
if has_image {
return w >= 24.0 && h >= 24.0;
}
area_ok(bbox, page_area)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intersects_works() {
assert!(bbox_intersects(
(0.0, 0.0, 10.0, 10.0),
(5.0, 5.0, 15.0, 15.0)
));
assert!(!bbox_intersects(
(0.0, 0.0, 10.0, 10.0),
(20.0, 20.0, 30.0, 30.0)
));
}
#[test]
fn h_rules_alone_classify_as_figure() {
assert!(GRID_RULE_MIN <= 2, "constant sanity");
let h = 3usize;
let v = 0usize;
let has_grid = h >= GRID_RULE_MIN && v >= GRID_RULE_MIN;
assert!(!has_grid, "H-only should not trigger grid");
}
#[test]
fn hv_grid_classifies_as_table() {
let h = 3usize;
let v = 3usize;
let has_grid = h >= GRID_RULE_MIN && v >= GRID_RULE_MIN;
assert!(has_grid, "H+V grid should trigger table");
}
#[test]
fn one_hv_rule_not_grid() {
let h = 1usize;
let v = 1usize;
let has_grid = h >= GRID_RULE_MIN && v >= GRID_RULE_MIN;
assert!(!has_grid, "single H+V rule should not trigger grid");
}
#[test]
fn all_rule_cluster_suppressed() {
struct R {
is_h_rule: bool,
is_v_rule: bool,
}
let members = [
R {
is_h_rule: true,
is_v_rule: false,
},
R {
is_h_rule: true,
is_v_rule: false,
},
R {
is_h_rule: false,
is_v_rule: true,
},
];
let all_rules = members.iter().all(|m| m.is_h_rule || m.is_v_rule);
assert!(all_rules, "pure-rule cluster should be suppressed");
}
#[test]
fn mixed_cluster_kept() {
struct R {
is_h_rule: bool,
is_v_rule: bool,
}
let members = [
R {
is_h_rule: true,
is_v_rule: false,
},
R {
is_h_rule: true,
is_v_rule: false,
},
R {
is_h_rule: false,
is_v_rule: false,
}, ];
let all_rules = members.iter().all(|m| m.is_h_rule || m.is_v_rule);
assert!(!all_rules, "mixed cluster should NOT be suppressed");
}
}