use crate::error::Result;
use crate::layout::TextSpan;
use crate::pipeline::{OrderedTextSpan, ReadingOrderInfo};
use super::{ReadingOrderContext, ReadingOrderStrategy, XYCutStrategy};
pub struct StructureTreeStrategy {
fallback: XYCutStrategy,
}
impl StructureTreeStrategy {
pub fn new() -> Self {
Self {
fallback: XYCutStrategy::new(),
}
}
}
fn mcid_order_zigzags_columns(spans: &[TextSpan], mcid_order: &[u32]) -> bool {
let mcid_to_idx: std::collections::HashMap<u32, usize> = spans
.iter()
.enumerate()
.filter_map(|(i, s)| s.mcid.map(|m| (m, i)))
.collect();
let ordered_x: Vec<f32> = mcid_order
.iter()
.filter_map(|m| mcid_to_idx.get(m))
.map(|&i| spans[i].bbox.x + spans[i].bbox.width * 0.5)
.collect();
if ordered_x.len() < 10 {
return false;
}
let mut xs_sorted: Vec<f32> = ordered_x.clone();
xs_sorted.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
let x_min = xs_sorted[0];
let x_max = xs_sorted[xs_sorted.len() - 1];
let x_extent = x_max - x_min;
if x_extent < 50.0 {
return false; }
let mut largest_gap = 0.0_f32;
let mut largest_gap_at = x_min;
for w in xs_sorted.windows(2) {
let gap = w[1] - w[0];
if gap > largest_gap {
largest_gap = gap;
largest_gap_at = (w[0] + w[1]) * 0.5;
}
}
if largest_gap < x_extent * 0.1 || largest_gap < 30.0 {
return false;
}
let columns: Vec<u8> = ordered_x
.iter()
.map(|&x| if x < largest_gap_at { 0 } else { 1 })
.collect();
let crossings = columns.windows(2).filter(|w| w[0] != w[1]).count();
crossings > 3
}
impl Default for StructureTreeStrategy {
fn default() -> Self {
Self::new()
}
}
impl ReadingOrderStrategy for StructureTreeStrategy {
fn apply(
&self,
spans: Vec<TextSpan>,
context: &ReadingOrderContext,
) -> Result<Vec<OrderedTextSpan>> {
if context.suspects {
log::debug!("Structure tree marked as suspect, falling back to geometric ordering");
return self.fallback.apply(spans, context);
}
let mcid_order = match &context.mcid_order {
Some(order) if !order.is_empty() => order,
_ => return self.fallback.apply(spans, context),
};
if mcid_order_zigzags_columns(&spans, mcid_order) {
log::debug!("MCID order zigzags across columns, falling back to geometric ordering");
return self.fallback.apply(spans, context);
}
let mcid_to_order: std::collections::HashMap<u32, usize> = mcid_order
.iter()
.enumerate()
.map(|(order, &mcid)| (mcid, order))
.collect();
let mut with_mcid: Vec<(TextSpan, usize)> = Vec::new();
let mut without_mcid: Vec<TextSpan> = Vec::new();
for span in spans {
if let Some(mcid) = span.mcid {
if let Some(&order) = mcid_to_order.get(&mcid) {
with_mcid.push((span, order));
} else {
without_mcid.push(span);
}
} else {
without_mcid.push(span);
}
}
with_mcid.sort_by_key(|(_, order)| *order);
let mut result = Vec::new();
let mut reading_order = 0;
for (span, _) in with_mcid {
result.push(OrderedTextSpan::with_info(
span,
reading_order,
ReadingOrderInfo::structure_tree(),
));
reading_order += 1;
}
if !without_mcid.is_empty() {
let untagged_ordered = self.fallback.apply(without_mcid, context)?;
for mut ordered_span in untagged_ordered {
ordered_span.reading_order = reading_order;
ordered_span.order_info = ReadingOrderInfo::fallback();
result.push(ordered_span);
reading_order += 1;
}
}
Ok(result)
}
fn name(&self) -> &'static str {
"StructureTreeStrategy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Rect;
use crate::layout::{Color, FontWeight};
fn make_span(text: &str, x: f32, y: f32, mcid: Option<u32>) -> TextSpan {
TextSpan {
artifact_type: None,
text: text.to_string(),
bbox: Rect::new(x, y, 50.0, 12.0),
font_name: "Test".to_string(),
font_size: 12.0,
font_weight: FontWeight::Normal,
is_italic: false,
is_monospace: false,
color: Color::black(),
mcid,
sequence: 0,
offset_semantic: false,
split_boundary_before: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
}
}
#[test]
fn test_structure_tree_ordering() {
let spans = vec![
make_span("Third", 0.0, 100.0, Some(2)),
make_span("First", 0.0, 50.0, Some(0)),
make_span("Second", 0.0, 75.0, Some(1)),
];
let strategy = StructureTreeStrategy::new();
let context = ReadingOrderContext::new().with_mcid_order(vec![0, 1, 2]);
let ordered = strategy.apply(spans, &context).unwrap();
assert_eq!(ordered[0].span.text, "First");
assert_eq!(ordered[1].span.text, "Second");
assert_eq!(ordered[2].span.text, "Third");
}
#[test]
fn test_fallback_for_untagged() {
let spans = vec![
make_span("Tagged", 0.0, 100.0, Some(0)),
make_span("Untagged", 0.0, 50.0, None),
];
let strategy = StructureTreeStrategy::new();
let context = ReadingOrderContext::new().with_mcid_order(vec![0]);
let ordered = strategy.apply(spans, &context).unwrap();
assert_eq!(ordered[0].span.text, "Tagged");
assert_eq!(ordered[1].span.text, "Untagged");
}
#[test]
fn test_no_structure_tree_fallback() {
let spans = vec![
make_span("Bottom", 0.0, 50.0, None),
make_span("Top", 0.0, 100.0, None),
];
let strategy = StructureTreeStrategy::new();
let context = ReadingOrderContext::new(); let ordered = strategy.apply(spans, &context).unwrap();
assert_eq!(ordered[0].span.text, "Top");
assert_eq!(ordered[1].span.text, "Bottom");
}
#[test]
fn test_geometric_fallback_multi_column() {
let spans = vec![
make_span("Left Top Word1", 0.0, 100.0, None),
make_span("Left Top Word2", 5.0, 100.0, None), make_span("Left Top Word3", 10.0, 100.0, None), make_span("Left Bottom", 0.0, 50.0, None),
make_span("Right Top", 200.0, 100.0, None),
make_span("Right Bottom", 200.0, 50.0, None),
];
let mut strategy = StructureTreeStrategy::new();
strategy.fallback = XYCutStrategy::new().with_prefer_horizontal(true);
let context = ReadingOrderContext::new(); let ordered = strategy.apply(spans, &context).unwrap();
let left_indices: Vec<_> = ordered
.iter()
.enumerate()
.filter(|(_, s)| s.span.text.starts_with("Left"))
.map(|(i, _)| i)
.collect();
let right_indices: Vec<_> = ordered
.iter()
.enumerate()
.filter(|(_, s)| s.span.text.starts_with("Right"))
.map(|(i, _)| i)
.collect();
assert!(
left_indices
.iter()
.all(|&l| right_indices.iter().all(|&r| l < r)),
"Left column should be processed before right column"
);
}
#[test]
fn test_suspects_fallback_to_geometric() {
let spans = vec![
make_span("StructOrder2", 0.0, 100.0, Some(1)), make_span("StructOrder1", 0.0, 50.0, Some(0)), ];
let strategy = StructureTreeStrategy::new();
let context = ReadingOrderContext::new()
.with_mcid_order(vec![0, 1])
.with_suspects(false);
let ordered = strategy.apply(spans.clone(), &context).unwrap();
assert_eq!(ordered[0].span.text, "StructOrder1"); assert_eq!(ordered[1].span.text, "StructOrder2");
let context = ReadingOrderContext::new()
.with_mcid_order(vec![0, 1])
.with_suspects(true);
let ordered = strategy.apply(spans, &context).unwrap();
assert_eq!(ordered[0].span.text, "StructOrder2"); assert_eq!(ordered[1].span.text, "StructOrder1"); }
}