use std::cmp::Ordering;
use std::collections::HashSet;
use std::sync::LazyLock;
use regex::Regex;
use kopitiam_pdf::{Page, TextSpan};
use super::{SAME_LINE_Y_TOLERANCE_RATIO, Line, build_line};
use crate::Figure;
static FIGURE_CAPTION: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^(figure|fig\.)\s*\d+").unwrap());
const MAX_LABEL_WORDS: usize = 4;
const MIN_LABEL_LINES: usize = 4;
const MIN_X_SCATTER_FRACTION: f32 = 0.10;
pub(super) fn try_figure(line: &Line) -> Option<Figure> {
let text = line.text.trim();
if FIGURE_CAPTION.is_match(text) {
Some(Figure {
caption: Some(text.to_string()),
image_path: None,
})
} else {
None
}
}
struct FigureLine {
span_indices: Vec<usize>,
is_caption: bool,
is_label_like: bool,
left_x: f32,
}
pub(crate) fn collapse_figure_regions(pages: &[Page]) -> Vec<Page> {
pages.iter().map(collapse_page).collect()
}
fn collapse_page(page: &Page) -> Page {
let lines = figure_lines(page);
let drop = labels_to_drop(&lines, page.width);
if drop.is_empty() {
return page.clone();
}
let spans = page
.spans
.iter()
.enumerate()
.filter(|(i, _)| !drop.contains(i))
.map(|(_, span)| span.clone())
.collect();
Page {
number: page.number,
width: page.width,
height: page.height,
spans,
}
}
fn labels_to_drop(lines: &[FigureLine], page_width: f32) -> HashSet<usize> {
let mut drop = HashSet::new();
for (c, line) in lines.iter().enumerate() {
if !line.is_caption {
continue;
}
let mut region: Vec<&FigureLine> = Vec::new();
let mut k = c;
while k > 0 && lines[k - 1].is_label_like {
k -= 1;
region.push(&lines[k]);
}
let mut k = c;
while k + 1 < lines.len() && lines[k + 1].is_label_like {
k += 1;
region.push(&lines[k]);
}
if region.len() < MIN_LABEL_LINES {
continue; }
let min_x = region.iter().map(|l| l.left_x).fold(f32::INFINITY, f32::min);
let max_x = region
.iter()
.map(|l| l.left_x)
.fold(f32::NEG_INFINITY, f32::max);
if (max_x - min_x) < page_width * MIN_X_SCATTER_FRACTION {
continue; }
for label in region {
drop.extend(&label.span_indices);
}
}
drop
}
fn figure_lines(page: &Page) -> Vec<FigureLine> {
let mut ordered: Vec<(usize, &TextSpan)> = page.spans.iter().enumerate().collect();
ordered.sort_by(|a, b| b.1.y.partial_cmp(&a.1.y).unwrap_or(Ordering::Equal));
let mut groups: Vec<Vec<(usize, &TextSpan)>> = Vec::new();
for (i, span) in ordered {
let joins_last = groups.last().is_some_and(|group| {
let anchor = group[0].1;
let tolerance = anchor.font_size.max(span.font_size) * SAME_LINE_Y_TOLERANCE_RATIO;
(anchor.y - span.y).abs() <= tolerance
});
if joins_last {
groups.last_mut().unwrap().push((i, span));
} else {
groups.push(vec![(i, span)]);
}
}
groups
.into_iter()
.map(|mut group| {
group.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap_or(Ordering::Equal));
let refs: Vec<&TextSpan> = group.iter().map(|(_, span)| *span).collect();
let text = build_line(&refs).text;
let trimmed = text.trim();
let is_caption = FIGURE_CAPTION.is_match(trimmed);
FigureLine {
span_indices: group.iter().map(|(i, _)| *i).collect(),
is_caption,
is_label_like: !is_caption && is_label_like(trimmed),
left_x: group
.iter()
.map(|(_, span)| span.x)
.fold(f32::INFINITY, f32::min),
}
})
.collect()
}
fn is_label_like(trimmed: &str) -> bool {
if trimmed.is_empty() {
return false;
}
let words = trimmed.split_whitespace().count();
if words == 0 || words > MAX_LABEL_WORDS {
return false;
}
!matches!(trimmed.chars().last(), Some('.' | '!' | '?'))
}
#[cfg(test)]
mod tests {
use super::*;
fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
x,
y,
width,
height: font_size,
font_size,
font_name: None,
..TextSpan::default()
}
}
fn texts(page: &Page) -> Vec<&str> {
page.spans.iter().map(|s| s.text.as_str()).collect()
}
fn diagram_page() -> Page {
let labels = [
"Sensor Array", "Data Ingestion", "Message Queue", "Stream Processor",
"Control Unit", "PID Controller", "Actuator Bank", "Feedback Loop",
"State Store", "Cache Layer", "Load Balancer", "API Gateway",
"Auth Service", "Audit Log", "Metrics Sink", "Alert Manager",
"Config Server", "Service Mesh", "Edge Node", "Core Cluster",
"Batch Worker", "Scheduler", "Dispatch Table", "Retry Buffer",
"Health Probe", "Trace Collector", "Span Exporter", "Model Registry",
"Inference Pod", "Result Cache",
];
let mut spans = Vec::new();
for (i, label) in labels.iter().enumerate() {
let x = 50.0 + ((i * 97) % 450) as f32; let y = 720.0 - (i as f32) * 12.0;
spans.push(span(label, x, y, 80.0, 10.0));
}
spans.push(span(
"Fig. 1 System architecture of the synthetic platform",
50.0,
340.0,
400.0,
10.0,
));
Page {
number: 1,
width: 600.0,
height: 800.0,
spans,
}
}
#[test]
fn scattered_labels_are_dropped_and_the_caption_survives() {
let page = diagram_page();
let before = page.spans.len();
let collapsed = collapse_figure_regions(&[page]);
let remaining = texts(&collapsed[0]);
assert_eq!(
remaining,
vec!["Fig. 1 System architecture of the synthetic platform"],
"every scattered label must be dropped, only the caption kept"
);
assert!(
collapsed[0].spans.len() < before / 2,
"expected >50% span reduction, went {} -> {}",
before,
collapsed[0].spans.len()
);
}
#[test]
fn a_terse_list_of_real_sentences_is_not_collapsed() {
let sentences = [
"The reactor is stable.",
"Pressure holds steady.",
"Coolant flows freely.",
"Sensors read normal.",
"Alarms remain silent.",
"The shift ends calmly.",
];
let mut spans = Vec::new();
for (i, s) in sentences.iter().enumerate() {
spans.push(span(s, 50.0, 700.0 - (i as f32) * 14.0, 300.0, 10.0));
}
let page = Page {
number: 1,
width: 600.0,
height: 800.0,
spans,
};
let collapsed = collapse_figure_regions(&[page]);
assert_eq!(
collapsed[0].spans.len(),
sentences.len(),
"real prose must never be collapsed: {:?}",
texts(&collapsed[0])
);
}
#[test]
fn scattered_short_labels_without_a_caption_are_left_alone() {
let mut page = diagram_page();
page.spans.pop(); let before = page.spans.len();
let collapsed = collapse_figure_regions(&[page]);
assert_eq!(
collapsed[0].spans.len(),
before,
"without a caption anchor nothing may be dropped"
);
}
#[test]
fn a_left_aligned_run_of_short_labels_below_a_caption_is_not_collapsed() {
let mut spans = vec![span("Fig. 2 A tidy caption", 50.0, 700.0, 200.0, 10.0)];
for i in 0..6 {
spans.push(span("Aligned label item", 50.0, 686.0 - (i as f32) * 12.0, 100.0, 10.0));
}
let page = Page {
number: 1,
width: 600.0,
height: 800.0,
spans,
};
let collapsed = collapse_figure_regions(&[page]);
assert_eq!(
collapsed[0].spans.len(),
7,
"a left-aligned (unscattered) run must not be collapsed: {:?}",
texts(&collapsed[0])
);
}
#[test]
fn is_label_like_rejects_sentences_and_long_lines() {
assert!(is_label_like("Data Ingestion Layer"));
assert!(is_label_like("Controller"));
assert!(!is_label_like("This is a complete sentence."));
assert!(!is_label_like("A run of clearly more than four words here"));
assert!(!is_label_like(""));
}
#[test]
fn empty_input_is_handled() {
assert!(collapse_figure_regions(&[]).is_empty());
}
}