use super::api_pool::TesseractApiPool;
use super::config::{apply_tesseract_variables, hash_config};
use super::validation::{
resolve_all_installed_languages, resolve_tessdata_path, strip_control_characters, validate_language_and_traineddata,
};
use crate::core::config::ExtractionConfig;
use crate::extraction::image::resolve_known_source_dpi;
use crate::extractors::security::SecurityLimits;
use crate::image::normalize_image_dpi_owned;
use crate::ocr::cache::OcrCache;
use crate::ocr::conversion::{TsvRow, iterator_word_to_element, tsv_row_to_element};
use crate::ocr::error::OcrError;
use crate::ocr::hocr_parser::{
DictionaryLineFilter, RetainedWordConfidenceStats, parse_hocr_to_internal_document_with_page_offset_and_stats,
};
use crate::ocr::preprocessing::preprocess_pix;
#[cfg(test)]
use crate::ocr::preprocessing::should_invert_for_polarity;
#[cfg(feature = "pdf")]
use crate::ocr::table::post_process_table;
use crate::ocr::table::{
TableWords, extract_table_words_from_tsv, extract_words_from_tsv, reconstruct_table_with_columns,
shading_mark_keep_mask, table_to_markdown,
};
#[cfg(test)]
use crate::ocr::types::BatchItemResult;
use crate::ocr::types::TesseractConfig;
use crate::types::internal::{ElementKind, InternalDocument};
use crate::types::{OcrExtractionResult, OcrTable, OcrTableBoundingBox};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use xberg_tesseract::{TessPageSegMode, TessPolyBlockType, TesseractAPI};
#[cfg(auto_rotate)]
fn doc_orientation_detector() -> &'static crate::doc_orientation::DocOrientationDetector {
static DETECTOR: std::sync::LazyLock<crate::doc_orientation::DocOrientationDetector> =
std::sync::LazyLock::new(|| {
crate::doc_orientation::DocOrientationDetector::with_acceleration(
crate::doc_orientation::resolve_cache_dir(),
None,
)
});
&DETECTOR
}
use crate::table_core::{
HocrWord, MIN_TABLE_CANDIDATE_WORDS, cluster_word_indices_into_table_regions, detect_rows,
drop_leading_caption_row, median_word_height, merge_disjoint_numeric_columns,
};
use crate::types::OcrElement;
#[cfg(auto_rotate)]
fn rotate_rgb_image_data(data: &[u8], width: u32, height: u32, degrees: i32) -> (Vec<u8>, u32, u32) {
let bpp = 3usize;
let w = width as usize;
let h = height as usize;
match degrees {
0 => (data.to_vec(), width, height),
90 => {
let new_w = h;
let new_h = w;
let mut out = vec![0u8; new_w * new_h * bpp];
for y in 0..h {
for x in 0..w {
let src_idx = (y * w + x) * bpp;
let dst_x = h - 1 - y;
let dst_y = x;
let dst_idx = (dst_y * new_w + dst_x) * bpp;
out[dst_idx..dst_idx + bpp].copy_from_slice(&data[src_idx..src_idx + bpp]);
}
}
(out, new_w as u32, new_h as u32)
}
180 => {
let mut out = vec![0u8; w * h * bpp];
for y in 0..h {
for x in 0..w {
let src_idx = (y * w + x) * bpp;
let dst_x = w - 1 - x;
let dst_y = h - 1 - y;
let dst_idx = (dst_y * w + dst_x) * bpp;
out[dst_idx..dst_idx + bpp].copy_from_slice(&data[src_idx..src_idx + bpp]);
}
}
(out, width, height)
}
270 => {
let new_w = h;
let new_h = w;
let mut out = vec![0u8; new_w * new_h * bpp];
for y in 0..h {
for x in 0..w {
let src_idx = (y * w + x) * bpp;
let dst_x = y;
let dst_y = w - 1 - x;
let dst_idx = (dst_y * new_w + dst_x) * bpp;
out[dst_idx..dst_idx + bpp].copy_from_slice(&data[src_idx..src_idx + bpp]);
}
}
(out, new_w as u32, new_h as u32)
}
_ => {
tracing::warn!("Unsupported rotation angle: {}°, skipping rotation", degrees);
(data.to_vec(), width, height)
}
}
}
fn parse_tsv_to_elements(tsv_data: &str, min_confidence: f64, page_number: u32) -> Vec<OcrElement> {
let mut elements = Vec::new();
for line in tsv_data.lines().skip(1) {
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 12 {
continue;
}
let level = fields[0].parse::<i32>().unwrap_or(0);
let page_num = fields[1].parse::<i32>().unwrap_or(1);
let block_num = fields[2].parse::<i32>().unwrap_or(0);
let par_num = fields[3].parse::<i32>().unwrap_or(0);
let line_num = fields[4].parse::<i32>().unwrap_or(0);
let word_num = fields[5].parse::<i32>().unwrap_or(0);
let left = fields[6].parse::<u32>().unwrap_or(0);
let top = fields[7].parse::<u32>().unwrap_or(0);
let width = fields[8].parse::<u32>().unwrap_or(0);
let height = fields[9].parse::<u32>().unwrap_or(0);
let conf = fields[10].parse::<f64>().unwrap_or(-1.0);
let text = fields[11].to_string();
if conf < 0.0 || conf < min_confidence || text.trim().is_empty() {
continue;
}
if level != 4 && level != 5 {
continue;
}
let tsv_row = TsvRow {
level,
page_num,
block_num,
par_num,
line_num,
word_num,
left,
top,
width,
height,
conf,
text,
};
let mut element = tsv_row_to_element(&tsv_row);
element.page_number = page_number;
elements.push(element);
}
elements
}
fn log_ci_debug<F>(enabled: bool, stage: &str, details: F)
where
F: FnOnce() -> String,
{
if !enabled {
return;
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
tracing::debug!(stage, timestamp = format!("{timestamp:.3}"), "{}", details());
}
const TABLE_REBUILD_MIN_WORD_RETENTION: usize = 0;
fn should_adopt_table_rebuild(original_content: &str, rebuilt_content: &str) -> bool {
let original_word_count = original_content.split_whitespace().count();
let rebuilt_word_count = rebuilt_content.split_whitespace().count();
rebuilt_word_count + TABLE_REBUILD_MIN_WORD_RETENTION >= original_word_count
}
const MIN_QUANTITY_COLUMN_SUPPORT: usize = 2;
const MAX_RETRY_QUANTITY_DIGITS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct QuantityRetryRegion {
row: usize,
column: usize,
left: u32,
top: u32,
width: u32,
height: u32,
word_left: u32,
word_top: u32,
word_width: u32,
word_height: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct QuantityRetryColumn {
index: usize,
left: u32,
right: u32,
median_height: u32,
word_width: u32,
cell_left: u32,
cell_right: u32,
}
fn nearest_position_index(positions: &[u32], value: u32) -> Option<usize> {
if positions.is_empty() {
return None;
}
match positions.binary_search(&value) {
Ok(index) => Some(index),
Err(0) => Some(0),
Err(index) if index == positions.len() => Some(index - 1),
Err(index) => {
let left = index - 1;
Some(if positions[left].abs_diff(value) <= positions[index].abs_diff(value) {
left
} else {
index
})
}
}
}
fn quantity_retry_region(
words: &[HocrWord],
quantity_column: usize,
blank_row: usize,
row_positions: &[u32],
column_positions: &[u32],
image_width: u32,
image_height: u32,
) -> Option<QuantityRetryRegion> {
if blank_row >= row_positions.len() || quantity_column >= column_positions.len() {
return None;
}
let column = quantity_retry_column(words, column_positions, quantity_column, image_width);
retry_region_for_row(words, blank_row, column, row_positions, image_width, image_height)
}
fn quantity_retry_column_index(table: &[Vec<String>]) -> Option<(usize, usize)> {
let quantity_column = table
.first()?
.iter()
.position(|header| header.trim().eq_ignore_ascii_case("qty"))?;
let mut support = 0usize;
let mut blank_row = None;
for (row_index, row) in table.iter().enumerate().skip(1) {
let quantity = row.get(quantity_column)?.trim();
if quantity.is_empty() {
if blank_row.replace(row_index).is_some() {
return None;
}
} else if quantity.bytes().all(|byte| byte.is_ascii_digit()) {
support += 1;
} else {
return None;
}
}
(support >= MIN_QUANTITY_COLUMN_SUPPORT).then_some((quantity_column, blank_row?))
}
fn quantity_retry_column(
words: &[HocrWord],
column_positions: &[u32],
index: usize,
image_width: u32,
) -> QuantityRetryColumn {
let (left, right, median_height, word_width) = quantity_column_geometry(words, column_positions, index);
let (cell_left, cell_right) = cell_axis_bounds(column_positions, index, image_width);
QuantityRetryColumn {
index,
left,
right,
median_height,
word_width,
cell_left,
cell_right,
}
}
fn quantity_column_geometry(words: &[HocrWord], column_positions: &[u32], index: usize) -> (u32, u32, u32, u32) {
let mean_height = nonzero_mean(words.iter().map(|word| word.height), 1);
let mut left = None;
let mut right = None;
let mut width_sum = 0u64;
let mut width_count = 0u64;
for word in words {
if nearest_position_index(column_positions, word.left) != Some(index) {
continue;
}
left = Some(left.map_or(word.left, |current: u32| current.min(word.left)));
let word_right = word.left.saturating_add(word.width);
right = Some(right.map_or(word_right, |current: u32| current.max(word_right)));
if word.width > 0 {
width_sum += u64::from(word.width);
width_count += 1;
}
}
let left = left.unwrap_or(column_positions[index]);
let right = right.unwrap_or(column_positions[index].saturating_add(mean_height));
let word_width = width_sum
.checked_div(width_count)
.map_or(mean_height, |mean| u32::try_from(mean).unwrap_or(u32::MAX));
(left, right, mean_height, word_width)
}
fn nonzero_mean(values: impl Iterator<Item = u32>, fallback: u32) -> u32 {
let (sum, count) = values
.filter(|&value| value > 0)
.fold((0u64, 0u64), |(sum, count), value| (sum + u64::from(value), count + 1));
sum.checked_div(count)
.map_or(fallback, |mean| u32::try_from(mean).unwrap_or(u32::MAX))
}
fn cell_axis_bounds(positions: &[u32], index: usize, limit: u32) -> (u32, u32) {
let midpoint = |left: u32, right: u32| u32::try_from((u64::from(left) + u64::from(right)) / 2).unwrap_or(limit);
let lower = index
.checked_sub(1)
.map_or(0, |left| midpoint(positions[left], positions[index]));
let upper = positions
.get(index + 1)
.map_or(limit, |&right| midpoint(positions[index], right));
(lower.min(limit), upper.min(limit))
}
fn retry_region_for_row(
words: &[HocrWord],
row: usize,
column: QuantityRetryColumn,
row_positions: &[u32],
image_width: u32,
image_height: u32,
) -> Option<QuantityRetryRegion> {
let mut row_top = None;
let mut row_bottom = None;
for word in words {
if nearest_position_index(row_positions, word.y_center() as u32) != Some(row) {
continue;
}
row_top = Some(row_top.map_or(word.top, |current: u32| current.min(word.top)));
let word_bottom = word.top.saturating_add(word.height);
row_bottom = Some(row_bottom.map_or(word_bottom, |current: u32| current.max(word_bottom)));
}
let row_top = row_top?;
let row_bottom = row_bottom?;
let horizontal_padding = column.median_height;
let vertical_padding = column.median_height / 2;
let (row_cell_top, row_cell_bottom) = cell_axis_bounds(row_positions, row, image_height);
let left = column.left.saturating_sub(horizontal_padding).max(column.cell_left);
let right = column
.right
.saturating_add(horizontal_padding)
.min(image_width)
.min(column.cell_right);
let top = row_top.saturating_sub(vertical_padding).max(row_cell_top);
let bottom = row_bottom
.saturating_add(vertical_padding)
.min(image_height)
.min(row_cell_bottom);
if right <= left || bottom <= top {
return None;
}
Some(QuantityRetryRegion {
row,
column: column.index,
left,
top,
width: right - left,
height: bottom - top,
word_left: column.left,
word_top: row_positions[row].saturating_sub(column.median_height / 2),
word_width: column.word_width,
word_height: column.median_height,
})
}
fn parse_retry_quantity(text: &str) -> Option<&str> {
let quantity = text.trim();
(!quantity.is_empty()
&& quantity.len() <= MAX_RETRY_QUANTITY_DIGITS
&& quantity.bytes().all(|byte| byte.is_ascii_digit())
&& quantity.bytes().any(|byte| byte != b'0'))
.then_some(quantity)
}
fn recover_blank_quantity_word(
api: &TesseractAPI,
config: &TesseractConfig,
region: Option<&QuantityRetryRegion>,
image_width: u32,
image_height: u32,
) -> Option<HocrWord> {
let region = region?;
if api.set_page_seg_mode(TessPageSegMode::PSM_SINGLE_LINE).is_err() {
return None;
}
let recovered = recognize_quantity_region(api, config, region);
let _ = api.set_rectangle(
0,
0,
i32::try_from(image_width).unwrap_or(i32::MAX),
i32::try_from(image_height).unwrap_or(i32::MAX),
);
let _ = api.set_page_seg_mode(TessPageSegMode::from_int(config.psm as i32));
recovered
}
fn recognize_quantity_region(
api: &TesseractAPI,
config: &TesseractConfig,
region: &QuantityRetryRegion,
) -> Option<HocrWord> {
let left = i32::try_from(region.left).ok()?;
let top = i32::try_from(region.top).ok()?;
let width = i32::try_from(region.width).ok()?;
let height = i32::try_from(region.height).ok()?;
api.set_rectangle(left, top, width, height).ok()?;
api.recognize().ok()?;
let text = api.get_utf8_text().ok()?;
let quantity = parse_retry_quantity(&text)?;
let confidence = api.mean_text_conf().unwrap_or(-1);
if f64::from(confidence) < config.table_min_confidence {
return None;
}
Some(HocrWord {
text: quantity.to_string(),
left: region.word_left,
top: region.word_top,
width: region.word_width,
height: region.word_height,
confidence: f64::from(confidence),
})
}
struct TableRegion {
words: Vec<HocrWord>,
read_boxes: Vec<HocrWord>,
}
impl TableRegion {
fn push(&mut self, word: HocrWord) {
self.read_boxes.push(word.clone());
self.words.push(word);
}
fn drop_shading_marks(&mut self, row_threshold_ratio: f64) {
let keep = shading_mark_keep_mask(&self.read_boxes, row_threshold_ratio);
debug_assert_eq!(
self.words.len(),
self.read_boxes.len(),
"TableRegion vectors must stay parallel"
);
self.words = std::mem::take(&mut self.words)
.into_iter()
.zip(&keep)
.filter(|(_, keep)| **keep)
.map(|(word, _)| word)
.collect();
self.read_boxes = std::mem::take(&mut self.read_boxes)
.into_iter()
.zip(&keep)
.filter(|(_, keep)| **keep)
.map(|(word, _)| word)
.collect();
}
fn drop_normalized_band_edge_glyphs(&mut self) {
if self.read_boxes.len() < 2 {
return;
}
let left = self.read_boxes.iter().map(|word| word.left).min().unwrap_or(0);
let right = self
.read_boxes
.iter()
.map(|word| word.left.saturating_add(word.width))
.max()
.unwrap_or(0);
let edge_tolerance = median_word_height(&self.read_boxes).max(1);
let is_bracket_prefix = |word: &HocrWord| {
word.left <= left.saturating_add(edge_tolerance)
&& word
.text
.strip_prefix('[')
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|character| character.is_alphabetic()))
};
if self.read_boxes.iter().filter(|word| is_bracket_prefix(word)).count() < 2 {
return;
}
let keep: Vec<bool> = self
.read_boxes
.iter()
.map(|word| {
let word_right = word.left.saturating_add(word.width);
word.text != "|"
|| !(word.left <= left.saturating_add(edge_tolerance)
|| word_right.saturating_add(edge_tolerance) >= right)
})
.collect();
for (word, read_box) in self.words.iter_mut().zip(&mut self.read_boxes) {
if is_bracket_prefix(read_box) {
word.text.remove(0);
read_box.text.remove(0);
}
}
self.words = std::mem::take(&mut self.words)
.into_iter()
.zip(&keep)
.filter(|(_, keep)| **keep)
.map(|(word, _)| word)
.collect();
self.read_boxes = std::mem::take(&mut self.read_boxes)
.into_iter()
.zip(&keep)
.filter(|(_, keep)| **keep)
.map(|(word, _)| word)
.collect();
}
fn bounding_box(&self) -> OcrTableBoundingBox {
OcrTableBoundingBox {
left: self.read_boxes.iter().map(|w| w.left).min().unwrap_or(0),
top: self.read_boxes.iter().map(|w| w.top).min().unwrap_or(0),
right: self.read_boxes.iter().map(|w| w.left + w.width).max().unwrap_or(0),
bottom: self.read_boxes.iter().map(|w| w.top + w.height).max().unwrap_or(0),
}
}
}
fn table_regions(table_words: &TableWords) -> Vec<TableRegion> {
cluster_word_indices_into_table_regions(&table_words.words)
.into_iter()
.map(|indices| TableRegion {
words: indices.iter().map(|&i| table_words.words[i].clone()).collect(),
read_boxes: indices.iter().map(|&i| table_words.read_boxes[i].clone()).collect(),
})
.collect()
}
fn build_content_with_inline_tables(tsv_data: &str, tables: &[OcrTable], min_confidence: f64) -> String {
let words = match extract_words_from_tsv(tsv_data, min_confidence) {
Ok(w) => w,
Err(_) => return String::new(),
};
if words.is_empty() {
return String::new();
}
let table_bboxes: Vec<_> = tables.iter().filter_map(|t| t.bounding_box.as_ref()).collect();
let mut non_table_words = Vec::new();
for word in &words {
let in_table = table_bboxes.iter().any(|bbox| {
let word_cx = word.left + word.width / 2;
let word_cy = word.top + word.height / 2;
word_cx >= bbox.left && word_cx <= bbox.right && word_cy >= bbox.top && word_cy <= bbox.bottom
});
if !in_table {
non_table_words.push(word);
}
}
if non_table_words.is_empty() && tables.is_empty() {
return String::new();
}
let mut sorted_words = non_table_words;
sorted_words.sort_by(|a, b| a.top.cmp(&b.top).then(a.left.cmp(&b.left)));
let avg_height = if sorted_words.is_empty() {
20
} else {
let total_h: u32 = sorted_words.iter().map(|w| w.height).sum();
(total_h / sorted_words.len() as u32).max(1)
};
let line_threshold = avg_height / 2;
struct TextLine {
y_center: u32,
text: String,
}
let mut lines: Vec<TextLine> = Vec::new();
for word in &sorted_words {
let word_y = word.top + word.height / 2;
if let Some(last_line) = lines.last_mut()
&& word_y.abs_diff(last_line.y_center) <= line_threshold
{
last_line.text.push(' ');
last_line.text.push_str(&word.text);
continue;
}
lines.push(TextLine {
y_center: word_y,
text: word.text.clone(),
});
}
let paragraph_gap = avg_height * 2;
struct Paragraph {
y_start: u32,
text: String,
}
let mut paragraphs: Vec<Paragraph> = Vec::new();
for line in &lines {
if let Some(last_para) = paragraphs.last_mut() {
let last_y = last_para.y_start;
if line.y_center.saturating_sub(last_y) <= paragraph_gap {
last_para.text.push('\n');
last_para.text.push_str(&line.text);
last_para.y_start = line.y_center;
continue;
}
}
paragraphs.push(Paragraph {
y_start: line.y_center,
text: line.text.clone(),
});
}
enum ContentElement<'a> {
Paragraph { y: u32, text: String },
Table { y: u32, markdown: &'a str },
}
let mut elements: Vec<ContentElement> = Vec::new();
{
let mut para_idx = 0;
let mut line_idx = 0;
for para in ¶graphs {
let line_count = para.text.matches('\n').count() + 1;
let first_y = if line_idx < lines.len() {
lines[line_idx].y_center
} else {
para.y_start
};
elements.push(ContentElement::Paragraph {
y: first_y,
text: para.text.clone(),
});
line_idx += line_count;
para_idx += 1;
}
let _ = para_idx;
}
for table in tables {
if let Some(ref bbox) = table.bounding_box {
elements.push(ContentElement::Table {
y: bbox.top,
markdown: &table.markdown,
});
} else {
elements.push(ContentElement::Table {
y: u32::MAX,
markdown: &table.markdown,
});
}
}
elements.sort_by_key(|e| match e {
ContentElement::Paragraph { y, .. } => *y,
ContentElement::Table { y, .. } => *y,
});
let mut output = String::new();
for elem in &elements {
let text = match elem {
ContentElement::Paragraph { text, .. } => text.as_str(),
ContentElement::Table { markdown, .. } => markdown,
};
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(trimmed);
}
output
}
fn flatten_hocr_elements_to_text(elements: &[crate::types::internal::InternalElement]) -> String {
elements
.iter()
.filter(|e| !matches!(e.kind, ElementKind::PageBreak) && !e.text.is_empty())
.map(|e| e.text.as_str())
.collect::<Vec<_>>()
.join("\n\n")
}
fn filter_elements_covered_by_tables(
elements: Vec<crate::types::internal::InternalElement>,
tables: &[OcrTable],
) -> Vec<crate::types::internal::InternalElement> {
let mut claiming_bboxes: Vec<&OcrTableBoundingBox> = Vec::new();
for table in tables {
let Some(bbox) = table.bounding_box.as_ref() else {
continue;
};
if table_retains_region_words(&elements, bbox, &table.cells) {
claiming_bboxes.push(bbox);
} else {
tracing::warn!(
target: "xberg::ocr::tables",
left = bbox.left,
top = bbox.top,
right = bbox.right,
bottom = bbox.bottom,
table_rows = table.cells.len(),
"reconstructed table dropped words from its region; keeping the region's lines in the page document"
);
}
}
if claiming_bboxes.is_empty() {
return elements;
}
elements
.into_iter()
.filter(|element| {
!claiming_bboxes
.iter()
.any(|table_bbox| element_center_within_table(element, table_bbox))
})
.collect()
}
fn element_center_within_table(
element: &crate::types::internal::InternalElement,
table_bbox: &OcrTableBoundingBox,
) -> bool {
let Some(bbox) = element.bbox.as_ref() else {
return false;
};
let center_x = (bbox.x0 + bbox.x1) / 2.0;
let center_y = (bbox.y0 + bbox.y1) / 2.0;
center_x >= table_bbox.left as f64
&& center_x <= table_bbox.right as f64
&& center_y >= table_bbox.top as f64
&& center_y <= table_bbox.bottom as f64
}
fn table_retains_region_words(
elements: &[crate::types::internal::InternalElement],
table_bbox: &OcrTableBoundingBox,
cells: &[Vec<String>],
) -> bool {
let region_text = elements
.iter()
.filter(|element| element_center_within_table(element, table_bbox))
.map(|element| element.text.as_str())
.collect::<Vec<_>>()
.join(" ");
let cell_text = cells
.iter()
.flatten()
.map(|cell| cell.trim())
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>()
.join(" ");
should_adopt_table_rebuild(®ion_text, &cell_text)
}
const MIN_ORIENTATION_CONFIDENCE: f32 = 0.35;
#[cfg(auto_rotate)]
const _: () = assert!(MIN_ORIENTATION_CONFIDENCE == crate::doc_orientation::MIN_CONFIDENCE);
const RAW_IMAGE_SOURCE_DPI: i32 = 72;
const PREPROCESSING_FALLBACK_SOURCE_DPI: i32 = 300;
#[cfg(test)]
const DEFAULT_PREPROCESSING_SAMPLE_STRIDE: usize = 4;
const CLEAN_PAGE_MEAN_LUMINANCE_THRESHOLD: f64 = 0.90;
const CLEAN_PAGE_LIGHT_PIXEL_THRESHOLD: f64 = 0.90;
const CLEAN_PAGE_LIGHT_CHANNEL_SUM_THRESHOLD: u16 =
(CLEAN_PAGE_LIGHT_PIXEL_THRESHOLD * RGB_CHANNEL_MAX * RGB_CHANNEL_COUNT as f64).ceil() as u16;
const CLEAN_PAGE_LIGHT_PIXEL_FRACTION_THRESHOLD: f64 = 0.80;
const CLEAN_PAGE_MIN_FOREGROUND_CONTRAST: f64 = 0.20;
const CLEAN_PAGE_DARK_PIXEL_FRACTION_THRESHOLD: f64 = 0.005;
const CLEAN_PAGE_DARK_PIXEL_COUNT_THRESHOLD: usize = 8;
const CLEAN_PAGE_TEXT_COMPONENT_MAX_FILL_NUMERATOR: usize = 3;
const CLEAN_PAGE_TEXT_COMPONENT_MAX_FILL_DENOMINATOR: usize = 4;
const CLEAN_PAGE_TEXT_COMPONENT_MIN_NARROW_ASPECT_RATIO: usize = 3;
const RGB_CHANNEL_MAX: f64 = u8::MAX as f64;
const RGB_CHANNEL_COUNT: usize = 3;
struct PreparedOcrImage {
data: Vec<u8>,
width: u32,
height: u32,
source_dpi: i32,
apply_pix_preprocessing: bool,
preprocessing: Option<crate::types::ImagePreprocessingConfig>,
image_preprocessing: Option<crate::types::ImagePreprocessingMetadata>,
}
#[allow(clippy::too_many_arguments)]
fn prepare_ocr_image(
rgb_data: Vec<u8>,
width: u32,
height: u32,
preprocessing: Option<&crate::types::ImagePreprocessingConfig>,
images_config: Option<&crate::core::config::ImageExtractionConfig>,
ci_debug_enabled: bool,
known_source_dpi: Option<f64>,
known_full_page_scan: bool,
) -> PreparedOcrImage {
let Some(preprocessing) = preprocessing else {
if known_full_page_scan || should_apply_default_preprocessing(&rgb_data, width, height) {
return prepare_preprocessed_ocr_image(
rgb_data,
width,
height,
&crate::types::ImagePreprocessingConfig::default(),
images_config,
ci_debug_enabled,
known_source_dpi,
);
}
return PreparedOcrImage {
data: rgb_data,
width,
height,
source_dpi: known_source_dpi.map_or(RAW_IMAGE_SOURCE_DPI, |dpi| dpi.round() as i32),
apply_pix_preprocessing: false,
preprocessing: None,
image_preprocessing: None,
};
};
prepare_preprocessed_ocr_image(
rgb_data,
width,
height,
preprocessing,
images_config,
ci_debug_enabled,
known_source_dpi,
)
}
fn should_apply_default_preprocessing(rgb_data: &[u8], width: u32, height: u32) -> bool {
let Some(pixel_count) = (width as usize).checked_mul(height as usize) else {
return false;
};
let Some(expected_len) = pixel_count.checked_mul(RGB_CHANNEL_COUNT) else {
return false;
};
if pixel_count == 0 || rgb_data.len() != expected_len {
return false;
}
let mut channel_sum = 0u128;
let mut light_channel_sum = 0u128;
let mut foreground_channel_sum = 0u128;
let mut light_pixels = 0usize;
let mut foreground_pixels = 0usize;
let mut foreground_histogram = [0usize; 256];
for pixel in rgb_data.chunks_exact(RGB_CHANNEL_COUNT) {
let pixel_channel_sum = u16::from(pixel[0]) + u16::from(pixel[1]) + u16::from(pixel[2]);
channel_sum += u128::from(pixel_channel_sum);
if pixel_channel_sum >= CLEAN_PAGE_LIGHT_CHANNEL_SUM_THRESHOLD {
light_channel_sum += u128::from(pixel_channel_sum);
light_pixels += 1;
} else {
foreground_channel_sum += u128::from(pixel_channel_sum);
foreground_pixels += 1;
foreground_histogram[((pixel_channel_sum + 1) / RGB_CHANNEL_COUNT as u16) as usize] += 1;
}
}
let pixel_count_f64 = pixel_count as f64;
let max_pixel_channel_sum = RGB_CHANNEL_MAX * RGB_CHANNEL_COUNT as f64;
if channel_sum as f64 / (pixel_count_f64 * max_pixel_channel_sum) < CLEAN_PAGE_MEAN_LUMINANCE_THRESHOLD
|| light_pixels as f64 / pixel_count_f64 < CLEAN_PAGE_LIGHT_PIXEL_FRACTION_THRESHOLD
{
return false;
}
if foreground_pixels == 0 {
return true;
}
let background_luminance = light_channel_sum as f64 / (light_pixels as f64 * max_pixel_channel_sum);
let foreground_luminance = foreground_channel_sum as f64 / (foreground_pixels as f64 * max_pixel_channel_sum);
if background_luminance - foreground_luminance >= CLEAN_PAGE_MIN_FOREGROUND_CONTRAST {
return true;
}
let Some((lower_mode_max, lower_mode_pixels)) = foreground_lower_mode(&foreground_histogram) else {
return false;
};
lower_mode_pixels >= CLEAN_PAGE_DARK_PIXEL_COUNT_THRESHOLD
&& lower_mode_pixels as f64 / pixel_count_f64 >= CLEAN_PAGE_DARK_PIXEL_FRACTION_THRESHOLD
&& lower_mode_has_text_structure(
rgb_data,
width as usize,
height as usize,
lower_mode_max,
lower_mode_pixels,
)
}
fn foreground_lower_mode(histogram: &[usize; 256]) -> Option<(u8, usize)> {
let total_count: usize = histogram.iter().sum();
let total_weight: u128 = histogram
.iter()
.enumerate()
.map(|(value, count)| value as u128 * *count as u128)
.sum();
let mut lower_count = 0usize;
let mut lower_weight = 0u128;
let mut best: Option<(f64, u8, usize, f64, f64)> = None;
for (threshold, &count) in histogram.iter().enumerate().take(u8::MAX as usize) {
lower_count += count;
lower_weight += threshold as u128 * count as u128;
let upper_count = total_count - lower_count;
if lower_count == 0 || upper_count == 0 {
continue;
}
let lower_mean = lower_weight as f64 / lower_count as f64;
let upper_mean = (total_weight - lower_weight) as f64 / upper_count as f64;
let separation = upper_mean - lower_mean;
let between_class_variance = lower_count as f64 * upper_count as f64 * separation * separation;
if best
.as_ref()
.is_none_or(|(variance, ..)| between_class_variance > *variance)
{
best = Some((
between_class_variance,
threshold as u8,
lower_count,
lower_mean,
upper_mean,
));
}
}
let (_, threshold, lower_count, lower_mean, upper_mean) = best?;
((upper_mean - lower_mean) / RGB_CHANNEL_MAX >= CLEAN_PAGE_MIN_FOREGROUND_CONTRAST)
.then_some((threshold, lower_count))
}
fn lower_mode_has_text_structure(
rgb_data: &[u8],
width: usize,
height: usize,
lower_mode_max: u8,
lower_mode_pixels: usize,
) -> bool {
let Some(pixel_count) = width.checked_mul(height) else {
return false;
};
let lower_mode_mask: Vec<bool> = rgb_data
.chunks_exact(RGB_CHANNEL_COUNT)
.map(|pixel| {
let channel_sum = u16::from(pixel[0]) + u16::from(pixel[1]) + u16::from(pixel[2]);
(channel_sum + 1) / RGB_CHANNEL_COUNT as u16 <= u16::from(lower_mode_max)
})
.collect();
if lower_mode_mask.len() != pixel_count {
return false;
}
let mut visited = vec![false; pixel_count];
let mut component_stack = Vec::new();
let mut structured_pixels = 0usize;
for start in 0..pixel_count {
if !lower_mode_mask[start] || visited[start] {
continue;
}
let component_pixels = structured_component_size(
&lower_mode_mask,
&mut visited,
&mut component_stack,
start,
width,
height,
);
let Some(total) = structured_pixels.checked_add(component_pixels) else {
return false;
};
structured_pixels = total;
}
structured_pixels >= lower_mode_pixels.div_ceil(2)
}
fn structured_component_size(
mask: &[bool],
visited: &mut [bool],
stack: &mut Vec<usize>,
start: usize,
width: usize,
height: usize,
) -> usize {
stack.clear();
stack.push(start);
visited[start] = true;
let (mut min_x, mut max_x) = (start % width, start % width);
let (mut min_y, mut max_y) = (start / width, start / width);
let mut size = 0usize;
while let Some(index) = stack.pop() {
let Some(next_size) = size.checked_add(1) else {
return 0;
};
size = next_size;
let x = index % width;
let y = index / width;
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
let left = x.checked_sub(1).and_then(|_| index.checked_sub(1));
let right = x
.checked_add(1)
.filter(|next_x| *next_x < width)
.and_then(|_| index.checked_add(1));
let above = y.checked_sub(1).and_then(|_| index.checked_sub(width));
let below = y
.checked_add(1)
.filter(|next_y| *next_y < height)
.and_then(|_| index.checked_add(width));
let neighbors = [left, right, above, below];
for neighbor in neighbors.into_iter().flatten() {
if mask[neighbor] && !visited[neighbor] {
visited[neighbor] = true;
stack.push(neighbor);
}
}
}
if component_is_text_like(size, min_x, max_x, min_y, max_y) {
size
} else {
0
}
}
fn component_is_text_like(size: usize, min_x: usize, max_x: usize, min_y: usize, max_y: usize) -> bool {
if min_x >= max_x || min_y >= max_y {
return false;
}
let component_width = max_x - min_x + 1;
let component_height = max_y - min_y + 1;
let Some(area) = component_width.checked_mul(component_height) else {
return false;
};
let Some(scaled_size) = size.checked_mul(CLEAN_PAGE_TEXT_COMPONENT_MAX_FILL_DENOMINATOR) else {
return false;
};
let Some(maximum_fill) = area.checked_mul(CLEAN_PAGE_TEXT_COMPONENT_MAX_FILL_NUMERATOR) else {
return false;
};
let short_side = component_width.min(component_height);
let long_side = component_width.max(component_height);
let is_narrow = short_side
.checked_mul(CLEAN_PAGE_TEXT_COMPONENT_MIN_NARROW_ASPECT_RATIO)
.is_some_and(|minimum_long_side| long_side >= minimum_long_side);
scaled_size <= maximum_fill || is_narrow
}
fn prepare_preprocessed_ocr_image(
rgb_data: Vec<u8>,
width: u32,
height: u32,
preprocessing: &crate::types::ImagePreprocessingConfig,
images_config: Option<&crate::core::config::ImageExtractionConfig>,
ci_debug_enabled: bool,
known_source_dpi: Option<f64>,
) -> PreparedOcrImage {
let dpi_config = match images_config {
Some(images_config) => crate::types::ImageDpiConfig {
target_dpi: preprocessing.target_dpi,
..crate::types::ImageDpiConfig::from(images_config)
},
None => crate::types::ImageDpiConfig {
target_dpi: preprocessing.target_dpi,
..Default::default()
},
};
match normalize_image_dpi_owned(rgb_data, width as usize, height as usize, &dpi_config, known_source_dpi) {
Ok(result) => {
let normalized_width = result.dimensions.0 as u32;
let normalized_height = result.dimensions.1 as u32;
let source_dpi = result.metadata.final_dpi;
log_ci_debug(ci_debug_enabled, "dpi_normalization", || {
format!(
"original={}x{} normalized={}x{} target_dpi={} final_dpi={} resized={}",
width,
height,
normalized_width,
normalized_height,
result.metadata.target_dpi,
source_dpi,
!result.metadata.skipped_resize
)
});
PreparedOcrImage {
data: result.rgb_data,
width: normalized_width,
height: normalized_height,
source_dpi,
apply_pix_preprocessing: true,
preprocessing: Some(preprocessing.clone()),
image_preprocessing: Some(result.metadata),
}
}
Err((error, data)) => {
tracing::warn!("DPI normalization failed, using original image: {}", error);
PreparedOcrImage {
data,
width,
height,
source_dpi: known_source_dpi.map_or(PREPROCESSING_FALLBACK_SOURCE_DPI, |dpi| dpi.round() as i32),
apply_pix_preprocessing: true,
preprocessing: Some(preprocessing.clone()),
image_preprocessing: None,
}
}
}
}
fn point_in_bbox(x: i32, y: i32, left: i32, top: i32, right: i32, bottom: i32) -> bool {
x >= left && x <= right && y >= top && y <= bottom
}
struct IteratorExtractionResult {
elements: Vec<OcrElement>,
retained_text_confidence_stats: Option<RetainedWordConfidenceStats>,
skipped_words: usize,
non_text_block_word_count: usize,
dict_invalid_word_ratio: Option<f64>,
}
const MIN_WORD_LEN_FOR_DICT_CHECK: usize = 3;
const MIN_DICT_CANDIDATES_FOR_RATIO: usize = 5;
fn dictionary_invalid_word_ratio(api: &TesseractAPI, words: &[xberg_tesseract::WordData]) -> Option<f64> {
invalid_word_ratio_from(words, |text| match api.is_valid_word(text) {
Ok(0) => Some(false),
Ok(_) => Some(true),
Err(_) => None,
})
}
fn invalid_word_ratio_from(
words: &[xberg_tesseract::WordData],
is_valid: impl Fn(&str) -> Option<bool>,
) -> Option<f64> {
let mut candidates = 0usize;
let mut invalid = 0usize;
for word in words {
let text = word.text.trim();
if text.chars().count() < MIN_WORD_LEN_FOR_DICT_CHECK || !text.chars().all(|c| c.is_alphabetic()) {
continue;
}
match is_valid(text) {
Some(true) => candidates += 1,
Some(false) => {
candidates += 1;
invalid += 1;
}
None => {}
}
}
if candidates < MIN_DICT_CANDIDATES_FOR_RATIO {
return None;
}
Some(invalid as f64 / candidates as f64)
}
const NON_TEXT_BLOCK_TYPES: [TessPolyBlockType; 6] = [
TessPolyBlockType::PT_NOISE,
TessPolyBlockType::PT_FLOWING_IMAGE,
TessPolyBlockType::PT_HEADING_IMAGE,
TessPolyBlockType::PT_PULLOUT_IMAGE,
TessPolyBlockType::PT_HORZ_LINE,
TessPolyBlockType::PT_VERT_LINE,
];
fn is_auto_rotate_requested_but_unavailable(auto_rotate_enabled: bool) -> bool {
#[cfg(auto_rotate)]
{
let _ = auto_rotate_enabled;
false
}
#[cfg(not(auto_rotate))]
{
auto_rotate_enabled
}
}
fn insert_word_iterator_skipped_count_metadata(
metadata: &mut HashMap<String, serde_json::Value>,
skipped_words: usize,
) {
if skipped_words > 0 {
metadata.insert(
"word_iterator_skipped_count".to_string(),
serde_json::Value::Number(skipped_words.into()),
);
}
}
fn insert_retained_word_confidence_metadata(
metadata: &mut HashMap<String, serde_json::Value>,
stats: &RetainedWordConfidenceStats,
) {
for key in [
"word_count",
"low_conf_word_count",
"mean_text_conf",
"median_word_conf",
"p10_word_conf",
] {
metadata.remove(key);
}
metadata.insert(
"word_count".to_string(),
serde_json::Value::Number(stats.word_count().into()),
);
metadata.insert(
"low_conf_word_count".to_string(),
serde_json::Value::Number(stats.low_confidence_word_count().into()),
);
if let Some(mean) = stats.mean() {
metadata.insert("mean_text_conf".to_string(), serde_json::Value::Number(mean.into()));
}
if let Some(median) = stats.median() {
metadata.insert("median_word_conf".to_string(), serde_json::Value::Number(median.into()));
}
if let Some(p10) = stats.p10() {
metadata.insert("p10_word_conf".to_string(), serde_json::Value::Number(p10.into()));
}
}
fn retained_text_word_confidence_stats(
content: &str,
words: &[xberg_tesseract::WordData],
) -> RetainedWordConfidenceStats {
let retained_tokens = content.split_whitespace().collect::<Vec<_>>();
let mut retained_index = 0usize;
let mut stats = RetainedWordConfidenceStats::default();
for word in words {
let cleaned = strip_control_characters(&word.text);
let word_tokens = cleaned.split_whitespace().collect::<Vec<_>>();
if word_tokens.is_empty() {
continue;
}
let Some(next_retained_index) = find_token_sequence_end(&retained_tokens, retained_index, &word_tokens) else {
break;
};
stats.record(f64::from(word.confidence));
retained_index = next_retained_index;
}
stats
}
fn find_token_sequence_end(haystack: &[&str], start: usize, needle: &[&str]) -> Option<usize> {
if needle.is_empty() {
return Some(start);
}
let mut prefix_lengths = vec![0usize; needle.len()];
for index in 1..needle.len() {
let mut matched = prefix_lengths[index - 1];
while matched > 0 && needle[index] != needle[matched] {
matched = prefix_lengths[matched - 1];
}
if needle[index] == needle[matched] {
matched += 1;
}
prefix_lengths[index] = matched;
}
let mut matched = 0usize;
for (offset, token) in haystack[start..].iter().enumerate() {
while matched > 0 && *token != needle[matched] {
matched = prefix_lengths[matched - 1];
}
if *token == needle[matched] {
matched += 1;
if matched == needle.len() {
return Some(start + offset + 1);
}
}
}
None
}
fn extract_elements_via_iterator(
api: &TesseractAPI,
page_number: u32,
min_confidence: f64,
retained_text: Option<&str>,
) -> Result<IteratorExtractionResult, OcrError> {
let empty = || IteratorExtractionResult {
elements: Vec::new(),
retained_text_confidence_stats: None,
skipped_words: 0,
non_text_block_word_count: 0,
dict_invalid_word_ratio: None,
};
let page_iter = match api.get_page_iterator() {
Ok(iter) => iter,
Err(e) => {
tracing::warn!(error = %e, "Tesseract page iterator unavailable; falling back to TSV-based OCR element extraction");
return Ok(empty());
}
};
let blocks = match page_iter.extract_all_blocks() {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "Failed to extract Tesseract page blocks; falling back to TSV-based OCR element extraction");
return Ok(empty());
}
};
let paragraph_extraction = match page_iter.extract_all_paragraphs() {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "Failed to extract Tesseract page paragraphs; falling back to TSV-based OCR element extraction");
return Ok(empty());
}
};
if paragraph_extraction.skipped_no_para_info > 0 || paragraph_extraction.skipped_no_bbox > 0 {
tracing::warn!(
skipped_no_para_info = paragraph_extraction.skipped_no_para_info,
skipped_no_bbox = paragraph_extraction.skipped_no_bbox,
extracted_paragraphs = paragraph_extraction.paragraphs.len(),
"Tesseract declined to describe some paragraphs; their words lose the is_crown, \
is_list_item and justification metadata"
);
}
let paragraphs = paragraph_extraction.paragraphs;
let result_iter = match api.get_iterator() {
Ok(iter) => iter,
Err(e) => {
tracing::warn!(error = %e, "Tesseract result iterator unavailable; falling back to TSV-based OCR element extraction");
return Ok(empty());
}
};
let word_extraction = match result_iter.extract_all_words() {
Ok(w) => w,
Err(e) => {
tracing::warn!(error = %e, "Tesseract result iterator failed; falling back to TSV-based OCR element extraction");
return Ok(empty());
}
};
if word_extraction.skipped > 0 {
tracing::warn!(
skipped_words = word_extraction.skipped,
extracted_words = word_extraction.words.len(),
"Tesseract result iterator failed to extract some words (null pointer, invalid \
parameter, or invalid UTF-8); they were dropped from OCR output"
);
}
let dict_invalid_word_ratio = dictionary_invalid_word_ratio(api, &word_extraction.words);
let retained_text_confidence_stats =
retained_text.map(|content| retained_text_word_confidence_stats(content, &word_extraction.words));
let mut elements = Vec::new();
let mut non_text_block_word_count = 0usize;
for word in &word_extraction.words {
if (word.confidence as f64) < min_confidence {
continue;
}
if word.text.trim().is_empty() {
continue;
}
let cx = (word.left + word.right) / 2;
let cy = (word.top + word.bottom) / 2;
let parent_block = blocks
.iter()
.find(|b| point_in_bbox(cx, cy, b.left, b.top, b.right, b.bottom));
let block_type = parent_block.map(|b| b.block_type);
if let Some(bt) = block_type
&& NON_TEXT_BLOCK_TYPES.contains(&bt)
{
non_text_block_word_count += 1;
}
let para_info = paragraphs
.iter()
.find(|p| point_in_bbox(cx, cy, p.left, p.top, p.right, p.bottom));
let element = iterator_word_to_element(word, block_type, para_info, page_number);
elements.push(element);
}
Ok(IteratorExtractionResult {
elements,
retained_text_confidence_stats,
skipped_words: word_extraction.skipped,
non_text_block_word_count,
dict_invalid_word_ratio,
})
}
fn reconstruct_cleaned_table(words: &[HocrWord], config: &TesseractConfig) -> (Vec<Vec<String>>, Vec<u32>) {
let (mut table, mut column_positions) =
reconstruct_table_with_columns(words, config.table_column_threshold, config.table_row_threshold_ratio);
drop_leading_caption_row(&mut table);
merge_disjoint_numeric_columns(&mut table, &mut column_positions, median_word_height(words));
(table, column_positions)
}
fn security_limits_for_ocr(
tesseract_config: &TesseractConfig,
extraction_config: Option<&ExtractionConfig>,
) -> SecurityLimits {
tesseract_config
.security_limits
.clone()
.or_else(|| extraction_config.and_then(|config| config.security_limits.clone()))
.unwrap_or_default()
}
fn underscore_word_symbols(api: &TesseractAPI) -> Vec<xberg_tesseract::WordSymbols> {
match api
.get_iterator()
.and_then(|iterator| iterator.extract_word_symbols(|text| text.contains('_')))
{
Ok(words) => words,
Err(error) => {
tracing::debug!(
target: "xberg::ocr::tables",
%error,
"Tesseract symbol boxes unavailable; underscore marks are cut by character share"
);
Vec::new()
}
}
}
pub(super) fn perform_ocr(
image_bytes: &[u8],
config: &TesseractConfig,
api_pool: &Arc<TesseractApiPool>,
extraction_config: Option<&ExtractionConfig>,
) -> Result<OcrExtractionResult, OcrError> {
let ci_debug_enabled = env::var_os("XBERG_CI_DEBUG").is_some();
log_ci_debug(ci_debug_enabled, "perform_ocr:start", || {
format!(
"bytes={} language={} output={} use_cache={}",
image_bytes.len(),
config.language,
config.output_format,
config.use_cache
)
});
let security_limits = security_limits_for_ocr(config, extraction_config);
let rgb_image = {
let img = crate::extraction::image::load_image_for_ocr(image_bytes, &security_limits)
.map_err(|e| OcrError::ImageProcessingFailed(e.to_string()))?;
img.into_rgb8()
};
let (orig_width, orig_height) = rgb_image.dimensions();
let rgb_data = rgb_image.into_raw();
log_ci_debug(ci_debug_enabled, "image", || {
format!("dimensions={}x{} color_type=RGB8", orig_width, orig_height)
});
let images_config = extraction_config.and_then(|extraction_config| extraction_config.images.as_ref());
let known_source_dpi = resolve_known_source_dpi(config.source_dpi, image_bytes, orig_width, orig_height);
let prepared_image = prepare_ocr_image(
rgb_data,
orig_width,
orig_height,
config.preprocessing.as_ref(),
images_config,
ci_debug_enabled,
known_source_dpi,
config.known_full_page_scan,
);
#[cfg_attr(not(auto_rotate), allow(unused_mut))]
let mut image_data = prepared_image.data;
#[cfg_attr(not(auto_rotate), allow(unused_mut))]
let mut width = prepared_image.width;
#[cfg_attr(not(auto_rotate), allow(unused_mut))]
let mut height = prepared_image.height;
let source_dpi = prepared_image.source_dpi;
let preprocessing = prepared_image.preprocessing;
let image_preprocessing = prepared_image.image_preprocessing;
#[cfg_attr(not(auto_rotate), allow(unused_mut))]
let mut ocr_image_width = width;
#[cfg_attr(not(auto_rotate), allow(unused_mut))]
let mut ocr_image_height = height;
let bytes_per_pixel: u32 = 3;
let languages: Vec<String> = config.language.split('+').map(|lang| lang.trim().to_string()).collect();
let tessdata_path = resolve_tessdata_path(&languages, config.tessdata_path.as_deref())?;
log_ci_debug(ci_debug_enabled, "tessdata", || {
let path_preview = env::var_os("PATH").map(|paths| {
env::split_paths(&paths)
.take(6)
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
});
let resolved_exists = !tessdata_path.is_empty() && std::path::Path::new(&tessdata_path).exists();
format!(
"env={:?} resolved={} exists={} path_preview={:?}",
env::var("TESSDATA_PREFIX").ok(),
if tessdata_path.is_empty() {
"unset"
} else {
&tessdata_path
},
resolved_exists,
path_preview
)
});
log_ci_debug(ci_debug_enabled, "tesseract_version", || {
format!("version={}", TesseractAPI::version())
});
validate_language_and_traineddata(&config.language, &tessdata_path)?;
let api = api_pool.checkout(&tessdata_path, &config.language)?;
log_ci_debug(ci_debug_enabled, "init", || {
format!("language={} datapath='{}'", config.language, tessdata_path)
});
if ci_debug_enabled {
match api.get_available_languages() {
Ok(languages) => {
log_ci_debug(ci_debug_enabled, "available_languages", move || {
let preview = languages.iter().take(10).cloned().collect::<Vec<_>>();
format!("count={} preview={:?}", languages.len(), preview)
});
}
Err(err) => {
log_ci_debug(ci_debug_enabled, "available_languages_error", move || {
format!("error={:?}", err)
});
}
}
}
let psm_mode = TessPageSegMode::from_int(config.psm as i32);
let psm_result = api.set_page_seg_mode(psm_mode);
log_ci_debug(ci_debug_enabled, "set_psm", || match &psm_result {
Ok(_) => format!("mode={}", config.psm),
Err(err) => format!("error={:?}", err),
});
psm_result.map_err(|e| OcrError::InvalidConfiguration(format!("Failed to set PSM mode: {}", e)))?;
apply_tesseract_variables(&api, config)?;
let source_dpi = source_dpi.max(70);
#[cfg_attr(not(auto_rotate), allow(unused_mut))]
let mut detected_orientation: Option<(i32, f32)> = None;
let auto_rotate_enabled =
config.preprocessing.as_ref().map(|p| p.auto_rotate).unwrap_or(false) || config.auto_rotate;
let auto_rotate_unavailable = is_auto_rotate_requested_but_unavailable(auto_rotate_enabled);
#[cfg(not(auto_rotate))]
if auto_rotate_enabled {
tracing::warn!(
"auto_rotate requested but the `auto-rotate` feature is not compiled in; skipping orientation detection"
);
}
#[cfg(auto_rotate)]
if auto_rotate_enabled {
let expected_rgb_len = usize::try_from(width)
.ok()
.and_then(|width| {
usize::try_from(height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.and_then(|pixels| pixels.checked_mul(bytes_per_pixel as usize));
let orientation_result = if expected_rgb_len != Some(image_data.len()) {
Err(crate::error::XbergError::Ocr {
message: "auto_rotate: image buffer does not match dimensions".to_string(),
source: None,
})
} else {
let image = image::RgbImage::from_raw(width, height, std::mem::take(&mut image_data))
.expect("validated RGB buffer length must match dimensions");
let result = doc_orientation_detector().detect(&image);
image_data = image.into_raw();
result
};
match orientation_result {
Err(e) => {
tracing::warn!("Orientation detection failed, proceeding without rotation: {}", e);
}
Ok(orientation) => {
let orient_deg = orientation.degrees as i32;
let orient_conf = orientation.confidence;
log_ci_debug(ci_debug_enabled, "orientation_detection", || {
format!("orientation={}° confidence={:.2}", orient_deg, orient_conf)
});
detected_orientation = Some((orient_deg, orient_conf));
if orient_deg != 0 && orient_conf > MIN_ORIENTATION_CONFIDENCE {
tracing::info!(
"Auto-rotating image by {} degrees (confidence: {:.2})",
orient_deg,
orient_conf
);
let correction_deg = (360 - orient_deg).rem_euclid(360);
let (rotated_data, new_width, new_height) =
rotate_rgb_image_data(&image_data, width, height, correction_deg);
image_data = rotated_data;
width = new_width;
height = new_height;
ocr_image_width = new_width;
ocr_image_height = new_height;
log_ci_debug(ci_debug_enabled, "auto_rotate", || {
format!("rotated={}° new_dimensions={}x{}", orient_deg, new_width, new_height)
});
} else {
tracing::debug!(
degrees = orient_deg,
confidence = orient_conf,
threshold = MIN_ORIENTATION_CONFIDENCE,
"auto_rotate: keeping original orientation"
);
}
}
}
}
let processed_pix: Option<xberg_tesseract::Pix> =
if let (true, Some(preprocessing)) = (prepared_image.apply_pix_preprocessing, preprocessing.as_ref()) {
match xberg_tesseract::Pix::from_raw_rgb(&image_data, width, height) {
Ok(pix) => match preprocess_pix(pix, preprocessing) {
Ok(processed) => Some(processed),
Err(error) => {
tracing::debug!(%error, "Leptonica preprocessing failed; using raw image");
None
}
},
Err(error) => {
tracing::debug!(%error, "Leptonica Pix creation failed; using raw image");
None
}
}
} else {
None
};
if let Some(ref pix) = processed_pix {
api.set_image_2(pix.as_ptr())
.map_err(|error| OcrError::ProcessingFailed(format!("Failed to set preprocessed image: {error}")))?;
} else {
let bytes_per_line = width * bytes_per_pixel;
api.set_image(
&image_data,
width as i32,
height as i32,
bytes_per_pixel as i32,
bytes_per_line as i32,
)
.map_err(|error| OcrError::ProcessingFailed(format!("Failed to set image: {error}")))?;
}
drop(processed_pix);
api.set_source_resolution(source_dpi)
.map_err(|error| OcrError::ProcessingFailed(format!("Failed to set source resolution: {error}")))?;
log_ci_debug(ci_debug_enabled, "set_image", || {
format!(
"width={} height={} bytes_per_pixel={} source_dpi={}",
width, height, bytes_per_pixel, source_dpi
)
});
drop(image_data);
api.recognize()
.map_err(|e| OcrError::ProcessingFailed(format!("Failed to recognize text: {}", e)))?;
let mean_text_conf = api.mean_text_conf().unwrap_or(-1);
log_ci_debug(ci_debug_enabled, "recognize", || {
format!("completed mean_text_conf={}", mean_text_conf)
});
let word_confidence_stats = if matches!(config.output_format.as_str(), "markdown" | "text") {
None
} else {
match api.all_word_confidences() {
Ok(confidences) if !confidences.is_empty() => {
let word_count = confidences.len();
let low_conf_word_count = confidences.iter().filter(|&&c| c < 50).count();
let mut sorted = confidences.clone();
sorted.sort_unstable();
let median_word_conf = if word_count % 2 == 0 {
(sorted[word_count / 2 - 1] + sorted[word_count / 2]) / 2
} else {
sorted[word_count / 2]
};
let p10_idx = ((word_count as f64 - 1.0) * 0.1).floor() as usize;
let p10_word_conf = sorted[p10_idx.min(word_count - 1)];
Some((median_word_conf, p10_word_conf, word_count, low_conf_word_count))
}
Ok(_) => match api.mean_text_conf() {
Ok(mean_conf) => Some((mean_conf, mean_conf, 0usize, 0usize)),
Err(_) => None,
},
Err(_) => match api.mean_text_conf() {
Ok(mean_conf) => Some((mean_conf, mean_conf, 0usize, 0usize)),
Err(_) => None,
},
}
};
let tsv_data_for_tables = if config.enable_table_detection || config.output_format == "tsv" {
Some(
api.get_tsv_text(0)
.map_err(|e| OcrError::ProcessingFailed(format!("Failed to extract TSV: {}", e)))?,
)
} else {
None
};
let table_mark_symbols = match tsv_data_for_tables.as_deref() {
Some(tsv) if config.enable_table_detection && tsv.contains('_') => underscore_word_symbols(&api),
_ => Vec::new(),
};
let mut hocr_document: Option<InternalDocument> = None;
let mut dictionary_filtered_line_count = 0usize;
let mut retained_hocr_confidence_stats = None;
let (raw_content, mime_type) = match config.output_format.as_str() {
"text" => {
let text = api
.get_utf8_text()
.map_err(|e| OcrError::ProcessingFailed(format!("Failed to extract text: {}", e)))?;
(text, "text/plain".to_string())
}
"markdown" => {
let hocr = api
.get_hocr_text(0)
.map_err(|e| OcrError::ProcessingFailed(format!("Failed to extract hOCR: {}", e)))?;
let is_valid_word = |text: &str| match api.is_valid_word(text) {
Ok(0) => Some(false),
Ok(_) => Some(true),
Err(_) => None,
};
let dictionary_filter = DictionaryLineFilter {
is_valid_word: &is_valid_word,
max_invalid_ratio: crate::ocr::hocr_parser::DEFAULT_DICT_INVALID_LINE_RATIO,
};
let parse_result = parse_hocr_to_internal_document_with_page_offset_and_stats(
&hocr,
Some(&dictionary_filter),
config.page_number,
);
dictionary_filtered_line_count = parse_result.dictionary_filtered_line_count;
let content = flatten_hocr_elements_to_text(&parse_result.document.elements);
retained_hocr_confidence_stats = Some(parse_result.retained_word_confidence_stats);
hocr_document = Some(parse_result.document);
let mime_type = extraction_config
.map(|c| match c.output_format {
crate::core::config::OutputFormat::Djot => "text/djot",
_ => "text/markdown",
})
.unwrap_or("text/markdown");
(content, mime_type.to_string())
}
"hocr" => {
let hocr = api
.get_hocr_text(0)
.map_err(|e| OcrError::ProcessingFailed(format!("Failed to extract hOCR: {}", e)))?;
(hocr, "text/html".to_string())
}
"tsv" => {
let tsv = tsv_data_for_tables
.as_ref()
.ok_or_else(|| OcrError::ProcessingFailed("TSV data not available".to_string()))?
.clone();
(tsv, "text/plain".to_string())
}
_ => {
return Err(OcrError::InvalidConfiguration(format!(
"Unsupported output format: {}",
config.output_format
)));
}
};
let mut metadata = HashMap::new();
if let Some(image_preprocessing) = image_preprocessing {
let value = serde_json::to_value(image_preprocessing).map_err(|error| {
OcrError::ProcessingFailed(format!("Failed to serialize image preprocessing metadata: {error}"))
})?;
metadata.insert(
crate::ocr_metadata_keys::OCR_IMAGE_PREPROCESSING_METADATA_KEY.to_string(),
value,
);
}
metadata.insert(
crate::ocr_metadata_keys::OCR_PROCESSED_IMAGE_WIDTH_METADATA_KEY.to_string(),
serde_json::Value::Number(ocr_image_width.into()),
);
metadata.insert(
crate::ocr_metadata_keys::OCR_PROCESSED_IMAGE_HEIGHT_METADATA_KEY.to_string(),
serde_json::Value::Number(ocr_image_height.into()),
);
metadata.insert(
"language".to_string(),
serde_json::Value::String(config.language.clone()),
);
metadata.insert("psm".to_string(), serde_json::Value::String(config.psm.to_string()));
metadata.insert("table_count".to_string(), serde_json::Value::Number(0.into()));
metadata.insert("tables_detected".to_string(), serde_json::Value::Number(0.into()));
if config.output_format == "markdown" {
metadata.insert(
"source_format".to_string(),
serde_json::Value::String("hocr".to_string()),
);
}
if auto_rotate_unavailable {
metadata.insert("auto_rotate_unavailable".to_string(), serde_json::Value::Bool(true));
}
if dictionary_filtered_line_count > 0 {
metadata.insert(
"dictionary_filtered_line_count".to_string(),
serde_json::Value::Number(dictionary_filtered_line_count.into()),
);
}
if let Some(stats) = retained_hocr_confidence_stats.as_ref() {
insert_retained_word_confidence_metadata(&mut metadata, stats);
} else if config.output_format != "text" {
if mean_text_conf >= 0 {
metadata.insert(
"mean_text_conf".to_string(),
serde_json::Value::Number(serde_json::Number::from(mean_text_conf)),
);
}
if let Some((median_conf, p10_conf, word_count, low_conf_count)) = word_confidence_stats {
metadata.insert(
"median_word_conf".to_string(),
serde_json::Value::Number(serde_json::Number::from(median_conf)),
);
metadata.insert(
"p10_word_conf".to_string(),
serde_json::Value::Number(serde_json::Number::from(p10_conf)),
);
metadata.insert(
"word_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(word_count)),
);
metadata.insert(
"low_conf_word_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(low_conf_count)),
);
}
}
if let Some((orient_deg, orient_conf)) = detected_orientation {
metadata.insert(
crate::ocr_metadata_keys::OCR_ORIENTATION_DEGREES_METADATA_KEY.to_string(),
serde_json::Value::Number(serde_json::Number::from(orient_deg)),
);
metadata.insert(
crate::ocr_metadata_keys::OCR_ORIENTATION_CONFIDENCE_METADATA_KEY.to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(orient_conf as f64).unwrap_or(serde_json::Number::from(0)),
),
);
if orient_deg != 0 && orient_conf > MIN_ORIENTATION_CONFIDENCE {
metadata.insert(
crate::ocr_metadata_keys::OCR_AUTO_ROTATED_METADATA_KEY.to_string(),
serde_json::Value::Bool(true),
);
}
}
let mut content = strip_control_characters(&raw_content).into_owned();
let retained_text = (config.output_format == "text").then_some(content.as_str());
let iterator_extraction =
extract_elements_via_iterator(&api, config.page_number, config.min_confidence, retained_text);
let mut ocr_elements = None;
if let Ok(extraction) = &iterator_extraction
&& let Some(stats) = extraction.retained_text_confidence_stats.as_ref()
{
insert_retained_word_confidence_metadata(&mut metadata, stats);
}
match iterator_extraction {
Ok(extraction) if !extraction.elements.is_empty() => {
insert_word_iterator_skipped_count_metadata(&mut metadata, extraction.skipped_words);
if extraction.non_text_block_word_count > 0 {
metadata.insert(
"non_text_block_word_count".to_string(),
serde_json::Value::Number(extraction.non_text_block_word_count.into()),
);
}
if let Some(ratio) = extraction.dict_invalid_word_ratio {
metadata.insert(
crate::ocr_metadata_keys::OCR_TESSERACT_DICT_INVALID_WORD_RATIO_METADATA_KEY.to_string(),
serde_json::Value::Number(
serde_json::Number::from_f64(ratio).unwrap_or(serde_json::Number::from(0)),
),
);
}
ocr_elements = Some(extraction.elements);
}
_ => {
if let Some(ref tsv_data) = tsv_data_for_tables {
let elements = parse_tsv_to_elements(tsv_data, config.min_confidence, config.page_number);
if !elements.is_empty() {
ocr_elements = Some(elements);
}
}
}
}
let mut tables = Vec::new();
if config.enable_table_detection {
let tsv_data = tsv_data_for_tables.as_ref().unwrap();
let words = extract_table_words_from_tsv(tsv_data, config.table_min_confidence, &table_mark_symbols)?;
for (region_index, mut region) in table_regions(&words).into_iter().enumerate() {
if region.words.len() < MIN_TABLE_CANDIDATE_WORDS {
tracing::debug!(
target: "xberg::ocr::tables",
region_index,
word_count = region.words.len(),
min_required = MIN_TABLE_CANDIDATE_WORDS,
"OCR table region skipped: below MIN_TABLE_CANDIDATE_WORDS"
);
continue;
}
region.drop_shading_marks(config.table_row_threshold_ratio);
if config
.preprocessing
.as_ref()
.is_some_and(|preprocessing| preprocessing.normalize_shaded_rows)
{
region.drop_normalized_band_edge_glyphs();
}
let region_left = region.words.iter().map(|w| w.left).min().unwrap_or(0);
let region_top = region.words.iter().map(|w| w.top).min().unwrap_or(0);
let region_right = region.words.iter().map(|w| w.left + w.width).max().unwrap_or(0);
let region_bottom = region.words.iter().map(|w| w.top + w.height).max().unwrap_or(0);
let word_preview: String = region
.words
.iter()
.take(12)
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(200)
.collect();
let (mut table, column_positions) = reconstruct_cleaned_table(®ion.words, config);
let retry_region = if let Some((quantity_column, blank_row)) = quantity_retry_column_index(&table) {
let row_positions = detect_rows(®ion.words, config.table_row_threshold_ratio);
if row_positions.len() == table.len() {
quantity_retry_region(
®ion.words,
quantity_column,
blank_row,
&row_positions,
&column_positions,
width,
height,
)
} else {
None
}
} else {
None
};
let recovered = recover_blank_quantity_word(&api, config, retry_region.as_ref(), width, height);
if let Some(recovered) = recovered {
region.push(recovered);
table = reconstruct_cleaned_table(®ion.words, config).0;
}
tracing::debug!(
target: "xberg::ocr::tables",
region_index,
word_count = region.words.len(),
left = region_left,
top = region_top,
right = region_right,
bottom = region_bottom,
word_preview = %word_preview,
raw_rows = table.len(),
raw_cols = table.first().map_or(0, Vec::len),
"OCR table region reconstructed"
);
if table.is_empty() || table[0].is_empty() {
continue;
}
#[cfg(feature = "pdf")]
let cleaned = post_process_table(table, false, false);
#[cfg(not(feature = "pdf"))]
let cleaned = Some(table);
let Some(cleaned) = cleaned else {
continue;
};
let markdown_table = table_to_markdown(&cleaned);
tables.push(OcrTable {
cells: cleaned,
markdown: markdown_table,
page_number: config.page_number,
bounding_box: Some(region.bounding_box()),
});
}
metadata.insert(
"table_count".to_string(),
serde_json::Value::Number(tables.len().into()),
);
metadata.insert(
"tables_detected".to_string(),
serde_json::Value::Number(tables.len().into()),
);
if let Some(first) = tables.first() {
metadata.insert(
"table_rows".to_string(),
serde_json::Value::Number(first.cells.len().into()),
);
metadata.insert(
"table_cols".to_string(),
serde_json::Value::Number(first.cells.first().map_or(0, Vec::len).into()),
);
}
}
if let Some(document) = hocr_document.as_mut() {
document.elements = filter_elements_covered_by_tables(std::mem::take(&mut document.elements), &tables);
}
let is_markdown_output = extraction_config
.map(|c| c.output_format == crate::core::config::OutputFormat::Markdown)
.unwrap_or(config.output_format == "markdown");
if !tables.is_empty()
&& is_markdown_output
&& let Some(ref tsv_data) = tsv_data_for_tables
{
let rebuilt = build_content_with_inline_tables(tsv_data, &tables, config.table_min_confidence);
if !rebuilt.is_empty() {
if should_adopt_table_rebuild(&content, &rebuilt) {
content = rebuilt;
metadata.insert(
"pre_formatted".to_string(),
serde_json::Value::String("markdown".to_string()),
);
} else {
tracing::warn!(
target: "xberg::ocr::tables",
original_word_count = content.split_whitespace().count(),
rebuilt_word_count = rebuilt.split_whitespace().count(),
"OCR markdown table rebuild dropped content relative to the original; keeping original content"
);
}
}
}
drop(api);
Ok(OcrExtractionResult {
content,
mime_type,
metadata,
tables,
ocr_elements,
internal_document: hocr_document,
})
}
pub(super) fn process_image_file_with_cache(
file_path: &str,
config: &TesseractConfig,
cache: &OcrCache,
api_pool: &Arc<TesseractApiPool>,
output_format: Option<crate::core::config::OutputFormat>,
) -> Result<OcrExtractionResult, OcrError> {
let image_bytes = std::fs::read(file_path)
.map_err(|e| OcrError::IOError(format!("Failed to read file '{}': {}", file_path, e)))?;
process_image_with_cache(&image_bytes, config, cache, api_pool, output_format)
}
fn is_all_languages(lang: &str) -> bool {
let lower = lang.to_ascii_lowercase();
lower == "all" || lower == "*"
}
fn resolve_config_language(config: &TesseractConfig) -> Result<Option<TesseractConfig>, OcrError> {
if is_all_languages(&config.language) {
let bootstrap_langs = vec!["eng".to_string()];
let tessdata_path = resolve_tessdata_path(&bootstrap_langs, config.tessdata_path.as_deref())?;
let resolved = resolve_all_installed_languages(&tessdata_path)?;
let mut resolved_config = config.clone();
resolved_config.language = resolved;
Ok(Some(resolved_config))
} else {
Ok(None)
}
}
pub(super) fn process_image_with_cache(
image_bytes: &[u8],
config: &TesseractConfig,
cache: &OcrCache,
api_pool: &Arc<TesseractApiPool>,
output_format: Option<crate::core::config::OutputFormat>,
) -> Result<OcrExtractionResult, OcrError> {
config.validate().map_err(OcrError::InvalidConfiguration)?;
let resolved = resolve_config_language(config)?;
let config = resolved.as_ref().unwrap_or(config);
process_image_resolved(image_bytes, config, cache, api_pool, output_format)
}
fn process_image_resolved(
image_bytes: &[u8],
config: &TesseractConfig,
cache: &OcrCache,
api_pool: &Arc<TesseractApiPool>,
output_format: Option<crate::core::config::OutputFormat>,
) -> Result<OcrExtractionResult, OcrError> {
let image_hash = crate::cache::blake3_hash_bytes(image_bytes);
let languages: Vec<String> = config.language.split('+').map(|lang| lang.trim().to_string()).collect();
let resolved_tessdata_path = resolve_tessdata_path(&languages, config.tessdata_path.as_deref())?;
let config_str = hash_config(config, &resolved_tessdata_path);
if config.use_cache
&& let Some(cached_result) =
cache.get_cached_result(&image_hash, "tesseract", &config_str, output_format.as_ref())?
{
#[cfg(feature = "otel")]
tracing::Span::current().record("cache.hit", true);
return Ok(cached_result);
}
#[cfg(feature = "otel")]
tracing::Span::current().record("cache.hit", false);
let extraction_config = output_format.as_ref().map(|fmt| ExtractionConfig {
output_format: fmt.clone(),
..Default::default()
});
let result = perform_ocr(image_bytes, config, api_pool, extraction_config.as_ref())?;
if config.use_cache
&& let Err(e) = cache.set_cached_result(&image_hash, "tesseract", &config_str, output_format.as_ref(), &result)
{
tracing::warn!(error = %e, "Failed to cache the OCR result; the next identical request will re-run OCR");
}
Ok(result)
}
#[cfg(test)]
pub(super) fn process_image_files_batch(
file_paths: Vec<String>,
config: &TesseractConfig,
cache: &OcrCache,
api_pool: &Arc<TesseractApiPool>,
) -> Vec<BatchItemResult> {
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
if let Err(e) = config.validate().map_err(OcrError::InvalidConfiguration) {
return file_paths
.into_iter()
.map(|path| BatchItemResult {
file_path: path,
success: false,
result: None,
error: Some(e.to_string()),
})
.collect();
}
let resolved = match resolve_config_language(config) {
Ok(r) => r,
Err(e) => {
return file_paths
.into_iter()
.map(|path| BatchItemResult {
file_path: path,
success: false,
result: None,
error: Some(e.to_string()),
})
.collect();
}
};
let config = resolved.as_ref().unwrap_or(config);
#[cfg(not(target_arch = "wasm32"))]
{
file_paths
.par_iter()
.map(|path| {
let image_bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
return BatchItemResult {
file_path: path.clone(),
success: false,
result: None,
error: Some(
OcrError::IOError(format!("Failed to read file '{}': {}", path, e)).to_string(),
),
};
}
};
match process_image_resolved(&image_bytes, config, cache, api_pool, None) {
Ok(result) => BatchItemResult {
file_path: path.clone(),
success: true,
result: Some(result),
error: None,
},
Err(e) => BatchItemResult {
file_path: path.clone(),
success: false,
result: None,
error: Some(e.to_string()),
},
}
})
.collect()
}
#[cfg(target_arch = "wasm32")]
{
file_paths
.iter()
.map(|path| {
let image_bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
return BatchItemResult {
file_path: path.clone(),
success: false,
result: None,
error: Some(
OcrError::IOError(format!("Failed to read file '{}': {}", path, e)).to_string(),
),
};
}
};
match process_image_resolved(&image_bytes, config, cache, api_pool, None) {
Ok(result) => BatchItemResult {
file_path: path.clone(),
success: true,
result: Some(result),
error: None,
},
Err(e) => BatchItemResult {
file_path: path.clone(),
success: false,
result: None,
error: Some(e.to_string()),
},
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ocr::hocr_parser::{
HOCR_FONT_SIZE_ATTRIBUTE, parse_hocr_to_internal_document_with_page_offset_and_stats,
};
use crate::table_core::cluster_words_into_table_regions;
use serial_test::serial;
use tempfile::tempdir;
#[cfg(feature = "bundle-tessdata-eng")]
fn recover_quantity_from_image(image: &image::RgbImage) -> Option<HocrWord> {
let tessdata = tempfile::tempdir().expect("temporary tessdata directory must be created");
std::fs::write(
tessdata.path().join("eng.traineddata"),
xberg_tesseract::bundled_eng_traineddata().expect("English tessdata must be bundled for this test"),
)
.expect("bundled English tessdata must be materialized");
let api = xberg_tesseract::TesseractAPI::new().expect("Tesseract API must initialize");
api.init(
tessdata.path().to_str().expect("temporary tessdata path must be UTF-8"),
"eng",
)
.expect("English tessdata must initialize");
api.set_page_seg_mode(TessPageSegMode::PSM_SPARSE_TEXT)
.expect("sparse page segmentation mode must apply");
api.set_image(
image.as_raw(),
image.width() as i32,
image.height() as i32,
3,
(image.width() * 3) as i32,
)
.expect("quantity image must load");
api.recognize().expect("initial sparse recognition must complete");
let config = TesseractConfig {
psm: TessPageSegMode::PSM_SPARSE_TEXT as u8,
..TesseractConfig::default()
};
let region = QuantityRetryRegion {
row: 0,
column: 0,
left: 0,
top: 0,
width: image.width(),
height: image.height(),
word_left: 0,
word_top: 0,
word_width: image.width(),
word_height: image.height(),
};
recover_blank_quantity_word(&api, &config, Some(®ion), image.width(), image.height())
}
#[cfg(feature = "bundle-tessdata-eng")]
fn isolated_quantity_image() -> image::RgbImage {
image::load_from_memory(include_bytes!("../../../test_data/ocr/isolated_quantity_cell.png"))
.expect("embedded quantity PNG must load")
.into_rgb8()
}
#[test]
#[cfg(feature = "bundle-tessdata-eng")]
fn blank_quantity_retry_recognizes_single_and_multi_digit_cells() {
let single_digit = isolated_quantity_image();
let recovered = recover_quantity_from_image(&single_digit).expect("isolated quantity must be recovered");
assert_eq!(recovered.text, "5");
let digit = image::imageops::crop_imm(&single_digit, 106, 25, 24, 36).to_image();
let mut multi_digit = image::RgbImage::from_pixel(170, 91, image::Rgb([255, 255, 255]));
for left in [45_i64, 69, 93] {
image::imageops::overlay(&mut multi_digit, &digit, left, 25);
}
let recovered = recover_quantity_from_image(&multi_digit).expect("multi-digit quantity must be recovered");
assert_eq!(recovered.text, "555");
}
fn confidence_word(text: &str, confidence: f32) -> xberg_tesseract::WordData {
xberg_tesseract::WordData {
text: text.to_string(),
left: 0,
top: 0,
right: 10,
bottom: 10,
confidence,
font_attrs: None,
language: None,
}
}
#[test]
fn should_reject_table_rebuild_that_drops_words_relative_to_original() {
let original = "Site inspections this quarter covered fourteen locations across \
the northern basin and several access roads remain washed out following spring runoff";
let rebuilt = "| Site | inspections |";
assert!(
!should_adopt_table_rebuild(original, rebuilt),
"a rebuild with far fewer words than the original must not replace it"
);
}
#[test]
fn should_adopt_table_rebuild_that_retains_or_exceeds_original_word_count() {
let original = "Name Age\nAlice 30\nBob 40";
let rebuilt = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 40 |";
assert!(
should_adopt_table_rebuild(original, rebuilt),
"a rebuild that faithfully reformats the same content into a table must still be adopted"
);
}
#[test]
fn should_publish_only_retained_hocr_word_confidence_metadata() {
let hocr = r#"<div class="ocr_page" title="ppageno 0">
<p class="ocr_par">
<span class="ocr_line">
<span class="ocrx_word" title="x_wconf 20">CLEAR</span>
<span class="ocrx_word" title="x_wconf 90">WORDS</span>
</span>
<span class="ocr_line">
<span class="ocrx_word" title="x_wconf 0">OWATS</span>
<span class="ocrx_word" title="x_wconf 0">DNDEVET</span>
</span>
</p>
</div>"#;
let is_valid_word = |word: &str| Some(matches!(word, "CLEAR" | "WORDS"));
let filter = DictionaryLineFilter {
is_valid_word: &is_valid_word,
max_invalid_ratio: crate::ocr::hocr_parser::DEFAULT_DICT_INVALID_LINE_RATIO,
};
let result = parse_hocr_to_internal_document_with_page_offset_and_stats(hocr, Some(&filter), 1);
let mut metadata = HashMap::new();
insert_retained_word_confidence_metadata(&mut metadata, &result.retained_word_confidence_stats);
assert_eq!(flatten_hocr_elements_to_text(&result.document.elements), "CLEAR WORDS");
assert_eq!(metadata.get("word_count"), Some(&serde_json::json!(2)));
assert_eq!(metadata.get("mean_text_conf"), Some(&serde_json::json!(55)));
assert_eq!(metadata.get("median_word_conf"), Some(&serde_json::json!(55)));
assert_eq!(metadata.get("p10_word_conf"), Some(&serde_json::json!(20)));
assert_eq!(metadata.get("low_conf_word_count"), Some(&serde_json::json!(1)));
assert_eq!(metadata.len(), 5);
}
#[test]
fn should_use_configured_security_limits_when_extraction_config_present() {
let config = ExtractionConfig {
security_limits: Some(SecurityLimits {
max_content_size: 200 * 1024 * 1024,
..Default::default()
}),
..Default::default()
};
let resolved = security_limits_for_ocr(&TesseractConfig::default(), Some(&config));
assert_eq!(resolved.max_content_size, 200 * 1024 * 1024);
}
#[test]
fn should_fall_back_to_default_security_limits_when_extraction_config_absent() {
let resolved = security_limits_for_ocr(&TesseractConfig::default(), None);
assert_eq!(resolved.max_content_size, SecurityLimits::default().max_content_size);
}
#[test]
fn should_prefer_the_tesseract_config_limits_over_the_extraction_config_limits() {
let tesseract_config = TesseractConfig {
security_limits: Some(SecurityLimits {
max_content_size: 300 * 1024 * 1024,
..Default::default()
}),
..Default::default()
};
let extraction_config = ExtractionConfig {
security_limits: Some(SecurityLimits {
max_content_size: 200 * 1024 * 1024,
..Default::default()
}),
..Default::default()
};
let resolved = security_limits_for_ocr(&tesseract_config, Some(&extraction_config));
assert_eq!(resolved.max_content_size, 300 * 1024 * 1024);
}
#[test]
fn should_use_the_tesseract_config_limits_when_no_extraction_config_is_present() {
let tesseract_config = TesseractConfig {
security_limits: Some(SecurityLimits {
max_content_size: 300 * 1024 * 1024,
..Default::default()
}),
..Default::default()
};
let resolved = security_limits_for_ocr(&tesseract_config, None);
assert_eq!(resolved.max_content_size, 300 * 1024 * 1024);
}
#[test]
fn should_omit_hocr_confidence_quantiles_when_every_word_is_rejected() {
let hocr = r#"<div class="ocr_page" title="ppageno 0">
<p class="ocr_par">
<span class="ocr_line">
<span class="ocrx_word" title="x_wconf 10">OWATS</span>
<span class="ocrx_word" title="x_wconf 90">DNDEVET</span>
</span>
</p>
</div>"#;
let always_invalid = |_: &str| Some(false);
let filter = DictionaryLineFilter {
is_valid_word: &always_invalid,
max_invalid_ratio: crate::ocr::hocr_parser::DEFAULT_DICT_INVALID_LINE_RATIO,
};
let result = parse_hocr_to_internal_document_with_page_offset_and_stats(hocr, Some(&filter), 1);
let mut metadata = HashMap::new();
insert_retained_word_confidence_metadata(&mut metadata, &result.retained_word_confidence_stats);
assert!(flatten_hocr_elements_to_text(&result.document.elements).is_empty());
assert_eq!(metadata.get("word_count"), Some(&serde_json::json!(0)));
assert_eq!(metadata.get("low_conf_word_count"), Some(&serde_json::json!(0)));
assert!(!metadata.contains_key("mean_text_conf"));
assert!(!metadata.contains_key("median_word_conf"));
assert!(!metadata.contains_key("p10_word_conf"));
assert_eq!(metadata.len(), 2);
}
#[test]
fn should_publish_zero_text_confidence_words_when_every_iterator_word_is_filtered() {
let words = [confidence_word("\u{001f}", 95.0)];
let stats = retained_text_word_confidence_stats("", &words);
let mut metadata = HashMap::from([
("word_count".to_string(), serde_json::json!(1)),
("low_conf_word_count".to_string(), serde_json::json!(0)),
("mean_text_conf".to_string(), serde_json::json!(95)),
("median_word_conf".to_string(), serde_json::json!(95)),
("p10_word_conf".to_string(), serde_json::json!(95)),
]);
insert_retained_word_confidence_metadata(&mut metadata, &stats);
assert_eq!(metadata.get("word_count"), Some(&serde_json::json!(0)));
assert_eq!(metadata.get("low_conf_word_count"), Some(&serde_json::json!(0)));
assert!(!metadata.contains_key("mean_text_conf"));
assert!(!metadata.contains_key("median_word_conf"));
assert!(!metadata.contains_key("p10_word_conf"));
}
#[test]
fn should_publish_only_partially_retained_text_word_confidences() {
let words = [
confidence_word("RETAINED", 20.0),
confidence_word("\u{001f}", 95.0),
confidence_word("WORDS", 90.0),
];
let stats = retained_text_word_confidence_stats("RETAINED WORDS", &words);
let mut metadata = HashMap::from([
("word_count".to_string(), serde_json::json!(3)),
("low_conf_word_count".to_string(), serde_json::json!(1)),
("mean_text_conf".to_string(), serde_json::json!(68)),
("median_word_conf".to_string(), serde_json::json!(90)),
("p10_word_conf".to_string(), serde_json::json!(20)),
]);
insert_retained_word_confidence_metadata(&mut metadata, &stats);
assert_eq!(metadata.get("word_count"), Some(&serde_json::json!(2)));
assert_eq!(metadata.get("mean_text_conf"), Some(&serde_json::json!(55)));
assert_eq!(metadata.get("median_word_conf"), Some(&serde_json::json!(55)));
assert_eq!(metadata.get("p10_word_conf"), Some(&serde_json::json!(20)));
assert_eq!(metadata.get("low_conf_word_count"), Some(&serde_json::json!(1)));
}
#[test]
fn should_align_words_after_a_retained_iterator_gap() {
let words = [confidence_word("FIRST", 10.0), confidence_word("LAST", 90.0)];
let stats = retained_text_word_confidence_stats("FIRST OMITTED LAST", &words);
assert_eq!(stats.word_count(), 2);
assert_eq!(stats.mean(), Some(50));
assert_eq!(stats.median(), Some(50));
assert_eq!(stats.p10(), Some(10));
assert_eq!(stats.low_confidence_word_count(), 1);
}
#[test]
fn should_align_repeated_words_without_losing_later_words() {
let words = [confidence_word("A", 20.0), confidence_word("B", 80.0)];
let stats = retained_text_word_confidence_stats("A A B", &words);
assert_eq!(stats.word_count(), 2);
assert_eq!(stats.mean(), Some(50));
assert_eq!(stats.median(), Some(50));
assert_eq!(stats.p10(), Some(20));
assert_eq!(stats.low_confidence_word_count(), 1);
}
#[test]
fn should_align_punctuation_and_unicode_exactly() {
let words = [
confidence_word("Hello,", 60.0),
confidence_word("Gr\u{00fc}\u{00df}e", 70.0),
confidence_word("\u{4e16}\u{754c}!", 80.0),
];
let stats = retained_text_word_confidence_stats("Hello, Gr\u{00fc}\u{00df}e \u{4e16}\u{754c}!", &words);
assert_eq!(stats.word_count(), 3);
assert_eq!(stats.mean(), Some(70));
assert_eq!(stats.median(), Some(70));
assert_eq!(stats.p10(), Some(60));
assert_eq!(stats.low_confidence_word_count(), 0);
}
#[test]
fn should_align_multi_token_words_across_whitespace_variants() {
let words = [confidence_word("ALPHA\tBETA", 75.0), confidence_word("GAMMA", 85.0)];
let stats = retained_text_word_confidence_stats("ALPHA \n BETA\r\nGAMMA", &words);
assert_eq!(stats.word_count(), 2);
assert_eq!(stats.mean(), Some(80));
assert_eq!(stats.median(), Some(80));
assert_eq!(stats.p10(), Some(75));
assert_eq!(stats.low_confidence_word_count(), 0);
}
#[test]
fn should_insert_exact_skipped_count_when_words_were_skipped() {
let mut metadata = HashMap::new();
insert_word_iterator_skipped_count_metadata(&mut metadata, 3);
assert_eq!(
metadata.get("word_iterator_skipped_count"),
Some(&serde_json::Value::Number(3.into()))
);
}
#[test]
fn should_omit_skipped_count_key_when_nothing_was_skipped() {
let mut metadata = HashMap::new();
insert_word_iterator_skipped_count_metadata(&mut metadata, 0);
assert!(
!metadata.contains_key("word_iterator_skipped_count"),
"key must be absent, not present with value 0"
);
assert!(metadata.is_empty());
}
#[test]
fn should_report_auto_rotate_available_when_not_requested() {
assert!(!is_auto_rotate_requested_but_unavailable(false));
}
#[test]
fn should_report_auto_rotate_unavailable_only_without_the_feature() {
assert_eq!(is_auto_rotate_requested_but_unavailable(true), cfg!(not(auto_rotate)));
}
fn dict_word(text: &str) -> xberg_tesseract::WordData {
xberg_tesseract::WordData {
text: text.to_string(),
left: 0,
top: 0,
right: 0,
bottom: 0,
confidence: 95.0,
font_attrs: None,
language: None,
}
}
#[test]
fn should_report_high_ratio_when_most_words_are_dictionary_invalid() {
let words = ["LAAALDLI", "sky", "AEA", "ails", "Bri", "ENT", "FRONT", "ELEVATION"].map(dict_word);
let is_valid = |text: &str| Some(matches!(text, "FRONT" | "ELEVATION"));
let ratio = invalid_word_ratio_from(&words, is_valid).expect("enough candidates to score");
assert_eq!(
ratio, 0.75,
"6 of 8 candidates are dictionary-invalid, expected exactly 0.75"
);
}
#[test]
fn should_report_low_ratio_when_all_words_are_dictionary_valid() {
let words = ["EXHIBIT", "PLANT", "LIST", "DATE", "November", "Nineteen"].map(dict_word);
let is_valid = |_: &str| Some(true);
let ratio = invalid_word_ratio_from(&words, is_valid).expect("enough candidates to score");
assert_eq!(ratio, 0.0, "an all-valid page must score exactly 0.0, not merely 'low'");
}
#[test]
fn should_exclude_short_and_non_alphabetic_words_from_candidates() {
let words = ["12", "a", "Bo", "3.14", "PLANT", "LIST", "DATE", "November", "Nineteen"].map(dict_word);
let is_valid = |_: &str| Some(true);
let ratio = invalid_word_ratio_from(&words, is_valid).expect("enough candidates to score");
assert_eq!(
ratio, 0.0,
"only the 5 alphabetic 3+ char words should count as candidates"
);
}
#[test]
fn should_return_none_when_too_few_candidates() {
let words = ["PLANT", "LIST"].map(dict_word);
let is_valid = |_: &str| Some(true);
assert_eq!(
invalid_word_ratio_from(&words, is_valid),
None,
"fewer than MIN_DICT_CANDIDATES_FOR_RATIO checkable words must not produce a ratio"
);
}
#[test]
fn should_exclude_failed_lookups_from_candidates() {
let words = ["PLANT", "LIST", "DATE", "November", "Nineteen", "BROKEN"].map(dict_word);
let is_valid = |text: &str| if text == "BROKEN" { None } else { Some(true) };
let ratio = invalid_word_ratio_from(&words, is_valid).expect("enough candidates to score");
assert_eq!(ratio, 0.0, "the failed lookup must not count as invalid");
}
fn word_at(left: u32, top: u32, width: u32, height: u32, text: &str) -> crate::table_core::HocrWord {
crate::table_core::HocrWord {
text: text.to_string(),
left,
top,
width,
height,
confidence: 95.0,
}
}
fn table_grid_words(left: u32, top: u32, rows: u32, cols: u32) -> Vec<crate::table_core::HocrWord> {
let mut words = Vec::new();
for row in 0..rows {
for col in 0..cols {
words.push(word_at(
left + col * 60,
top + row * 30,
40,
20,
&format!("r{row}c{col}"),
));
}
}
words
}
fn value_row_with_a_mark(values: u32) -> Vec<crate::table_core::HocrWord> {
let mut words = table_grid_words(0, 100, 1, values);
words.push(crate::table_core::HocrWord {
confidence: 10.0,
..word_at(45, 88, 10, 45, "=")
});
words
}
#[test]
fn a_region_counts_its_words_before_its_marks_are_dropped() {
let at_minimum = value_row_with_a_mark(MIN_TABLE_CANDIDATE_WORDS as u32 - 1);
assert_eq!(
at_minimum.len(),
MIN_TABLE_CANDIDATE_WORDS,
"the mark is what brings this region up to the minimum, which is the point of the test"
);
let mut region = TableRegion {
words: at_minimum.clone(),
read_boxes: at_minimum,
};
region.drop_shading_marks(0.5);
assert_eq!(region.words.len(), MIN_TABLE_CANDIDATE_WORDS - 1, "the mark is dropped");
assert_eq!(
region.read_boxes.len(),
region.words.len(),
"both vectors stay parallel"
);
assert!(region.words.iter().all(|word| word.text != "="));
}
#[test]
fn a_mark_normalised_onto_its_row_band_is_still_dropped() {
let words = value_row_with_a_mark(MIN_TABLE_CANDIDATE_WORDS as u32 - 1);
let read_boxes = words.clone();
let band_top = words.iter().map(|word| word.top).min().expect("the row has words");
let band_height = words.iter().map(|word| word.height).max().expect("the row has words");
let normalised: Vec<_> = words
.into_iter()
.map(|word| crate::table_core::HocrWord {
top: band_top,
height: band_height,
..word
})
.collect();
assert!(
normalised.iter().all(|word| word.height == band_height),
"the normalisation must erase the height anomaly, or this proves nothing"
);
let mut region = TableRegion {
words: normalised,
read_boxes,
};
region.drop_shading_marks(0.5);
assert!(
region.words.iter().all(|word| word.text != "="),
"the mark must be dropped on its read box, not its normalised one: {:?}",
region.words.iter().map(|word| word.text.as_str()).collect::<Vec<_>>()
);
assert_eq!(
region.read_boxes.len(),
region.words.len(),
"both vectors stay parallel"
);
}
#[test]
fn normalized_band_frame_glyphs_are_removed_from_table_edges() {
let words = vec![
word_at(95, 100, 3, 20, "|"),
word_at(100, 100, 70, 20, "[TOTAL"),
word_at(300, 100, 60, 20, "1,000"),
word_at(700, 100, 3, 20, "|"),
word_at(100, 140, 70, 20, "[TOTAL"),
word_at(300, 140, 60, 20, "2,000"),
word_at(700, 140, 3, 20, "|"),
];
let mut region = TableRegion {
read_boxes: words.clone(),
words,
};
region.drop_normalized_band_edge_glyphs();
let texts: Vec<&str> = region.words.iter().map(|word| word.text.as_str()).collect();
assert_eq!(texts, ["TOTAL", "1,000", "TOTAL", "2,000"]);
assert_eq!(region.read_boxes.len(), region.words.len());
}
#[test]
fn one_bracketed_label_does_not_license_edge_glyph_cleanup() {
let words = vec![
word_at(100, 100, 90, 20, "[Pending]"),
word_at(300, 100, 60, 20, "1,000"),
word_at(700, 100, 3, 20, "|"),
word_at(100, 140, 70, 20, "Account"),
word_at(300, 140, 60, 20, "2,000"),
];
let mut region = TableRegion {
read_boxes: words.clone(),
words,
};
region.drop_normalized_band_edge_glyphs();
let texts: Vec<&str> = region.words.iter().map(|word| word.text.as_str()).collect();
assert_eq!(texts, ["[Pending]", "1,000", "|", "Account", "2,000"]);
assert_eq!(region.read_boxes.len(), region.words.len());
}
#[test]
fn cluster_words_into_table_regions_separates_two_distant_tables() {
let mut words = table_grid_words(0, 0, 3, 3);
words.extend(table_grid_words(0, 1000, 3, 3));
let regions = cluster_words_into_table_regions(&words);
assert_eq!(regions.len(), 2, "two spatially distant grids should form two regions");
assert_eq!(regions[0].len(), 9);
assert_eq!(regions[1].len(), 9);
}
#[test]
fn cluster_words_into_table_regions_keeps_one_table_as_a_single_region() {
let words = table_grid_words(0, 0, 4, 3);
let regions = cluster_words_into_table_regions(&words);
assert_eq!(
regions.len(),
1,
"a single table's own row gaps must not be split into regions"
);
assert_eq!(regions[0].len(), 12);
}
#[test]
fn cluster_words_into_table_regions_empty_input_yields_no_regions() {
assert!(cluster_words_into_table_regions(&[]).is_empty());
}
const TSV_HEADER: &str =
"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n";
#[test]
fn a_stretched_edge_row_word_is_inside_its_table_and_prints_once() {
let tsv = format!(
"{TSV_HEADER}\
5\t1\t1\t1\t1\t1\t100\t120\t90\t26\t90\tAlpha\n\
5\t1\t2\t1\t1\t1\t600\t120\t60\t26\t90\t10\n\
5\t1\t3\t1\t1\t1\t800\t120\t60\t26\t90\t20\n\
5\t1\t4\t1\t1\t1\t100\t170\t90\t26\t90\tBravo\n\
5\t1\t5\t1\t1\t1\t600\t170\t60\t26\t90\t30\n\
5\t1\t6\t1\t1\t1\t800\t170\t60\t26\t90\t40\n\
5\t1\t7\t1\t1\t1\t100\t220\t100\t62\t90\tCharlie\n\
5\t1\t7\t1\t1\t2\t210\t220\t80\t26\t90\tTail\n\
5\t1\t8\t1\t1\t1\t600\t220\t60\t26\t90\t50\n\
5\t1\t9\t1\t1\t1\t800\t220\t60\t26\t90\t60\n"
);
let words = extract_table_words_from_tsv(&tsv, 0.0, &[]).unwrap();
let regions = table_regions(&words);
assert_eq!(regions.len(), 1);
let region = ®ions[0];
let cells = reconstruct_table_with_columns(®ion.words, 20, 0.5).0;
let tables = [OcrTable {
markdown: table_to_markdown(&cells),
cells,
page_number: 1,
bounding_box: Some(region.bounding_box()),
}];
let content = build_content_with_inline_tables(&tsv, &tables, 0.0);
assert!(content.contains("Charlie Tail"), "the label shares its row: {content}");
assert_eq!(
content.matches("Charlie").count(),
1,
"the stretched word prints once, in the table: {content}"
);
}
#[test]
fn an_edge_value_cut_from_its_underscore_mark_prints_once() {
let tsv = format!(
"{TSV_HEADER}\
5\t1\t1\t1\t1\t1\t100\t120\t90\t26\t90\tAlpha\n\
5\t1\t2\t1\t1\t1\t600\t120\t60\t26\t90\t10\n\
5\t1\t3\t1\t1\t1\t800\t120\t60\t26\t90\t20\n\
5\t1\t4\t1\t1\t1\t100\t170\t90\t26\t90\tBravo\n\
5\t1\t5\t1\t1\t1\t600\t170\t60\t26\t90\t30\n\
5\t1\t6\t1\t1\t1\t800\t170\t320\t26\t90\t47______________\n\
5\t1\t7\t1\t1\t1\t100\t220\t90\t26\t90\tCharlie\n\
5\t1\t8\t1\t1\t1\t600\t220\t60\t26\t90\t50\n\
5\t1\t9\t1\t1\t1\t800\t220\t60\t26\t90\t60\n"
);
let words = extract_table_words_from_tsv(&tsv, 0.0, &[]).unwrap();
let regions = table_regions(&words);
assert_eq!(regions.len(), 1);
let region = ®ions[0];
let cells = reconstruct_table_with_columns(®ion.words, 20, 0.5).0;
let tables = [OcrTable {
markdown: table_to_markdown(&cells),
cells,
page_number: 1,
bounding_box: Some(region.bounding_box()),
}];
let content = build_content_with_inline_tables(&tsv, &tables, 0.0);
assert!(
tables[0].cells.iter().flatten().any(|cell| cell == "47"),
"the value sits in its table cell: {:?}",
tables[0].cells
);
assert_eq!(
content.matches("47").count(),
1,
"the value prints once, in the table: {content}"
);
}
#[test]
fn a_word_pushed_onto_a_table_region_widens_its_bounding_box() {
let mut region = TableRegion {
words: table_grid_words(0, 0, 2, 2),
read_boxes: table_grid_words(0, 0, 2, 2),
};
let before = region.bounding_box();
let below = HocrWord {
text: "7".to_string(),
left: before.left,
top: before.bottom + 40,
width: 10,
height: 10,
confidence: 90.0,
};
region.push(below.clone());
assert_eq!(region.bounding_box().bottom, below.top + below.height);
assert_eq!(region.words.len(), region.read_boxes.len());
}
fn text_element_with_font_size(text: &str, font_size: Option<f64>) -> crate::types::internal::InternalElement {
let mut elem = crate::types::internal::InternalElement::text(
ElementKind::OcrText {
level: crate::types::OcrElementLevel::Block,
},
text,
0,
);
if let Some(size) = font_size {
elem.attributes
.get_or_insert_with(Default::default)
.insert(HOCR_FONT_SIZE_ATTRIBUTE.to_string(), size.to_string());
}
elem
}
#[test]
fn flatten_hocr_elements_to_text_never_bakes_heading_syntax_regardless_of_font_size() {
let elements = vec![
text_element_with_font_size("Chapter One", Some(28.0)),
text_element_with_font_size("Body text at normal size.", Some(12.0)),
];
let flattened = flatten_hocr_elements_to_text(&elements);
assert_eq!(flattened, "Chapter One\n\nBody text at normal size.");
assert!(
!flattened.contains('#'),
"flattened OCR text must contain no markdown heading syntax: {flattened:?}"
);
}
fn paragraph_with_bbox(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> crate::types::internal::InternalElement {
let mut elem = crate::types::internal::InternalElement::text(ElementKind::Paragraph, text, 0);
elem.bbox = Some(crate::types::extraction::BoundingBox { x0, y0, x1, y1 });
elem
}
fn table_at(left: u32, top: u32, right: u32, bottom: u32) -> OcrTable {
table_at_with_cells(left, top, right, bottom, vec![vec!["cell".to_string()]])
}
fn table_at_with_cells(left: u32, top: u32, right: u32, bottom: u32, cells: Vec<Vec<String>>) -> OcrTable {
OcrTable {
markdown: table_to_markdown(&cells),
cells,
page_number: 1,
bounding_box: Some(OcrTableBoundingBox {
left,
top,
right,
bottom,
}),
}
}
fn cells_of(text: &str) -> Vec<Vec<String>> {
vec![text.split_whitespace().map(str::to_string).collect()]
}
#[test]
fn filter_elements_covered_by_tables_drops_paragraph_inside_table_bbox() {
let elements = vec![paragraph_with_bbox("Apple 50 10 00", 10.0, 10.0, 90.0, 30.0)];
let tables = vec![table_at_with_cells(0, 0, 100, 100, cells_of("Apple 50 10 00"))];
let filtered = filter_elements_covered_by_tables(elements, &tables);
assert!(
filtered.is_empty(),
"paragraph centred inside the table bbox must be dropped"
);
}
#[test]
fn filter_elements_covered_by_tables_keeps_paragraph_adjacent_to_table() {
let elements = vec![
paragraph_with_bbox("Vehicle Maintenance Guide", 10.0, 0.0, 90.0, 15.0),
paragraph_with_bbox("Apple 50 10 00", 10.0, 50.0, 90.0, 70.0),
];
let tables = vec![table_at_with_cells(0, 40, 100, 140, cells_of("Apple 50 10 00"))];
let filtered = filter_elements_covered_by_tables(elements, &tables);
assert_eq!(
filtered.len(),
1,
"only the paragraph centred inside the table bbox should be dropped"
);
assert_eq!(filtered[0].text, "Vehicle Maintenance Guide");
}
#[test]
fn filter_elements_covered_by_tables_keeps_lines_whose_words_the_table_dropped() {
let elements = vec![paragraph_with_bbox("73 4 4 4 4 -", 10.0, 10.0, 90.0, 30.0)];
let sparse_row = vec![vec![
"73".to_string(),
String::new(),
String::new(),
String::new(),
String::new(),
]];
let tables = vec![table_at_with_cells(0, 0, 100, 100, sparse_row)];
let filtered = filter_elements_covered_by_tables(elements, &tables);
assert_eq!(
filtered.len(),
1,
"a table that dropped the region's words must not claim it"
);
assert_eq!(filtered[0].text, "73 4 4 4 4 -");
}
#[test]
fn filter_elements_covered_by_tables_claims_only_the_tables_that_retained_their_words() {
let elements = vec![
paragraph_with_bbox("Apple 50 10 00", 10.0, 10.0, 90.0, 30.0),
paragraph_with_bbox("Pear 61 11 01", 10.0, 210.0, 90.0, 230.0),
];
let tables = vec![
table_at_with_cells(0, 0, 100, 100, cells_of("Apple 50 10 00")),
table_at_with_cells(0, 200, 100, 300, cells_of("Pear")),
];
let filtered = filter_elements_covered_by_tables(elements, &tables);
assert_eq!(filtered.len(), 1, "{filtered:?}");
assert_eq!(filtered[0].text, "Pear 61 11 01");
}
#[test]
fn filter_elements_covered_by_tables_is_noop_without_tables() {
let elements = vec![paragraph_with_bbox("Apple 50 10 00", 10.0, 10.0, 90.0, 30.0)];
let filtered = filter_elements_covered_by_tables(elements, &[]);
assert_eq!(filtered.len(), 1, "no tables detected means nothing should be filtered");
}
#[test]
fn filter_elements_covered_by_tables_keeps_elements_without_bbox() {
let mut elem = crate::types::internal::InternalElement::text(ElementKind::Paragraph, "no geometry", 0);
elem.bbox = None;
let tables = vec![table_at(0, 0, 100, 100)];
let filtered = filter_elements_covered_by_tables(vec![elem], &tables);
assert_eq!(
filtered.len(),
1,
"an element with no bbox cannot be tested against a table and must survive"
);
}
#[test]
fn flatten_hocr_elements_to_text_leaves_content_unchanged_without_font_sizes() {
let elements = vec![
text_element_with_font_size("First paragraph.", None),
text_element_with_font_size("Second paragraph.", None),
];
let markdown = flatten_hocr_elements_to_text(&elements);
assert_eq!(markdown, "First paragraph.\n\nSecond paragraph.");
}
#[test]
fn test_is_all_languages() {
assert!(is_all_languages("all"));
assert!(is_all_languages("ALL"));
assert!(is_all_languages("All"));
assert!(is_all_languages("*"));
assert!(!is_all_languages("eng"));
assert!(!is_all_languages("eng+fra"));
assert!(!is_all_languages(""));
}
#[test]
fn test_resolve_config_language_passthrough() {
let config = TesseractConfig {
language: "eng".to_string(),
..TesseractConfig::default()
};
let resolved = resolve_config_language(&config).unwrap();
assert!(resolved.is_none(), "non-wildcard should return None (no clone)");
}
#[test]
fn test_compute_image_hash_deterministic() {
let image_bytes = vec![1, 2, 3, 4, 5];
let hash1 = crate::cache::blake3_hash_bytes(&image_bytes);
let hash2 = crate::cache::blake3_hash_bytes(&image_bytes);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 32);
}
#[test]
fn test_compute_image_hash_different_data() {
let image_bytes1 = vec![1, 2, 3, 4, 5];
let image_bytes2 = vec![5, 4, 3, 2, 1];
let hash1 = crate::cache::blake3_hash_bytes(&image_bytes1);
let hash2 = crate::cache::blake3_hash_bytes(&image_bytes2);
assert_ne!(hash1, hash2);
}
fn invoice_word(text: &str, left: u32, top: u32, width: u32) -> crate::table_core::HocrWord {
crate::table_core::HocrWord {
text: text.to_string(),
left,
top,
width,
height: 40,
confidence: 95.0,
}
}
#[test]
fn blank_quantity_retry_is_bounded_to_the_missing_quantity_cell() {
let words = vec![
invoice_word("DESCRIPTION", 279, 100, 250),
invoice_word("QTY", 1_699, 100, 90),
invoice_word("UNIT PRICE", 2_096, 100, 258),
invoice_word("LINE TOTAL", 2_663, 100, 258),
invoice_word("Espresso Beans", 279, 200, 400),
invoice_word("10", 1_740, 200, 48),
invoice_word("$45.00", 2_206, 200, 150),
invoice_word("$450.00", 2_744, 200, 175),
invoice_word("Cups", 279, 300, 100),
invoice_word("200", 1_709, 300, 75),
invoice_word("$1.20", 2_233, 300, 125),
invoice_word("$240.00", 2_744, 300, 175),
invoice_word("Cleaning Tablets", 279, 400, 360),
invoice_word("$18.50", 2_206, 400, 150),
invoice_word("$92.50", 2_772, 400, 150),
];
let table = vec![
vec!["DESCRIPTION", "QTY", "UNIT PRICE", "LINE TOTAL"],
vec!["Espresso Beans", "10", "$45.00", "$450.00"],
vec!["Cups", "200", "$1.20", "$240.00"],
vec!["Cleaning Tablets", "", "$18.50", "$92.50"],
]
.into_iter()
.map(|row| row.into_iter().map(str::to_string).collect())
.collect::<Vec<Vec<String>>>();
let (quantity_column, blank_row) =
quantity_retry_column_index(&table).expect("fixture qualifies for one retry");
let region = quantity_retry_region(
&words,
quantity_column,
blank_row,
&[120, 220, 320, 420],
&[279, 1_709, 2_219, 2_744],
3_200,
4_089,
)
.expect("captured invoice has one bounded retry region");
assert_eq!(region.row, 3);
assert_eq!(region.column, 1);
assert_eq!((region.left, region.top), (1_659, 380));
assert_eq!((region.width, region.height), (170, 80));
}
#[test]
fn blank_quantity_retry_requires_two_recognized_quantity_rows() {
let table = vec![
vec!["DESCRIPTION", "QTY", "UNIT PRICE", "LINE TOTAL"],
vec!["Espresso Beans", "10", "$45.00", "$450.00"],
vec!["Cleaning Tablets", "", "$18.50", "$92.50"],
]
.into_iter()
.map(|row| row.into_iter().map(str::to_string).collect())
.collect::<Vec<Vec<String>>>();
assert!(quantity_retry_column_index(&table).is_none());
}
#[test]
fn blank_quantity_retry_skips_tables_with_multiple_blank_quantities() {
let table = vec![
vec!["DESCRIPTION", "QTY", "PRICE"],
vec!["Item A", "10", "$10.00"],
vec!["Item B", "20", "$20.00"],
vec!["Shipping", "", "$25.00"],
vec!["Tax", "", "$5.00"],
]
.into_iter()
.map(|row| row.into_iter().map(str::to_string).collect())
.collect::<Vec<Vec<String>>>();
assert!(quantity_retry_column_index(&table).is_none());
}
#[test]
fn blank_quantity_retry_clamps_crop_to_adjacent_cells() {
let words = vec![
invoice_word("DESCRIPTION", 100, 100, 160),
invoice_word("QTY", 500, 100, 40),
invoice_word("PRICE", 560, 100, 90),
invoice_word("Item A", 100, 180, 100),
invoice_word("10", 500, 180, 40),
invoice_word("$10", 560, 180, 60),
invoice_word("Missing", 100, 210, 120),
invoice_word("$5", 560, 210, 50),
invoice_word("Item B", 100, 240, 100),
invoice_word("20", 500, 240, 40),
invoice_word("$20", 560, 240, 60),
];
let table = vec![
vec!["DESCRIPTION", "QTY", "PRICE"],
vec!["Item A", "10", "$10"],
vec!["Missing", "", "$5"],
vec!["Item B", "20", "$20"],
]
.into_iter()
.map(|row| row.into_iter().map(str::to_string).collect())
.collect::<Vec<Vec<String>>>();
let (quantity_column, blank_row) =
quantity_retry_column_index(&table).expect("fixture qualifies for one retry");
let region = quantity_retry_region(
&words,
quantity_column,
blank_row,
&[120, 200, 230, 260],
&[100, 500, 560],
800,
600,
)
.expect("tight table has one bounded retry region");
assert_eq!((region.left, region.top), (460, 215));
assert_eq!((region.width, region.height), (70, 30));
}
#[test]
fn non_quantity_table_does_not_enter_retry_geometry() {
let table = vec![
vec!["NAME".to_string(), "PRICE".to_string()],
vec!["Item A".to_string(), "$10".to_string()],
vec!["Item B".to_string(), "$20".to_string()],
];
assert!(quantity_retry_column_index(&table).is_none());
}
#[test]
fn quantity_retry_text_accepts_only_a_short_positive_integer() {
assert_eq!(parse_retry_quantity(" 5\n"), Some("5"));
assert_eq!(parse_retry_quantity("200"), Some("200"));
assert_eq!(parse_retry_quantity(""), None);
assert_eq!(parse_retry_quantity("$5"), None);
assert_eq!(parse_retry_quantity("5.0"), None);
assert_eq!(parse_retry_quantity("000000000"), None);
}
#[test]
fn test_log_ci_debug_disabled() {
log_ci_debug(false, "test_stage", || "test message".to_string());
}
#[test]
fn test_log_ci_debug_enabled() {
log_ci_debug(true, "test_stage", || "test message".to_string());
}
#[test]
fn test_process_image_file_nonexistent() {
let temp_dir = tempdir().unwrap();
let cache = OcrCache::new(Some(temp_dir.path().to_path_buf())).unwrap();
let config = TesseractConfig {
output_format: "text".to_string(),
enable_table_detection: false,
use_cache: false,
..TesseractConfig::default()
};
let api_pool = TesseractApiPool::new();
let result = process_image_file_with_cache("/nonexistent/file.png", &config, &cache, &api_pool, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to read file"));
}
#[test]
fn test_process_image_invalid_image_data() {
let temp_dir = tempdir().unwrap();
let cache = OcrCache::new(Some(temp_dir.path().to_path_buf())).unwrap();
let config = TesseractConfig {
output_format: "text".to_string(),
enable_table_detection: false,
use_cache: false,
..TesseractConfig::default()
};
let invalid_data = vec![0, 1, 2, 3, 4];
let api_pool = TesseractApiPool::new();
let result = process_image_with_cache(&invalid_data, &config, &cache, &api_pool, None);
assert!(result.is_err());
}
fn tiny_test_png_bytes() -> Vec<u8> {
use image::{ImageBuffer, ImageFormat, Rgb, RgbImage};
use std::io::Cursor;
let img: RgbImage = ImageBuffer::from_pixel(32, 32, Rgb([255, 255, 255]));
let mut bytes: Vec<u8> = Vec::new();
img.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap();
bytes
}
#[test]
#[serial]
fn process_image_with_cache_does_not_read_or_write_the_cache_when_use_cache_is_false() {
let api = match xberg_tesseract::TesseractAPI::new() {
Ok(api) => api,
Err(_) => return, };
if api.init("", "eng").is_err() {
return; }
let temp_dir = tempdir().unwrap();
let cache = OcrCache::new(Some(temp_dir.path().to_path_buf())).unwrap();
let image_bytes = tiny_test_png_bytes();
let config = TesseractConfig {
output_format: "text".to_string(),
enable_table_detection: false,
use_cache: false,
..TesseractConfig::default()
};
let image_hash = crate::cache::blake3_hash_bytes(&image_bytes);
let languages: Vec<String> = config.language.split('+').map(|lang| lang.trim().to_string()).collect();
let resolved_tessdata_path =
resolve_tessdata_path(&languages, config.tessdata_path.as_deref()).expect("tessdata must resolve");
let config_str = hash_config(&config, &resolved_tessdata_path);
let marker = OcrExtractionResult {
content: "STALE MARKER: use_cache=false must never return this".to_string(),
mime_type: "text/plain".to_string(),
metadata: HashMap::new(),
tables: Vec::new(),
ocr_elements: None,
internal_document: None,
};
cache
.set_cached_result(&image_hash, "tesseract", &config_str, None, &marker)
.unwrap();
let api_pool = TesseractApiPool::new();
let result = process_image_with_cache(&image_bytes, &config, &cache, &api_pool, None)
.expect("OCR on a valid image must succeed");
assert_ne!(
result.content, marker.content,
"use_cache=false must not read the pre-seeded cache entry"
);
let stats = cache.get_stats().unwrap();
assert_eq!(
stats.total_files, 1,
"use_cache=false must not write a new cache entry (only the pre-seeded marker file may exist)"
);
}
fn real_eng_traineddata_bytes_from_sibling_build_dir() -> Option<Vec<u8>> {
let this_out_dir = std::path::PathBuf::from(env!("OUT_DIR"));
let build_dir = this_out_dir.parent()?.parent()?;
let mut entries: Vec<_> = std::fs::read_dir(build_dir).ok()?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name();
if !name.to_string_lossy().starts_with("xberg-tesseract-") {
continue;
}
let candidate = entry.path().join("out").join("eng.traineddata");
if let Ok(bytes) = std::fs::read(&candidate) {
return Some(bytes);
}
}
None
}
fn copy_working_eng_tessdata_into(dest_dir: &std::path::Path) -> Option<()> {
let bytes = real_eng_traineddata_bytes_from_sibling_build_dir()?;
std::fs::write(dest_dir.join("eng.traineddata"), bytes).ok()
}
#[test]
fn process_image_with_cache_does_not_share_entries_across_different_resolved_tessdata_directories() {
let dir_a = tempdir().unwrap();
let dir_b = tempdir().unwrap();
if copy_working_eng_tessdata_into(dir_a.path()).is_none()
|| copy_working_eng_tessdata_into(dir_b.path()).is_none()
{
return; }
let cache_dir = tempdir().unwrap();
let cache = OcrCache::new(Some(cache_dir.path().to_path_buf())).unwrap();
let api_pool = TesseractApiPool::new();
let image_bytes = tiny_test_png_bytes();
let config_a = TesseractConfig {
output_format: "text".to_string(),
enable_table_detection: false,
use_cache: true,
tessdata_path: Some(dir_a.path().to_path_buf()),
..TesseractConfig::default()
};
let config_b = TesseractConfig {
tessdata_path: Some(dir_b.path().to_path_buf()),
..config_a.clone()
};
process_image_with_cache(&image_bytes, &config_a, &cache, &api_pool, None)
.expect("OCR against dir_a must succeed");
process_image_with_cache(&image_bytes, &config_b, &cache, &api_pool, None)
.expect("OCR against dir_b must succeed");
let stats = cache.get_stats().unwrap();
assert_eq!(
stats.total_files, 2,
"two different resolved tessdata directories must write two separate cache entries \
(#1787), not share one"
);
}
#[test]
fn process_image_with_cache_still_shares_entries_for_the_same_resolved_tessdata_directory() {
let dir_a = tempdir().unwrap();
if copy_working_eng_tessdata_into(dir_a.path()).is_none() {
return; }
let cache_dir = tempdir().unwrap();
let cache = OcrCache::new(Some(cache_dir.path().to_path_buf())).unwrap();
let api_pool = TesseractApiPool::new();
let image_bytes = tiny_test_png_bytes();
let config = TesseractConfig {
output_format: "text".to_string(),
enable_table_detection: false,
use_cache: true,
tessdata_path: Some(dir_a.path().to_path_buf()),
..TesseractConfig::default()
};
process_image_with_cache(&image_bytes, &config, &cache, &api_pool, None).expect("first OCR call must succeed");
process_image_with_cache(&image_bytes, &config, &cache, &api_pool, None).expect("second OCR call must succeed");
let stats = cache.get_stats().unwrap();
assert_eq!(
stats.total_files, 1,
"two calls resolving to the same tessdata directory must share one cache entry, \
not write a second"
);
}
#[test]
fn test_preprocess_pix_produces_grayscale_with_valid_resolution() {
let width = 32;
let height = 32;
let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
for y in 0..height {
for x in 0..width {
let value = if (x + y) % 2 == 0 { 32 } else { 224 };
rgb_data.extend_from_slice(&[value, value, value]);
}
}
let mut pix = xberg_tesseract::Pix::from_raw_rgb(&rgb_data, width, height).unwrap();
pix.set_resolution(300, 300).unwrap();
let processed = preprocess_pix(pix, &preprocessing_config()).unwrap();
assert_eq!(processed.width(), width as i32);
assert_eq!(processed.height(), height as i32);
assert_eq!(processed.depth(), 1);
assert_eq!(processed.get_resolution().unwrap(), (300, 300));
}
fn preprocessing_fixture() -> (Vec<u8>, u32, u32) {
const WIDTH: u32 = 640;
const HEIGHT: u32 = 480;
const CHANNELS: usize = 3;
let mut rgb_data = Vec::with_capacity(WIDTH as usize * HEIGHT as usize * CHANNELS);
for y in 0..HEIGHT {
for x in 0..WIDTH {
let background = 176 + ((x * 48) / WIDTH) as u8;
let skewed_line = (40..=390).step_by(50).any(|line_y| y.abs_diff(line_y + x / 16) <= 2);
let noise = (x * 37 + y * 19).is_multiple_of(97);
let value = if noise {
16
} else if skewed_line && (20..620).contains(&x) {
72
} else {
background
};
rgb_data.extend_from_slice(&[value, value, value]);
}
}
(rgb_data, WIDTH, HEIGHT)
}
fn preprocessing_config() -> crate::types::ImagePreprocessingConfig {
crate::types::ImagePreprocessingConfig {
target_dpi: 300,
auto_rotate: false,
deskew: false,
denoise: false,
contrast_enhance: false,
binarization_method: "otsu".to_string(),
invert_colors: false,
normalize_shaded_rows: false,
}
}
fn preprocess_fixture_with_config(config: &crate::types::ImagePreprocessingConfig) -> xberg_tesseract::Pix {
let (rgb_data, width, height) = preprocessing_fixture();
preprocess_rgb_with_config(rgb_data, width, height, config)
}
fn preprocess_rgb_with_config(
rgb_data: Vec<u8>,
width: u32,
height: u32,
config: &crate::types::ImagePreprocessingConfig,
) -> xberg_tesseract::Pix {
let pix = xberg_tesseract::Pix::from_raw_rgb(&rgb_data, width, height).unwrap();
preprocess_pix(pix, config).unwrap()
}
fn sampled_pixel_signature(pix: &xberg_tesseract::Pix) -> Vec<u32> {
const SAMPLE_STEP: usize = 8;
let mut signature = vec![pix.width() as u32, pix.height() as u32, pix.depth() as u32];
for y in (0..pix.height()).step_by(SAMPLE_STEP) {
for x in (0..pix.width()).step_by(SAMPLE_STEP) {
let sample = pix.clip_rectangle(x, y, 1, 1).unwrap();
let (mean, _) = sample.grayscale_stats(1, 1).unwrap();
signature.push(mean.round() as u32);
}
}
signature
}
fn binary_pixel_value(pix: &xberg_tesseract::Pix, x: i32, y: i32) -> u32 {
let sample = pix.clip_rectangle(x, y, 1, 1).unwrap();
let (mean, _) = sample.grayscale_stats(1, 1).unwrap();
mean.round() as u32
}
#[test]
fn should_change_preprocessed_pixels_when_deskew_is_enabled() {
let disabled = preprocessing_config();
let enabled = crate::types::ImagePreprocessingConfig {
deskew: true,
..disabled.clone()
};
let disabled_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&disabled));
let enabled_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&enabled));
assert_ne!(
enabled_signature, disabled_signature,
"deskew must affect the OCR raster"
);
}
#[test]
fn should_change_preprocessed_pixels_when_denoise_is_enabled() {
let disabled = preprocessing_config();
let enabled = crate::types::ImagePreprocessingConfig {
denoise: true,
..disabled.clone()
};
let disabled_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&disabled));
let enabled_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&enabled));
assert_ne!(
enabled_signature, disabled_signature,
"denoise must affect the OCR raster"
);
}
#[test]
fn should_change_preprocessed_pixels_when_contrast_enhance_is_enabled() {
let disabled = crate::types::ImagePreprocessingConfig {
binarization_method: "adaptive".to_string(),
..preprocessing_config()
};
let enabled = crate::types::ImagePreprocessingConfig {
contrast_enhance: true,
..disabled.clone()
};
let disabled_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&disabled));
let enabled_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&enabled));
assert_ne!(
enabled_signature, disabled_signature,
"contrast enhancement must affect the OCR raster"
);
}
#[test]
fn should_change_preprocessed_pixels_when_binarization_method_changes() {
let otsu = preprocessing_config();
let adaptive = crate::types::ImagePreprocessingConfig {
binarization_method: "adaptive".to_string(),
..otsu.clone()
};
let otsu_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&otsu));
let adaptive_signature = sampled_pixel_signature(&preprocess_fixture_with_config(&adaptive));
assert_ne!(
adaptive_signature, otsu_signature,
"binarization_method must select a distinct preprocessing operation"
);
}
#[test]
fn should_change_preprocessed_pixels_when_sauvola_is_selected() {
const WIDTH: u32 = 256;
const HEIGHT: u32 = 128;
let mut rgb_data = Vec::with_capacity(WIDTH as usize * HEIGHT as usize * RGB_CHANNEL_COUNT);
for y in 0..HEIGHT {
for x in 0..WIDTH {
let background = 72 + ((x * 168) / WIDTH) as u8;
let is_text = (20..=100).step_by(20).any(|line| y.abs_diff(line) <= 1) && (8..248).contains(&x);
let value = if is_text {
background.saturating_sub(32)
} else {
background
};
rgb_data.extend_from_slice(&[value, value, value]);
}
}
let otsu = preprocessing_config();
let sauvola = crate::types::ImagePreprocessingConfig {
binarization_method: "sauvola".to_string(),
..otsu.clone()
};
let otsu_signature =
sampled_pixel_signature(&preprocess_rgb_with_config(rgb_data.clone(), WIDTH, HEIGHT, &otsu));
let sauvola_signature = sampled_pixel_signature(&preprocess_rgb_with_config(rgb_data, WIDTH, HEIGHT, &sauvola));
assert_ne!(
sauvola_signature, otsu_signature,
"Sauvola must select a distinct operation"
);
}
#[test]
fn test_prepare_ocr_image_without_config_preserves_shadowed_rgb() {
let rgb_data = vec![0, 1, 2, 3, 4, 5];
let prepared = prepare_ocr_image(rgb_data.clone(), 2, 1, None, None, false, None, false);
assert_eq!(prepared.data, rgb_data);
assert_eq!(prepared.width, 2);
assert_eq!(prepared.height, 1);
assert_eq!(prepared.source_dpi, RAW_IMAGE_SOURCE_DPI);
assert!(!prepared.apply_pix_preprocessing);
}
#[test]
fn should_apply_default_preprocessing_for_a_known_scan_page_however_dark() {
const SAMPLE_PIXEL_COUNT: usize = 16;
let rgb_data = vec![0u8; SAMPLE_PIXEL_COUNT * RGB_CHANNEL_COUNT];
assert!(
!should_apply_default_preprocessing(&rgb_data, SAMPLE_PIXEL_COUNT as u32, 1),
"fixture must fail the pixel-brightness heuristic on its own"
);
let prepared = prepare_ocr_image(rgb_data, SAMPLE_PIXEL_COUNT as u32, 1, None, None, false, None, true);
assert!(
prepared.apply_pix_preprocessing,
"a known scan page must take default preprocessing even when the pixels read as dark"
);
}
#[test]
fn should_keep_pixel_test_for_a_dark_image_with_no_scan_signal() {
const SAMPLE_PIXEL_COUNT: usize = 16;
let rgb_data = vec![0u8; SAMPLE_PIXEL_COUNT * RGB_CHANNEL_COUNT];
let prepared = prepare_ocr_image(rgb_data, SAMPLE_PIXEL_COUNT as u32, 1, None, None, false, None, false);
assert!(
!prepared.apply_pix_preprocessing,
"a bare dark image with no scan signal must still be judged by pixel brightness"
);
}
#[test]
fn test_clean_white_rgb_selects_default_preprocessing() {
const SAMPLE_PIXEL_COUNT: usize = 16;
let rgb_data = vec![u8::MAX; SAMPLE_PIXEL_COUNT * RGB_CHANNEL_COUNT];
assert!(should_apply_default_preprocessing(
&rgb_data,
SAMPLE_PIXEL_COUNT as u32,
1
));
}
const CONTRAST_FIXTURE_WIDTH: u32 = 512;
const CONTRAST_FIXTURE_HEIGHT: u32 = 128;
const CONTRAST_FIXTURE_FILL_WIDTH: u32 = 80;
const PALE_TEXT: [u8; RGB_CHANNEL_COUNT] = [200, 220, 245];
const DARK_TEXT: [u8; RGB_CHANNEL_COUNT] = [30, 30, 30];
const BRIGHT_BLUE_FILL: [u8; RGB_CHANNEL_COUNT] = [210, 220, 240];
fn glyph_mask(x: u32, y: u32, offset: u32) -> bool {
const TOP: u32 = 48;
const HEIGHT: u32 = 24;
const WIDTH: u32 = 20;
const STROKE: u32 = 2;
for left in [4 + offset, 30 + offset, 56 + offset] {
let local_x = x.checked_sub(left);
let local_y = y.checked_sub(TOP);
if let (Some(local_x), Some(local_y)) = (local_x, local_y)
&& local_x < WIDTH
&& local_y < HEIGHT
&& (!(STROKE..WIDTH - STROKE).contains(&local_x)
|| (HEIGHT / 2..HEIGHT / 2 + STROKE).contains(&local_y))
{
return true;
}
}
false
}
fn contrast_fixture(text: [u8; RGB_CHANNEL_COUNT], fill: bool, offset: u32) -> (Vec<u8>, usize) {
let mut rgb_data =
Vec::with_capacity(CONTRAST_FIXTURE_WIDTH as usize * CONTRAST_FIXTURE_HEIGHT as usize * RGB_CHANNEL_COUNT);
let mut glyph_pixels = 0usize;
for y in 0..CONTRAST_FIXTURE_HEIGHT {
for x in 0..CONTRAST_FIXTURE_WIDTH {
let pixel = if glyph_mask(x, y, offset) {
glyph_pixels += 1;
text
} else if fill && x < CONTRAST_FIXTURE_FILL_WIDTH {
BRIGHT_BLUE_FILL
} else {
[u8::MAX; RGB_CHANNEL_COUNT]
};
rgb_data.extend_from_slice(&pixel);
}
}
(rgb_data, glyph_pixels)
}
fn aggregate_foreground_contrast(rgb_data: &[u8]) -> f64 {
let mut background_sum = 0.0;
let mut background_count = 0usize;
let mut foreground_sum = 0.0;
let mut foreground_count = 0usize;
for pixel in rgb_data.chunks_exact(RGB_CHANNEL_COUNT) {
let luminance = pixel.iter().map(|channel| f64::from(*channel)).sum::<f64>()
/ (RGB_CHANNEL_MAX * RGB_CHANNEL_COUNT as f64);
if luminance >= CLEAN_PAGE_LIGHT_PIXEL_THRESHOLD {
background_sum += luminance;
background_count += 1;
} else {
foreground_sum += luminance;
foreground_count += 1;
}
}
background_sum / background_count as f64 - foreground_sum / foreground_count as f64
}
#[test]
fn should_preserve_pale_glyphs_without_implicit_otsu() {
let (rgb_data, glyph_pixels) = contrast_fixture(PALE_TEXT, false, 0);
assert_eq!(
glyph_pixels, 384,
"fixture must contain the intended nonempty glyph mask"
);
let prepared = prepare_ocr_image(
rgb_data.clone(),
CONTRAST_FIXTURE_WIDTH,
CONTRAST_FIXTURE_HEIGHT,
None,
None,
false,
Some(300.0),
false,
);
assert!(!prepared.apply_pix_preprocessing);
assert!(prepared.preprocessing.is_none());
assert!(prepared.image_preprocessing.is_none());
assert_eq!(
prepared.data, rgb_data,
"implicit preprocessing must preserve pale glyph pixels"
);
}
#[test]
fn should_apply_implicit_otsu_to_dark_glyphs_over_bright_fill_at_every_sample_phase() {
for offset in 0..DEFAULT_PREPROCESSING_SAMPLE_STRIDE as u32 {
let (rgb_data, glyph_pixels) = contrast_fixture(DARK_TEXT, true, offset);
assert_eq!(glyph_pixels, 384, "offset {offset} changed the glyph population");
assert!(
aggregate_foreground_contrast(&rgb_data) < CLEAN_PAGE_MIN_FOREGROUND_CONTRAST,
"fixture must fail the legacy aggregate-contrast gate at offset {offset}"
);
let prepared = prepare_ocr_image(
rgb_data,
CONTRAST_FIXTURE_WIDTH,
CONTRAST_FIXTURE_HEIGHT,
None,
None,
false,
Some(300.0),
false,
);
assert!(
prepared.apply_pix_preprocessing,
"dark glyphs were missed at offset {offset}"
);
assert_eq!(prepared.width, CONTRAST_FIXTURE_WIDTH);
assert_eq!(prepared.height, CONTRAST_FIXTURE_HEIGHT);
assert_eq!(
prepared
.preprocessing
.as_ref()
.map(|config| config.binarization_method.as_str()),
Some("otsu")
);
}
}
#[test]
fn should_reject_scattered_speckles_with_the_same_dark_pixel_count_as_glyphs() {
let (mut rgb_data, glyph_pixels) = contrast_fixture(BRIGHT_BLUE_FILL, true, 0);
let mut speckles = 0usize;
'rows: for y in (0..CONTRAST_FIXTURE_HEIGHT).step_by(2) {
for x in (0..CONTRAST_FIXTURE_WIDTH).step_by(DEFAULT_PREPROCESSING_SAMPLE_STRIDE) {
let pixel_index = (y as usize * CONTRAST_FIXTURE_WIDTH as usize + x as usize) * RGB_CHANNEL_COUNT;
rgb_data[pixel_index..pixel_index + RGB_CHANNEL_COUNT].copy_from_slice(&DARK_TEXT);
speckles += 1;
if speckles == glyph_pixels {
break 'rows;
}
}
}
assert_eq!(
speckles, glyph_pixels,
"control must have the same dark-pixel population"
);
let prepared = prepare_ocr_image(
rgb_data.clone(),
CONTRAST_FIXTURE_WIDTH,
CONTRAST_FIXTURE_HEIGHT,
None,
None,
false,
Some(300.0),
false,
);
assert!(
!prepared.apply_pix_preprocessing,
"isolated speckles are not text structure"
);
assert_eq!(prepared.data, rgb_data);
}
#[test]
fn should_reject_a_contiguous_blob_with_the_same_dark_pixel_count_as_glyphs() {
const BLOB_WIDTH: u32 = 16;
const BLOB_HEIGHT: u32 = 24;
let (mut rgb_data, glyph_pixels) = contrast_fixture(BRIGHT_BLUE_FILL, true, 0);
assert_eq!(BLOB_WIDTH as usize * BLOB_HEIGHT as usize, glyph_pixels);
for y in 48..48 + BLOB_HEIGHT {
for x in 4..4 + BLOB_WIDTH {
let pixel_index = (y as usize * CONTRAST_FIXTURE_WIDTH as usize + x as usize) * RGB_CHANNEL_COUNT;
rgb_data[pixel_index..pixel_index + RGB_CHANNEL_COUNT].copy_from_slice(&DARK_TEXT);
}
}
let prepared = prepare_ocr_image(
rgb_data.clone(),
CONTRAST_FIXTURE_WIDTH,
CONTRAST_FIXTURE_HEIGHT,
None,
None,
false,
Some(300.0),
false,
);
assert!(!prepared.apply_pix_preprocessing, "a solid blob is not text structure");
assert_eq!(prepared.data, rgb_data);
}
#[test]
fn should_preserve_three_narrow_solid_glyphs_with_the_same_dark_pixel_count() {
const GLYPH_WIDTH: u32 = 4;
const GLYPH_HEIGHT: u32 = 32;
let (mut rgb_data, glyph_pixels) = contrast_fixture(BRIGHT_BLUE_FILL, true, 0);
assert_eq!(3 * GLYPH_WIDTH as usize * GLYPH_HEIGHT as usize, glyph_pixels);
for left in [4, 12, 20] {
for y in 44..44 + GLYPH_HEIGHT {
for x in left..left + GLYPH_WIDTH {
let pixel_index = (y as usize * CONTRAST_FIXTURE_WIDTH as usize + x as usize) * RGB_CHANNEL_COUNT;
rgb_data[pixel_index..pixel_index + RGB_CHANNEL_COUNT].copy_from_slice(&DARK_TEXT);
}
}
}
let prepared = prepare_ocr_image(
rgb_data,
CONTRAST_FIXTURE_WIDTH,
CONTRAST_FIXTURE_HEIGHT,
None,
None,
false,
Some(300.0),
false,
);
assert!(
prepared.apply_pix_preprocessing,
"narrow solid glyphs remain text structure"
);
}
#[test]
fn should_reject_a_small_defect_among_equal_count_scattered_speckles() {
const DEFECT_PIXELS: usize = 4;
let (mut rgb_data, glyph_pixels) = contrast_fixture(BRIGHT_BLUE_FILL, true, 0);
for y in 0..2 {
for x in 0..2 {
let pixel_index = (y * CONTRAST_FIXTURE_WIDTH as usize + x) * RGB_CHANNEL_COUNT;
rgb_data[pixel_index..pixel_index + RGB_CHANNEL_COUNT].copy_from_slice(&DARK_TEXT);
}
}
let mut speckles = 0usize;
'rows: for y in (4..CONTRAST_FIXTURE_HEIGHT).step_by(2) {
for x in (4..CONTRAST_FIXTURE_WIDTH).step_by(DEFAULT_PREPROCESSING_SAMPLE_STRIDE) {
let pixel_index = (y as usize * CONTRAST_FIXTURE_WIDTH as usize + x as usize) * RGB_CHANNEL_COUNT;
rgb_data[pixel_index..pixel_index + RGB_CHANNEL_COUNT].copy_from_slice(&DARK_TEXT);
speckles += 1;
if speckles + DEFECT_PIXELS == glyph_pixels {
break 'rows;
}
}
}
assert_eq!(speckles + DEFECT_PIXELS, glyph_pixels);
let prepared = prepare_ocr_image(
rgb_data.clone(),
CONTRAST_FIXTURE_WIDTH,
CONTRAST_FIXTURE_HEIGHT,
None,
None,
false,
Some(300.0),
false,
);
assert!(
!prepared.apply_pix_preprocessing,
"a small connected defect does not make scattered noise text-like"
);
assert_eq!(prepared.data, rgb_data);
}
#[test]
fn should_reject_malformed_or_overflowing_rgb_dimensions() {
assert!(!should_apply_default_preprocessing(&[u8::MAX; 3], 2, 2));
assert!(!should_apply_default_preprocessing(&[], u32::MAX, u32::MAX));
}
#[test]
fn should_select_default_preprocessing_for_black_text_on_white_page() {
const WIDTH: u32 = 80;
const HEIGHT: u32 = 20;
const FOREGROUND_WIDTH: u32 = 4;
let mut rgb_data = Vec::with_capacity(WIDTH as usize * HEIGHT as usize * RGB_CHANNEL_COUNT);
for _y in 0..HEIGHT {
for x in 0..WIDTH {
let pixel = if x < FOREGROUND_WIDTH {
[0; RGB_CHANNEL_COUNT]
} else {
[u8::MAX; RGB_CHANNEL_COUNT]
};
rgb_data.extend_from_slice(&pixel);
}
}
assert!(should_apply_default_preprocessing(&rgb_data, WIDTH, HEIGHT));
}
#[test]
fn test_prepare_ocr_image_without_config_preprocesses_clean_white_rgb() {
const WIDTH: u32 = 4;
const HEIGHT: u32 = 4;
let rgb_data = vec![u8::MAX; WIDTH as usize * HEIGHT as usize * RGB_CHANNEL_COUNT];
let prepared = prepare_ocr_image(rgb_data, WIDTH, HEIGHT, None, None, false, None, false);
assert!(prepared.apply_pix_preprocessing);
let preprocessing = prepared
.preprocessing
.expect("implicit preprocessing must retain its effective configuration");
assert!(preprocessing.deskew);
assert_eq!(preprocessing.binarization_method, "otsu");
}
#[test]
fn test_shadowed_rgb_skips_default_preprocessing() {
const SAMPLE_PIXEL_COUNT: usize = 16;
const SHADOWED_CHANNEL_VALUE: u8 = 128;
let rgb_data = vec![SHADOWED_CHANNEL_VALUE; SAMPLE_PIXEL_COUNT * RGB_CHANNEL_COUNT];
assert!(!should_apply_default_preprocessing(
&rgb_data,
SAMPLE_PIXEL_COUNT as u32,
1
));
}
#[test]
fn test_prepare_ocr_image_with_config_keeps_preprocessing_enabled() {
let rgb_data = vec![255; 12];
let preprocessing = crate::types::ImagePreprocessingConfig {
target_dpi: 72,
..Default::default()
};
let prepared = prepare_ocr_image(rgb_data, 2, 2, Some(&preprocessing), None, false, None, false);
assert!(prepared.apply_pix_preprocessing);
assert_eq!(prepared.preprocessing.unwrap().target_dpi, 72);
}
#[test]
fn test_prepare_preprocessed_ocr_image_honours_image_extraction_config_limits() {
const SOURCE_DIMENSION: u32 = 4;
let rgb_data = vec![255; SOURCE_DIMENSION as usize * SOURCE_DIMENSION as usize * RGB_CHANNEL_COUNT];
let preprocessing = crate::types::ImagePreprocessingConfig {
target_dpi: 300,
..Default::default()
};
let images_config = crate::core::config::ImageExtractionConfig {
max_image_dimension: 2,
auto_adjust_dpi: false,
..Default::default()
};
let prepared = prepare_preprocessed_ocr_image(
rgb_data,
SOURCE_DIMENSION,
SOURCE_DIMENSION,
&preprocessing,
Some(&images_config),
false,
None,
);
assert_eq!(prepared.width, 2, "max_image_dimension=2 must clamp the resized width");
assert_eq!(
prepared.height, 2,
"max_image_dimension=2 must clamp the resized height"
);
let metadata = prepared
.image_preprocessing
.expect("successful normalization must retain its metadata");
assert_eq!(metadata.original_dimensions.width, SOURCE_DIMENSION as usize);
assert_eq!(metadata.original_dimensions.height, SOURCE_DIMENSION as usize);
assert_eq!(
metadata.new_dimensions.as_ref().map(|dimensions| dimensions.width),
Some(2)
);
assert_eq!(
metadata.new_dimensions.as_ref().map(|dimensions| dimensions.height),
Some(2)
);
assert!(metadata.dimension_clamped);
}
const LETTER_AT_150_DPI_WIDTH_PX: u32 = 1275;
const LETTER_AT_150_DPI_HEIGHT_PX: u32 = 1650;
fn letter_page_raster_at_150_dpi() -> Vec<u8> {
vec![u8::MAX; LETTER_AT_150_DPI_WIDTH_PX as usize * LETTER_AT_150_DPI_HEIGHT_PX as usize * RGB_CHANNEL_COUNT]
}
#[test]
fn should_honour_known_source_dpi_instead_of_assuming_72() {
const KNOWN_RENDER_DPI: f64 = 150.0;
const EXPECTED_WIDTH_PX: u32 = 2550;
const EXPECTED_HEIGHT_PX: u32 = 3300;
const EXPECTED_SOURCE_DPI: i32 = 300;
let prepared = prepare_ocr_image(
letter_page_raster_at_150_dpi(),
LETTER_AT_150_DPI_WIDTH_PX,
LETTER_AT_150_DPI_HEIGHT_PX,
Some(&crate::types::ImagePreprocessingConfig::default()),
None,
false,
Some(KNOWN_RENDER_DPI),
false,
);
assert_eq!(
prepared.height, EXPECTED_HEIGHT_PX,
"a 150 DPI Letter page scaled to the 300 DPI target is 3300px tall, not the 4096px \
the max_image_dimension clamp produces when the source is mistaken for 72 DPI"
);
assert_eq!(
prepared.width, EXPECTED_WIDTH_PX,
"the matching width for a clean 2.0x resize"
);
assert_eq!(
prepared.source_dpi, EXPECTED_SOURCE_DPI,
"Tesseract must be told the raster's real resolution, not the 179 the 72 assumption \
derives"
);
}
#[test]
fn should_keep_assuming_72_dpi_when_source_dpi_is_unknown() {
const CLAMPED_DIMENSION_PX: u32 = 4096;
const DERIVED_SOURCE_DPI: i32 = 179;
let prepared = prepare_ocr_image(
letter_page_raster_at_150_dpi(),
LETTER_AT_150_DPI_WIDTH_PX,
LETTER_AT_150_DPI_HEIGHT_PX,
Some(&crate::types::ImagePreprocessingConfig::default()),
None,
false,
None,
false,
);
assert_eq!(
prepared.height, CLAMPED_DIMENSION_PX,
"without a known source DPI the 72 assumption drives the resize into the clamp"
);
assert_eq!(
prepared.source_dpi, DERIVED_SOURCE_DPI,
"and reports the DPI that assumption implies"
);
}
#[test]
fn should_default_to_72_dpi_for_unpreprocessed_raw_image_without_known_dpi() {
let rgb_data = vec![0, 1, 2, 3, 4, 5];
let prepared = prepare_ocr_image(rgb_data.clone(), 2, 1, None, None, false, None, false);
assert_eq!(prepared.data, rgb_data, "the raster must pass through untouched");
assert_eq!(prepared.source_dpi, RAW_IMAGE_SOURCE_DPI);
}
#[test]
fn should_report_known_source_dpi_on_the_unpreprocessed_branch() {
const KNOWN_RENDER_DPI: f64 = 150.0;
let rgb_data = vec![0, 1, 2, 3, 4, 5];
let prepared = prepare_ocr_image(rgb_data, 2, 1, None, None, false, Some(KNOWN_RENDER_DPI), false);
assert_eq!(prepared.source_dpi, 150);
assert!(!prepared.apply_pix_preprocessing);
}
mod png_source_dpi {
use super::*;
fn png_crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
fn png_chunk(chunk_type: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(4 + data.len());
body.extend_from_slice(chunk_type);
body.extend_from_slice(data);
let mut out = Vec::with_capacity(8 + body.len());
out.extend_from_slice(&(u32::try_from(data.len()).unwrap()).to_be_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&png_crc32(&body).to_be_bytes());
out
}
fn encode_png(width: u32, height: u32) -> Vec<u8> {
let img = image::RgbImage::from_pixel(width, height, image::Rgb([255, 255, 255]));
let mut png = Vec::new();
{
use image::ImageEncoder;
image::codecs::png::PngEncoder::new(&mut png)
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.expect("encode PNG");
}
png
}
fn png_with_phys_density(width: u32, height: u32, ppu: u32) -> Vec<u8> {
const SIGNATURE_AND_IHDR_LEN: usize = 8 + 25;
let mut phys_data = Vec::with_capacity(9);
phys_data.extend_from_slice(&ppu.to_be_bytes());
phys_data.extend_from_slice(&ppu.to_be_bytes());
phys_data.push(1); let phys_chunk = png_chunk(b"pHYs", &phys_data);
let png = encode_png(width, height);
let mut out = Vec::with_capacity(png.len() + phys_chunk.len());
out.extend_from_slice(&png[..SIGNATURE_AND_IHDR_LEN]);
out.extend_from_slice(&phys_chunk);
out.extend_from_slice(&png[SIGNATURE_AND_IHDR_LEN..]);
out
}
const REPORTER_FIXTURE_PPU: u32 = 11_811;
const EXPECTED_DPI_FROM_REPORTER_FIXTURE: f64 = 299.999_4;
const DPI_TOLERANCE: f64 = 0.001;
const TEST_IMAGE_SIDE: u32 = 4;
fn rgb_fixture() -> Vec<u8> {
vec![255u8; TEST_IMAGE_SIDE as usize * TEST_IMAGE_SIDE as usize * RGB_CHANNEL_COUNT]
}
fn preprocessing_targeting(target_dpi: i32) -> crate::types::ImagePreprocessingConfig {
crate::types::ImagePreprocessingConfig {
target_dpi,
..Default::default()
}
}
fn images_config_without_auto_adjust() -> crate::core::config::ImageExtractionConfig {
crate::core::config::ImageExtractionConfig {
auto_adjust_dpi: false,
..Default::default()
}
}
#[test]
fn should_prefer_explicit_source_dpi_over_embedded_png_density() {
let png = png_with_phys_density(TEST_IMAGE_SIDE, TEST_IMAGE_SIDE, REPORTER_FIXTURE_PPU);
let resolved = resolve_known_source_dpi(Some(150.0), &png, TEST_IMAGE_SIDE, TEST_IMAGE_SIDE);
assert_eq!(
resolved,
Some(150.0),
"a caller-supplied source_dpi hint must win over embedded PNG metadata"
);
}
#[test]
fn should_decode_source_density_from_embedded_png_metadata() {
let png = png_with_phys_density(TEST_IMAGE_SIDE, TEST_IMAGE_SIDE, REPORTER_FIXTURE_PPU);
let resolved = resolve_known_source_dpi(None, &png, TEST_IMAGE_SIDE, TEST_IMAGE_SIDE)
.expect("pHYs density must be detected");
assert!(
(resolved - EXPECTED_DPI_FROM_REPORTER_FIXTURE).abs() < DPI_TOLERANCE,
"expected ~{EXPECTED_DPI_FROM_REPORTER_FIXTURE} DPI, got {resolved}"
);
}
#[test]
fn should_resolve_none_when_neither_explicit_hint_nor_embedded_density_exist() {
let png = encode_png(TEST_IMAGE_SIDE, TEST_IMAGE_SIDE);
assert_eq!(
resolve_known_source_dpi(None, &png, TEST_IMAGE_SIDE, TEST_IMAGE_SIDE),
None,
"a PNG without density metadata must not fabricate a source DPI"
);
}
#[test]
fn should_skip_resize_when_target_dpi_matches_known_png_density() {
let png = png_with_phys_density(TEST_IMAGE_SIDE, TEST_IMAGE_SIDE, REPORTER_FIXTURE_PPU);
let known_source_dpi = resolve_known_source_dpi(None, &png, TEST_IMAGE_SIDE, TEST_IMAGE_SIDE);
let images_config = images_config_without_auto_adjust();
let preprocessing = preprocessing_targeting(300);
let prepared = prepare_ocr_image(
rgb_fixture(),
TEST_IMAGE_SIDE,
TEST_IMAGE_SIDE,
Some(&preprocessing),
Some(&images_config),
false,
known_source_dpi,
false,
);
assert_eq!(prepared.width, TEST_IMAGE_SIDE);
assert_eq!(prepared.height, TEST_IMAGE_SIDE);
let metadata = prepared
.image_preprocessing
.expect("preprocessing metadata is recorded");
assert!(
metadata.skipped_resize,
"a 300 target against a ~300 detected source must not resize"
);
}
#[test]
fn should_resize_correctly_for_a_different_target_dpi() {
let png = png_with_phys_density(TEST_IMAGE_SIDE, TEST_IMAGE_SIDE, REPORTER_FIXTURE_PPU);
let known_source_dpi = resolve_known_source_dpi(None, &png, TEST_IMAGE_SIDE, TEST_IMAGE_SIDE);
let images_config = images_config_without_auto_adjust();
let preprocessing = preprocessing_targeting(150);
let prepared = prepare_ocr_image(
rgb_fixture(),
TEST_IMAGE_SIDE,
TEST_IMAGE_SIDE,
Some(&preprocessing),
Some(&images_config),
false,
known_source_dpi,
false,
);
assert_eq!(
prepared.width, 2,
"halving from a ~300 DPI source to a 150 DPI target halves the raster"
);
assert_eq!(prepared.height, 2);
}
#[test]
fn should_default_to_72_dpi_when_png_has_no_density_metadata() {
let png = encode_png(TEST_IMAGE_SIDE, TEST_IMAGE_SIDE);
let known_source_dpi = resolve_known_source_dpi(None, &png, TEST_IMAGE_SIDE, TEST_IMAGE_SIDE);
assert_eq!(known_source_dpi, None);
let images_config = images_config_without_auto_adjust();
let preprocessing = preprocessing_targeting(300);
let prepared = prepare_ocr_image(
rgb_fixture(),
TEST_IMAGE_SIDE,
TEST_IMAGE_SIDE,
Some(&preprocessing),
Some(&images_config),
false,
known_source_dpi,
false,
);
let metadata = prepared
.image_preprocessing
.expect("preprocessing metadata is recorded");
assert_eq!(
metadata.original_dpi,
crate::types::ImageDpi::from((f64::from(RAW_IMAGE_SOURCE_DPI), f64::from(RAW_IMAGE_SOURCE_DPI))),
"no embedded metadata must leave the historical 72 assumption unchanged"
);
}
}
#[test]
fn test_should_invert_for_polarity_dark_background_with_text() {
assert!(should_invert_for_polarity(40.0, 0.05, false));
}
#[test]
fn test_should_invert_for_polarity_light_background_not_inverted() {
assert!(!should_invert_for_polarity(220.0, 0.9, false));
}
#[test]
fn test_should_invert_for_polarity_uniformly_dark_image_not_inverted() {
assert!(!should_invert_for_polarity(20.0, 0.0, false));
}
#[test]
fn test_should_invert_for_polarity_force_true_overrides_light_background() {
assert!(should_invert_for_polarity(220.0, 0.9, true));
}
#[test]
fn test_should_invert_for_polarity_force_false_still_auto_detects() {
assert!(should_invert_for_polarity(10.0, 0.5, false));
}
#[test]
fn test_preprocess_pix_inverts_light_on_dark_image() {
let width = 40u32;
let height = 40u32;
let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
for y in 0..height {
for x in 0..width {
let is_text = (10..13).contains(&y) && (5..25).contains(&x);
let value = if is_text { 230u8 } else { 20u8 };
rgb_data.extend_from_slice(&[value, value, value]);
}
}
let pix = xberg_tesseract::Pix::from_raw_rgb(&rgb_data, width, height).unwrap();
let processed = preprocess_pix(pix, &preprocessing_config()).unwrap();
assert_eq!(processed.depth(), 1);
assert_eq!(binary_pixel_value(&processed, 0, 0), 0, "background must be white");
assert_eq!(binary_pixel_value(&processed, 10, 11), 1, "text must be black");
}
#[test]
fn test_preprocess_pix_does_not_invert_dark_on_light_image() {
let width = 40u32;
let height = 40u32;
let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
for y in 0..height {
for x in 0..width {
let is_text = (10..13).contains(&y) && (5..25).contains(&x);
let value = if is_text { 20u8 } else { 230u8 };
rgb_data.extend_from_slice(&[value, value, value]);
}
}
let pix = xberg_tesseract::Pix::from_raw_rgb(&rgb_data, width, height).unwrap();
let processed = preprocess_pix(pix, &preprocessing_config()).unwrap();
assert_eq!(processed.depth(), 1);
assert_eq!(binary_pixel_value(&processed, 0, 0), 0, "background must be white");
assert_eq!(binary_pixel_value(&processed, 10, 11), 1, "text must be black");
}
#[cfg(auto_rotate)]
#[test]
#[cfg(auto_rotate)]
fn test_rotate_rgb_image_data_identity() {
let data: Vec<u8> = (0..18).collect();
let (out, w, h) = rotate_rgb_image_data(&data, 2, 3, 0);
assert_eq!(out, data);
assert_eq!(w, 2);
assert_eq!(h, 3);
}
#[cfg(auto_rotate)]
#[test]
#[cfg(auto_rotate)]
fn test_rotate_rgb_image_data_180() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
let (out, w, h) = rotate_rgb_image_data(&data, 2, 2, 180);
assert_eq!(w, 2);
assert_eq!(h, 2);
assert_eq!(out, vec![10, 11, 12, 7, 8, 9, 4, 5, 6, 1, 2, 3]);
}
#[cfg(auto_rotate)]
#[test]
#[cfg(auto_rotate)]
fn test_rotate_rgb_image_data_90_swaps_dimensions() {
let data: Vec<u8> = (0..18).collect();
let (_, w, h) = rotate_rgb_image_data(&data, 2, 3, 90);
assert_eq!(w, 3);
assert_eq!(h, 2);
}
#[cfg(auto_rotate)]
#[test]
#[cfg(auto_rotate)]
fn test_rotate_rgb_image_data_270_swaps_dimensions() {
let data: Vec<u8> = (0..18).collect();
let (_, w, h) = rotate_rgb_image_data(&data, 2, 3, 270);
assert_eq!(w, 3);
assert_eq!(h, 2);
}
#[cfg(auto_rotate)]
#[test]
#[cfg(auto_rotate)]
fn test_rotate_rgb_image_data_90_then_270_is_identity() {
let data: Vec<u8> = (0..18).collect();
let (rotated_90, w1, h1) = rotate_rgb_image_data(&data, 2, 3, 90);
let (back, w2, h2) = rotate_rgb_image_data(&rotated_90, w1, h1, 270);
assert_eq!(w2, 2);
assert_eq!(h2, 3);
assert_eq!(back, data);
}
#[cfg(auto_rotate)]
#[test]
#[cfg(auto_rotate)]
fn test_rotate_rgb_image_data_unsupported_angle() {
let data: Vec<u8> = (0..12).collect();
let (out, w, h) = rotate_rgb_image_data(&data, 2, 2, 45);
assert_eq!(out, data);
assert_eq!(w, 2);
assert_eq!(h, 2);
}
}