use super::super::*;
use super::common::*;
#[test]
fn search_index_reused_across_calls_and_invalidated_by_redaction() {
let content = b"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello World) Tj ET";
let pdf = build_minimal_pdf_with_font(content);
let doc = PdfDocument::from_bytes(pdf).expect("open pdf");
let first = doc.search_page_index(0).expect("build search index");
let second = doc.search_page_index(0).expect("reuse search index");
assert!(
std::sync::Arc::ptr_eq(&first, &second),
"a second search_page_index() call should hit the cache, not rebuild"
);
doc.erase_region(0, crate::geometry::Rect::new(0.0, 0.0, 10.0, 10.0))
.expect("erase_region");
let third = doc.search_page_index(0).expect("rebuild after redaction");
assert!(
!std::sync::Arc::ptr_eq(&first, &third),
"erase_region must invalidate the cached search index"
);
}
#[test]
fn clear_search_index_forces_rebuild() {
let content = b"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello World) Tj ET";
let pdf = build_minimal_pdf_with_font(content);
let doc = PdfDocument::from_bytes(pdf).expect("open pdf");
let first = doc.search_page_index(0).expect("build search index");
doc.clear_search_index();
let second = doc.search_page_index(0).expect("rebuild after clear");
assert!(
!std::sync::Arc::ptr_eq(&first, &second),
"clear_search_index() should force the next call to rebuild"
);
}
#[test]
fn prepare_search_populates_every_page() {
let pdf = build_multi_page_pdf(10);
let doc = PdfDocument::from_bytes(pdf).expect("open pdf");
doc.prepare_search().expect("prepare_search");
for page in 0..10 {
let a = doc.search_page_index(page).expect("indexed page");
let b = doc.search_page_index(page).expect("still cached");
assert!(
std::sync::Arc::ptr_eq(&a, &b),
"page {page} should already be cached by prepare_search()"
);
}
}
#[test]
fn test_may_contain_text_with_bt() {
let data = b"q BT /F1 12 Tf (Hello) Tj ET Q";
assert!(PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_with_do() {
let data = b"q /Im0 Do Q";
assert!(PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_no_text_operators() {
let data = b"100 200 300 400 re S";
assert!(!PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_empty() {
let data = b"";
assert!(!PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_bt_at_start() {
let data = b"BT /F1 12 Tf ET";
assert!(PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_bt_at_end() {
let data = b"q Q BT";
assert!(PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_false_positive_btype() {
let data = b"BTerror";
assert!(!PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_false_positive_document() {
let data = b"Document";
assert!(!PdfDocument::may_contain_text(data));
}
#[test]
fn test_may_contain_text_do_with_name() {
let data = b"/Im0 Do\n";
assert!(PdfDocument::may_contain_text(data));
}
#[test]
fn test_order_mcid_spans_pure_rtl_emitted_right_to_left() {
let spans = vec![
make_rtl_test_span("שלוש", 100.0, 700.0), make_rtl_test_span("שתיים", 200.0, 700.0),
make_rtl_test_span("אחת", 300.0, 700.0), ];
let ordered = PdfDocument::order_mcid_spans(&spans);
let texts: Vec<&str> = ordered.iter().map(|s| s.text.as_str()).collect();
assert_eq!(
texts,
vec!["אחת", "שתיים", "שלוש"],
"pure-RTL MCID spans must emit rightmost-first (logical RTL order), got {texts:?}"
);
}
#[test]
fn test_merge_interleaved_rtl_word_reconstructs_logical() {
use crate::geometry::Rect;
let body = TextSpan {
text: "تاييدلا".to_string(),
bbox: Rect::new(100.0, 700.0, 70.0, 12.0),
char_widths: vec![10.0; 7],
char_x_offsets: Vec::new(),
font_size: 12.0,
..TextSpan::default()
};
let theh = TextSpan {
text: "ث".to_string(),
bbox: Rect::new(145.0, 700.0, 0.0, 12.0),
font_size: 12.0,
..TextSpan::default()
};
let spans = [body, theh];
let line: Vec<&TextSpan> = spans.iter().collect();
assert!(
PdfDocument::rtl_line_needs_glyph_reorder(&line),
"interleaved zero-width consonant must trigger the glyph-reorder gate"
);
let merged = PdfDocument::merge_rtl_line_to_visual_span(&line);
let mut out = String::new();
PdfDocument::push_span_text_bidi(&mut out, &merged, true);
assert_eq!(out, "الثدييات", "interleaved word not reconstructed, got {out:?}");
}
#[test]
fn test_merge_rtl_preserves_producer_word_boundary_after_dual_joining() {
use crate::geometry::Rect;
let word_two = TextSpan {
text: "ةعئاش".to_string(),
bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
char_widths: vec![10.0; 5],
char_x_offsets: Vec::new(),
font_size: 12.0,
..TextSpan::default()
};
let space = TextSpan {
text: " ".to_string(),
bbox: Rect::new(150.0, 700.0, 8.0, 12.0),
font_size: 12.0,
..TextSpan::default()
};
let word_one = TextSpan {
text: "عاونأ".to_string(),
bbox: Rect::new(160.0, 700.0, 50.0, 12.0),
char_widths: vec![10.0; 5],
char_x_offsets: Vec::new(),
font_size: 12.0,
..TextSpan::default()
};
let spans = [word_two, space, word_one];
let line: Vec<&TextSpan> = spans.iter().collect();
let merged = PdfDocument::merge_rtl_line_to_visual_span(&line);
let mut out = String::new();
PdfDocument::push_span_text_bidi(&mut out, &merged, true);
assert_eq!(
out, "أنواع شائعة",
"producer word boundary after a dual-joining letter must be kept, got {out:?}"
);
assert!(
!out.contains(PdfDocument::RTL_WORD_BOUNDARY),
"word-boundary sentinel must be restored to a space, not leaked: {out:?}"
);
}
#[test]
fn test_merge_keeps_subbaseline_zero_width_glyph_on_line() {
use crate::geometry::Rect;
let span = |text: &str, x: f32, y: f32, w: f32| TextSpan {
text: text.to_string(),
bbox: Rect::new(x, y, w, 12.0),
font_size: 13.0,
..TextSpan::default()
};
let spans = vec![
span(".", 95.94, 664.5, 3.48),
span("ملاعلا", 99.42, 664.5, 27.71),
span(" ", 127.13, 664.5, 3.38),
span("ي", 132.92, 661.48, 0.0), span("ف", 141.99, 664.99, 0.0),
span(" ", 145.89, 664.5, 3.38),
span("االهسا", 149.26, 664.5, 41.64),
span("كً", 153.57, 666.57, 0.0),
];
let merged = PdfDocument::merge_interleaved_rtl_lines(&spans).expect("interleaved gate must fire on this RTL line");
assert_eq!(
merged.len(),
1,
"the sub-baseline ي must stay in the line's band, not split off (got {} spans)",
merged.len()
);
let mut out = String::new();
for s in &merged {
PdfDocument::push_span_text_bidi(&mut out, s, true);
}
assert!(out.contains("في"), "ي must reattach to ف as the word في; got {out:?}");
assert!(
!out.trim_end().ends_with('ي'),
"ي must not be stranded after the sentence terminator; got {out:?}"
);
}
#[test]
fn test_rtl_line_no_interleave_skips_glyph_reorder() {
let spans = vec![
make_rtl_test_span("אחת", 300.0, 700.0),
make_rtl_test_span("שתיים", 200.0, 700.0),
make_rtl_test_span("שלוש", 100.0, 700.0),
];
let line: Vec<&TextSpan> = spans.iter().collect();
assert!(
!PdfDocument::rtl_line_needs_glyph_reorder(&line),
"no zero-width interleave → gate must stay off (byte-identical path)"
);
assert!(
PdfDocument::merge_interleaved_rtl_lines(&spans).is_none(),
"no interleaved line → no merge (caller uses original spans)"
);
}
#[test]
fn test_order_pure_rtl_spans_keeps_jittery_baseline_in_one_line() {
let spans = vec![
make_rtl_test_span("\u{0627}", 300.0, 701.0), make_rtl_test_span("\u{0644}", 250.0, 703.0), make_rtl_test_span("\u{0642}", 200.0, 700.0), make_rtl_test_span("\u{0637}", 150.0, 702.0),
make_rtl_test_span("\u{0645}", 100.0, 699.0), ];
let ordered = PdfDocument::order_pure_rtl_spans(&spans);
let texts: Vec<&str> = ordered.iter().map(|s| s.text.as_str()).collect();
assert_eq!(
texts,
vec!["\u{0627}", "\u{0644}", "\u{0642}", "\u{0637}", "\u{0645}"],
"jittery-baseline RTL line must stay in one rightmost-first run, got {texts:?}"
);
}
#[test]
fn test_order_pure_rtl_spans_breaks_distinct_lines() {
let spans = vec![
make_rtl_test_span("\u{0628}", 200.0, 714.0),
make_rtl_test_span("\u{0627}", 300.0, 714.0),
make_rtl_test_span("\u{062F}", 200.0, 700.0),
make_rtl_test_span("\u{062C}", 300.0, 700.0),
];
let ordered = PdfDocument::order_pure_rtl_spans(&spans);
let texts: Vec<&str> = ordered.iter().map(|s| s.text.as_str()).collect();
assert_eq!(
texts,
vec!["\u{0627}", "\u{0628}", "\u{062C}", "\u{062F}"],
"distinct RTL lines must break (top first, each rightmost-first), got {texts:?}"
);
}
#[test]
fn test_order_mcid_spans_mixed_rtl_latin_kept_raw() {
let spans = vec![
make_rtl_test_span("שלום", 100.0, 700.0),
make_rtl_test_span("World", 200.0, 700.0),
];
let ordered = PdfDocument::order_mcid_spans(&spans);
let texts: Vec<&str> = ordered.iter().map(|s| s.text.as_str()).collect();
assert_eq!(texts, vec!["שלום", "World"], "mixed RTL+Latin must stay in raw order");
}
#[test]
fn test_may_contain_text_bt_with_newline() {
assert!(PdfDocument::may_contain_text(b"\nBT\n"));
}
#[test]
fn test_may_contain_text_do_with_bracket() {
assert!(PdfDocument::may_contain_text(b"]Do["));
}
#[test]
fn test_may_contain_text_single_b() {
assert!(!PdfDocument::may_contain_text(b"B"));
}
#[test]
fn test_may_contain_text_single_d() {
assert!(!PdfDocument::may_contain_text(b"D"));
}
#[test]
fn test_reading_order_enum_default() {
let order = ReadingOrder::default();
assert_eq!(order, ReadingOrder::TopToBottom);
}
#[test]
fn test_reading_order_enum_variants() {
assert_ne!(ReadingOrder::TopToBottom, ReadingOrder::ColumnAware);
let a = ReadingOrder::ColumnAware;
let b = a;
assert_eq!(a, b);
}
#[test]
fn test_column_aware_reads_column1_before_column2() {
use crate::geometry::Rect;
use crate::layout::{Color, FontWeight, TextSpan};
use crate::pipeline::reading_order::{ReadingOrderContext as ROContext, ReadingOrderStrategy, XYCutStrategy};
fn make_span(label: &str, x: f32, y: f32) -> TextSpan {
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: label.to_string(),
bbox: Rect::new(x, y, 80.0, 12.0),
font_size: 12.0,
font_name: "Test".to_string(),
font_weight: FontWeight::Normal,
is_italic: false,
is_monospace: false,
color: Color { r: 0.0, g: 0.0, b: 0.0 },
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
}
}
let spans = vec![
make_span("L1", 10.0, 700.0),
make_span("R1", 200.0, 700.0),
make_span("L2", 10.0, 680.0),
make_span("R2", 200.0, 680.0),
make_span("L3", 10.0, 660.0),
make_span("R3", 200.0, 660.0),
];
let strategy = XYCutStrategy::new();
let context = ROContext::new();
let ordered = strategy.apply(spans, &context).expect("XYCut should not fail");
let labels: Vec<&str> = ordered.iter().map(|o| o.span.text.as_str()).collect();
assert_eq!(
labels,
vec!["L1", "L2", "L3", "R1", "R2", "R3"],
"ColumnAware should read left column fully before right column"
);
}
#[test]
fn test_column_aware_sparse_two_column_follows_stream_order() {
use crate::geometry::Rect;
use crate::layout::{Color, FontWeight, TextSpan};
use crate::pipeline::reading_order::{ReadingOrderContext as ROContext, ReadingOrderStrategy, XYCutStrategy};
fn make_span(label: &str, x: f32, y: f32, sequence: usize) -> TextSpan {
TextSpan {
provenance: None,
text_rise: 0.0,
mirrored: false,
page_rotation_applied: 0,
artifact_type: None,
text: label.to_string(),
bbox: Rect::new(x, y, 80.0, 12.0),
font_size: 12.0,
font_name: "Test".to_string(),
font_weight: FontWeight::Normal,
is_italic: false,
is_monospace: false,
color: Color { r: 0.0, g: 0.0, b: 0.0 },
mcid: None,
mcid_scope: None,
sequence,
split_boundary_before: false,
offset_semantic: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
}
}
let spans = vec![
make_span("L1", 10.0, 700.0, 0),
make_span("L2", 10.0, 680.0, 1),
make_span("R1", 200.0, 700.0, 2),
make_span("R2", 200.0, 680.0, 3),
];
let strategy = XYCutStrategy::new();
let context = ROContext::new();
let ordered = strategy.apply(spans, &context).expect("XYCut should not fail");
let labels: Vec<&str> = ordered.iter().map(|o| o.span.text.as_str()).collect();
assert_eq!(
labels,
vec!["L1", "L2", "R1", "R2"],
"a sparse 2-column page below min_spans_for_split must follow \
content-stream order (column-major here), not interleave the \
columns via a flat Y-then-X sort"
);
}
#[test]
fn test_column_aware_sparse_2x2_table_stays_row_major() {
use crate::geometry::Rect;
use crate::layout::{Color, FontWeight, TextSpan};
use crate::pipeline::reading_order::{ReadingOrderContext as ROContext, ReadingOrderStrategy, XYCutStrategy};
fn make_cell(label: &str, x: f32, y: f32, sequence: usize) -> TextSpan {
TextSpan {
provenance: None,
text_rise: 0.0,
mirrored: false,
page_rotation_applied: 0,
artifact_type: None,
text: label.to_string(),
bbox: Rect::new(x, y, 80.0, 12.0),
font_size: 12.0,
font_name: "Test".to_string(),
font_weight: FontWeight::Normal,
is_italic: false,
is_monospace: false,
color: Color { r: 0.0, g: 0.0, b: 0.0 },
mcid: None,
mcid_scope: None,
sequence,
split_boundary_before: false,
offset_semantic: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
}
}
let spans = vec![
make_cell("R1C1", 10.0, 700.0, 0),
make_cell("R1C2", 200.0, 700.0, 1),
make_cell("R2C1", 10.0, 680.0, 2),
make_cell("R2C2", 200.0, 680.0, 3),
];
let strategy = XYCutStrategy::new();
let context = ROContext::new();
let ordered = strategy.apply(spans, &context).expect("XYCut should not fail");
let labels: Vec<&str> = ordered.iter().map(|o| o.span.text.as_str()).collect();
assert_eq!(
labels,
vec!["R1C1", "R1C2", "R2C1", "R2C2"],
"a row-major-emitted 2x2 table must stay row-major, not be \
reshuffled into a column-major read order"
);
}
#[test]
fn test_rowspan_label_skips_spans_aligned_with_dense_column() {
use crate::layout::TextSpan;
fn mk(text: &str, x: f32, y: f32) -> TextSpan {
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: text.to_string(),
bbox: crate::geometry::Rect::new(x, y, 80.0, 10.0),
font_size: 12.0,
font_name: "Arial".into(),
font_weight: crate::layout::FontWeight::Normal,
is_italic: false,
is_monospace: false,
color: crate::layout::Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
}
}
let ys_dense = [640.0f32, 620.0, 600.0, 580.0, 560.0, 540.0, 520.0, 500.0, 480.0, 460.0];
let ys_sparse = [600.0f32, 560.0, 520.0, 480.0];
let mut spans: Vec<TextSpan> = Vec::new();
for &y in &ys_dense {
spans.push(mk(&format!("dense_y{}", y as i32), 63.0, y));
}
for &y in &ys_sparse {
spans.push(mk(&format!("sparse_y{}", y as i32), 430.0, y));
}
spans.sort_by(|a, b| crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x));
let before: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
super::super::PdfDocument::reorder_rowspan_labels(&mut spans);
let after: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
assert_eq!(
before, after,
"reorder_rowspan_labels must not change order when sparse spans \
share Y-bands with the dense column; \
before={before:?} after={after:?}"
);
}
#[test]
fn test_rowspan_skips_numbered_reference_continuation() {
use crate::layout::TextSpan;
fn mk(text: &str, x: f32, y: f32, w: f32) -> TextSpan {
TextSpan {
provenance: None,
text_rise: 0.0,
artifact_type: None,
text: text.to_string(),
bbox: crate::geometry::Rect::new(x, y, w, 10.0),
font_size: 12.0,
font_name: "Arial".into(),
font_weight: crate::layout::FontWeight::Normal,
is_italic: false,
is_monospace: false,
color: crate::layout::Color::black(),
mcid: None,
mcid_scope: None,
sequence: 0,
split_boundary_before: false,
offset_semantic: false,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scaling: 100.0,
primary_detected: false,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: 0.0,
wmode: 0,
rtl_draw_logical: false,
mirrored: false,
page_rotation_applied: 0,
}
}
let mut spans = vec![
mk("1.", 50.0, 95.0, 40.0),
mk("2.", 50.0, 65.0, 40.0),
mk("3.", 50.0, 35.0, 40.0),
mk("4.", 50.0, 5.0, 40.0),
];
for i in 0..12 {
let y = 100.0 - (i as f32) * 10.0;
spans.push(mk(&format!("b{:02}", i), 200.0, y, 20.0));
}
spans.sort_by(|a, b| crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x));
let before: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
super::super::PdfDocument::reorder_rowspan_labels(&mut spans);
let after: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
assert_eq!(
before, after,
"numbered reference markers must not be promoted as rowspan \
labels; before={before:?} after={after:?}"
);
}