use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::error::LiteParseError;
use crate::ocr::{OcrEngine, OcrOptions, OcrResult};
use crate::types::{CutAxis, Page, ParsedPage, Rect, Region, RegionKind, TextItem, WordBox};
#[cfg(target_arch = "wasm32")]
use futures_util::{
FutureExt,
stream::{FuturesUnordered, StreamExt},
};
use pdfium::{Document, ImageBounds};
use serde::{Deserialize, Serialize};
const UNCOVERED_VECTOR_AREA_THRESHOLD: f32 = 400.0;
const MIN_IMAGE_SIZE_PT: f32 = 25.0;
const MAX_IMAGE_PAGE_COVERAGE: f32 = 0.9;
const MIN_COLUMN_ITEMS: usize = 8;
const MIN_COLUMN_ITEM_FRACTION: f32 = 0.15;
const DENSE_GRAPHICS_MIN_COVERAGE: f32 = 0.2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OcrRaster {
pub page_number: usize,
#[serde(with = "base64_bytes")]
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub dpi: f32,
pub has_native_text: bool,
pub image_rects: Vec<Rect>,
}
#[derive(Debug, Clone)]
pub struct OcrRenderOptions {
pub max_rasters: usize,
pub dpi: f32,
pub grayscale: bool,
pub render_form_fields: bool,
pub continue_on_page_error: bool,
pub reflatten_pages: HashSet<u32>,
pub selection: Option<HashSet<u32>>,
}
impl Default for OcrRenderOptions {
fn default() -> Self {
Self {
max_rasters: 0,
dpi: crate::config::DEFAULT_DPI,
grayscale: false,
render_form_fields: false,
continue_on_page_error: false,
reflatten_pages: HashSet::new(),
selection: None,
}
}
}
mod base64_bytes {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = String::deserialize(deserializer)?;
STANDARD.decode(encoded).map_err(serde::de::Error::custom)
}
}
type OcrTaskResult = Result<Vec<OcrResult>, Box<dyn std::error::Error + Send + Sync>>;
impl PageOcrOutcome {
fn pending(raster: &OcrRaster) -> Self {
Self {
page_number: raster.page_number,
dpi: raster.dpi,
has_native_text: raster.has_native_text,
image_rects: raster.image_rects.clone(),
results: Vec::new(),
error: None,
}
}
fn with_result(mut self, result: OcrTaskResult) -> Self {
match result {
Ok(results) => self.results = results,
Err(error) => self.error = Some(error.to_string()),
}
self
}
}
#[cfg(target_arch = "wasm32")]
type OcrJob = std::pin::Pin<Box<dyn std::future::Future<Output = (usize, PageOcrOutcome)>>>;
pub(crate) struct OcrTaskPool {
max_workers: usize,
engine: Arc<dyn OcrEngine>,
language: String,
#[cfg(not(target_arch = "wasm32"))]
tasks: tokio::task::JoinSet<OcrTaskResult>,
#[cfg(target_arch = "wasm32")]
tasks: FuturesUnordered<OcrJob>,
#[cfg(not(target_arch = "wasm32"))]
running: HashMap<tokio::task::Id, (usize, PageOcrOutcome)>,
completed: Vec<(usize, PageOcrOutcome)>,
submitted: usize,
}
impl OcrTaskPool {
pub(crate) fn new(engine: Arc<dyn OcrEngine>, language: &str, num_workers: usize) -> Self {
Self {
max_workers: num_workers.max(1),
engine,
language: language.to_string(),
#[cfg(not(target_arch = "wasm32"))]
tasks: tokio::task::JoinSet::new(),
#[cfg(target_arch = "wasm32")]
tasks: FuturesUnordered::new(),
#[cfg(not(target_arch = "wasm32"))]
running: HashMap::new(),
completed: Vec::new(),
submitted: 0,
}
}
pub(crate) fn available_capacity(&self) -> usize {
self.max_workers - self.tasks.len()
}
pub(crate) async fn submit(&mut self, raster: OcrRaster) {
while self.available_capacity() == 0 {
self.complete_one().await;
}
let pending = PageOcrOutcome::pending(&raster);
let engine = self.engine.clone();
let options = OcrOptions {
language: self.language.clone(),
dpi: raster.dpi,
};
#[cfg(not(target_arch = "wasm32"))]
{
let runtime = tokio::runtime::Handle::current();
let task = self.tasks.spawn(async move {
match tokio::task::spawn_blocking(move || {
runtime.block_on(engine.recognize(
&raster.pixels,
raster.width,
raster.height,
&options,
))
})
.await
{
Ok(result) => result,
Err(join_err) => Err(Box::new(join_err) as _),
}
});
self.running.insert(task.id(), (self.submitted, pending));
}
#[cfg(target_arch = "wasm32")]
{
let order = self.submitted;
self.tasks.push(Box::pin(async move {
let result = engine
.recognize(&raster.pixels, raster.width, raster.height, &options)
.await;
(order, pending.with_result(result))
}));
}
self.submitted += 1;
}
pub(crate) async fn complete_one(&mut self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
match self.tasks.join_next_with_id().await {
Some(joined) => {
self.record_completion(joined);
true
}
None => false,
}
}
#[cfg(target_arch = "wasm32")]
{
match self.tasks.next().await {
Some(outcome) => {
self.completed.push(outcome);
true
}
None => false,
}
}
}
pub(crate) fn complete_ready(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
while let Some(joined) = self.tasks.try_join_next_with_id() {
self.record_completion(joined);
}
#[cfg(target_arch = "wasm32")]
while let Some(Some(outcome)) = self.tasks.next().now_or_never() {
self.completed.push(outcome);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn record_completion(
&mut self,
joined: Result<(tokio::task::Id, OcrTaskResult), tokio::task::JoinError>,
) {
let (id, result) = match joined {
Ok((id, result)) => (id, result),
Err(join_err) => (join_err.id(), Err(Box::new(join_err) as _)),
};
let (order, pending) = self
.running
.remove(&id)
.expect("every spawned OCR task is tracked");
self.completed.push((order, pending.with_result(result)));
}
pub(crate) async fn finish(mut self) -> Vec<PageOcrOutcome> {
while self.complete_one().await {}
self.take_completed()
}
pub(crate) fn take_completed(&mut self) -> Vec<PageOcrOutcome> {
self.completed.sort_by_key(|(order, _)| *order);
std::mem::take(&mut self.completed)
.into_iter()
.map(|(_, outcome)| outcome)
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComplexityReason {
Scanned,
NoText,
SparseText,
EmbeddedImages,
Garbled,
VectorText,
AnnotationText,
}
impl ComplexityReason {
pub fn as_str(&self) -> &'static str {
match self {
ComplexityReason::Scanned => "scanned",
ComplexityReason::NoText => "no-text",
ComplexityReason::SparseText => "sparse-text",
ComplexityReason::EmbeddedImages => "embedded-images",
ComplexityReason::Garbled => "garbled",
ComplexityReason::VectorText => "vector-text",
ComplexityReason::AnnotationText => "annotation-text",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageComplexityStats {
pub page_number: usize,
pub text_length: usize,
pub text_coverage: f32,
pub has_substantial_images: bool,
pub image_block_count: usize,
pub image_coverage: f32,
pub largest_image_coverage: f32,
pub full_page_image: bool,
pub uncovered_vector_area: Option<f32>,
pub is_garbled: bool,
pub page_area: f32,
pub needs_ocr: bool,
pub reasons: Vec<ComplexityReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout: Option<LayoutComplexityStats>,
}
pub(crate) fn calculate_page_complexity(
page: &Page,
page_obj: &pdfium::Page,
) -> Result<PageComplexityStats, LiteParseError> {
let text_length: usize = page
.text_items
.iter()
.filter(|item| !is_unusable_native(item))
.map(|item| item.text.len())
.sum();
let pw = page.page_width;
let ph = page.page_height;
let all_images = page_obj.image_bounds(MIN_IMAGE_SIZE_PT, f32::INFINITY);
let is_full_page = |b: &ImageBounds| {
b.width > pw * MAX_IMAGE_PAGE_COVERAGE && b.height > ph * MAX_IMAGE_PAGE_COVERAGE
};
let full_page_image = all_images.iter().any(is_full_page);
let image_bounds: Vec<&ImageBounds> = all_images.iter().filter(|b| !is_full_page(b)).collect();
let has_images = !image_bounds.is_empty();
let page_area = pw * ph;
let (image_area_sum, largest_image_area) =
image_bounds
.iter()
.fold((0.0_f32, 0.0_f32), |(sum, max), b| {
let area = b.width.max(0.0) * b.height.max(0.0);
(sum + area, max.max(area))
});
let (image_coverage, largest_image_coverage) = if page_area > 0.0 {
(
(image_area_sum / page_area).min(1.0),
(largest_image_area / page_area).min(1.0),
)
} else {
(0.0, 0.0)
};
let text_bbox_area: f32 = page
.text_items
.iter()
.filter(|item| !is_unusable_native(item))
.map(|item| item.width * item.height)
.sum();
let text_coverage = if page_area > 0.0 {
text_bbox_area / page_area
} else {
0.0
};
let sparse_text = text_length < 2000 && text_coverage < 0.15;
let is_garbled = page_is_garbled(page);
let mut reasons = Vec::new();
if text_length < MIN_NATIVE_TEXT_LEN {
reasons.push(if full_page_image {
ComplexityReason::Scanned
} else {
ComplexityReason::NoText
});
if page_obj.has_annotation_text() {
reasons.push(ComplexityReason::AnnotationText);
}
} else if sparse_text {
reasons.push(ComplexityReason::SparseText);
}
if has_images {
reasons.push(ComplexityReason::EmbeddedImages);
}
if is_garbled {
reasons.push(ComplexityReason::Garbled);
}
let mut needs_ocr = !reasons.is_empty();
let uncovered_vector_area = if !needs_ocr {
let path_bounds = page_obj.filled_path_bounds(3.0, 0.9);
let uncovered = uncovered_path_area(&path_bounds, &page.text_items);
if uncovered >= UNCOVERED_VECTOR_AREA_THRESHOLD {
needs_ocr = true;
reasons.push(ComplexityReason::VectorText);
}
Some(uncovered)
} else {
None
};
Ok(PageComplexityStats {
page_number: page.page_number,
text_length,
text_coverage,
has_substantial_images: has_images,
image_block_count: image_bounds.len(),
image_coverage,
largest_image_coverage,
full_page_image,
uncovered_vector_area,
is_garbled,
page_area,
needs_ocr,
reasons,
layout: None,
})
}
pub fn calculate_native_page_complexity(
page: &Page,
image_rects: &[crate::types::Rect],
table_count: usize,
column_count: usize,
) -> PageComplexityStats {
let pw = page.page_width;
let ph = page.page_height;
let page_area = pw * ph;
let text_length: usize = page
.text_items
.iter()
.filter(|item| !is_unusable_native(item))
.map(|item| item.text.len())
.sum();
let sized: Vec<&crate::types::Rect> = image_rects
.iter()
.filter(|r| r.width >= MIN_IMAGE_SIZE_PT && r.height >= MIN_IMAGE_SIZE_PT)
.collect();
let is_full_page = |r: &crate::types::Rect| {
r.width > pw * MAX_IMAGE_PAGE_COVERAGE && r.height > ph * MAX_IMAGE_PAGE_COVERAGE
};
let full_page_image = sized.iter().any(|r| is_full_page(r));
let counted: Vec<&crate::types::Rect> =
sized.iter().copied().filter(|r| !is_full_page(r)).collect();
let has_images = !counted.is_empty();
let (image_area_sum, largest_image_area) =
counted.iter().fold((0.0_f32, 0.0_f32), |(sum, max), r| {
let area = r.width.max(0.0) * r.height.max(0.0);
(sum + area, max.max(area))
});
let (image_coverage, largest_image_coverage) = if page_area > 0.0 {
(
(image_area_sum / page_area).min(1.0),
(largest_image_area / page_area).min(1.0),
)
} else {
(0.0, 0.0)
};
let text_bbox_area: f32 = page
.text_items
.iter()
.filter(|item| !is_unusable_native(item))
.map(|item| item.width * item.height)
.sum();
let text_coverage = if page_area > 0.0 {
text_bbox_area / page_area
} else {
0.0
};
let sparse_text = text_length < 2000 && text_coverage < 0.15;
let is_garbled = page_is_garbled(page);
let mut reasons = Vec::new();
if text_length < MIN_NATIVE_TEXT_LEN {
reasons.push(if full_page_image {
ComplexityReason::Scanned
} else {
ComplexityReason::NoText
});
} else if sparse_text {
reasons.push(ComplexityReason::SparseText);
}
if has_images {
reasons.push(ComplexityReason::EmbeddedImages);
}
if is_garbled {
reasons.push(ComplexityReason::Garbled);
}
let needs_ocr = !reasons.is_empty();
let uncovered_vector_area = (!needs_ocr).then_some(0.0);
let all_image_area: f32 = image_rects
.iter()
.map(|r| r.width.max(0.0) * r.height.max(0.0))
.sum();
let figure_coverage = if page_area > 0.0 && all_image_area > 0.0 {
(all_image_area / page_area).min(1.0)
} else {
0.0
};
let column_count = column_count.max(1);
let mut layout_reasons = Vec::new();
if column_count >= 2 {
layout_reasons.push(LayoutComplexityReason::MultiColumn);
}
if table_count > 0 {
layout_reasons.push(LayoutComplexityReason::TableLikely);
}
if figure_coverage >= DENSE_GRAPHICS_MIN_COVERAGE {
layout_reasons.push(LayoutComplexityReason::DenseGraphics);
}
let layout = LayoutComplexityStats {
column_count,
ruled_table_count: table_count,
ruled_table_coverage: 0.0,
text_table_run_count: 0,
figure_count: image_rects.len(),
figure_coverage,
is_complex: !layout_reasons.is_empty(),
reasons: layout_reasons,
};
PageComplexityStats {
page_number: page.page_number,
text_length,
text_coverage,
has_substantial_images: has_images,
image_block_count: counted.len(),
image_coverage,
largest_image_coverage,
full_page_image,
uncovered_vector_area,
is_garbled,
page_area,
needs_ocr,
reasons,
layout: Some(layout),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutComplexityReason {
MultiColumn,
TableLikely,
DenseGraphics,
}
impl LayoutComplexityReason {
pub fn as_str(&self) -> &'static str {
match self {
LayoutComplexityReason::MultiColumn => "multi-column",
LayoutComplexityReason::TableLikely => "table-likely",
LayoutComplexityReason::DenseGraphics => "dense-graphics",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutComplexityStats {
pub column_count: usize,
pub ruled_table_count: usize,
pub ruled_table_coverage: f32,
pub text_table_run_count: usize,
pub figure_count: usize,
pub figure_coverage: f32,
pub is_complex: bool,
pub reasons: Vec<LayoutComplexityReason>,
}
pub(crate) fn calculate_layout_complexity(page: &ParsedPage) -> LayoutComplexityStats {
let page_area = page.page_width * page.page_height;
let total_items = page.text_items.len();
let column_count = count_columns(&page.regions, total_items).max(1);
let table_rects = crate::markdown_layout::validated_ruled_table_rects(
&page.projected_lines,
&page.graphics,
page.page_width,
page.page_height,
);
let text_table_run_count = crate::markdown_layout::count_text_table_runs(&page.projected_lines);
let table_area: f32 = table_rects
.iter()
.map(|r| r.width.max(0.0) * r.height.max(0.0))
.sum();
let figure_area: f32 = page
.figures
.iter()
.map(|r| r.width.max(0.0) * r.height.max(0.0))
.sum();
let coverage = |area: f32| {
if page_area > 0.0 && area > 0.0 {
(area / page_area).min(1.0)
} else {
0.0
}
};
let ruled_table_coverage = coverage(table_area);
let figure_coverage = coverage(figure_area);
let mut reasons = Vec::new();
if column_count >= 2 {
reasons.push(LayoutComplexityReason::MultiColumn);
}
if !table_rects.is_empty() || text_table_run_count > 0 {
reasons.push(LayoutComplexityReason::TableLikely);
}
if figure_coverage >= DENSE_GRAPHICS_MIN_COVERAGE {
reasons.push(LayoutComplexityReason::DenseGraphics);
}
LayoutComplexityStats {
column_count,
ruled_table_count: table_rects.len(),
ruled_table_coverage,
text_table_run_count,
figure_count: page.figures.len(),
figure_coverage,
is_complex: !reasons.is_empty(),
reasons,
}
}
fn region_item_count(region: &Region) -> usize {
match ®ion.kind {
RegionKind::Leaf { item_indices } => item_indices.len(),
RegionKind::Split { children, .. } => children.iter().map(region_item_count).sum(),
}
}
fn count_columns(region: &Region, total_items: usize) -> usize {
let min_items =
MIN_COLUMN_ITEMS.max((total_items as f32 * MIN_COLUMN_ITEM_FRACTION).ceil() as usize);
if region_item_count(region) < min_items {
return 0;
}
match ®ion.kind {
RegionKind::Leaf { .. } => 1,
RegionKind::Split { axis, children } => {
let counts = children.iter().map(|c| count_columns(c, total_items));
match axis {
CutAxis::Vertical => counts.sum(),
CutAxis::Horizontal => counts.max().unwrap_or(0),
}
}
}
}
pub(crate) const MAX_OCR_RENDER_LONG_EDGE_PX: f32 = 4096.0;
pub fn render_pages_for_ocr(
document: &Document,
pages: &[Page],
start: usize,
options: &OcrRenderOptions,
) -> Result<(Vec<OcrRaster>, usize), LiteParseError> {
render_pages_for_ocr_capped(document, pages, start, options, options.max_rasters)
}
pub(crate) fn render_pages_for_ocr_capped(
document: &Document,
pages: &[Page],
start: usize,
options: &OcrRenderOptions,
max_rasters: usize,
) -> Result<(Vec<OcrRaster>, usize), LiteParseError> {
let OcrRenderOptions {
max_rasters: _,
dpi,
grayscale,
render_form_fields,
continue_on_page_error,
reflatten_pages: flatten_page_numbers,
selection,
} = options;
let (dpi, grayscale, render_form_fields, continue_on_page_error) = (
*dpi,
*grayscale,
*render_form_fields,
*continue_on_page_error,
);
let mut rendered = Vec::new();
let form = render_form_fields
.then(|| document.form_environment())
.flatten();
if let Some(form) = form.as_ref() {
form.run_document_actions();
}
let mut next_start = pages.len();
for (idx, page) in pages.iter().enumerate().skip(start) {
if let Some(selection) = selection
&& !selection.contains(&(page.page_number as u32))
{
continue;
}
let page_render = (|| -> Result<Option<OcrRaster>, LiteParseError> {
let page_index = (page.page_number - 1) as i32;
let page_obj = if flatten_page_numbers.contains(&(page.page_number as u32)) {
match document.flatten_form_widgets(page_index)? {
Some(flattened_page) => flattened_page,
None => document.page(page_index)?,
}
} else {
document.page(page_index)?
};
let has_native_text = match selection {
Some(_) => {
native_text_length(page) >= MIN_NATIVE_TEXT_LEN && !page_is_garbled(page)
}
None => {
let page_complexity = calculate_page_complexity(page, &page_obj)?;
if !page_complexity.needs_ocr {
return Ok(None);
}
!page_complexity.reasons.iter().any(|r| {
matches!(
r,
ComplexityReason::Scanned
| ComplexityReason::NoText
| ComplexityReason::Garbled
| ComplexityReason::AnnotationText
)
})
}
};
let long_edge_pt = page.page_width.max(page.page_height);
let mut eff_dpi = dpi;
if long_edge_pt > 0.0 {
let max_dpi = MAX_OCR_RENDER_LONG_EDGE_PX * 72.0 / long_edge_pt;
if eff_dpi > max_dpi {
eff_dpi = max_dpi;
}
}
let bitmap = page_obj.render_with_form(eff_dpi, form.as_ref())?;
let width = bitmap.width() as u32;
let height = bitmap.height() as u32;
let pixels = if grayscale {
bitmap.to_luma()
} else {
bitmap.to_rgb()
};
let image_rects = if has_native_text {
let pw = page.page_width;
let ph = page.page_height;
page_obj
.image_bounds(MIN_IMAGE_SIZE_PT, f32::INFINITY)
.into_iter()
.filter(|b| {
!(b.width > pw * MAX_IMAGE_PAGE_COVERAGE
&& b.height > ph * MAX_IMAGE_PAGE_COVERAGE)
})
.map(|b| Rect {
x: b.x,
y: b.y,
width: b.width,
height: b.height,
})
.collect()
} else {
Vec::new()
};
Ok(Some(OcrRaster {
page_number: page.page_number,
pixels,
width,
height,
dpi: eff_dpi,
has_native_text,
image_rects,
}))
})();
match page_render {
Ok(Some(render)) => {
rendered.push(render);
if max_rasters > 0 && rendered.len() >= max_rasters {
next_start = idx + 1;
break;
}
}
Ok(None) => {}
Err(error) if continue_on_page_error => eprintln!(
"[ocr] render failed for page {}: {} — keeping native text without OCR (continue_on_page_error)",
page.page_number, error
),
Err(error) => return Err(error),
}
}
Ok((rendered, next_start))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageOcrOutcome {
pub page_number: usize,
pub dpi: f32,
pub has_native_text: bool,
pub image_rects: Vec<Rect>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub results: Vec<OcrResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn recognize_rasters(
rendered: Vec<OcrRaster>,
ocr_engine: Arc<dyn OcrEngine>,
ocr_language: &str,
num_workers: usize,
) -> Vec<PageOcrOutcome> {
let mut pool = OcrTaskPool::new(ocr_engine, ocr_language, num_workers);
for raster in rendered {
pool.submit(raster).await;
}
pool.finish().await
}
pub fn merge_ocr_results(
pages: &mut [Page],
outcomes: Vec<PageOcrOutcome>,
ocr_failure_fatal: bool,
emit_word_boxes: bool,
) -> Result<(), LiteParseError> {
let total_tasks = outcomes.len();
let mut failed_tasks = 0usize;
let mut failed_sparse_text_page = false;
let mut first_error: Option<String> = None;
let index_by_page_number: HashMap<usize, usize> = pages
.iter()
.enumerate()
.map(|(idx, page)| (page.page_number, idx))
.collect();
for outcome in outcomes {
let PageOcrOutcome {
page_number,
dpi: page_dpi,
has_native_text,
image_rects,
results: ocr_results,
error,
} = outcome;
let Some(&idx) = index_by_page_number.get(&page_number) else {
return Err(LiteParseError::Other(format!(
"OCR outcome for page {page_number} has no matching page among the {} page(s) being merged",
pages.len()
)));
};
if let Some(msg) = error {
failed_tasks += 1;
failed_sparse_text_page |= page_has_sparse_native_text(&pages[idx]);
if first_error.is_none() {
eprintln!("[ocr] failed for page {}: {}", page_number, msg);
first_error = Some(msg);
}
continue;
}
if ocr_results.is_empty() {
continue;
}
let scale_factor = 72.0 / page_dpi;
let page = &mut pages[idx];
match garbled_scope(page) {
GarbledScope::None => page.text_items.retain(|item| !is_unusable_native(item)),
GarbledScope::WholePage => page.text_items.clear(),
GarbledScope::Fonts(fonts) => page
.text_items
.retain(|item| !fonts.contains(&item.font_name) && !is_unusable_native(item)),
}
let native_count = page.text_items.len();
let debug_ocr = std::env::var_os("LITEPARSE_DEBUG_OCR").is_some();
let chart_images: Vec<bool> = if has_native_text {
chart_like_images(&image_rects, &ocr_results, scale_factor)
} else {
Vec::new()
};
if debug_ocr {
eprintln!(
"[ocr-merge] page {} native_items={} ocr_results={} dpi={}",
page_number,
native_count,
ocr_results.len(),
page_dpi
);
}
for r in &ocr_results {
if r.confidence <= 0.1 {
continue;
}
let (ocr_x, ocr_y, ocr_w, ocr_h, rotation) = match r.polygon {
Some(poly) => {
let xs = [poly[0][0], poly[1][0], poly[2][0], poly[3][0]];
let ys = [poly[0][1], poly[1][1], poly[2][1], poly[3][1]];
let x_min = xs.iter().copied().fold(f32::INFINITY, f32::min);
let x_max = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let y_min = ys.iter().copied().fold(f32::INFINITY, f32::min);
let y_max = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let rot = polygon_rotation_deg(&poly);
(
x_min * scale_factor,
y_min * scale_factor,
(x_max - x_min) * scale_factor,
(y_max - y_min) * scale_factor,
rot,
)
}
None => (
r.bbox[0] * scale_factor,
r.bbox[1] * scale_factor,
(r.bbox[2] - r.bbox[0]) * scale_factor,
(r.bbox[3] - r.bbox[1]) * scale_factor,
0.0,
),
};
if has_native_text && is_rule_artifact(&r.text, ocr_w, ocr_h) {
if debug_ocr {
eprintln!(
"[ocr-merge] skip(rule-artifact) {:?} w={:.1} h={:.1}",
r.text, ocr_w, ocr_h
);
}
continue;
}
if has_native_text
&& chart_images
.iter()
.zip(&image_rects)
.any(|(is_chart, rect)| {
*is_chart && box_center_in(rect, ocr_x, ocr_y, ocr_w, ocr_h)
})
{
if debug_ocr {
eprintln!("[ocr-merge] skip(chart-figure) {:?}", r.text);
}
continue;
}
let overlaps = overlaps_existing_text(
&page.text_items[..native_count],
ocr_x,
ocr_y,
ocr_w,
ocr_h,
2.0,
);
if debug_ocr {
eprintln!(
"[ocr-merge] {} conf={:.2} x={:.1} y={:.1} w={:.1} h={:.1} {:?}",
if overlaps { "skip(overlap)" } else { "ADD" },
r.confidence,
ocr_x,
ocr_y,
ocr_w,
ocr_h,
r.text
);
}
if overlaps {
continue;
}
let cleaned = clean_ocr_table_artifacts(&r.text);
if cleaned.is_empty() {
continue;
}
let font_size_hint = if rotation == 90.0 || rotation == 270.0 {
ocr_w.max(1.0)
} else {
ocr_h
};
let words = if emit_word_boxes {
vec![WordBox {
text: cleaned.clone(),
x: ocr_x,
y: ocr_y,
width: ocr_w,
height: ocr_h,
}]
} else {
Vec::new()
};
page.text_items.push(TextItem {
text: cleaned,
x: ocr_x,
y: ocr_y,
width: ocr_w,
height: ocr_h,
rotation,
font_name: Some("OCR".to_string()),
font_size: Some(font_size_hint),
confidence: Some((r.confidence * 1000.0).round() / 1000.0),
words,
..Default::default()
});
}
}
if total_tasks > 0 && failed_tasks == total_tasks && failed_sparse_text_page {
let detail = first_error.unwrap_or_else(|| "unknown error".to_string());
if ocr_failure_fatal {
return Err(LiteParseError::Ocr(format!(
"OCR failed for all {} page(s): {}",
total_tasks, detail
)));
}
eprintln!(
"[ocr] OCR failed for all {} page(s): {} — continuing with partial (native-text) results (ocr_failure_fatal=false)",
total_tasks, detail
);
}
if failed_tasks > 0 {
eprintln!(
"[ocr] {}/{} page(s) failed OCR; continuing with partial results",
failed_tasks, total_tasks
);
}
Ok(())
}
fn page_has_sparse_native_text(page: &Page) -> bool {
let text_length = native_text_length(page);
let page_area = page.page_width * page.page_height;
let text_bbox_area: f32 = page
.text_items
.iter()
.filter(|item| !is_unusable_native(item))
.map(|item| item.width * item.height)
.sum();
let text_coverage = if page_area > 0.0 {
text_bbox_area / page_area
} else {
0.0
};
text_length < MIN_NATIVE_TEXT_LEN || text_coverage < 0.15
}
const MIN_NATIVE_TEXT_LEN: usize = 20;
fn native_text_length(page: &Page) -> usize {
page.text_items
.iter()
.filter(|item| !is_unusable_native(item))
.map(|item| item.text.len())
.sum()
}
fn is_unusable_native(item: &TextItem) -> bool {
item.has_unicode_map_error || is_likely_garbled(&item.text)
}
fn uncovered_path_area(paths: &[ImageBounds], items: &[TextItem]) -> f32 {
let mut uncovered = 0.0f32;
for p in paths {
let p_area = p.width * p.height;
if p_area <= 0.0 {
continue;
}
let mut covered = 0.0f32;
for item in items {
let ix = (p.x + p.width).min(item.x + item.width) - p.x.max(item.x);
let iy = (p.y + p.height).min(item.y + item.height) - p.y.max(item.y);
if ix > 0.0 && iy > 0.0 {
covered += ix * iy;
if covered >= p_area {
break;
}
}
}
uncovered += (p_area - covered).max(0.0);
}
uncovered
}
fn is_likely_garbled(text: &str) -> bool {
let (letters, vowels) = count_letters_and_vowels(text);
if letters < 10 {
return false;
}
vowels * 10 < letters
}
const ACCENTED_VOWELS: &str = "àáâãäåāăąǎȁȃȧ\
ạảấầẩẫậắằẳẵặ\
èéêëēĕėęěȅȇ\
ẹẻẽếềểễệ\
ìíîïĩīĭįǐȉȋ\
ỉị\
òóôõöøōŏőǒȍȏȯ\
ọỏốồổỗộơớờởỡợ\
ùúûüũūŭůűųǔǖǘǚǜȕȗ\
ụủưứừửữự";
fn count_letters_and_vowels(text: &str) -> (usize, usize) {
let mut letters = 0usize;
let mut vowels = 0usize;
for ch in text.chars() {
if ch.is_ascii_alphabetic() {
letters += 1;
if matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') {
vowels += 1;
}
} else if !ch.is_ascii() && ch.to_lowercase().any(|c| ACCENTED_VOWELS.contains(c)) {
letters += 1;
vowels += 1;
}
}
(letters, vowels)
}
const GARBLE_MIN_LETTERS: usize = 30;
const GARBLE_MIN_FONT_SHARE: f32 = 0.15;
fn ratio_is_garbled(letters: usize, vowels: usize) -> bool {
letters >= GARBLE_MIN_LETTERS && vowels * 5 < letters
}
pub(crate) enum GarbledScope {
None,
WholePage,
Fonts(HashSet<Option<String>>),
}
fn garbled_scope(page: &Page) -> GarbledScope {
let mut total_letters = 0usize;
let mut total_vowels = 0usize;
let mut per_font: HashMap<Option<&str>, (usize, usize)> = HashMap::new();
for it in &page.text_items {
let (l, v) = count_letters_and_vowels(&it.text);
total_letters += l;
total_vowels += v;
let entry = per_font.entry(it.font_name.as_deref()).or_insert((0, 0));
entry.0 += l;
entry.1 += v;
}
if ratio_is_garbled(total_letters, total_vowels) {
return GarbledScope::WholePage;
}
let min_share = (total_letters as f32 * GARBLE_MIN_FONT_SHARE).ceil() as usize;
let fonts: HashSet<Option<String>> = per_font
.into_iter()
.filter(|&(_, (letters, vowels))| letters >= min_share && ratio_is_garbled(letters, vowels))
.map(|(font, _)| font.map(str::to_string))
.collect();
if fonts.is_empty() {
GarbledScope::None
} else {
GarbledScope::Fonts(fonts)
}
}
fn page_is_garbled(page: &Page) -> bool {
!matches!(garbled_scope(page), GarbledScope::None) || page_has_unmapped_text(page)
}
fn page_has_unmapped_text(page: &Page) -> bool {
let (unmapped, total) = page.text_items.iter().fold((0usize, 0usize), |(u, t), it| {
let n = it.text.chars().filter(|c| !c.is_whitespace()).count();
(u + if it.has_unicode_map_error { n } else { 0 }, t + n)
});
unmapped >= GARBLE_MIN_LETTERS && unmapped as f32 >= total as f32 * GARBLE_MIN_FONT_SHARE
}
fn polygon_rotation_deg(poly: &[[f32; 2]; 4]) -> f32 {
let e0 = [poly[1][0] - poly[0][0], poly[1][1] - poly[0][1]];
let e1 = [poly[2][0] - poly[1][0], poly[2][1] - poly[1][1]];
let len0 = (e0[0] * e0[0] + e0[1] * e0[1]).sqrt();
let len1 = (e1[0] * e1[0] + e1[1] * e1[1]).sqrt();
if len0.max(len1) < 1.0 {
return 0.0;
}
let (longer, shorter) = if len0 >= len1 {
(len0, len1)
} else {
(len1, len0)
};
if shorter > 0.0 && longer / shorter < 1.3 {
return 0.0;
}
let reading = if len0 >= len1 { e0 } else { e1 };
let angle_ccw = -reading[1].atan2(reading[0]).to_degrees();
let normalized = angle_ccw.rem_euclid(360.0);
((normalized / 90.0).round() as i32 * 90).rem_euclid(360) as f32
}
const RULE_ARTIFACT_MAX_ASPECT: f32 = 0.35;
fn is_rule_artifact(text: &str, w: f32, h: f32) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() || !trimmed.chars().any(|c| c.is_alphanumeric()) {
return true;
}
let mut chars = trimmed.chars();
let (first, rest) = (chars.next(), chars.next());
if rest.is_none() {
if let Some(c) = first {
if matches!(
c,
'|' | 'I'
| 'l'
| '1'
| '!'
| 'i'
| 'j'
| '['
| ']'
| '('
| ')'
| '{'
| '}'
| '/'
| '\\'
) && h > 0.0
&& w < RULE_ARTIFACT_MAX_ASPECT * h
{
return true;
}
}
}
false
}
const CHART_MIN_RESULTS: usize = 4;
const CHART_LABEL_FRACTION: f32 = 0.5;
fn box_center_in(rect: &Rect, x: f32, y: f32, w: f32, h: f32) -> bool {
let cx = x + w / 2.0;
let cy = y + h / 2.0;
cx >= rect.x && cx <= rect.x + rect.width && cy >= rect.y && cy <= rect.y + rect.height
}
fn is_chart_label(text: &str) -> bool {
let t = text.trim();
if t.chars().count() <= 3 && !t.chars().any(is_cjk) {
return true;
}
t.chars().any(|c| c.is_ascii_digit())
&& t.chars().all(|c| {
c.is_ascii_digit()
|| matches!(
c,
',' | '.' | '%' | '-' | '+' | '$' | '€' | '£' | ' ' | 'k' | 'K' | 'M' | 'x'
)
})
}
fn is_cjk(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{11FF}' | '\u{3040}'..='\u{30FF}' | '\u{3130}'..='\u{318F}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF66}'..='\u{FF9F}' )
}
fn chart_like_images(rects: &[Rect], results: &[OcrResult], scale_factor: f32) -> Vec<bool> {
rects
.iter()
.map(|rect| {
let (mut n, mut labels) = (0usize, 0usize);
for r in results {
let (x, y, w, h) = (
r.bbox[0] * scale_factor,
r.bbox[1] * scale_factor,
(r.bbox[2] - r.bbox[0]) * scale_factor,
(r.bbox[3] - r.bbox[1]) * scale_factor,
);
if box_center_in(rect, x, y, w, h) {
n += 1;
if is_chart_label(&r.text) {
labels += 1;
}
}
}
n >= CHART_MIN_RESULTS && labels as f32 >= CHART_LABEL_FRACTION * n as f32
})
.collect()
}
fn overlaps_existing_text(
items: &[TextItem],
ocr_x: f32,
ocr_y: f32,
ocr_w: f32,
ocr_h: f32,
tolerance: f32,
) -> bool {
for item in items {
let item_right = item.x + item.width;
let item_bottom = item.y + item.height;
let overlap_x = ocr_x < item_right + tolerance && ocr_x + ocr_w > item.x - tolerance;
let overlap_y = ocr_y < item_bottom + tolerance && ocr_y + ocr_h > item.y - tolerance;
if overlap_x && overlap_y {
return true;
}
}
false
}
fn clean_ocr_table_artifacts(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
let without_artifacts: &str = trimmed
.trim_start_matches(['|', '[', ']', '(', ')', '{', '}'])
.trim_end_matches(['|', '[', ']', '(', ')', '{', '}'])
.trim();
if without_artifacts.is_empty() {
return trimmed.to_string();
}
let is_numeric_ish = without_artifacts
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, ',' | '.' | ' ' | '%' | '-' | '+' | '*' | '/'))
|| without_artifacts == "N/A"
|| without_artifacts == "Z"
|| without_artifacts == "-";
if is_numeric_ish {
if let Some((before, after)) = trimmed.split_once(without_artifacts)
&& before.ends_with('(')
&& after.starts_with(')')
{
return format!("({without_artifacts})");
}
without_artifacts.to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Rect;
#[test]
fn rule_artifact_catches_bars_and_decoration() {
assert!(is_rule_artifact("|", 4.3, 12.5));
assert!(is_rule_artifact("I", 1.0, 7.2));
assert!(is_rule_artifact("| |", 3.0, 12.0));
assert!(is_rule_artifact("—", 20.0, 3.0));
assert!(is_rule_artifact(" ", 1.0, 1.0));
assert!(!is_rule_artifact("1", 5.0, 12.0));
assert!(!is_rule_artifact("I", 4.0, 10.0));
assert!(!is_rule_artifact("If", 1.0, 12.0));
assert!(!is_rule_artifact("N/A", 12.0, 8.0));
}
#[test]
fn chart_labels_and_chart_like_figures() {
assert!(is_chart_label("140,000"));
assert!(is_chart_label("2016"));
assert!(is_chart_label("$1.5M"));
assert!(is_chart_label("0"));
assert!(is_chart_label("Yr"));
assert!(!is_chart_label("Year"));
assert!(!is_chart_label("Fruit Production in British Columbia"));
assert!(!is_chart_label("を"));
assert!(!is_chart_label("変換"));
assert!(!is_chart_label("します"));
assert!(!is_chart_label("中文"));
assert!(!is_chart_label("한국"));
let rect = Rect {
x: 50.0,
y: 100.0,
width: 200.0,
height: 150.0,
};
let mk = |text: &str, x: f32, y: f32| OcrResult {
text: text.to_string(),
bbox: [x, y, x + 20.0, y + 8.0],
confidence: 0.9,
polygon: None,
};
let chart = vec![
mk("100", 60.0, 110.0),
mk("80", 60.0, 130.0),
mk("60", 60.0, 150.0),
mk("2016", 100.0, 240.0),
mk("Fruit Production", 120.0, 105.0),
];
assert_eq!(chart_like_images(&[rect.clone()], &chart, 1.0), vec![true]);
let prose = vec![
mk("Wash your hands", 60.0, 110.0),
mk("for twenty seconds", 60.0, 130.0),
mk("before every meal", 60.0, 150.0),
mk("2020", 100.0, 240.0),
];
assert_eq!(chart_like_images(&[rect.clone()], &prose, 1.0), vec![false]);
let japanese = vec![
mk("文書", 60.0, 110.0),
mk("を", 80.0, 110.0),
mk("変換", 100.0, 110.0),
mk("します", 120.0, 110.0),
mk("。", 140.0, 110.0),
];
assert_eq!(
chart_like_images(&[rect.clone()], &japanese, 1.0),
vec![false]
);
assert_eq!(chart_like_images(&[rect], &chart[..3], 1.0), vec![false]);
}
fn leaf(n: usize) -> Region {
Region {
bbox: Rect::default(),
kind: RegionKind::Leaf {
item_indices: (0..n).collect(),
},
}
}
fn split(axis: CutAxis, children: Vec<Region>) -> Region {
Region {
bbox: Rect::default(),
kind: RegionKind::Split { axis, children },
}
}
#[test]
fn test_count_columns_single_leaf() {
assert_eq!(count_columns(&leaf(50), 50), 1);
}
#[test]
fn test_count_columns_two_columns() {
let root = split(CutAxis::Vertical, vec![leaf(40), leaf(40)]);
assert_eq!(count_columns(&root, 80), 2);
}
#[test]
fn test_count_columns_nested_three_columns() {
let inner = split(CutAxis::Vertical, vec![leaf(30), leaf(30)]);
let root = split(CutAxis::Vertical, vec![leaf(40), inner]);
assert_eq!(count_columns(&root, 100), 3);
}
#[test]
fn test_count_columns_header_over_two_columns() {
let body = split(CutAxis::Vertical, vec![leaf(40), leaf(40)]);
let root = split(CutAxis::Horizontal, vec![leaf(20), body]);
assert_eq!(count_columns(&root, 100), 2);
}
#[test]
fn test_count_columns_thin_sidebar_not_a_column() {
let root = split(CutAxis::Vertical, vec![leaf(6), leaf(94)]);
assert_eq!(count_columns(&root, 100), 1);
}
#[test]
fn test_count_columns_narrow_table_slices_not_columns() {
let root = split(
CutAxis::Vertical,
vec![leaf(10), leaf(10), leaf(10), leaf(10)],
);
assert_eq!(count_columns(&root, 100), 0);
}
#[test]
fn test_count_columns_empty_page() {
assert_eq!(count_columns(&leaf(0), 0), 0);
}
mod native {
use super::super::*;
use crate::types::Rect;
fn item(text: &str, x: f32, y: f32, w: f32, h: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width: w,
height: h,
..TextItem::default()
}
}
fn page(items: Vec<TextItem>) -> Page {
Page {
page_number: 1,
page_label: None,
page_width: 612.0,
page_height: 792.0,
content_bounds: None,
text_items: items,
graphics: Vec::new(),
vector_graphics: None,
struct_nodes: Vec::new(),
image_refs: Vec::new(),
annotations: None,
form_fields: None,
structure_tree: None,
}
}
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn text_dense_page_is_simple_with_known_zero_vector_area() {
let items: Vec<TextItem> = (0..40)
.map(|i| item("some meaningful text", 40.0, 20.0 * i as f32, 500.0, 18.0))
.collect();
let stats = calculate_native_page_complexity(&page(items), &[], 0, 1);
assert!(!stats.needs_ocr);
assert!(stats.reasons.is_empty());
assert_eq!(stats.uncovered_vector_area, Some(0.0));
let layout = stats.layout.expect("native stats always carry layout");
assert_eq!(layout.column_count, 1);
assert!(!layout.is_complex);
}
#[test]
fn tables_columns_and_images_drive_layout_reasons() {
let items = vec![item(
"cell text and more cell text",
40.0,
40.0,
500.0,
700.0,
)];
let figures = [rect(100.0, 100.0, 400.0, 300.0)];
let stats = calculate_native_page_complexity(&page(items), &figures, 2, 2);
assert!(
stats.reasons.contains(&ComplexityReason::EmbeddedImages),
"sized inline image counts"
);
let layout = stats.layout.unwrap();
assert_eq!(layout.ruled_table_count, 2);
assert_eq!(layout.column_count, 2);
assert!(
layout
.reasons
.contains(&LayoutComplexityReason::TableLikely)
);
assert!(
layout
.reasons
.contains(&LayoutComplexityReason::MultiColumn)
);
assert!(
layout
.reasons
.contains(&LayoutComplexityReason::DenseGraphics),
"400x300 over 612x792 is ~25% coverage"
);
assert!(layout.is_complex);
}
#[test]
fn full_page_image_with_no_text_reads_as_scanned() {
let figures = [rect(0.0, 0.0, 612.0, 792.0)];
let stats = calculate_native_page_complexity(&page(Vec::new()), &figures, 0, 1);
assert!(stats.reasons.contains(&ComplexityReason::Scanned));
assert!(stats.full_page_image);
assert_eq!(
stats.image_block_count, 0,
"full-page background is not an inline figure"
);
assert_eq!(stats.uncovered_vector_area, None);
}
#[test]
fn tiny_decorations_are_ignored() {
let items: Vec<TextItem> = (0..40)
.map(|i| item("some meaningful text", 40.0, 20.0 * i as f32, 500.0, 18.0))
.collect();
let figures = [rect(40.0, 40.0, 10.0, 10.0)];
let stats = calculate_native_page_complexity(&page(items), &figures, 0, 1);
assert!(!stats.has_substantial_images);
assert!(!stats.needs_ocr);
}
}
#[test]
fn test_polygon_rotation_horizontal() {
let p = [[0.0, 0.0], [100.0, 0.0], [100.0, 20.0], [0.0, 20.0]];
assert_eq!(polygon_rotation_deg(&p), 0.0);
}
#[test]
fn test_polygon_rotation_90_ccw() {
let p = [[10.0, 100.0], [10.0, 0.0], [30.0, 0.0], [30.0, 100.0]];
assert_eq!(polygon_rotation_deg(&p), 90.0);
}
#[test]
fn test_polygon_rotation_270_ccw() {
let p = [[10.0, 0.0], [10.0, 100.0], [30.0, 100.0], [30.0, 0.0]];
assert_eq!(polygon_rotation_deg(&p), 270.0);
}
#[test]
fn test_polygon_rotation_screen_axis_vertical() {
let p = [[20.0, 50.0], [50.0, 50.0], [50.0, 750.0], [20.0, 750.0]];
let r = polygon_rotation_deg(&p);
assert!(r == 90.0 || r == 270.0, "expected 90 or 270, got {r}");
}
#[test]
fn test_polygon_rotation_near_square() {
let p = [[0.0, 0.0], [20.0, 0.0], [20.0, 22.0], [0.0, 22.0]];
assert_eq!(polygon_rotation_deg(&p), 0.0);
}
#[test]
fn test_polygon_rotation_180() {
let p = [[100.0, 20.0], [0.0, 20.0], [0.0, 0.0], [100.0, 0.0]];
assert_eq!(polygon_rotation_deg(&p), 180.0);
}
#[test]
fn test_clean_ocr_table_artifacts() {
assert_eq!(clean_ocr_table_artifacts("44520]"), "44520");
assert_eq!(clean_ocr_table_artifacts("|123"), "123");
assert_eq!(clean_ocr_table_artifacts("0.3|"), "0.3");
assert_eq!(clean_ocr_table_artifacts("(note)"), "(note)");
assert_eq!(clean_ocr_table_artifacts("|hello|"), "|hello|");
assert_eq!(clean_ocr_table_artifacts("N/A"), "N/A");
assert_eq!(clean_ocr_table_artifacts(""), "");
assert_eq!(clean_ocr_table_artifacts("|||"), "|||");
}
#[test]
fn test_clean_ocr_keeps_accounting_negatives() {
assert_eq!(clean_ocr_table_artifacts("(78,939)"), "(78,939)");
assert_eq!(clean_ocr_table_artifacts("(12.5%)"), "(12.5%)");
assert_eq!(clean_ocr_table_artifacts("(78,939)|"), "(78,939)");
assert_eq!(clean_ocr_table_artifacts("|(78,939)]"), "(78,939)");
assert_eq!(clean_ocr_table_artifacts("78,939)"), "78,939");
assert_eq!(clean_ocr_table_artifacts("(78,939"), "78,939");
}
fn make_item(x: f32, y: f32, w: f32, h: f32) -> TextItem {
TextItem {
text: "x".into(),
x,
y,
width: w,
height: h,
..Default::default()
}
}
#[test]
fn test_overlaps_existing_text_inside() {
let items = vec![make_item(10.0, 10.0, 20.0, 5.0)];
assert!(overlaps_existing_text(&items, 12.0, 11.0, 5.0, 2.0, 2.0));
}
#[test]
fn test_overlaps_existing_text_disjoint() {
let items = vec![make_item(10.0, 10.0, 20.0, 5.0)];
assert!(!overlaps_existing_text(&items, 100.0, 100.0, 5.0, 5.0, 2.0));
}
#[test]
fn test_overlaps_existing_text_tolerance() {
let items = vec![make_item(10.0, 10.0, 20.0, 5.0)];
assert!(overlaps_existing_text(&items, 31.0, 10.0, 5.0, 5.0, 2.0));
assert!(!overlaps_existing_text(&items, 35.0, 10.0, 5.0, 5.0, 2.0));
}
#[test]
fn test_overlaps_empty() {
assert!(!overlaps_existing_text(&[], 0.0, 0.0, 1.0, 1.0, 0.0));
}
fn pb(x: f32, y: f32, w: f32, h: f32) -> ImageBounds {
ImageBounds {
x,
y,
width: w,
height: h,
}
}
#[test]
fn test_uncovered_path_area_no_text() {
let paths = vec![pb(50.0, 300.0, 200.0, 12.0)];
let area = uncovered_path_area(&paths, &[]);
assert!((area - 2400.0).abs() < 1.0);
assert!(area >= UNCOVERED_VECTOR_AREA_THRESHOLD);
}
#[test]
fn test_uncovered_path_area_fully_covered_by_text() {
let paths = vec![pb(50.0, 300.0, 200.0, 12.0)];
let items = vec![make_item(40.0, 295.0, 250.0, 25.0)];
let area = uncovered_path_area(&paths, &items);
assert_eq!(area, 0.0);
}
#[test]
fn test_uncovered_path_area_partial_coverage() {
let paths = vec![pb(0.0, 0.0, 100.0, 10.0)];
let items = vec![make_item(0.0, 0.0, 50.0, 10.0)];
let area = uncovered_path_area(&paths, &items);
assert!((area - 500.0).abs() < 1.0);
}
#[test]
fn test_uncovered_path_area_small_decoration_below_threshold() {
let paths = vec![pb(10.0, 10.0, 8.0, 8.0), pb(10.0, 30.0, 8.0, 8.0)];
let area = uncovered_path_area(&paths, &[]);
assert!(area < UNCOVERED_VECTOR_AREA_THRESHOLD);
}
#[test]
fn test_unusable_native_unicode_map_error() {
let mut item = make_item(0.0, 0.0, 10.0, 10.0);
assert!(!is_unusable_native(&item));
item.has_unicode_map_error = true;
assert!(is_unusable_native(&item));
}
#[test]
fn test_clean_ocr_keeps_whitespace_trimmed() {
assert_eq!(clean_ocr_table_artifacts(" "), "");
assert_eq!(clean_ocr_table_artifacts(" 123 "), "123");
}
struct FailingEngine;
impl OcrEngine for FailingEngine {
fn name(&self) -> &str {
"failing"
}
fn recognize<'a, 'b: 'a, 'c: 'a>(
&'a self,
_image_data: &'c [u8],
_width: u32,
_height: u32,
_options: &'b OcrOptions,
) -> std::pin::Pin<
Box<
dyn Future<
Output = Result<Vec<OcrResult>, Box<dyn std::error::Error + Send + Sync>>,
> + Send
+ '_,
>,
> {
Box::pin(async move { Err("Error opening data file tessdata/eng.traineddata".into()) })
}
}
fn make_blank_page(page_number: usize) -> Page {
Page {
page_number,
page_label: None,
page_width: 100.0,
page_height: 100.0,
content_bounds: None,
text_items: Vec::new(),
graphics: Vec::new(),
vector_graphics: None,
struct_nodes: Vec::new(),
image_refs: Vec::new(),
annotations: None,
form_fields: None,
structure_tree: None,
}
}
fn make_font_page(items: &[(&str, &str)]) -> Page {
let mut page = make_blank_page(1);
page.text_items = items
.iter()
.map(|(font, text)| TextItem {
text: (*text).into(),
font_name: Some((*font).to_string()),
width: 10.0,
height: 10.0,
..Default::default()
})
.collect();
page
}
const GARBLED_ROWS: &[&str] = &[
"Tepx Nzop Opdnctaetzy Bflyetej Cpgtph Olep",
"KI-334 Cpawlnpxpye Qtwepc Lddpxmwj 44 5359-30-34",
"KI-335 Azcelmwp Dpydzc Wzfdtyr 45 5359-30-35",
"KI-336 Nlwtmcletzy Mclnvpe 46 5359-30-36",
];
const HEALTHY_PARAGRAPH: &[&str] = &[
"This fictional report is a public parser-routing fixture.",
"The upper section uses a standard font and extracts normally.",
"All names, identifiers, quantities, and dates are fabricated.",
"A PDF viewer still renders both sections as readable text.",
];
const VIETNAMESE_PARAGRAPH: &[&str] = &[
"Trường học tổ chức buổi họp phụ huynh vào sáng thứ bảy tuần trước.",
"Những người tham dự đã thảo luận về chương trình học kỳ mới.",
"Thầy hiệu trưởng nhấn mạnh việc rèn luyện thể chất cho học sinh.",
"Cuối buổi, phụ huynh được nhận tài liệu hướng dẫn chi tiết.",
];
const PINYIN_PARAGRAPH: &[&str] = &[
"Rénrén shēng ér zìyóu, zài zūnyán hé quánlì shàng yīlǜ píngděng.",
"Tāmen fùyǒu lǐxìng hé liángxīn, bìng yīng yǐ xiōngdì guānxì de jīngshén xiāng duìdài.",
];
#[test]
fn test_accented_vowels_are_not_garbled() {
for para in [VIETNAMESE_PARAGRAPH, PINYIN_PARAGRAPH] {
let items: Vec<(&str, &str)> = para.iter().map(|t| ("ArialUnicodeMS", *t)).collect();
let page = make_font_page(&items);
assert!(
matches!(garbled_scope(&page), GarbledScope::None),
"{:?}",
para[0]
);
assert!(!page_is_garbled(&page));
}
}
#[test]
fn test_count_letters_and_vowels_accented() {
assert_eq!(count_letters_and_vowels("trường"), (6, 2));
assert_eq!(
count_letters_and_vowels("tru\u{031b}o\u{031b}\u{0300}ng"),
(6, 2)
);
assert_eq!(count_letters_and_vowels("مرحبا こんにちは"), (0, 0));
}
#[test]
fn test_garbled_scope_clean_page() {
let items: Vec<(&str, &str)> = HEALTHY_PARAGRAPH
.iter()
.map(|t| ("Helvetica", *t))
.collect();
let page = make_font_page(&items);
assert!(matches!(garbled_scope(&page), GarbledScope::None));
assert!(!page_is_garbled(&page));
}
#[test]
fn test_garbled_scope_whole_page() {
let items: Vec<(&str, &str)> = GARBLED_ROWS.iter().map(|t| ("BadFont", *t)).collect();
let page = make_font_page(&items);
assert!(matches!(garbled_scope(&page), GarbledScope::WholePage));
}
#[test]
fn test_garbled_scope_mixed_fonts_flags_only_bad_font() {
let mut items: Vec<(&str, &str)> = HEALTHY_PARAGRAPH
.iter()
.map(|t| ("Helvetica", *t))
.collect();
items.extend(GARBLED_ROWS.iter().map(|t| ("BitstreamVeraSans-Roman", *t)));
let page = make_font_page(&items);
let (letters, vowels) = page.text_items.iter().fold((0, 0), |(l, v), it| {
let (il, iv) = count_letters_and_vowels(&it.text);
(l + il, v + iv)
});
assert!(!ratio_is_garbled(letters, vowels));
match garbled_scope(&page) {
GarbledScope::Fonts(fonts) => {
assert_eq!(fonts.len(), 1);
assert!(fonts.contains(&Some("BitstreamVeraSans-Roman".to_string())));
}
_ => panic!("expected only the corrupt font group to be flagged"),
}
assert!(page_is_garbled(&page));
}
#[test]
fn test_page_is_garbled_unmapped_text() {
let mut page = make_font_page(&[
(
"T3Font",
"\u{E001}\u{E002}\u{E003}\u{E004}\u{E005}\u{E006}\u{E007}\u{E008}",
),
(
"T3Font",
"\u{E011}\u{E012}\u{E013}\u{E014}\u{E015}\u{E016}\u{E017}\u{E018}",
),
(
"T3Font",
"\u{E021}\u{E022}\u{E023}\u{E024}\u{E025}\u{E026}\u{E027}\u{E028}",
),
(
"T3Font",
"\u{E031}\u{E032}\u{E033}\u{E034}\u{E035}\u{E036}\u{E037}\u{E038}",
),
]);
for it in &mut page.text_items {
it.has_unicode_map_error = true;
}
assert!(matches!(garbled_scope(&page), GarbledScope::None));
assert_eq!(native_text_length(&page), 0);
assert!(page_is_garbled(&page));
}
#[test]
fn test_page_is_garbled_ignores_stray_unmapped_glyph() {
let mut items: Vec<(&str, &str)> = HEALTHY_PARAGRAPH
.iter()
.map(|t| ("Helvetica", *t))
.collect();
items.push(("T3Font", "\u{E001}"));
let mut page = make_font_page(&items);
page.text_items.last_mut().unwrap().has_unicode_map_error = true;
assert!(!page_is_garbled(&page));
}
#[test]
fn test_garbled_scope_ignores_small_low_vowel_font() {
let mut items: Vec<(&str, &str)> = HEALTHY_PARAGRAPH
.iter()
.map(|t| ("Helvetica", *t))
.collect();
items.push(("TickerFont", "NYSE NASDAQ FTSE DAX SPX"));
let page = make_font_page(&items);
assert!(matches!(garbled_scope(&page), GarbledScope::None));
}
#[test]
fn test_garbled_scope_without_font_names() {
let mut page = make_blank_page(1);
page.text_items = GARBLED_ROWS
.iter()
.map(|text| TextItem {
text: (*text).into(),
font_name: None,
..Default::default()
})
.collect();
assert!(matches!(garbled_scope(&page), GarbledScope::WholePage));
}
fn make_rendered(idx: usize) -> OcrRaster {
OcrRaster {
has_native_text: false,
image_rects: Vec::new(),
page_number: idx + 1,
pixels: vec![0u8],
width: 1,
height: 1,
dpi: 72.0,
}
}
async fn ocr_and_merge_rendered(
pages: &mut [Page],
rendered: Vec<OcrRaster>,
engine: Arc<dyn OcrEngine>,
language: &str,
num_workers: usize,
ocr_failure_fatal: bool,
emit_word_boxes: bool,
) -> Result<(), LiteParseError> {
let outcomes = recognize_rasters(rendered, engine, language, num_workers).await;
merge_ocr_results(pages, outcomes, ocr_failure_fatal, emit_word_boxes)
}
fn make_native_text_page(page_number: usize) -> Page {
Page {
page_number,
page_label: None,
page_width: 100.0,
page_height: 100.0,
content_bounds: None,
text_items: vec![TextItem {
text: "this page already has real native text content".into(),
x: 0.0,
y: 0.0,
width: 50.0,
height: 50.0,
..Default::default()
}],
graphics: Vec::new(),
vector_graphics: None,
struct_nodes: Vec::new(),
image_refs: Vec::new(),
annotations: None,
form_fields: None,
structure_tree: None,
}
}
fn make_low_coverage_text_page(page_number: usize) -> Page {
Page {
page_number,
page_label: None,
page_width: 100.0,
page_height: 100.0,
content_bounds: None,
text_items: vec![TextItem {
text: "small native header that is not enough".into(),
x: 0.0,
y: 0.0,
width: 10.0,
height: 5.0,
..Default::default()
}],
graphics: Vec::new(),
vector_graphics: None,
struct_nodes: Vec::new(),
image_refs: Vec::new(),
annotations: None,
form_fields: None,
structure_tree: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_all_pages_fail_returns_error() {
let mut pages = vec![make_blank_page(1), make_blank_page(2)];
let rendered = vec![make_rendered(0), make_rendered(1)];
let engine: Arc<dyn OcrEngine> = Arc::new(FailingEngine);
let result =
ocr_and_merge_rendered(&mut pages, rendered, engine, "eng", 2, true, false).await;
let err = result.expect_err("expected systemic OCR failure to be surfaced");
let msg = err.to_string();
assert!(
msg.contains("OCR failed for all 2 page(s)"),
"unexpected error message: {msg}"
);
assert!(
msg.contains("traineddata"),
"error should carry the underlying cause: {msg}"
);
}
struct ReverseLatencyEngine;
impl OcrEngine for ReverseLatencyEngine {
fn name(&self) -> &str {
"reverse-latency"
}
fn recognize<'a, 'b: 'a, 'c: 'a>(
&'a self,
_image_data: &'c [u8],
_width: u32,
_height: u32,
options: &'b OcrOptions,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = OcrTaskResult> + Send + 'a>>
{
let dpi = options.dpi;
Box::pin(async move {
let delay = (100.0 - dpi).max(0.0) as u64 * 5;
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
Err(format!("dpi {dpi}").into())
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recognize_returns_submission_order() {
let rendered = (0..5)
.map(|i| OcrRaster {
dpi: 72.0 + i as f32 * 4.0,
..make_rendered(i)
})
.collect();
let engine: Arc<dyn OcrEngine> = Arc::new(ReverseLatencyEngine);
let outcomes = recognize_rasters(rendered, engine, "eng", 2).await;
let got: Vec<(usize, String)> = outcomes
.iter()
.map(|o| (o.page_number, o.error.clone().unwrap_or_default()))
.collect();
let want: Vec<(usize, String)> = (0..5)
.map(|i| (i + 1, format!("dpi {}", 72.0 + i as f32 * 4.0)))
.collect();
assert_eq!(got, want);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_empty_pool_complete_one_does_not_wait() {
let mut pool = OcrTaskPool::new(Arc::new(FailingEngine), "eng", 2);
assert!(!pool.complete_one().await);
assert!(pool.finish().await.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_rendered_pages_is_ok() {
let mut pages = vec![make_blank_page(1)];
let engine: Arc<dyn OcrEngine> = Arc::new(FailingEngine);
let result =
ocr_and_merge_rendered(&mut pages, Vec::new(), engine, "eng", 2, true, false).await;
assert!(result.is_ok(), "empty OCR set should succeed: {result:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_native_text_pages_not_failed_on_ocr_error() {
let mut pages = vec![make_native_text_page(1), make_native_text_page(2)];
let rendered = vec![make_rendered(0), make_rendered(1)];
let engine: Arc<dyn OcrEngine> = Arc::new(FailingEngine);
let result =
ocr_and_merge_rendered(&mut pages, rendered, engine, "eng", 2, true, false).await;
assert!(
result.is_ok(),
"OCR failure on already-native-text pages must not abort the parse: {result:?}"
);
assert_eq!(pages[0].text_items.len(), 1);
assert_eq!(pages[1].text_items.len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mixed_failure_with_sparse_text_page_returns_error() {
let mut pages = vec![make_native_text_page(1), make_blank_page(2)];
let rendered = vec![make_rendered(0), make_rendered(1)];
let engine: Arc<dyn OcrEngine> = Arc::new(FailingEngine);
let result =
ocr_and_merge_rendered(&mut pages, rendered, engine, "eng", 2, true, false).await;
let err = result.expect_err("a text-starved page losing all OCR must surface an error");
assert!(
err.to_string().contains("OCR failed for all 2 page(s)"),
"unexpected error message: {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_low_coverage_text_page_failure_returns_error() {
let mut pages = vec![make_low_coverage_text_page(1)];
let rendered = vec![make_rendered(0)];
let engine: Arc<dyn OcrEngine> = Arc::new(FailingEngine);
let result =
ocr_and_merge_rendered(&mut pages, rendered, engine, "eng", 2, true, false).await;
let err = result.expect_err("low-coverage text page losing OCR must surface an error");
assert!(
err.to_string().contains("OCR failed for all 1 page(s)"),
"unexpected error message: {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_non_fatal_systemic_failure_returns_partial() {
let mut pages = vec![make_native_text_page(1), make_blank_page(2)];
let rendered = vec![make_rendered(0), make_rendered(1)];
let engine: Arc<dyn OcrEngine> = Arc::new(FailingEngine);
let result =
ocr_and_merge_rendered(&mut pages, rendered, engine, "eng", 2, false, false).await;
assert!(
result.is_ok(),
"non-fatal mode must not abort on systemic OCR failure: {result:?}"
);
assert_eq!(pages[0].text_items.len(), 1);
}
#[test]
fn test_ocr_items_carry_word_boxes_when_enabled() {
let outcome = |page_number: usize| PageOcrOutcome {
page_number,
dpi: 72.0,
has_native_text: false,
image_rects: Vec::new(),
results: vec![
OcrResult {
text: "hello".into(),
bbox: [10.0, 20.0, 60.0, 30.0],
confidence: 0.9,
polygon: None,
},
OcrResult {
text: "world".into(),
bbox: [62.0, 20.0, 110.0, 30.0],
confidence: 0.9,
polygon: None,
},
],
error: None,
};
let mut enabled = vec![make_blank_page(1)];
merge_ocr_results(&mut enabled, vec![outcome(1)], false, true).unwrap();
assert_eq!(enabled[0].text_items.len(), 2);
for item in &enabled[0].text_items {
assert_eq!(
item.words.len(),
1,
"OCR item {:?} should carry one word box",
item.text
);
let word = &item.words[0];
assert_eq!(word.text, item.text);
assert_eq!(
(word.x, word.y, word.width, word.height),
(item.x, item.y, item.width, item.height),
"the word box is the result's own box"
);
}
let mut disabled = vec![make_blank_page(1)];
merge_ocr_results(&mut disabled, vec![outcome(1)], false, false).unwrap();
assert_eq!(disabled[0].text_items.len(), 2);
assert!(disabled[0].text_items.iter().all(|i| i.words.is_empty()));
}
}