use super::super::*;
use super::common::*;
#[test]
fn test_push_line_breaks_table_row_single_newline() {
let prev = make_test_span("North", 72.0, 700.0, 30.0, 12.0);
let span = make_test_span("South", 72.0, 676.0, 30.0, 12.0); let mut out = String::new();
PdfDocument::push_line_breaks(&mut out, &prev, &span, 24.0, true);
assert_eq!(out, "\n", "table row boundary must be a single newline");
let mut out2 = String::new();
PdfDocument::push_line_breaks(&mut out2, &prev, &span, 24.0, false);
assert_eq!(out2, "\n\n", "non-table ~1.7em gap keeps the geometric blank line");
let mut out3 = String::new();
PdfDocument::push_line_breaks(&mut out3, &prev, &span, 14.0, false);
assert_eq!(out3, "\n");
}
#[test]
fn test_should_insert_space_same_line_with_gap() {
let prev = make_test_span("Hello", 0.0, 100.0, 50.0, 12.0);
let current = make_test_span("World", 56.0, 100.0, 50.0, 12.0);
assert!(PdfDocument::should_insert_space(&prev, ¤t));
}
#[test]
fn test_reliable_kerning_overlap_recognizes_split_word() {
let mut prev = make_test_span("PLANAL", 100.0, 700.0, 38.35, 10.0);
prev.char_widths = vec![6.67, 5.56, 6.67, 7.22, 6.67, 5.56]; let span = make_test_span("TINA", 136.64, 700.0, 22.78, 10.0);
let gap = span.bbox.x - (prev.bbox.x + prev.bbox.width); assert!(
PdfDocument::is_reliable_kerning_overlap(&prev, &span, gap),
"varying-width same-font runs overlapping by <1em must read as one word"
);
}
#[test]
fn test_reliable_kerning_overlap_rejects_uniform_fallback_widths() {
let mut prev = make_test_span("STATION", 100.0, 700.0, 42.0, 10.0);
prev.char_widths = vec![6.0; 7]; let span = make_test_span("FREEDOM", 141.0, 700.0, 42.0, 10.0);
let gap = span.bbox.x - (prev.bbox.x + prev.bbox.width); assert!(
!PdfDocument::is_reliable_kerning_overlap(&prev, &span, gap),
"uniform fallback widths are an inflated-width artifact, not kerning"
);
}
#[test]
fn test_reliable_kerning_overlap_rejects_coarse_two_value_widths() {
let mut prev = make_test_span("território", 32.0, 700.0, 68.0, 13.6);
prev.char_widths = vec![6.8, 6.8, 6.8, 6.8, 6.8, 6.8, 3.4, 3.4, 6.8, 6.8];
let span = make_test_span("e", 66.0, 700.0, 6.8, 13.6);
let gap = span.bbox.x - (prev.bbox.x + prev.bbox.width);
assert!(!PdfDocument::is_reliable_kerning_overlap(&prev, &span, gap));
}
#[test]
fn test_reliable_kerning_overlap_rejects_lowercase_to_uppercase_boundary() {
let mut prev = make_test_span("with", 100.0, 700.0, 20.0, 12.0);
prev.char_widths = vec![6.7, 3.3, 4.8, 6.7];
let span = make_test_span("Gp53", 118.0, 700.0, 24.0, 12.0);
let gap = span.bbox.x - (prev.bbox.x + prev.bbox.width); assert!(!PdfDocument::is_reliable_kerning_overlap(&prev, &span, gap));
}
#[test]
fn merge_sub_superscript_accepts_fstatistic() {
let mut spans = vec![
make_test_span("F", 0.0, 100.0, 8.0, 12.0),
make_test_span("4,176", 8.0, 98.0, 12.0, 8.0),
];
PdfDocument::merge_sub_superscript_spans(&mut spans);
assert_eq!(spans.len(), 1, "index cluster must merge into base");
assert_eq!(spans[0].text, "F4,176");
}
#[test]
fn merge_sub_superscript_accepts_text_rise_flagged() {
let mut base = make_test_span("M", 0.0, 100.0, 10.0, 12.0);
base.text_rise = 0.0;
let mut sup = make_test_span("\u{22C6}", 10.5, 103.0, 6.0, 12.0);
sup.text_rise = 0.30;
let mut spans = vec![base, sup];
PdfDocument::merge_sub_superscript_spans(&mut spans);
assert_eq!(spans.len(), 1, "Ts-flagged superscript must merge into base");
assert_eq!(spans[0].text, "M\u{22C6}");
}
#[test]
fn merge_sub_superscript_accepts_same_baseline_numeric() {
let base = make_test_span("[", 0.0, 100.0, 6.0, 18.0);
let sup = make_test_span("123", 6.0, 100.0, 20.0, 13.0);
let mut spans = vec![base, sup];
PdfDocument::merge_sub_superscript_spans(&mut spans);
assert_eq!(spans.len(), 1, "same-baseline numeric superscript must merge");
assert_eq!(spans[0].text, "[123");
}
#[test]
fn merge_sub_superscript_rejects_same_baseline_alpha() {
let base = make_test_span("A", 0.0, 100.0, 12.0, 18.0);
let sub = make_test_span("bc", 12.0, 100.0, 8.0, 13.0);
let mut spans = vec![base, sub];
PdfDocument::merge_sub_superscript_spans(&mut spans);
assert_eq!(spans.len(), 2, "same-baseline alpha run must not merge");
}
#[test]
fn merge_sub_superscript_keeps_table_number_separate() {
let mut spans = vec![
make_test_span("Table", 0.0, 100.0, 30.0, 12.0),
make_test_span("3", 31.0, 100.0, 6.0, 12.0),
];
PdfDocument::merge_sub_superscript_spans(&mut spans);
assert_eq!(spans.len(), 2, "Table 3 must not merge");
}
#[test]
fn test_should_insert_space_same_line_no_gap() {
let prev = make_test_span("Hello", 0.0, 100.0, 50.0, 12.0);
let current = make_test_span("World", 51.0, 100.0, 50.0, 12.0);
assert!(!PdfDocument::should_insert_space(&prev, ¤t));
}
#[test]
fn test_should_insert_space_different_lines() {
let prev = make_test_span("Hello", 0.0, 100.0, 50.0, 12.0);
let current = make_test_span("World", 56.0, 120.0, 50.0, 12.0);
assert!(!PdfDocument::should_insert_space(&prev, ¤t));
}
#[test]
fn test_should_insert_space_column_gap() {
let prev = make_test_span("Hello", 0.0, 100.0, 50.0, 12.0);
let current = make_test_span("World", 200.0, 100.0, 50.0, 12.0);
assert!(PdfDocument::should_insert_space(&prev, ¤t));
}
#[test]
fn test_stacked_cell_needs_space_overlapping_rows() {
let upper = make_test_span("Comparison", 0.0, 108.0, 60.0, 12.0);
let lower = make_test_span("rate", 20.0, 100.0, 25.0, 12.0);
assert!(
PdfDocument::stacked_cell_needs_space(&upper, &lower),
"stacked overlapping cells with a baseline shift must be separated by a space"
);
}
#[test]
fn test_stacked_cell_same_baseline_overlap_is_kerning() {
let prev = make_test_span("eigen", 0.0, 100.0, 30.0, 12.0);
let current = make_test_span("value", 28.0, 100.0, 30.0, 12.0);
assert!(
!PdfDocument::stacked_cell_needs_space(&prev, ¤t),
"same-baseline overlap is intra-word kerning, not a word boundary"
);
}
#[test]
fn test_should_insert_space_suppressed_within_complex_script() {
let prev = make_test_span("\u{099B}\u{09CB}", 0.0, 100.0, 7.0, 13.0);
let current = make_test_span("\u{099F}", 16.0, 100.0, 5.0, 13.0);
assert!(
!PdfDocument::should_insert_space(&prev, ¤t),
"intra-word complex-script gap must not insert a space"
);
let p2 = make_test_span("\u{0B87}\u{0BA9}\u{0BC8}", 0.0, 100.0, 20.0, 13.0);
let c2 = make_test_span("\u{0B9A}\u{0BCD}", 30.0, 100.0, 8.0, 13.0);
assert!(!PdfDocument::should_insert_space(&p2, &c2));
}
#[test]
fn test_should_insert_space_kept_across_script_boundary() {
let prev = make_test_span("\u{0B95}", 0.0, 100.0, 12.0, 13.0);
let current = make_test_span("A", 18.0, 100.0, 8.0, 13.0);
assert!(
PdfDocument::should_insert_space(&prev, ¤t),
"complex↔Latin boundary gap must keep its space"
);
}
#[test]
fn test_column_spanning_decimal_wide_bbox() {
let span = make_decimal_span("1.10", vec![3.9811199], 25.72, 7.0);
assert!(PdfDocument::is_column_spanning_decimal(&span));
}
#[test]
fn test_column_spanning_decimal_5char_span() {
let span = make_decimal_span("12.11", vec![3.9811199, 3.9811199], 27.64, 7.0);
assert!(PdfDocument::is_column_spanning_decimal(&span));
}
#[test]
fn test_column_spanning_decimal_normal_bbox() {
let span = make_decimal_span("1.5", vec![3.0, 3.0, 3.0], 9.0, 7.0);
assert!(!PdfDocument::is_column_spanning_decimal(&span));
}
#[test]
fn test_column_spanning_decimal_non_digit() {
let span = make_decimal_span("hello.world", vec![], 60.0, 12.0);
assert!(!PdfDocument::is_column_spanning_decimal(&span));
}
#[test]
fn test_column_spanning_decimal_multiple_dots() {
let span = make_decimal_span("1.2.3", vec![3.0], 25.0, 7.0);
assert!(!PdfDocument::is_column_spanning_decimal(&span));
}
#[test]
fn test_push_span_text_splits_wide_decimal() {
let span = make_decimal_span("1.10", vec![3.9811199], 25.72, 7.0);
let mut out = String::new();
PdfDocument::push_span_text(&mut out, &span);
assert_eq!(out, "1 10");
}
#[test]
fn test_push_span_text_leaves_normal_decimal() {
let span = make_decimal_span("3.14", vec![4.0, 4.0, 4.0, 4.0], 16.0, 12.0);
let mut out = String::new();
PdfDocument::push_span_text(&mut out, &span);
assert_eq!(out, "3.14");
}
#[test]
fn test_push_span_text_strips_soft_hyphen_mid_word() {
let span = make_decimal_span("recon\u{00AD}struction", vec![], 80.0, 12.0);
let mut out = String::new();
PdfDocument::push_span_text(&mut out, &span);
assert_eq!(out, "reconstruction");
}
#[test]
fn test_push_span_text_strips_multiple_soft_hyphens() {
let span = make_decimal_span("un\u{00AD}be\u{00AD}liev\u{00AD}able", vec![], 100.0, 12.0);
let mut out = String::new();
PdfDocument::push_span_text(&mut out, &span);
assert_eq!(out, "unbelievable");
}
#[test]
fn test_cw_boundary_split_theorem_number() {
let span = make_decimal_span("Theorem1.7", vec![11.2, 8.9, 7.4, 8.1, 6.6, 7.4, 13.4], 83.8, 14.3);
let result = PdfDocument::char_widths_boundary_split(&span);
assert_eq!(result, Some(7)); }
#[test]
fn test_cw_boundary_split_let_capital() {
let span = make_decimal_span("LetC", vec![7.3, 5.2, 4.5], 26.7, 12.0);
let result = PdfDocument::char_widths_boundary_split(&span);
assert_eq!(result, Some(3)); }
#[test]
fn test_cw_boundary_no_split_already_space() {
let span = make_decimal_span("Theorem 1.1", vec![9.3, 7.5, 6.1, 6.7, 5.5, 6.1, 11.2], 80.0, 12.0);
assert!(PdfDocument::char_widths_boundary_split(&span).is_none());
}
#[test]
fn test_cw_boundary_no_split_matching_count() {
let span = make_decimal_span("hello", vec![5.0, 5.0, 5.0, 5.0, 5.0], 25.0, 12.0);
assert!(PdfDocument::char_widths_boundary_split(&span).is_none());
}
#[test]
fn test_cw_boundary_no_split_nonascii_boundary() {
let span = make_decimal_span("Marysia Prus-Gł", vec![5.0; 14], 80.0, 12.0);
assert!(PdfDocument::char_widths_boundary_split(&span).is_none());
}
#[test]
fn test_push_span_text_splits_let_capital() {
let span = make_decimal_span("LetC", vec![7.3, 5.2, 4.5], 26.7, 12.0);
let mut out = String::new();
PdfDocument::push_span_text(&mut out, &span);
assert_eq!(out, "Let C");
}
#[test]
fn test_push_span_text_splits_theorem_number() {
let span = make_decimal_span("Theorem1.7", vec![11.2, 8.9, 7.4, 8.1, 6.6, 7.4, 13.4], 83.8, 14.3);
let mut out = String::new();
PdfDocument::push_span_text(&mut out, &span);
assert_eq!(out, "Theorem 1.7");
}
#[test]
fn test_should_insert_space_overlapping() {
let prev = make_test_span("Hello", 0.0, 100.0, 50.0, 12.0);
let current = make_test_span("World", 40.0, 100.0, 50.0, 12.0);
assert!(!PdfDocument::should_insert_space(&prev, ¤t));
}
#[test]
fn test_should_insert_space_zero_font_size() {
let prev = make_test_span("A", 0.0, 100.0, 10.0, 0.0);
let current = make_test_span("B", 15.0, 100.0, 10.0, 0.0);
let _ = PdfDocument::should_insert_space(&prev, ¤t);
}
#[test]
fn test_should_insert_space_large_font() {
let prev = make_test_span("A", 0.0, 100.0, 100.0, 72.0);
let current = make_test_span("B", 120.0, 100.0, 100.0, 72.0);
assert!(PdfDocument::should_insert_space(&prev, ¤t));
}
#[test]
fn test_should_insert_space_hangul_digit_no_space() {
let one = make_test_span("1", 0.0, 100.0, 8.0, 12.0);
let man = make_test_span("만", 8.5, 100.0, 12.0, 12.0);
assert!(!PdfDocument::should_insert_space(&one, &man));
let nyeon = make_test_span("년", 0.0, 100.0, 12.0, 12.0);
let two = make_test_span("2", 12.5, 100.0, 8.0, 12.0);
assert!(!PdfDocument::should_insert_space(&nyeon, &two));
}
#[test]
fn test_should_insert_space_ideograph_digit_still_splits() {
let tuan = make_test_span("团", 0.0, 100.0, 12.0, 12.0);
let year = make_test_span("2", 12.5, 100.0, 8.0, 12.0);
assert!(PdfDocument::should_insert_space(&tuan, &year));
}
#[test]
fn test_should_insert_space_indic_clause_punct_hugs() {
let beng = make_test_span("ী", 0.0, 100.0, 12.0, 12.0);
let danda = make_test_span("।", 15.0, 100.0, 6.0, 12.0);
assert!(!PdfDocument::should_insert_space(&beng, &danda));
let deva = make_test_span("ी", 0.0, 100.0, 12.0, 12.0);
let comma = make_test_span(",", 15.0, 100.0, 5.0, 12.0);
assert!(!PdfDocument::should_insert_space(&deva, &comma));
let latin = make_test_span("word", 0.0, 100.0, 12.0, 12.0);
let comma2 = make_test_span(",", 15.0, 100.0, 5.0, 12.0);
assert!(PdfDocument::should_insert_space(&latin, &comma2));
}
#[test]
fn test_hangul_midword_line_wrap() {
let prev = make_test_span("집고양", 480.0, 110.0, 36.0, 12.0);
let next = make_test_span("이의", 50.0, 95.0, 24.0, 12.0);
assert!(PdfDocument::hangul_midword_line_wrap("…집고양", &prev, &next));
assert!(!PdfDocument::hangul_midword_line_wrap("…했다 ", &prev, &next));
let latin = make_test_span("the", 50.0, 95.0, 24.0, 12.0);
assert!(!PdfDocument::hangul_midword_line_wrap("…집고양", &prev, &latin));
}
#[test]
fn contains_rect_with_tolerance_absorbs_subpixel_drift() {
use crate::geometry::Rect;
let table = Rect::new(0.0, 0.0, 100.0, 100.0);
let drifted = Rect::new(10.0, 10.0, 90.02, 80.0);
assert!(
(drifted.right() - table.right() - 0.02).abs() < 1e-4,
"drifted span right-edge should be 0.02pt past table right-edge; got drift = {}",
drifted.right() - table.right()
);
assert_eq!(drifted.left(), 10.0, "span should start at x=10");
assert_eq!(drifted.top(), 10.0, "span should start at y=10");
assert_eq!(drifted.bottom(), 90.0, "span should end at y=90");
assert!(PdfDocument::contains_rect_with_tolerance(&table, &drifted, 0.1));
}
#[test]
fn contains_rect_with_tolerance_rejects_genuinely_outside() {
use crate::geometry::Rect;
let table = Rect::new(0.0, 0.0, 100.0, 100.0);
let outside = Rect::new(10.0, 10.0, 91.0, 80.0);
assert!(
(outside.right() - table.right() - 1.0).abs() < 1e-6,
"outside span right-edge should be 1.0pt past table right-edge; got drift = {}",
outside.right() - table.right()
);
assert!(!PdfDocument::contains_rect_with_tolerance(&table, &outside, 0.1));
}
#[test]
fn contains_rect_with_tolerance_accepts_fully_inside() {
use crate::geometry::Rect;
let table = Rect::new(0.0, 0.0, 100.0, 100.0);
let inside = Rect::new(10.0, 10.0, 80.0, 80.0);
assert!(
inside.left() > table.left()
&& inside.right() < table.right()
&& inside.top() > table.top()
&& inside.bottom() < table.bottom(),
"control span should be strictly inside the table"
);
assert!(PdfDocument::contains_rect_with_tolerance(&table, &inside, 0.1));
}
#[test]
fn cell_bbox_filter_preserves_span_in_outer_bbox_gap() {
use crate::geometry::Rect;
use crate::structure::table_extractor::{Table, TableCell, TableRow};
let mut table = Table::new();
let mut row = TableRow::new(false);
row.cells.push(TableCell {
text: "left".to_string(),
spans: vec![],
colspan: 1,
rowspan: 1,
mcids: vec![],
bbox: Some(Rect::new(0.0, 0.0, 90.0, 100.0)),
is_header: false,
});
row.cells.push(TableCell {
text: "right".to_string(),
spans: vec![],
colspan: 1,
rowspan: 1,
mcids: vec![],
bbox: Some(Rect::new(110.0, 0.0, 90.0, 100.0)),
is_header: false,
});
table.add_row(row);
table.bbox = Some(Rect::new(0.0, 0.0, 200.0, 100.0));
const TOL: f32 = 0.1;
let span_cell = Rect::new(10.0, 10.0, 50.0, 20.0);
let in_any_cell = table.rows.iter().any(|r| {
r.cells.iter().any(|c| {
c.bbox
.is_some_and(|b| PdfDocument::contains_rect_with_tolerance(&b, &span_cell, TOL))
})
});
assert!(in_any_cell, "span inside a cell bbox must be identified as in-table");
let span_gap = Rect::new(95.0, 10.0, 10.0, 20.0);
let in_outer_bbox = PdfDocument::contains_rect_with_tolerance(&table.bbox.unwrap(), &span_gap, TOL);
assert!(
in_outer_bbox,
"gap span must be inside the outer table bbox (precondition for the bug to trigger)"
);
let in_any_cell_gap = table.rows.iter().any(|r| {
r.cells.iter().any(|c| {
c.bbox
.is_some_and(|b| PdfDocument::contains_rect_with_tolerance(&b, &span_gap, TOL))
})
});
assert!(
!in_any_cell_gap,
"gap span must NOT be inside any cell bbox — cell-bbox filter must preserve it"
);
}
#[test]
fn reorder_same_line_runs_preserves_disjoint_x_rows() {
use crate::geometry::Rect;
use crate::layout::TextSpan;
let mut spans = vec![
TextSpan {
text: "VerDate".to_string(),
bbox: Rect::new(350.0, 200.0, 85.0, 10.0),
font_size: 10.0,
sequence: 0,
..Default::default()
},
TextSpan {
text: "skersey".to_string(),
bbox: Rect::new(50.0, 195.5, 75.0, 10.0),
font_size: 10.0,
sequence: 1,
..Default::default()
},
];
PdfDocument::reorder_same_line_runs(&mut spans);
let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
assert_eq!(texts, vec!["VerDate", "skersey"]);
}
#[test]
fn reorder_same_line_runs_orders_suffix_superscript_by_x() {
use crate::geometry::Rect;
use crate::layout::TextSpan;
let mut spans = vec![
TextSpan {
text: "th".to_string(),
bbox: Rect::new(180.0, 205.0, 10.0, 10.0),
font_size: 10.0,
sequence: 0,
..Default::default()
},
TextSpan {
text: "September".to_string(),
bbox: Rect::new(100.0, 200.0, 50.0, 14.0),
font_size: 14.0,
sequence: 1,
..Default::default()
},
TextSpan {
text: "11".to_string(),
bbox: Rect::new(165.0, 200.0, 15.0, 14.0),
font_size: 14.0,
sequence: 2,
..Default::default()
},
];
PdfDocument::reorder_same_line_runs(&mut spans);
let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
assert_eq!(texts, vec!["September", "11", "th"]);
}
#[test]
fn reorder_same_line_runs_de_interleaves_two_stacked_lines() {
use crate::geometry::Rect;
use crate::layout::TextSpan;
let span = |t: &str, x: f32, y: f32, w: f32, seq: usize| TextSpan {
text: t.to_string(),
bbox: Rect::new(x, y, w, 10.0),
font_size: 10.0,
sequence: seq,
..Default::default()
};
let mut spans = vec![
span("The", 100.0, 200.0, 30.0, 0),
span("Story", 110.0, 192.0, 40.0, 1),
span("Book", 140.0, 200.0, 40.0, 2),
span("Review", 150.0, 192.0, 55.0, 3),
];
PdfDocument::reorder_same_line_runs(&mut spans);
let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
assert_eq!(
texts,
vec!["The", "Book", "Story", "Review"],
"stacked lines must de-interleave, not X-sort into one fake line"
);
}