- Fixed missing fields in BlockJson, SpanJson, ExtractionOptions initializations - Added feature gates to ocr_integration tests for conditional compilation - Fixed McpServerState::new calls to include audit writer argument - Fixed CCITTFaxDecoder::decode calls to use instance method - Fixed type casts for ObjRef::new calls - Fixed serde_json::Value method calls (is_some -> !is_null) - Fixed ProfileType test feature gates - Worked around lifetime issues in schema roundtrip tests These changes fix numerous compilation errors that were blocking the codebase from building. The main library and tests now compile successfully. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
351 lines
11 KiB
Rust
351 lines
11 KiB
Rust
//! OCR integration tests for end-to-end WER validation.
|
|
//!
|
|
//! These tests verify the complete OCR pipeline:
|
|
//! - Image rendering at specified DPI
|
|
//! - Preprocessing (border padding, contrast, binarization)
|
|
//! - Tesseract OCR with HOCR output
|
|
//! - Coordinate conversion to PDF space
|
|
//! - WER calculation against ground truth
|
|
//!
|
|
//! Run with: cargo test --test ocr_integration --features ocr -- --ignored
|
|
|
|
use std::path::Path;
|
|
|
|
/// Only run these tests if Tesseract is available.
|
|
fn tesseract_available() -> bool {
|
|
#[cfg(feature = "ocr")]
|
|
{
|
|
// Try to initialize Tesseract - if it fails, skip the test
|
|
use pdftract_core::ocr::{borrow_or_init, TessOpts};
|
|
|
|
std::panic::catch_unwind(|| {
|
|
let opts = TessOpts::default();
|
|
let _state = borrow_or_init(&opts);
|
|
})
|
|
.is_ok()
|
|
}
|
|
|
|
#[cfg(not(feature = "ocr"))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Test that calculate_wer produces correct results on known inputs.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_wer_calculation_known_inputs() {
|
|
use pdftract_core::ocr::calculate_wer;
|
|
|
|
// Perfect match
|
|
assert_eq!(calculate_wer("hello world", "hello world"), 0.0);
|
|
|
|
// One substitution
|
|
let wer = calculate_wer("hello world", "hallo world");
|
|
assert!((wer - 0.5).abs() < 0.01, "Expected WER ≈ 0.5, got {}", wer);
|
|
|
|
// All wrong
|
|
assert_eq!(calculate_wer("abc def", "xyz uvw"), 1.0);
|
|
|
|
// Case and punctuation normalization
|
|
assert_eq!(calculate_wer("Hello, World!", "hello world"), 0.0);
|
|
}
|
|
|
|
/// Integration test: Verify clean Lorem Ipsum can achieve WER < 2%.
|
|
///
|
|
/// This is a critical acceptance test from Phase 5.4.5.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
#[ignore] // Requires manual fixture generation
|
|
fn test_clean_lorem_ipsum_wer() {
|
|
if !tesseract_available() {
|
|
println!("Skipping: Tesseract not available");
|
|
return;
|
|
}
|
|
|
|
use pdftract_core::ocr::calculate_wer;
|
|
|
|
let fixture_dir = Path::new("tests/fixtures/ocr/clean_lorem_ipsum");
|
|
let ground_truth_path = fixture_dir.join("ground_truth.txt");
|
|
|
|
// For this test to work, source.pdf must be generated manually
|
|
// See README.md in the fixture directory
|
|
|
|
if !ground_truth_path.exists() {
|
|
println!("Skipping: Ground truth file not found");
|
|
return;
|
|
}
|
|
|
|
// Read ground truth
|
|
let ground_truth =
|
|
std::fs::read_to_string(ground_truth_path).expect("Failed to read ground truth");
|
|
|
|
// In a real test, we would:
|
|
// 1. Render the PDF at 300 DPI
|
|
// 2. Run OCR using run_tesseract
|
|
// 3. Concatenate all span texts
|
|
// 4. Calculate WER
|
|
|
|
// For now, just verify the ground truth is valid
|
|
assert!(!ground_truth.is_empty(), "Ground truth should not be empty");
|
|
assert!(
|
|
ground_truth.len() > 1000,
|
|
"Ground truth should have substantial content"
|
|
);
|
|
|
|
// Simulate perfect OCR for now
|
|
let ocr_output = &ground_truth;
|
|
let wer = calculate_wer(ocr_output, &ground_truth);
|
|
|
|
assert_eq!(wer, 0.0, "Perfect match should have WER = 0");
|
|
}
|
|
|
|
/// Integration test: Verify multi-language fixture works correctly.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
#[ignore] // Requires manual fixture generation
|
|
fn test_multilang_eng_fra_wer() {
|
|
if !tesseract_available() {
|
|
println!("Skipping: Tesseract not available");
|
|
return;
|
|
}
|
|
|
|
use pdftract_core::ocr::calculate_wer;
|
|
|
|
let fixture_dir = Path::new("tests/fixtures/ocr/eng_fra_mixed");
|
|
let ground_truth_path = fixture_dir.join("ground_truth.txt");
|
|
|
|
if !ground_truth_path.exists() {
|
|
println!("Skipping: Ground truth file not found");
|
|
return;
|
|
}
|
|
|
|
let ground_truth =
|
|
std::fs::read_to_string(ground_truth_path).expect("Failed to read ground truth");
|
|
|
|
// Verify both English and French text are present
|
|
assert!(
|
|
ground_truth.to_lowercase().contains("english"),
|
|
"Should contain English text"
|
|
);
|
|
assert!(
|
|
ground_truth.to_lowercase().contains("french"),
|
|
"Should contain French text"
|
|
);
|
|
|
|
// Verify common words from each language
|
|
assert!(
|
|
ground_truth.contains("the") || ground_truth.contains("quick"),
|
|
"Should contain English words"
|
|
);
|
|
assert!(
|
|
ground_truth.contains("le") || ground_truth.contains("la"),
|
|
"Should contain French words"
|
|
);
|
|
}
|
|
|
|
/// Test run_tesseract returns spans with valid structure.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_run_tesseract_span_structure() {
|
|
if !tesseract_available() {
|
|
println!("Skipping: Tesseract not available");
|
|
return;
|
|
}
|
|
|
|
use image::{GrayImage, ImageBuffer, Luma};
|
|
use pdftract_core::ocr::{run_tesseract, TessOpts};
|
|
|
|
// Create a simple test image with some text
|
|
// (In practice, you'd use a real image with text)
|
|
let img: GrayImage = ImageBuffer::from_pixel(200, 50, Luma([255u8]));
|
|
|
|
let opts = TessOpts::default();
|
|
let result = run_tesseract(&img, 300, 792.0, &opts);
|
|
|
|
assert!(result.is_ok(), "run_tesseract should succeed");
|
|
|
|
let spans = result.unwrap();
|
|
// Empty image produces minimal or no spans
|
|
// Just verify the structure is correct
|
|
for span in spans {
|
|
assert!(span.bbox.len() == 4, "Span bbox should have 4 coordinates");
|
|
assert!(
|
|
span.confidence >= 0.0 && span.confidence <= 1.0,
|
|
"Confidence should be in [0, 1]"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test WER threshold validation helper.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_wer_threshold_validation() {
|
|
use pdftract_core::ocr::calculate_wer;
|
|
|
|
// Test clean fixture threshold (2%)
|
|
let clean_text = "Lorem ipsum dolor sit amet consectetur adipiscing elit";
|
|
let ocr_perfect = clean_text;
|
|
let ocr_one_error = "Lorem ipsum dolor sit amet consectetur adipiscing elit"; // Same
|
|
let ocr_bad = "Xxxxx xxxxx xxxxx xxxx xxxx xxxxxxxxxxx xxxxxxxxx xxxx"; // All wrong
|
|
|
|
assert!(
|
|
calculate_wer(ocr_perfect, clean_text) < 0.02,
|
|
"Perfect match should pass 2% threshold"
|
|
);
|
|
|
|
// With one substitution in 10 words
|
|
let ocr_one_sub = "Lorem ipsum dolor sit amet consectetur adipiscing elix";
|
|
let wer = calculate_wer(ocr_one_sub, clean_text);
|
|
assert!(wer >= 0.09 && wer <= 0.11, "One sub in 10 words = 10% WER");
|
|
}
|
|
|
|
/// Performance test: Verify 10-page fixture can be processed in reasonable time.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
#[ignore] // Requires manual fixture generation
|
|
fn test_performance_10_pages() {
|
|
if !tesseract_available() {
|
|
println!("Skipping: Tesseract not available");
|
|
return;
|
|
}
|
|
|
|
let fixture_dir = Path::new("tests/fixtures/ocr/perf_10_page");
|
|
|
|
// Verify fixture structure exists
|
|
assert!(
|
|
fixture_dir.exists(),
|
|
"Performance fixture directory should exist"
|
|
);
|
|
assert!(
|
|
fixture_dir.join("ground_truth.txt").exists(),
|
|
"Ground truth should exist"
|
|
);
|
|
|
|
// Check that all page files exist
|
|
for i in 1..=10 {
|
|
let page_file = fixture_dir.join(format!("page_{}.txt", i));
|
|
assert!(page_file.exists(), "Page {} file should exist", i);
|
|
}
|
|
|
|
// In a real test, we would measure actual OCR processing time
|
|
// For now, just verify the fixture structure is correct
|
|
}
|
|
|
|
/// Test coordinate conversion for full-page OCR.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_full_page_coordinate_conversion() {
|
|
use image::{GrayImage, ImageBuffer, Luma};
|
|
use pdftract_core::ocr::{run_tesseract, TessOpts};
|
|
|
|
if !tesseract_available() {
|
|
println!("Skipping: Tesseract not available");
|
|
return;
|
|
}
|
|
|
|
// Create a test image
|
|
let img: GrayImage = ImageBuffer::from_pixel(612, 792, Luma([255u8])); // Letter size at 72 DPI
|
|
|
|
let opts = TessOpts::default();
|
|
let result = run_tesseract(&img, 72, 792.0, &opts);
|
|
|
|
assert!(result.is_ok(), "run_tesseract should succeed");
|
|
|
|
let spans = result.unwrap();
|
|
// Verify all spans have coordinates within page bounds
|
|
for span in spans {
|
|
assert!(span.bbox[0] >= 0.0, "x0 should be non-negative");
|
|
assert!(span.bbox[1] >= 0.0, "y0 should be non-negative");
|
|
assert!(span.bbox[2] <= 612.0, "x1 should be within page width");
|
|
assert!(span.bbox[3] <= 792.0, "y1 should be within page height");
|
|
}
|
|
}
|
|
|
|
/// Test cell OCR coordinate conversion.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_cell_coordinate_conversion() {
|
|
use image::{GrayImage, ImageBuffer, Luma};
|
|
use pdftract_core::ocr::run_tesseract_on_cell;
|
|
|
|
if !tesseract_available() {
|
|
println!("Skipping: Tesseract not available");
|
|
return;
|
|
}
|
|
|
|
// Create a small cell image
|
|
let img: GrayImage = ImageBuffer::from_pixel(100, 100, Luma([255u8]));
|
|
|
|
let opts = TessOpts::default();
|
|
let cell_origin = [50.0, 100.0];
|
|
|
|
let result = run_tesseract_on_cell(&img, 300, 100.0, cell_origin, &opts);
|
|
|
|
assert!(result.is_ok(), "run_tesseract_on_cell should succeed");
|
|
|
|
let spans = result.unwrap();
|
|
// Verify all spans are offset by cell origin
|
|
for span in spans {
|
|
assert!(span.bbox[0] >= 50.0, "X should be offset by cell origin");
|
|
assert!(span.bbox[1] >= 100.0, "Y should be offset by cell origin");
|
|
}
|
|
}
|
|
|
|
/// Test language validation with diagnostics.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_language_validation() {
|
|
use pdftract_core::ocr::{detect_available_languages, validate_ocr_languages};
|
|
|
|
let available = detect_available_languages();
|
|
|
|
if available.is_empty() {
|
|
println!("Skipping: No language packs detected");
|
|
return;
|
|
}
|
|
|
|
let mut diagnostics = Vec::new();
|
|
|
|
// Test with available language
|
|
if available.contains("eng") {
|
|
let result = validate_ocr_languages(&["eng".to_string()], &mut diagnostics);
|
|
assert_eq!(result, "eng", "Should return eng when available");
|
|
}
|
|
|
|
// Test with missing language
|
|
let missing_lang = "xxx_this_lang_does_not_exist_xxx";
|
|
let result = validate_ocr_languages(&[missing_lang.to_string()], &mut diagnostics);
|
|
|
|
// Should fall back to eng if available, or return the missing lang (causing init failure)
|
|
if available.contains("eng") {
|
|
assert_eq!(result, "eng", "Should fall back to eng");
|
|
assert!(
|
|
!diagnostics.is_empty(),
|
|
"Should emit diagnostic for missing language"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test multi-language string construction.
|
|
#[test]
|
|
#[cfg(feature = "ocr")]
|
|
fn test_multi_language_string() {
|
|
use pdftract_core::ocr::validate_ocr_languages;
|
|
|
|
let mut diagnostics = Vec::new();
|
|
|
|
// Mock available languages by not running actual detection
|
|
// Just test the string construction logic
|
|
|
|
let langs = vec!["eng".to_string(), "fra".to_string(), "deu".to_string()];
|
|
let result = validate_ocr_languages(&langs, &mut diagnostics);
|
|
|
|
// Should concatenate with +
|
|
if !result.contains('+') {
|
|
// If languages are missing, result might be just "eng"
|
|
println!("Language validation result: {}", result);
|
|
} else {
|
|
assert!(result.contains("eng+"), "Should contain eng+");
|
|
}
|
|
}
|