#[cfg(all(any(feature = "ocr", feature = "ocr-wasm"), feature = "layout-detection"))]
use super::document::assemble_ocr_page_paragraphs;
#[cfg(all(
any(feature = "ocr", feature = "ocr-pipeline"),
any(
feature = "ocr",
feature = "ocr-wasm",
feature = "pdf",
not(feature = "layout-detection")
)
))]
use super::document::resolved_ocr_layout_dimensions;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use super::document::{
accepted_ocr_page_replacements, apply_ocr_page_replacements, apply_ocr_text_list_fallback,
fill_unstructured_ocr_pages, heuristically_restructured_ocr_pages,
};
#[cfg(all(
any(feature = "ocr", feature = "ocr-pipeline"),
any(feature = "ocr", feature = "ocr-wasm", not(feature = "layout-detection"))
))]
use super::document::{apply_ocr_layout_content_filter, ocr_points_per_pixel};
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
use super::document::{
build_mixed_ocr_page_document, build_pipeline_ocr_page_document, formula_bbox_to_page_points,
ocr_margin_filter_capability_warning, public_ocr_elements_for_pdf_page, rescale_ocr_bboxes_to_page_points,
should_use_document_processing, split_document_global_ocr_structure_by_page, undo_auto_rotate_point,
};
#[cfg(all(
any(feature = "ocr", feature = "ocr-pipeline"),
feature = "pdf",
feature = "layout-detection"
))]
use super::document::{detection_for_mixed_route_page, single_stage_pipeline_for_layout};
#[cfg(all(
any(feature = "ocr", feature = "ocr-pipeline"),
feature = "pdf",
any(feature = "ocr", feature = "ocr-wasm", not(feature = "layout-detection"))
))]
use super::document::{filter_ocr_paragraphs_by_page_margins, ocr_paragraphs_plain_text};
#[cfg(all(feature = "layout-detection", any(feature = "ocr", feature = "ocr-wasm")))]
use super::document::{
recognized_table_to_public_table, scale_detection_to_dimensions, scale_detection_to_ocr_coordinates,
transform_ocr_elements_to_render_space,
};
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use super::rendering::EncodedPage;
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
use super::rendering::{
XObjectRecoveryOutcome, clone_rgb_for_png_encode, fallback_render_document, open_pdf_for_full_ocr,
open_pdf_for_page_ocr, page_dimensions_pt, page_needs_xobject_fallback, recover_page_text_from_image_xobjects,
render_full_pdf_ocr_batch, render_selected_pages_from_document, share_rendered_page_images, valid_page_indices,
validate_png_encode_batch_peak, xobject_fallback_warning,
};
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use super::scoring::{
NativeTextStats, OcrPageNoiseVerdict, accept_or_reject_ocr_page, compute_quality_score, mean_text_conf_of,
page_ocr_confidence, pipeline_stage_score, repair_ocr_list_markers, word_count_of,
};
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use std::borrow::Cow;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use crate::core::config::ExtractionConfig;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use crate::core::config::OcrQualityThresholds;
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(crate) async fn extract_mixed_ocr_native(
native_text: &str,
boundaries: &[crate::types::PageBoundary],
ocr_page_numbers: &[u32],
content: &[u8],
config: &ExtractionConfig,
_path: Option<&std::path::Path>,
) -> crate::Result<(
String,
ahash::AHashMap<u32, String>,
ahash::AHashMap<u32, crate::types::internal::InternalDocument>,
Vec<crate::types::LlmUsage>,
Option<Vec<crate::types::ExtractedImage>>,
Vec<crate::types::Formula>,
ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
ahash::AHashMap<u32, crate::types::page::PageOcrConfidence>,
Vec<crate::types::ProcessingWarning>,
)> {
let ocr_set: std::collections::HashSet<u32> = ocr_page_numbers
.iter()
.copied()
.filter(|&p| {
if p == 0 {
tracing::warn!("force_ocr_pages contains 0; page numbers are 1-indexed, ignoring");
false
} else {
true
}
})
.collect();
if ocr_set.is_empty() {
return Ok((
native_text.to_string(),
ahash::AHashMap::new(),
ahash::AHashMap::new(),
Vec::new(),
None,
Vec::new(),
ahash::AHashMap::new(),
ahash::AHashMap::new(),
Vec::new(),
));
}
let mut page_indices: Vec<usize> = ocr_set.iter().map(|&p| (p - 1) as usize).collect();
page_indices.sort_unstable();
let (render_doc, page_count, page_rotations) = open_pdf_for_page_ocr(content)?;
page_indices = valid_page_indices(&page_indices, page_count);
if page_indices.is_empty() {
return Ok((
native_text.to_string(),
ahash::AHashMap::new(),
ahash::AHashMap::new(),
Vec::new(),
None,
Vec::new(),
ahash::AHashMap::new(),
ahash::AHashMap::new(),
Vec::new(),
));
}
#[cfg(feature = "layout-detection")]
let (layout_detections_for_mixed, layout_pass_warning, layout_pass_glyph_drop_warnings): (
Option<Vec<crate::layout::DetectionResult>>,
Option<crate::types::ProcessingWarning>,
Vec<crate::types::ProcessingWarning>,
) = if let Some(layout_config) = config.resolved_layout_config() {
let layout_thread_budget = crate::core::config::concurrency::resolve_thread_budget(config.concurrency.as_ref());
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
match super::super::layout_runner::run_layout_for_ocr(
content,
layout_config.as_ref(),
layout_thread_budget,
security_limits,
)
.await
{
Ok((
super::super::layout_runner::LayoutAttempt {
output:
super::super::layout_runner::LayoutRunOutput {
data: Some((_, _, _, detections)),
..
},
warning,
..
},
glyph_drop_warnings,
)) => (Some(detections), warning, glyph_drop_warnings),
Ok((
super::super::layout_runner::LayoutAttempt {
output: super::super::layout_runner::LayoutRunOutput { data: None, .. },
warning,
..
},
glyph_drop_warnings,
)) => {
tracing::info!(
"OCR layout (mixed route): auto gate skipped every page, continuing without layout assembly"
);
(None, warning, glyph_drop_warnings)
}
Err(error) => {
tracing::warn!(
error = %error,
"OCR layout detection failed for mixed OCR route; continuing without layout assembly"
);
(
None,
Some(super::super::layout_runner::layout_failure_warning(&error)),
Vec::new(),
)
}
}
} else {
(None, None, Vec::new())
};
#[cfg(feature = "layout-detection")]
let mixed_route_layout_active = layout_detections_for_mixed.is_some();
#[cfg(not(feature = "layout-detection"))]
let mixed_route_layout_active = false;
use image::ImageEncoder;
use image::codecs::png::PngEncoder;
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
use rayon::prelude::*;
use std::io::Cursor;
use std::sync::Arc;
let default_ocr_config = crate::core::config::OcrConfig::default();
let mut ocr_config_resolved = config.ocr.as_ref().unwrap_or(&default_ocr_config).clone();
if ocr_config_resolved.acceleration.is_none() {
ocr_config_resolved.acceleration = config.acceleration.clone();
}
if ocr_config_resolved.security_limits.is_none() {
ocr_config_resolved.security_limits = config.security_limits.clone();
}
let batch_size = crate::core::config::concurrency::resolve_thread_budget(config.concurrency.as_ref());
let capture_rasters = config.images.as_ref().is_some_and(|c| c.include_page_rasters);
let ocr_config_owned = ensure_elements_enabled(&ocr_config_resolved);
let effective_pipeline = if ocr_config_owned.vlm_fallback != crate::core::config::VlmFallbackPolicy::Disabled
|| ocr_config_owned.pipeline.is_some()
{
ocr_config_owned.effective_pipeline()
} else if mixed_route_layout_active {
#[cfg(feature = "layout-detection")]
{
Some(single_stage_pipeline_for_layout(&ocr_config_owned))
}
#[cfg(not(feature = "layout-detection"))]
{
None
}
} else {
None
};
let backend = if effective_pipeline.is_none() {
let registry = crate::plugins::registry::get_ocr_backend_registry();
let registry = registry.read();
Some(registry.get(&ocr_config_owned.backend)?)
} else {
None
};
let total = page_indices.len();
let mut ocr_results: ahash::AHashMap<u32, String> = ahash::AHashMap::with_capacity(total);
let mut page_mean_confidence: ahash::AHashMap<u32, f64> = ahash::AHashMap::new();
let mut page_dictionary_invalid_word_ratio: ahash::AHashMap<u32, f64> = ahash::AHashMap::new();
let mut page_word_count: ahash::AHashMap<u32, u32> = ahash::AHashMap::new();
let mut ocr_confidence_by_page: ahash::AHashMap<u32, crate::types::page::PageOcrConfidence> =
ahash::AHashMap::new();
let mut structured_ocr_pages: ahash::AHashMap<u32, crate::types::internal::InternalDocument> =
ahash::AHashMap::with_capacity(total);
let mut ocr_page_paragraphs: ahash::AHashMap<u32, Vec<crate::pdf::structure::types::PdfParagraph>> =
ahash::AHashMap::with_capacity(total);
let mut accumulated_llm_usage: Vec<crate::types::LlmUsage> = Vec::new();
let mut accumulated_formulas: Vec<crate::types::Formula> = Vec::new();
let mut accumulated_warnings: Vec<crate::types::ProcessingWarning> = Vec::new();
#[cfg(feature = "layout-detection")]
{
if let Some(warning) = layout_pass_warning {
crate::core::diagnostics::push_warning_deduped(&mut accumulated_warnings, warning);
}
for warning in layout_pass_glyph_drop_warnings {
crate::core::diagnostics::push_warning_deduped(&mut accumulated_warnings, warning);
}
}
let mut captured_rasters: Vec<crate::types::ExtractedImage> = Vec::new();
let mut preprocessing_by_page: ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata> =
ahash::AHashMap::new();
for batch_start in (0..total).step_by(batch_size) {
let batch_end = (batch_start + batch_size).min(total);
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
let page_images = render_selected_pages_from_document(
&render_doc,
&page_rotations,
&page_indices[batch_start..batch_end],
security_limits,
)?;
if let Some(ref pipeline) = effective_pipeline {
let page_images = share_rendered_page_images(page_images);
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
{
let mut join_set = tokio::task::JoinSet::new();
for (page_idx, image) in &page_images {
let image_arc = Arc::clone(image);
let pipeline_clone = pipeline.clone();
let config_clone = config.clone();
let idx = *page_idx;
let page_rotation_degrees = page_rotations.get(*page_idx).copied().unwrap_or(0);
let points_per_pixel_override = {
let (_, page_height_pt) = page_dimensions_pt(&render_doc, *page_idx);
let image_height_px = image_arc.height();
(image_height_px > 0).then(|| page_height_pt / image_height_px as f32)
};
#[cfg(feature = "layout-detection")]
let page_detection: Option<crate::layout::DetectionResult> =
detection_for_mixed_route_page(layout_detections_for_mixed.as_deref(), *page_idx).cloned();
join_set.spawn(async move {
#[cfg(feature = "layout-detection")]
let page_detection_slice = page_detection.as_ref().map(std::slice::from_ref);
let result = Box::pin(run_ocr_pipeline_for_page(
None,
Some(std::slice::from_ref(image_arc.as_ref())),
#[cfg(feature = "layout-detection")]
page_detection_slice,
&config_clone,
&pipeline_clone,
None,
page_rotation_degrees,
true,
points_per_pixel_override,
idx,
))
.await;
(idx, result)
});
}
while let Some(join_result) = join_set.join_next().await {
let (page_idx, result) = join_result.map_err(|e| crate::XbergError::Plugin {
message: format!("OCR pipeline task panicked: {}", e),
plugin_name: "ocr".to_string(),
})?;
let (
text,
tables,
elements,
doc,
usage,
page_texts,
_rasters,
formulas,
mut page_raw_paragraphs,
preprocessing,
page_ocr_confidences,
) = result?;
accumulated_llm_usage.extend(usage);
ocr_confidence_by_page.extend(page_ocr_confidences);
let page_number = (page_idx + 1) as u32;
if let Some(metadata) = preprocessing.into_values().next() {
preprocessing_by_page.insert(page_number, metadata);
}
if let Some(paragraphs) = page_raw_paragraphs.pop().filter(|p| !p.is_empty()) {
ocr_page_paragraphs.insert(page_number, paragraphs);
}
let page_dims = page_images
.iter()
.find(|(i, _)| *i == page_idx)
.map(|(_, img)| (img.width(), img.height()));
for mut formula in formulas {
formula.page = Some(page_number);
if let Some((w, h)) = page_dims {
formula_bbox_to_page_points(&mut formula, &render_doc, page_idx, None, w, h);
}
accumulated_formulas.push(formula);
}
let page_text = page_texts.into_iter().next().unwrap_or(text);
let raster_size_px = page_images
.iter()
.find(|(rendered_page, _)| *rendered_page == page_idx)
.map_or((0, 0), |(_, image)| (image.width(), image.height()));
if let Some(mut d) = build_pipeline_ocr_page_document(
doc,
tables,
elements,
&page_text,
page_number,
raster_size_px,
page_dimensions_pt(&render_doc, page_idx),
) {
crate::core::diagnostics::dedup_extend_warnings(
&mut accumulated_warnings,
std::mem::take(&mut d.processing_warnings),
);
structured_ocr_pages.insert(page_number, d);
}
ocr_results.insert(page_number, page_text);
}
}
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
{
for (page_idx, image) in &page_images {
let page_rotation_degrees = page_rotations.get(*page_idx).copied().unwrap_or(0);
let points_per_pixel_override = {
let (_, page_height_pt) = page_dimensions_pt(&render_doc, *page_idx);
let image_height_px = image.height();
(image_height_px > 0).then(|| page_height_pt / image_height_px as f32)
};
#[cfg(feature = "layout-detection")]
let page_detection: Option<&crate::layout::DetectionResult> =
detection_for_mixed_route_page(layout_detections_for_mixed.as_deref(), *page_idx);
let (
text,
tables,
elements,
doc,
usage,
page_texts,
_rasters,
formulas,
mut page_raw_paragraphs,
preprocessing,
page_ocr_confidences,
) = Box::pin(run_ocr_pipeline_for_page(
None,
Some(std::slice::from_ref(image.as_ref())),
#[cfg(feature = "layout-detection")]
page_detection.map(std::slice::from_ref),
config,
pipeline,
None,
page_rotation_degrees,
true,
points_per_pixel_override,
*page_idx,
))
.await?;
accumulated_llm_usage.extend(usage);
ocr_confidence_by_page.extend(page_ocr_confidences);
let page_number = (*page_idx + 1) as u32;
if let Some(metadata) = preprocessing.into_values().next() {
preprocessing_by_page.insert(page_number, metadata);
}
if let Some(paragraphs) = page_raw_paragraphs.pop().filter(|p| !p.is_empty()) {
ocr_page_paragraphs.insert(page_number, paragraphs);
}
for mut formula in formulas {
formula.page = Some(page_number);
formula_bbox_to_page_points(
&mut formula,
&render_doc,
*page_idx,
None,
image.width(),
image.height(),
);
accumulated_formulas.push(formula);
}
let page_text = page_texts.into_iter().next().unwrap_or(text);
if let Some(mut d) = build_pipeline_ocr_page_document(
doc,
tables,
elements,
&page_text,
page_number,
(image.width(), image.height()),
page_dimensions_pt(&render_doc, *page_idx),
) {
crate::core::diagnostics::dedup_extend_warnings(
&mut accumulated_warnings,
std::mem::take(&mut d.processing_warnings),
);
structured_ocr_pages.insert(page_number, d);
}
ocr_results.insert(page_number, page_text);
}
}
if capture_rasters {
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
validate_png_encode_batch_peak(
page_images.iter().map(|(_, image)| image.as_ref()),
false,
security_limits,
)?;
for (page_idx, image) in &page_images {
let rgb = clone_rgb_for_png_encode(image, security_limits)?;
let (w, h) = rgb.dimensions();
let mut buf = Cursor::new(Vec::new());
PngEncoder::new(&mut buf)
.write_image(&rgb, w, h, image::ColorType::Rgb8.into())
.map_err(|e| crate::XbergError::Parsing {
message: format!("Failed to encode page {} raster: {}", page_idx + 1, e),
source: None,
})?;
captured_rasters.push(build_page_raster_image(
*page_idx,
bytes::Bytes::from(buf.into_inner()),
w,
h,
));
}
}
continue;
}
let backend = backend
.as_ref()
.expect("backend is resolved above whenever effective_pipeline is None");
let orientation_handling = backend.page_orientation_handling();
let batch_slice = &page_images;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
validate_png_encode_batch_peak(batch_slice.iter().map(|(_, image)| image), true, security_limits)?;
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
validate_png_encode_batch_peak(batch_slice.iter().map(|(_, image)| image), false, security_limits)?;
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
let encoded: crate::Result<Vec<EncodedPage>> = batch_slice
.par_iter()
.map(|(page_idx, image)| {
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
let rgb = clone_rgb_for_png_encode(image, security_limits)?;
let (w, h) = rgb.dimensions();
let mut buf = Cursor::new(Vec::new());
PngEncoder::new(&mut buf)
.write_image(&rgb, w, h, image::ColorType::Rgb8.into())
.map_err(|e| crate::XbergError::Parsing {
message: format!("Failed to encode page {} for OCR: {}", page_idx + 1, e),
source: None,
})?;
Ok((*page_idx, Arc::new(buf.into_inner()), w, h))
})
.collect();
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
let encoded: crate::Result<Vec<EncodedPage>> = batch_slice
.iter()
.map(|(page_idx, image)| {
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
let rgb = clone_rgb_for_png_encode(image, security_limits)?;
let (w, h) = rgb.dimensions();
let mut buf = Cursor::new(Vec::new());
PngEncoder::new(&mut buf)
.write_image(&rgb, w, h, image::ColorType::Rgb8.into())
.map_err(|e| crate::XbergError::Parsing {
message: format!("Failed to encode page {} for OCR: {}", page_idx + 1, e),
source: None,
})?;
Ok((*page_idx, Arc::new(buf.into_inner()), w, h))
})
.collect();
let encoded = encoded?;
drop(page_images);
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
{
let mut join_set = tokio::task::JoinSet::new();
for (page_idx, data, width, height) in &encoded {
let backend_clone = Arc::clone(backend);
let page_rotation_degrees = page_rotations.get(*page_idx).copied().unwrap_or(0);
let source_dpi = rendered_page_source_dpi(&render_doc, *page_idx, *width);
let config_clone =
ocr_config_with_page_rotation_hint(&ocr_config_owned, page_rotation_degrees, source_dpi)
.into_owned();
let (upright_data, upright_width, upright_height, correction_degrees) = upright_raster_for_backend(
data,
*width,
*height,
page_rotation_degrees,
orientation_handling,
config.security_limits.as_ref(),
)?;
let idx = *page_idx;
join_set.spawn(async move {
let result = backend_clone.process_image_owned(upright_data, &config_clone).await;
(idx, correction_degrees, upright_width, upright_height, result)
});
}
while let Some(join_result) = join_set.join_next().await {
let (page_idx, correction_degrees, upright_width, upright_height, result) =
join_result.map_err(|e| crate::XbergError::Plugin {
message: format!("OCR task panicked: {}", e),
plugin_name: "ocr".to_string(),
})?;
let mut extraction_result = result?;
if let Some(metadata) = extraction_result.metadata.image_preprocessing.clone() {
preprocessing_by_page.insert((page_idx + 1) as u32, metadata);
}
undo_upright_raster_correction(
&mut extraction_result,
correction_degrees,
upright_width,
upright_height,
);
if let Some(usage) = extraction_result.llm_usage.take() {
accumulated_llm_usage.extend(usage);
}
let page_dims = encoded
.iter()
.find(|(encoded_page, ..)| *encoded_page == page_idx)
.map(|(_, _, w, h)| (*w, *h));
for mut formula in std::mem::take(&mut extraction_result.formulas) {
formula.page = Some((page_idx + 1) as u32);
if let Some((w, h)) = page_dims {
formula_bbox_to_page_points(
&mut formula,
&render_doc,
page_idx,
Some(&extraction_result.metadata),
w,
h,
);
}
accumulated_formulas.push(formula);
}
crate::core::diagnostics::dedup_extend_warnings(
&mut accumulated_warnings,
std::mem::take(&mut extraction_result.processing_warnings),
);
let (width, height) = encoded
.iter()
.find(|(encoded_page, ..)| *encoded_page == page_idx)
.map_or((0, 0), |(_, _, w, h)| (*w, *h));
let (page_width_pt, page_height_pt) = page_dimensions_pt(&render_doc, page_idx);
if let Some((mut page_doc, paragraphs)) = build_mixed_ocr_page_document(
&mut extraction_result,
&ocr_config_resolved,
(page_idx + 1) as u32,
width,
height,
page_width_pt,
page_height_pt,
crate::pdf::native::text::PageMarginFractions::from_extraction_config(Some(config)),
) {
crate::core::diagnostics::dedup_extend_warnings(
&mut accumulated_warnings,
std::mem::take(&mut page_doc.processing_warnings),
);
if !paragraphs.is_empty() {
ocr_page_paragraphs.insert((page_idx + 1) as u32, paragraphs);
}
structured_ocr_pages.insert((page_idx + 1) as u32, page_doc);
}
if let Some(conf) = mean_text_conf_of(&extraction_result.metadata.additional) {
page_mean_confidence.insert((page_idx + 1) as u32, conf);
}
if let Some(words) = word_count_of(&extraction_result.metadata.additional) {
page_word_count.insert((page_idx + 1) as u32, words);
}
if let Some(ratio) = extraction_result
.metadata
.additional
.get(crate::ocr_metadata_keys::OCR_TESSERACT_DICT_INVALID_WORD_RATIO_METADATA_KEY)
.and_then(serde_json::Value::as_f64)
{
page_dictionary_invalid_word_ratio.insert((page_idx + 1) as u32, ratio);
}
ocr_results.insert((page_idx + 1) as u32, extraction_result.content);
}
}
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
{
for (page_idx, data, width, height) in &encoded {
let page_rotation_degrees = page_rotations.get(*page_idx).copied().unwrap_or(0);
let source_dpi = rendered_page_source_dpi(&render_doc, *page_idx, *width);
let config_for_page =
ocr_config_with_page_rotation_hint(&ocr_config_owned, page_rotation_degrees, source_dpi);
let (upright_data, upright_width, upright_height, correction_degrees) = upright_raster_for_backend(
data,
*width,
*height,
page_rotation_degrees,
orientation_handling,
config.security_limits.as_ref(),
)?;
let mut extraction_result = backend
.process_image(upright_data.as_slice(), config_for_page.as_ref())
.await?;
if let Some(metadata) = extraction_result.metadata.image_preprocessing.clone() {
preprocessing_by_page.insert((*page_idx + 1) as u32, metadata);
}
undo_upright_raster_correction(
&mut extraction_result,
correction_degrees,
upright_width,
upright_height,
);
if let Some(usage) = extraction_result.llm_usage.take() {
accumulated_llm_usage.extend(usage);
}
for mut formula in std::mem::take(&mut extraction_result.formulas) {
formula.page = Some((*page_idx + 1) as u32);
formula_bbox_to_page_points(
&mut formula,
&render_doc,
*page_idx,
Some(&extraction_result.metadata),
*width,
*height,
);
accumulated_formulas.push(formula);
}
crate::core::diagnostics::dedup_extend_warnings(
&mut accumulated_warnings,
std::mem::take(&mut extraction_result.processing_warnings),
);
let (page_width_pt, page_height_pt) = page_dimensions_pt(&render_doc, *page_idx);
if let Some((mut page_doc, paragraphs)) = build_mixed_ocr_page_document(
&mut extraction_result,
&ocr_config_resolved,
(*page_idx + 1) as u32,
*width,
*height,
page_width_pt,
page_height_pt,
crate::pdf::native::text::PageMarginFractions::from_extraction_config(Some(config)),
) {
crate::core::diagnostics::dedup_extend_warnings(
&mut accumulated_warnings,
std::mem::take(&mut page_doc.processing_warnings),
);
if !paragraphs.is_empty() {
ocr_page_paragraphs.insert((*page_idx + 1) as u32, paragraphs);
}
structured_ocr_pages.insert((*page_idx + 1) as u32, page_doc);
}
if let Some(conf) = mean_text_conf_of(&extraction_result.metadata.additional) {
page_mean_confidence.insert((*page_idx + 1) as u32, conf);
}
if let Some(words) = word_count_of(&extraction_result.metadata.additional) {
page_word_count.insert((*page_idx + 1) as u32, words);
}
if let Some(ratio) = extraction_result
.metadata
.additional
.get(crate::ocr_metadata_keys::OCR_TESSERACT_DICT_INVALID_WORD_RATIO_METADATA_KEY)
.and_then(serde_json::Value::as_f64)
{
page_dictionary_invalid_word_ratio.insert((*page_idx + 1) as u32, ratio);
}
ocr_results.insert((*page_idx + 1) as u32, extraction_result.content);
}
}
if capture_rasters {
for (page_idx, png_arc, w, h) in &encoded {
let png_bytes = bytes::Bytes::copy_from_slice(png_arc.as_ref());
captured_rasters.push(build_page_raster_image(*page_idx, png_bytes, *w, *h));
}
}
}
let ocr_output_thresholds = config
.ocr
.as_ref()
.and_then(|ocr| ocr.quality_thresholds.clone())
.unwrap_or_default();
if let Some(producing_backend) = backend.as_ref() {
let confidence_semantics = producing_backend.confidence_semantics();
let producing_backend_name = producing_backend.name().to_string();
for (page_number, text) in &mut ocr_results {
let confidence = page_mean_confidence.get(page_number).copied();
let dictionary_ratio = page_dictionary_invalid_word_ratio.get(page_number).copied();
tracing::debug!(page = *page_number, ?confidence, "OCR page mean confidence");
if let Some(summary) = page_ocr_confidence(
confidence_semantics,
confidence,
page_word_count.get(page_number).copied().unwrap_or(0),
&producing_backend_name,
) {
ocr_confidence_by_page.insert(*page_number, summary);
}
let acceptance = accept_or_reject_ocr_page(
(*page_number as usize).saturating_sub(1),
std::mem::take(text),
&ocr_output_thresholds,
&mut accumulated_warnings,
dictionary_ratio,
confidence_semantics,
confidence,
);
*text = acceptance.content;
}
}
for text in ocr_results.values_mut() {
if let std::borrow::Cow::Owned(repaired) = repair_ocr_list_markers(text) {
*text = repaired;
}
}
let accepted_replacements =
accepted_ocr_page_replacements(native_text, boundaries, &ocr_results, &ocr_output_thresholds);
structured_ocr_pages.retain(|page, _| accepted_replacements.contains_key(page));
retain_ocr_formulas_for_accepted_pages(&mut accumulated_formulas, &accepted_replacements);
if !structured_ocr_pages.is_empty() {
let mut pages_for_heuristic: Vec<Vec<crate::pdf::structure::types::PdfParagraph>> =
vec![Vec::new(); page_count];
for (&page_number, paragraphs) in &ocr_page_paragraphs {
if structured_ocr_pages.contains_key(&page_number)
&& let Some(slot) = pages_for_heuristic.get_mut((page_number - 1) as usize)
{
*slot = paragraphs.clone();
}
}
let tables_for_heuristic: Vec<crate::types::Table> = structured_ocr_pages
.values()
.flat_map(|doc| doc.tables.iter().cloned())
.collect();
let mut ocr_page_numbers: Vec<u32> = structured_ocr_pages.keys().copied().collect();
ocr_page_numbers.sort_unstable();
let page_heights_for_heuristic = (0..page_count)
.map(|page_index| page_dimensions_pt(&render_doc, page_index).1)
.collect::<Vec<_>>();
let mut split_pages = heuristically_restructured_ocr_pages(
&pages_for_heuristic,
&page_heights_for_heuristic,
&tables_for_heuristic,
config,
)
.map(|combined_doc| split_document_global_ocr_structure_by_page(combined_doc, &ocr_page_numbers))
.unwrap_or_default();
for page_number in &ocr_page_numbers {
let Some(existing) = structured_ocr_pages.get(page_number) else {
continue;
};
let new_page_doc = match split_pages.remove(page_number) {
Some(mut new_page_doc) => {
new_page_doc.prebuilt_ocr_elements = existing.prebuilt_ocr_elements.clone();
new_page_doc.processing_warnings = existing.processing_warnings.clone();
new_page_doc
}
None => {
let mut paragraphs = pages_for_heuristic
.get((*page_number - 1) as usize)
.cloned()
.unwrap_or_default();
apply_ocr_text_list_fallback(&mut paragraphs);
let page_rotation_degrees = page_rotations.get((*page_number - 1) as usize).copied().unwrap_or(0);
crate::pdf::structure::adapters::reattach_detached_ocr_list_markers(
&mut paragraphs,
page_rotation_degrees,
);
let mut new_page_doc = crate::pdf::structure::assemble_internal_document(
vec![paragraphs],
&existing.tables,
Some(&existing.images),
&[],
);
new_page_doc.prebuilt_ocr_elements = existing.prebuilt_ocr_elements.clone();
new_page_doc.processing_warnings = existing.processing_warnings.clone();
new_page_doc
}
};
structured_ocr_pages.insert(*page_number, new_page_doc);
}
}
let result = apply_ocr_page_replacements(native_text, boundaries, &accepted_replacements);
Ok((
result,
accepted_replacements,
structured_ocr_pages,
accumulated_llm_usage,
if capture_rasters { Some(captured_rasters) } else { None },
accumulated_formulas,
preprocessing_by_page,
ocr_confidence_by_page,
accumulated_warnings,
))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) async fn extract_with_ocr(
content: Option<&[u8]>,
images: Option<&[image::DynamicImage]>,
#[cfg(feature = "layout-detection")] layout_detections: Option<&[crate::layout::DetectionResult]>,
config: &ExtractionConfig,
path: Option<&std::path::Path>,
) -> crate::Result<(
String,
Option<f64>,
Vec<crate::types::Table>,
Vec<crate::types::OcrElement>,
Option<crate::types::internal::InternalDocument>,
Vec<crate::types::LlmUsage>,
Vec<String>,
Option<Vec<crate::types::ExtractedImage>>,
Vec<crate::types::Formula>,
ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
ahash::AHashMap<u32, crate::types::page::PageOcrConfidence>,
)> {
let (
text,
mean_conf,
tables,
elements,
doc,
usage,
page_texts,
rasters,
formulas,
_raw_page_paragraphs,
preprocessing,
ocr_confidence,
_recognition_noise_verdicts,
) = Box::pin(extract_with_ocr_for_page(
content,
images,
#[cfg(feature = "layout-detection")]
layout_detections,
config,
path,
0,
false,
None,
0,
))
.await?;
Ok((
text,
mean_conf,
tables,
elements,
doc,
usage,
page_texts,
rasters,
formulas,
preprocessing,
ocr_confidence,
))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[allow(clippy::too_many_arguments)]
pub(super) async fn extract_with_ocr_for_page(
content: Option<&[u8]>,
images: Option<&[image::DynamicImage]>,
#[cfg(feature = "layout-detection")] layout_detections: Option<&[crate::layout::DetectionResult]>,
config: &ExtractionConfig,
path: Option<&std::path::Path>,
page_rotation_override: u32,
skip_document_global_heuristic: bool,
points_per_pixel_override: Option<f32>,
page_index_offset: usize,
) -> crate::Result<(
String,
Option<f64>,
Vec<crate::types::Table>,
Vec<crate::types::OcrElement>,
Option<crate::types::internal::InternalDocument>,
Vec<crate::types::LlmUsage>,
Vec<String>,
Option<Vec<crate::types::ExtractedImage>>,
Vec<crate::types::Formula>,
Vec<Vec<crate::pdf::structure::types::PdfParagraph>>,
ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
ahash::AHashMap<u32, crate::types::page::PageOcrConfidence>,
Vec<OcrPageNoiseVerdict>,
)> {
use crate::plugins::registry::get_ocr_backend_registry;
use image::ImageEncoder;
use image::codecs::png::PngEncoder;
use std::io::Cursor;
crate::plugins::ensure_ocr_backends_initialized();
#[cfg(all(feature = "layout-detection", not(feature = "ocr"), not(feature = "ocr-wasm")))]
let _ = points_per_pixel_override;
let default_ocr_config = crate::core::config::OcrConfig::default();
let base_ocr_config = config.ocr.as_ref().unwrap_or(&default_ocr_config);
let accel_ocr_config;
let base_ocr_config = if (base_ocr_config.acceleration.is_none() && config.acceleration.is_some())
|| (base_ocr_config.security_limits.is_none() && config.security_limits.is_some())
{
accel_ocr_config = {
let mut c = base_ocr_config.clone();
if c.acceleration.is_none() {
c.acceleration = config.acceleration.clone();
}
if c.security_limits.is_none() {
c.security_limits = config.security_limits.clone();
}
c
};
&accel_ocr_config
} else {
base_ocr_config
};
let backend = {
let registry = get_ocr_backend_registry();
let registry = registry.read();
registry.get(&base_ocr_config.backend)?
};
let backend_confidence_semantics = backend.confidence_semantics();
let backend_name = backend.name().to_string();
let backend_confidence_scale = match backend_confidence_semantics {
crate::plugins::ConfidenceSemantics::Legibility { scale_max } if scale_max > 0.0 => Some(scale_max),
_ => None,
};
#[cfg(feature = "pdf")]
let orientation_handling = backend.page_orientation_handling();
let structured_ocr_config;
let ocr_config = {
let cfg = ensure_elements_enabled(base_ocr_config);
#[cfg(all(feature = "ocr", feature = "layout-detection"))]
let cfg = if layout_detections.is_some() || backend.emits_structured_markdown() {
inject_layout_config_to_backend(&cfg, config)
} else {
cfg
};
structured_ocr_config = cfg;
&structured_ocr_config
};
#[cfg(not(feature = "layout-detection"))]
let supports_doc = backend.supports_document_processing();
#[cfg(feature = "layout-detection")]
let supports_doc = backend.supports_document_processing() && layout_detections.is_none();
#[cfg(feature = "pdf")]
let page_margins = crate::pdf::native::text::PageMarginFractions::from_extraction_config(Some(config));
#[cfg(not(feature = "pdf"))]
let use_document_processing = supports_doc && path.is_some();
#[cfg(feature = "pdf")]
let use_document_processing = should_use_document_processing(supports_doc, path.is_some(), page_margins);
if let Some(doc_path) = path
&& use_document_processing
{
tracing::debug!(backend = %ocr_config.backend, "Using document-level OCR processing");
let result = backend.process_document(doc_path, ocr_config).await?;
let preprocessing = result
.metadata
.image_preprocessing
.clone()
.map(|metadata| ahash::AHashMap::from([(1, metadata)]))
.unwrap_or_default();
let mean_conf = backend_confidence_scale.and_then(|scale_max| {
result
.metadata
.additional
.get("mean_text_conf")
.and_then(|v| v.as_f64())
.map(|v| v / scale_max)
});
let backend_elements = result.ocr_elements.unwrap_or_default();
let ocr_elements = filter_public_ocr_elements(&backend_elements, base_ocr_config);
let llm_usage = result.llm_usage.unwrap_or_default();
let formulas = result.formulas;
let page_texts = if let Some(pages) = result.pages {
pages.into_iter().map(|p| p.content).collect()
} else {
vec![result.content.clone()]
};
return Ok((
result.content,
mean_conf,
Vec::new(),
ocr_elements,
None,
llm_usage,
page_texts,
None,
formulas,
Vec::new(),
preprocessing,
ahash::AHashMap::new(),
Vec::new(),
));
}
let capture_rasters = config.images.as_ref().is_some_and(|c| c.include_page_rasters);
let mut captured_rasters: Vec<crate::types::ExtractedImage> = Vec::new();
let mut preprocessing_by_page: ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata> =
ahash::AHashMap::new();
#[cfg(feature = "pdf")]
let lazy_pdf_render_state = if !use_document_processing && images.is_none() {
content.map(open_pdf_for_full_ocr).transpose()?
} else {
None
};
#[cfg(feature = "pdf")]
let lazy_pdf_page_count = lazy_pdf_render_state
.as_ref()
.map_or(0, |(_, page_count, _)| *page_count);
#[cfg(not(feature = "pdf"))]
let lazy_pdf_page_count = 0;
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
use rayon::prelude::*;
use std::sync::Arc;
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
use tokio::task::JoinSet;
let configured_batch_size = crate::core::config::concurrency::resolve_thread_budget(config.concurrency.as_ref());
let batch_size = if images.is_none() {
adapt_batch_size_to_memory(configured_batch_size, content.map(|b| b.len()).unwrap_or(0))
} else {
configured_batch_size
};
if batch_size < configured_batch_size {
tracing::info!(
configured = configured_batch_size,
adapted = batch_size,
"Reduced OCR batch size to fit available memory"
);
}
let mut ocr_config_owned = ocr_config.clone();
ocr_config_owned.acceleration = config.acceleration.clone();
ocr_config_owned.security_limits = config.security_limits.clone();
let total_pages = if let Some(imgs) = images {
imgs.len()
} else {
lazy_pdf_page_count
};
#[cfg(feature = "pdf")]
let external_image_page_rotations: Option<Vec<u32>> = if images.is_some() {
content.map(|c| crate::pdf::render::get_page_rotations_from_bytes(c, total_pages))
} else {
None
};
let mut page_texts = vec![String::new(); total_pages];
let mut rejected_pages = vec![false; total_pages];
let mut all_page_paragraphs: Vec<Option<Vec<crate::pdf::structure::types::PdfParagraph>>> = vec![None; total_pages];
#[cfg_attr(
all(feature = "layout-detection", not(feature = "ocr"), not(feature = "ocr-wasm")),
allow(unused_mut)
)]
let mut ocr_page_heights = vec![0.0_f32; total_pages];
#[allow(unused_mut)]
let mut collected_tables: Vec<crate::types::Table> = Vec::new();
let mut all_ocr_elements: Vec<crate::types::OcrElement> = Vec::new();
let mut accumulated_llm_usage: Vec<crate::types::LlmUsage> = Vec::new();
let mut accumulated_formulas: Vec<crate::types::Formula> = Vec::new();
let mut conf_sum: f64 = 0.0;
let mut conf_count: usize = 0;
let mut ocr_confidence_by_page: ahash::AHashMap<u32, crate::types::page::PageOcrConfidence> =
ahash::AHashMap::new();
#[cfg(feature = "pdf")]
let mut image_fallback_warnings: Vec<crate::types::ProcessingWarning> = Vec::new();
#[cfg(feature = "pdf")]
let mut xobject_recovery_budget = crate::extractors::security::SecurityBudget::from_config(config);
let mut page_backend_errors: Vec<(usize, String)> = Vec::new();
let mut page_failure_warnings: Vec<crate::types::ProcessingWarning> = Vec::new();
#[cfg(feature = "pdf")]
let mut margin_filter_warnings: Vec<crate::types::ProcessingWarning> = Vec::new();
#[cfg(feature = "pdf")]
let mut fallback_pdf_state: Option<Option<xberg_native_pdf::PdfDocument>> = None;
let ocr_output_thresholds = base_ocr_config.quality_thresholds.clone().unwrap_or_default();
let mut recognition_noise_warnings: Vec<crate::types::ProcessingWarning> = Vec::new();
let mut recognition_noise_verdicts: Vec<OcrPageNoiseVerdict> = Vec::new();
#[cfg(feature = "layout-detection")]
let mut tatr_model = if layout_detections.is_some() {
crate::layout::take_or_create_tatr(
config.resolved_layout_acceleration(),
crate::core::config::concurrency::resolve_thread_budget(config.concurrency.as_ref()),
)
} else {
None
};
for batch_start in (0..total_pages).step_by(batch_size) {
let batch_end = (batch_start + batch_size).min(total_pages);
#[allow(unused_variables)]
let (batch_slice, encoded_batch) = if let Some(imgs) = images {
let slice: Cow<'_, [image::DynamicImage]> = Cow::Borrowed(&imgs[batch_start..batch_end]);
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
validate_png_encode_batch_peak(slice.iter(), true, security_limits)?;
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
validate_png_encode_batch_peak(slice.iter(), false, security_limits)?;
#[allow(clippy::type_complexity)]
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
let encoded: crate::Result<Vec<(usize, Arc<Vec<u8>>, u32, u32)>> = slice
.par_iter()
.enumerate()
.map(|(offset, image)| {
let page_idx = batch_start + offset;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
let rgb_image = clone_rgb_for_png_encode(image, security_limits)?;
let (width, height) = rgb_image.dimensions();
let mut image_bytes = Cursor::new(Vec::new());
let encoder = PngEncoder::new(&mut image_bytes);
encoder
.write_image(&rgb_image, width, height, image::ColorType::Rgb8.into())
.map_err(|e| crate::XbergError::Parsing {
message: format!("Failed to encode image: {}", e),
source: None,
})?;
Ok((page_idx, Arc::new(image_bytes.into_inner()), width, height))
})
.collect();
#[allow(clippy::type_complexity)]
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
let encoded: crate::Result<Vec<(usize, Arc<Vec<u8>>, u32, u32)>> = slice
.iter()
.enumerate()
.map(|(offset, image)| {
let page_idx = batch_start + offset;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
let rgb_image = clone_rgb_for_png_encode(image, security_limits)?;
let (width, height) = rgb_image.dimensions();
let mut image_bytes = Cursor::new(Vec::new());
let encoder = PngEncoder::new(&mut image_bytes);
encoder
.write_image(&rgb_image, width, height, image::ColorType::Rgb8.into())
.map_err(|e| crate::XbergError::Parsing {
message: format!("Failed to encode image: {}", e),
source: None,
})?;
Ok((page_idx, Arc::new(image_bytes.into_inner()), width, height))
})
.collect();
(Some(slice), encoded?)
} else {
#[cfg(feature = "pdf")]
let encoded = {
let (doc, _, page_rotations) =
lazy_pdf_render_state
.as_ref()
.ok_or_else(|| crate::XbergError::Parsing {
message: "PDF content is required for OCR rendering but was not provided".to_string(),
source: None,
})?;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
render_full_pdf_ocr_batch(doc, page_rotations, batch_start..batch_end, security_limits)?
};
#[cfg(not(feature = "pdf"))]
let encoded: Vec<(usize, Arc<Vec<u8>>, u32, u32)> = Vec::new();
(None::<Cow<'_, [image::DynamicImage]>>, encoded)
};
let batch_count = encoded_batch.len();
let mut batch_ocr_results: Vec<Option<crate::types::ExtractedDocument>> = vec![None; batch_count];
let mut batch_page_errors: Vec<Option<String>> = vec![None; batch_count];
let mut batch_upright_correction: Vec<(u32, u32, u32)> = vec![(0, 0, 0); batch_count];
#[cfg(all(feature = "tokio-runtime", not(target_arch = "wasm32")))]
{
let mut join_set: JoinSet<(usize, u32, u32, u32, crate::Result<crate::types::ExtractedDocument>)> =
JoinSet::new();
for (page_idx, image_data, width, height) in &encoded_batch {
let backend_clone = std::sync::Arc::clone(&backend);
#[cfg(feature = "pdf")]
let page_rotation_degrees = if page_rotation_override != 0 {
page_rotation_override
} else {
lazy_pdf_render_state
.as_ref()
.and_then(|(_, _, rotations)| rotations.get(*page_idx))
.or_else(|| external_image_page_rotations.as_ref().and_then(|r| r.get(*page_idx)))
.copied()
.unwrap_or(0)
};
#[cfg(not(feature = "pdf"))]
let page_rotation_degrees: u32 = 0;
#[cfg(feature = "pdf")]
let source_dpi = lazy_pdf_render_state
.as_ref()
.and_then(|(doc, _, _)| rendered_page_source_dpi(doc, *page_idx, *width));
#[cfg(not(feature = "pdf"))]
let source_dpi: Option<f64> = None;
let config_clone =
ocr_config_with_page_rotation_hint(&ocr_config_owned, page_rotation_degrees, source_dpi)
.into_owned();
#[cfg(feature = "pdf")]
let (upright_data, upright_width, upright_height, correction_degrees) = upright_raster_for_backend(
image_data,
*width,
*height,
page_rotation_degrees,
orientation_handling,
config.security_limits.as_ref(),
)?;
#[cfg(not(feature = "pdf"))]
let (upright_data, upright_width, upright_height, correction_degrees) =
(Arc::clone(image_data), *width, *height, 0u32);
let idx = *page_idx;
join_set.spawn(async move {
let result = backend_clone.process_image_owned(upright_data, &config_clone).await;
(idx, correction_degrees, upright_width, upright_height, result)
});
}
while let Some(join_result) = join_set.join_next().await {
let (page_idx, correction_degrees, upright_width, upright_height, ocr_result) =
join_result.map_err(|e| crate::XbergError::Plugin {
message: format!("OCR task panicked: {}", e),
plugin_name: "ocr".to_string(),
})?;
batch_upright_correction[page_idx - batch_start] = (correction_degrees, upright_width, upright_height);
match ocr_result {
Ok(document) => batch_ocr_results[page_idx - batch_start] = Some(document),
Err(error) => {
tracing::warn!(
page = page_index_offset + page_idx + 1,
error = %error,
"OCR backend failed for page"
);
batch_page_errors[page_idx - batch_start] = Some(error.to_string());
batch_ocr_results[page_idx - batch_start] = Some(crate::types::ExtractedDocument::default());
}
}
}
}
#[cfg(any(not(feature = "tokio-runtime"), target_arch = "wasm32"))]
{
for (page_idx, image_data, width, height) in &encoded_batch {
#[cfg(feature = "pdf")]
let page_rotation_degrees = if page_rotation_override != 0 {
page_rotation_override
} else {
lazy_pdf_render_state
.as_ref()
.and_then(|(_, _, rotations)| rotations.get(*page_idx))
.or_else(|| external_image_page_rotations.as_ref().and_then(|r| r.get(*page_idx)))
.copied()
.unwrap_or(0)
};
#[cfg(not(feature = "pdf"))]
let page_rotation_degrees: u32 = 0;
#[cfg(feature = "pdf")]
let source_dpi = lazy_pdf_render_state
.as_ref()
.and_then(|(doc, _, _)| rendered_page_source_dpi(doc, *page_idx, *width));
#[cfg(not(feature = "pdf"))]
let source_dpi: Option<f64> = None;
let config_for_page =
ocr_config_with_page_rotation_hint(&ocr_config_owned, page_rotation_degrees, source_dpi);
#[cfg(feature = "pdf")]
let (upright_data, upright_width, upright_height, correction_degrees) = upright_raster_for_backend(
image_data,
*width,
*height,
page_rotation_degrees,
orientation_handling,
config.security_limits.as_ref(),
)?;
#[cfg(not(feature = "pdf"))]
let (upright_data, upright_width, upright_height, correction_degrees) =
(Arc::clone(image_data), *width, *height, 0u32);
let ocr_result = backend
.process_image(upright_data.as_slice(), config_for_page.as_ref())
.await;
batch_upright_correction[page_idx - batch_start] = (correction_degrees, upright_width, upright_height);
match ocr_result {
Ok(document) => batch_ocr_results[page_idx - batch_start] = Some(document),
Err(error) => {
tracing::warn!(
page = page_index_offset + page_idx + 1,
error = %error,
"OCR backend failed for page"
);
batch_page_errors[page_idx - batch_start] = Some(error.to_string());
batch_ocr_results[page_idx - batch_start] = Some(crate::types::ExtractedDocument::default());
}
}
}
}
for offset in 0..batch_count {
let page_idx = batch_start + offset;
let document_page_idx = page_index_offset + page_idx;
let document_page_number = (document_page_idx + 1) as u32;
let mut ocr_result = batch_ocr_results[offset].take().expect("OCR result missing for page");
if let Some(metadata) = ocr_result.metadata.image_preprocessing.clone() {
preprocessing_by_page.insert(document_page_number, metadata);
}
#[cfg(feature = "pdf")]
{
let (correction_degrees, upright_width, upright_height) = batch_upright_correction[offset];
undo_upright_raster_correction(&mut ocr_result, correction_degrees, upright_width, upright_height);
}
#[cfg(feature = "layout-detection")]
let _height = encoded_batch[offset].3;
if let Some(conf_val) = ocr_result
.metadata
.additional
.get("mean_text_conf")
.and_then(|v| v.as_i64())
{
conf_sum += conf_val as f64;
conf_count += 1;
}
if let Some(usage) = ocr_result.llm_usage.take() {
accumulated_llm_usage.extend(usage);
}
let mut backend_tables = std::mem::take(&mut ocr_result.tables);
for table in &mut backend_tables {
table.page_number = document_page_number;
}
#[cfg(feature = "pdf")]
if let Some((doc, _, _)) = lazy_pdf_render_state.as_ref() {
let (page_width_pt, page_height_pt) = page_dimensions_pt(doc, page_idx);
rescale_ocr_bboxes_to_page_points(
None,
&mut backend_tables,
encoded_batch[offset].2,
encoded_batch[offset].3,
page_width_pt,
page_height_pt,
);
}
collected_tables.append(&mut backend_tables);
if let Some(ref mut elems) = ocr_result.ocr_elements {
#[cfg(feature = "pdf")]
let public_elements = {
let (_, layout_height) = resolved_ocr_layout_dimensions(
&ocr_result.metadata,
encoded_batch[offset].2,
encoded_batch[offset].3,
);
let (public_elements, outcome) = public_ocr_elements_for_pdf_page(
elems,
base_ocr_config,
document_page_number,
layout_height,
page_margins,
);
if outcome.missing_geometry && !public_elements.is_empty() {
crate::core::diagnostics::push_warning_deduped(
&mut margin_filter_warnings,
ocr_margin_filter_capability_warning(),
);
}
public_elements
};
#[cfg(not(feature = "pdf"))]
let public_elements = {
for elem in elems.iter_mut() {
elem.page_number = document_page_number;
}
filter_public_ocr_elements(elems, base_ocr_config)
};
all_ocr_elements.extend(public_elements);
}
for mut formula in ocr_result.formulas {
formula.page = Some(document_page_number);
#[cfg(feature = "pdf")]
if let Some((doc, _, _)) = lazy_pdf_render_state.as_ref() {
let (w, h) = (encoded_batch[offset].2, encoded_batch[offset].3);
formula_bbox_to_page_points(&mut formula, doc, page_idx, Some(&ocr_result.metadata), w, h);
}
accumulated_formulas.push(formula);
}
#[cfg(feature = "pdf")]
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
if page_needs_xobject_fallback(&ocr_result.content, encoded_batch[offset].1.as_slice(), security_limits) {
let render_doc = match lazy_pdf_render_state.as_ref() {
Some((doc, _, _)) => Some(doc),
None => fallback_render_document(&mut fallback_pdf_state, content),
};
if let Some(render_doc) = render_doc
&& let Some(recovery) = recover_page_text_from_image_xobjects(
&backend,
render_doc,
document_page_idx,
&ocr_config_owned,
&mut xobject_recovery_budget,
)
.await?
{
let XObjectRecoveryOutcome {
text,
attempted,
images,
mut llm_usage,
mut tables,
mut formulas,
image_preprocessing,
} = recovery;
if !text.is_empty() {
ocr_result.content = text;
}
accumulated_llm_usage.append(&mut llm_usage);
collected_tables.append(&mut tables);
accumulated_formulas.append(&mut formulas);
if let Some(metadata) = image_preprocessing {
preprocessing_by_page.insert(document_page_number, metadata);
}
if capture_rasters {
captured_rasters.extend(images);
}
image_fallback_warnings.push(xobject_fallback_warning(document_page_idx, attempted));
}
}
if let Some(error) = batch_page_errors[offset].take() {
let recovered = !ocr_result.content.trim().is_empty();
page_failure_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("ocr"),
message: std::borrow::Cow::Owned(if recovered {
format!(
"OCR of page {} failed ({error}); its text was recovered from the page's \
embedded image XObjects instead.",
document_page_number
)
} else {
format!(
"OCR of page {} failed and could not be recovered: {error}",
document_page_number
)
}),
});
page_backend_errors.push((page_idx, error));
}
#[cfg(feature = "pdf")]
#[cfg_attr(
all(feature = "layout-detection", not(feature = "ocr"), not(feature = "ocr-wasm")),
allow(unused_mut)
)]
let mut margin_filtered_content: Option<String> = None;
#[cfg(feature = "pdf")]
#[cfg_attr(
all(feature = "layout-detection", not(feature = "ocr"), not(feature = "ocr-wasm")),
allow(unused_mut)
)]
let mut margin_filter_complete = false;
#[cfg(all(feature = "layout-detection", any(feature = "ocr", feature = "ocr-wasm")))]
if ocr_result.ocr_internal_document.is_some()
|| ocr_result
.ocr_elements
.as_ref()
.is_some_and(|elements| !elements.is_empty())
{
let elements = ocr_result.ocr_elements.as_deref().unwrap_or_default();
let detection = layout_detections.and_then(|detections| detections.get(page_idx));
let ocr_render_width = encoded_batch[offset].2;
let ocr_render_height = encoded_batch[offset].3;
let render_scaled_detection =
detection.map(|det| scale_detection_to_dimensions(det, ocr_render_width, ocr_render_height));
let (_, ocr_layout_height) =
resolved_ocr_layout_dimensions(&ocr_result.metadata, ocr_render_width, ocr_render_height);
ocr_page_heights[page_idx] = ocr_layout_height as f32;
let points_per_pixel = points_per_pixel_override.unwrap_or_else(|| {
ocr_points_per_pixel(
#[cfg(feature = "pdf")]
lazy_pdf_render_state.as_ref(),
page_idx,
ocr_layout_height,
)
});
let ocr_scaled_detection = detection.map(|det| {
scale_detection_to_ocr_coordinates(det, &ocr_result.metadata, ocr_render_width, ocr_render_height)
});
let render_ocr_elements = transform_ocr_elements_to_render_space(
elements,
&ocr_result.metadata,
ocr_render_width,
ocr_render_height,
);
let recognized_tables = match (render_scaled_detection.as_ref(), tatr_model.as_mut()) {
(Some(scaled_det), Some(model)) => {
let rgb = if let Some(ref slice) = batch_slice {
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
crate::extraction::image_decode::clone_dynamic_image_to_rgb8_with_security_limits(
&slice[offset],
security_limits,
)?
} else {
let png_data = &encoded_batch[offset].1;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = config.security_limits.as_ref().unwrap_or(&default_security_limits);
crate::extraction::image_decode::decode_standard_rgb8_with_security_limits(
png_data,
security_limits,
)
.map_err(|e| crate::XbergError::Parsing {
message: format!("Failed to decode PNG for TATR: {}", e),
source: None,
})?
};
crate::ocr::layout_assembly::recognize_page_tables(
&rgb,
scaled_det,
&render_ocr_elements,
model,
)
}
_ => Vec::new(),
};
for rt in &recognized_tables {
if !rt.markdown.is_empty() {
let table_index = collected_tables.len();
collected_tables.push(recognized_table_to_public_table(rt, document_page_number, table_index));
}
}
if let Some(ref ocr_doc) = ocr_result.ocr_internal_document {
#[cfg(feature = "pdf")]
let page_rotation_degrees = if page_rotation_override != 0 {
page_rotation_override
} else {
lazy_pdf_render_state
.as_ref()
.and_then(|(_, _, rotations)| rotations.get(page_idx))
.or_else(|| external_image_page_rotations.as_ref().and_then(|r| r.get(page_idx)))
.copied()
.unwrap_or(0)
};
#[cfg(not(feature = "pdf"))]
let page_rotation_degrees: u32 = 0;
let mut paragraphs = assemble_ocr_page_paragraphs(
ocr_doc,
ocr_layout_height,
ocr_scaled_detection.as_ref(),
points_per_pixel,
page_rotation_degrees,
);
apply_ocr_layout_content_filter(&mut paragraphs, config);
#[cfg(feature = "pdf")]
{
let outcome = filter_ocr_paragraphs_by_page_margins(
&mut paragraphs,
ocr_layout_height as f32,
page_margins,
);
margin_filter_complete = !outcome.missing_geometry;
if outcome.removed {
margin_filtered_content = Some(ocr_paragraphs_plain_text(¶graphs));
}
}
tracing::debug!(
page = document_page_number,
paragraphs = paragraphs.len(),
raw_content_len = ocr_result.content.len(),
"OCR page layout classification complete"
);
all_page_paragraphs[page_idx] = Some(paragraphs);
}
if capture_rasters {
let (_, png_arc, w, h) = &encoded_batch[offset];
let png_bytes = bytes::Bytes::copy_from_slice(png_arc.as_ref());
captured_rasters.push(build_page_raster_image(document_page_idx, png_bytes, *w, *h));
}
let dict_invalid_word_ratio = ocr_result
.metadata
.additional
.get(crate::ocr_metadata_keys::OCR_TESSERACT_DICT_INVALID_WORD_RATIO_METADATA_KEY)
.and_then(|v| v.as_f64());
let confidence = mean_text_conf_of(&ocr_result.metadata.additional);
if let Some(summary) = page_ocr_confidence(
backend_confidence_semantics,
confidence,
word_count_of(&ocr_result.metadata.additional).unwrap_or(0),
&backend_name,
) {
ocr_confidence_by_page.insert(document_page_number, summary);
}
#[cfg(feature = "pdf")]
if (page_margins.top != 0.0 || page_margins.bottom != 0.0)
&& !margin_filter_complete
&& !ocr_result.content.trim().is_empty()
{
crate::core::diagnostics::push_warning_deduped(
&mut margin_filter_warnings,
ocr_margin_filter_capability_warning(),
);
}
#[cfg(feature = "pdf")]
let page_content = margin_filtered_content.unwrap_or(ocr_result.content);
#[cfg(not(feature = "pdf"))]
let page_content = ocr_result.content;
let acceptance = accept_or_reject_ocr_page(
document_page_idx,
page_content,
&ocr_output_thresholds,
&mut recognition_noise_warnings,
dict_invalid_word_ratio,
backend_confidence_semantics,
confidence,
);
page_texts[page_idx] = acceptance.content;
rejected_pages[page_idx] = acceptance.discarded;
if let Some(verdict) = acceptance.verdict {
recognition_noise_verdicts.push(verdict);
}
continue;
}
#[cfg(not(feature = "layout-detection"))]
if let Some(ref ocr_doc) = ocr_result.ocr_internal_document {
let ocr_render_width = encoded_batch[offset].2;
let ocr_render_height = encoded_batch[offset].3;
let (_, ocr_layout_height) =
resolved_ocr_layout_dimensions(&ocr_result.metadata, ocr_render_width, ocr_render_height);
ocr_page_heights[page_idx] = ocr_layout_height as f32;
let points_per_pixel = points_per_pixel_override.unwrap_or_else(|| {
ocr_points_per_pixel(
#[cfg(feature = "pdf")]
lazy_pdf_render_state.as_ref(),
page_idx,
ocr_layout_height,
)
});
let font_size_scale = crate::pdf::structure::adapters::OcrFontSizeScale::uniform(points_per_pixel);
let mut paragraphs =
crate::pdf::structure::adapters::ocr_doc_to_paragraphs(ocr_doc, ocr_layout_height, font_size_scale);
apply_ocr_layout_content_filter(&mut paragraphs, config);
#[cfg(feature = "pdf")]
{
let outcome =
filter_ocr_paragraphs_by_page_margins(&mut paragraphs, ocr_layout_height as f32, page_margins);
margin_filter_complete = !outcome.missing_geometry;
if outcome.removed {
margin_filtered_content = Some(ocr_paragraphs_plain_text(¶graphs));
}
}
all_page_paragraphs[page_idx] = Some(paragraphs);
}
let _ = page_idx;
if capture_rasters {
let (_, png_arc, w, h) = &encoded_batch[offset];
let png_bytes = bytes::Bytes::copy_from_slice(png_arc.as_ref());
captured_rasters.push(build_page_raster_image(document_page_idx, png_bytes, *w, *h));
}
let dict_invalid_word_ratio = ocr_result
.metadata
.additional
.get(crate::ocr_metadata_keys::OCR_TESSERACT_DICT_INVALID_WORD_RATIO_METADATA_KEY)
.and_then(|v| v.as_f64());
let confidence = mean_text_conf_of(&ocr_result.metadata.additional);
if let Some(summary) = page_ocr_confidence(
backend_confidence_semantics,
confidence,
word_count_of(&ocr_result.metadata.additional).unwrap_or(0),
&backend_name,
) {
ocr_confidence_by_page.insert(document_page_number, summary);
}
#[cfg(feature = "pdf")]
if (page_margins.top != 0.0 || page_margins.bottom != 0.0)
&& !margin_filter_complete
&& !ocr_result.content.trim().is_empty()
{
crate::core::diagnostics::push_warning_deduped(
&mut margin_filter_warnings,
ocr_margin_filter_capability_warning(),
);
}
#[cfg(feature = "pdf")]
let page_content = margin_filtered_content.unwrap_or(ocr_result.content);
#[cfg(not(feature = "pdf"))]
let page_content = ocr_result.content;
let acceptance = accept_or_reject_ocr_page(
document_page_idx,
page_content,
&ocr_output_thresholds,
&mut recognition_noise_warnings,
dict_invalid_word_ratio,
backend_confidence_semantics,
confidence,
);
page_texts[page_idx] = acceptance.content;
rejected_pages[page_idx] = acceptance.discarded;
if let Some(verdict) = acceptance.verdict {
recognition_noise_verdicts.push(verdict);
}
}
}
#[cfg(feature = "layout-detection")]
if let Some(model) = tatr_model.take() {
crate::layout::return_tatr(model);
}
if !page_backend_errors.is_empty()
&& page_backend_errors.len() == total_pages
&& page_texts.iter().all(|text| text.trim().is_empty())
{
let (_, first_error) = &page_backend_errors[0];
return Err(crate::XbergError::Plugin {
message: format!(
"OCR failed on all {total_pages} page(s) and no text could be recovered from the pages' \
embedded images; first failure: {first_error}"
),
plugin_name: "ocr".to_string(),
});
}
let mean_text_conf = match (conf_count > 0, backend_confidence_scale) {
(true, Some(scale_max)) => Some((conf_sum / conf_count as f64) / scale_max),
_ => None,
};
let page_marker_cfg = config.pages.as_ref().filter(|p| p.insert_page_markers);
let mut result = String::new();
for (i, text) in page_texts.iter().enumerate() {
if let Some(cfg) = page_marker_cfg {
let marker = cfg
.marker_format
.replace("{page_num}", &(page_index_offset + i + 1).to_string());
result.push_str(&marker);
} else if i > 0 {
result.push_str("\n\n");
}
result.push_str(text);
}
for (page_idx, page_rejected) in rejected_pages.iter().enumerate() {
if *page_rejected {
all_page_paragraphs[page_idx] = None;
}
}
discard_ocr_elements_from_rejected_pages(&mut all_ocr_elements, &rejected_pages, page_index_offset);
discard_rejected_ocr_page_payloads(
&mut collected_tables,
&mut accumulated_formulas,
&rejected_pages,
page_index_offset,
);
fill_unstructured_ocr_pages(&mut all_page_paragraphs, &page_texts);
let (ocr_doc, raw_page_paragraphs) = {
let has_structured = all_page_paragraphs
.iter()
.any(|paragraphs| paragraphs.as_ref().is_some_and(|paragraphs| !paragraphs.is_empty()));
if has_structured {
let pages: Vec<Vec<crate::pdf::structure::types::PdfParagraph>> = all_page_paragraphs
.into_iter()
.map(|opt| opt.unwrap_or_default())
.collect();
#[cfg(feature = "layout-detection")]
let pages = {
let mut pages = pages;
crate::pdf::structure::adapters::promote_anchored_ordered_list_sequences(&mut pages);
pages
};
let doc = if skip_document_global_heuristic {
Some(crate::pdf::structure::assemble_internal_document(
pages.clone(),
&collected_tables,
None,
&[],
))
} else {
match heuristically_restructured_ocr_pages(&pages, &ocr_page_heights, &collected_tables, config) {
Some(doc) => Some(doc),
None => {
let mut fallback_pages = pages.clone();
for (page_idx, page) in fallback_pages.iter_mut().enumerate() {
apply_ocr_text_list_fallback(page);
#[cfg(feature = "pdf")]
let page_rotation_degrees = if page_rotation_override != 0 {
page_rotation_override
} else {
lazy_pdf_render_state
.as_ref()
.and_then(|(_, _, rotations)| rotations.get(page_idx))
.or_else(|| external_image_page_rotations.as_ref().and_then(|r| r.get(page_idx)))
.copied()
.unwrap_or(0)
};
#[cfg(not(feature = "pdf"))]
let page_rotation_degrees: u32 = 0;
crate::pdf::structure::adapters::reattach_detached_ocr_list_markers(
page,
page_rotation_degrees,
);
}
Some(crate::pdf::structure::assemble_internal_document(
fallback_pages,
&collected_tables,
None,
&[],
))
}
}
};
(doc, pages)
} else {
(None, Vec::new())
}
};
#[cfg(feature = "pdf")]
let ocr_doc = {
let mut warnings = image_fallback_warnings;
warnings.extend(recognition_noise_warnings);
warnings.extend(page_failure_warnings);
warnings.extend(margin_filter_warnings);
attach_ocr_fallback_warnings(ocr_doc, &result, warnings)
};
#[cfg(not(feature = "pdf"))]
let _ = (recognition_noise_warnings, page_failure_warnings);
Ok((
result,
mean_text_conf,
collected_tables,
all_ocr_elements,
ocr_doc,
accumulated_llm_usage,
page_texts,
if capture_rasters { Some(captured_rasters) } else { None },
accumulated_formulas,
raw_page_paragraphs,
preprocessing_by_page,
ocr_confidence_by_page,
recognition_noise_verdicts,
))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn build_page_raster_image(
page_idx: usize,
png_bytes: bytes::Bytes,
width: u32,
height: u32,
) -> crate::types::ExtractedImage {
crate::types::ExtractedImage {
data: png_bytes,
format: std::borrow::Cow::Borrowed("png"),
image_index: 0,
page_number: Some((page_idx + 1) as u32),
width: Some(width),
height: Some(height),
colorspace: Some("RGB".to_string()),
bits_per_component: Some(8),
is_mask: false,
description: None,
ocr_result: None,
bounding_box: None,
source_path: None,
image_kind: Some(crate::types::ImageKind::PageRaster),
kind_confidence: None,
cluster_id: None,
caption: None,
qr_codes: None,
data_base64: None,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn adapt_batch_size_to_memory(configured: usize, document_size: usize) -> usize {
let available_bytes = get_available_memory();
if available_bytes == 0 {
return configured;
}
let reserved = document_size + 512 * 1024 * 1024;
let usable = available_bytes.saturating_sub(reserved);
const PER_PAGE_ESTIMATE: usize = 150 * 1024 * 1024;
let memory_limited_batch = (usable / PER_PAGE_ESTIMATE).max(1);
let result = configured.min(memory_limited_batch);
tracing::debug!(
available_mb = available_bytes / (1024 * 1024),
usable_mb = usable / (1024 * 1024),
document_mb = document_size / (1024 * 1024),
memory_limited_batch,
configured,
result,
"OCR batch size adaptation"
);
result
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn get_available_memory() -> usize {
#[cfg(target_os = "linux")]
{
let host = read_meminfo_available();
host.min(cgroup_headroom().unwrap_or(usize::MAX))
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
if let Ok(output) = Command::new("sysctl").args(["-n", "hw.memsize"]).output()
&& let Ok(s) = std::str::from_utf8(&output.stdout)
&& let Ok(total) = s.trim().parse::<usize>()
{
return total / 2;
}
0
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
0
}
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), target_os = "linux"))]
pub(super) fn parse_meminfo_available(contents: &str) -> usize {
contents
.lines()
.find_map(|l| {
l.strip_prefix("MemAvailable:")?
.trim()
.trim_end_matches("kB")
.trim()
.parse::<usize>()
.ok()
})
.map(|kb| kb * 1024)
.unwrap_or(0)
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), target_os = "linux"))]
pub(super) fn read_meminfo_available() -> usize {
parse_meminfo_available(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default())
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), target_os = "linux"))]
pub(super) fn parse_cgroup_v2(max: &str, current: &str) -> Option<usize> {
let max = max.trim();
if max == "max" {
return None;
}
let limit = max.parse::<usize>().ok()?;
let usage = current.trim().parse::<usize>().ok()?;
Some(limit.saturating_sub(usage))
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), target_os = "linux"))]
pub(super) fn parse_cgroup_v1(limit: &str, usage: &str) -> Option<usize> {
let limit = limit.trim().parse::<usize>().ok()?;
let usage = usage.trim().parse::<usize>().ok()?;
(limit < (isize::MAX as usize)).then(|| limit.saturating_sub(usage))
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), target_os = "linux"))]
pub(super) fn cgroup_headroom() -> Option<usize> {
if let (Ok(max), Ok(cur)) = (
std::fs::read_to_string("/sys/fs/cgroup/memory.max"),
std::fs::read_to_string("/sys/fs/cgroup/memory.current"),
) {
return parse_cgroup_v2(&max, &cur);
}
let limit = std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes").ok()?;
let usage = std::fs::read_to_string("/sys/fs/cgroup/memory/memory.usage_in_bytes").ok()?;
parse_cgroup_v1(&limit, &usage)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const MIN_VLM_OVERRIDE_WORD_DENSITY_PER_1000_CHARS: f64 = 60.0;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const MIN_TOKENS_FOR_DENSITY_JUDGEMENT: usize = 10;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn meaningful_word_density_per_1000_chars(text: &str, thresholds: &OcrQualityThresholds) -> Option<f64> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let input = super::scoring::scoring_input(trimmed);
let stats = NativeTextStats::compute(&input, thresholds);
if stats.non_whitespace == 0 || stats.word_count < MIN_TOKENS_FOR_DENSITY_JUDGEMENT {
return None;
}
Some(stats.meaningful_words as f64 / stats.non_whitespace as f64 * 1000.0)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
const MIN_CJK_CHAR_RATIO_FOR_SCRIPT_ABSTAIN: f64 = 0.3;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
fn is_cjk_ideographic_or_kana(ch: char) -> bool {
matches!(ch as u32,
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x3040..=0x309F | 0x30A0..=0x30FF )
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
fn is_non_space_delimited_script(text: &str) -> bool {
let mut non_whitespace = 0usize;
let mut cjk = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
continue;
}
non_whitespace += 1;
if is_cjk_ideographic_or_kana(ch) {
cjk += 1;
}
}
non_whitespace > 0 && (cjk as f64 / non_whitespace as f64) >= MIN_CJK_CHAR_RATIO_FOR_SCRIPT_ABSTAIN
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn candidate_is_materially_degraded(
candidate_text: &str,
incumbent_text: &str,
thresholds: &OcrQualityThresholds,
) -> bool {
if is_non_space_delimited_script(incumbent_text) || is_non_space_delimited_script(candidate_text) {
return false;
}
let Some(incumbent_density) = meaningful_word_density_per_1000_chars(incumbent_text, thresholds) else {
return false;
};
if incumbent_density < MIN_VLM_OVERRIDE_WORD_DENSITY_PER_1000_CHARS {
return false;
}
let Some(candidate_density) = meaningful_word_density_per_1000_chars(candidate_text, thresholds) else {
return true;
};
candidate_density < MIN_VLM_OVERRIDE_WORD_DENSITY_PER_1000_CHARS
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn should_replace_best_effort_result(
selection: crate::core::config::OcrPipelineSelection,
best_score: Option<f64>,
best_text: Option<&str>,
candidate_text: &str,
candidate_score: f64,
thresholds: &OcrQualityThresholds,
) -> bool {
use crate::core::config::OcrPipelineSelection;
match selection {
OcrPipelineSelection::HighestScore => match best_score {
Some(best) => candidate_score > best,
None => true,
},
OcrPipelineSelection::PreferLastNonEmpty => {
if candidate_text.trim().is_empty() {
return best_score.is_none();
}
match best_text.map(str::trim).filter(|text| !text.is_empty()) {
Some(incumbent_text) => !candidate_is_materially_degraded(candidate_text, incumbent_text, thresholds),
None => true,
}
}
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn attach_ocr_pipeline_stage_warnings(
mut doc: Option<crate::types::internal::InternalDocument>,
text: &str,
unavailable_backends: &[String],
stage_failures: &[(String, String)],
) -> Option<crate::types::internal::InternalDocument> {
if unavailable_backends.is_empty() && stage_failures.is_empty() {
return doc;
}
let retained_doc = doc.get_or_insert_with(|| {
let mut doc = crate::types::internal::InternalDocument::new("pdf");
let text = crate::extraction::transform::normalize_line_endings(text);
for paragraph in text.split("\n\n").map(str::trim).filter(|text| !text.is_empty()) {
doc.push_element(crate::types::internal::InternalElement::text(
crate::types::internal::ElementKind::Paragraph,
paragraph,
0,
));
}
doc
});
for backend in unavailable_backends {
retained_doc.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("ocr_pipeline"),
message: std::borrow::Cow::Owned(format!(
"Requested OCR pipeline backend '{backend}' is unavailable and was skipped."
)),
});
}
for (backend, error) in stage_failures {
retained_doc.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("ocr_pipeline"),
message: std::borrow::Cow::Owned(format!(
"OCR fallback backend '{backend}' failed and was skipped: {error}"
)),
});
}
doc
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
pub(super) fn attach_ocr_fallback_warnings(
mut doc: Option<crate::types::internal::InternalDocument>,
text: &str,
warnings: Vec<crate::types::ProcessingWarning>,
) -> Option<crate::types::internal::InternalDocument> {
if warnings.is_empty() {
return doc;
}
let retained_doc = doc.get_or_insert_with(|| {
let mut doc = crate::types::internal::InternalDocument::new("pdf");
let text = crate::extraction::transform::normalize_line_endings(text);
for paragraph in text.split("\n\n").map(str::trim).filter(|text| !text.is_empty()) {
doc.push_element(crate::types::internal::InternalElement::text(
crate::types::internal::ElementKind::Paragraph,
paragraph,
0,
));
}
doc
});
retained_doc.processing_warnings.extend(warnings);
doc
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
pub(super) struct PipelineXObjectRecoveryOutcome {
pub(super) text: String,
pub(super) page_texts: Vec<String>,
pub(super) images: Vec<crate::types::ExtractedImage>,
pub(super) warnings: Vec<crate::types::ProcessingWarning>,
pub(super) llm_usage: Vec<crate::types::LlmUsage>,
pub(super) tables: Vec<crate::types::Table>,
pub(super) formulas: Vec<crate::types::Formula>,
pub(super) preprocessing: ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
impl PipelineXObjectRecoveryOutcome {
fn new(page_count: usize) -> Self {
Self {
text: String::new(),
page_texts: vec![String::new(); page_count],
images: Vec::new(),
warnings: Vec::new(),
llm_usage: Vec::new(),
tables: Vec::new(),
formulas: Vec::new(),
preprocessing: ahash::AHashMap::new(),
}
}
fn has_content(&self) -> bool {
self.page_texts.iter().any(|text| !text.trim().is_empty())
|| !self.tables.is_empty()
|| !self.formulas.is_empty()
}
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
pub(super) async fn collect_pipeline_xobject_pages(
backend: &std::sync::Arc<dyn crate::plugins::OcrBackend>,
doc: &xberg_native_pdf::PdfDocument,
page_count: usize,
ocr_config: &crate::core::config::OcrConfig,
budget: &mut crate::extractors::security::SecurityBudget,
) -> crate::Result<PipelineXObjectRecoveryOutcome> {
let mut outcome = PipelineXObjectRecoveryOutcome::new(page_count);
for page_idx in 0..page_count {
let Some(mut recovery) =
recover_page_text_from_image_xobjects(backend, doc, page_idx, ocr_config, budget).await?
else {
continue;
};
let recovered_payload =
!recovery.text.is_empty() || !recovery.tables.is_empty() || !recovery.formulas.is_empty();
outcome.page_texts[page_idx] = std::mem::take(&mut recovery.text);
if recovered_payload {
outcome
.warnings
.push(xobject_fallback_warning(page_idx, recovery.attempted));
}
outcome.llm_usage.append(&mut recovery.llm_usage);
outcome.tables.append(&mut recovery.tables);
outcome.formulas.append(&mut recovery.formulas);
if let Some(metadata) = recovery.image_preprocessing {
outcome.preprocessing.insert((page_idx + 1) as u32, metadata);
}
outcome.images.append(&mut recovery.images);
}
Ok(outcome)
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
pub(super) fn join_pipeline_xobject_text(
page_texts: &[String],
config: &ExtractionConfig,
budget: &mut crate::extractors::security::SecurityBudget,
) -> crate::Result<String> {
let page_marker_cfg = config.pages.as_ref().filter(|pages| pages.insert_page_markers);
let mut text = String::new();
for (page_idx, page_text) in page_texts.iter().enumerate() {
if let Some(cfg) = page_marker_cfg {
let marker = cfg.marker_format.replace("{page_num}", &(page_idx + 1).to_string());
budget.account_text(marker.len())?;
text.push_str(&marker);
} else if page_idx > 0 {
budget.account_text(2)?;
text.push_str("\n\n");
}
text.push_str(page_text);
}
Ok(text)
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
pub(super) async fn recover_pipeline_document_from_image_xobjects(
content: Option<&[u8]>,
config: &ExtractionConfig,
ocr_config: &crate::core::config::OcrConfig,
backend_name: &str,
) -> crate::Result<Option<PipelineXObjectRecoveryOutcome>> {
let Some(content) = content else {
return Ok(None);
};
let backend = {
let registry = crate::plugins::registry::get_ocr_backend_registry();
let registry = registry.read();
let Ok(backend) = registry.get(backend_name) else {
return Ok(None);
};
backend
};
let Ok((doc, page_count, _)) = open_pdf_for_full_ocr(content) else {
return Ok(None);
};
let mut budget = crate::extractors::security::SecurityBudget::from_config(config);
let mut outcome = collect_pipeline_xobject_pages(&backend, &doc, page_count, ocr_config, &mut budget).await?;
if !outcome.has_content() {
return Ok(None);
}
outcome.text = join_pipeline_xobject_text(&outcome.page_texts, config, &mut budget)?;
Ok(Some(outcome))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) async fn run_ocr_pipeline(
content: Option<&[u8]>,
images: Option<&[image::DynamicImage]>,
#[cfg(feature = "layout-detection")] layout_detections: Option<&[crate::layout::DetectionResult]>,
config: &ExtractionConfig,
pipeline: &crate::core::config::OcrPipelineConfig,
path: Option<&std::path::Path>,
) -> crate::Result<(
String,
Vec<crate::types::Table>,
Vec<crate::types::OcrElement>,
Option<crate::types::internal::InternalDocument>,
Vec<crate::types::LlmUsage>,
Vec<String>,
Option<Vec<crate::types::ExtractedImage>>,
Vec<crate::types::Formula>,
ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
ahash::AHashMap<u32, crate::types::page::PageOcrConfidence>,
)> {
let (
text,
tables,
elements,
doc,
usage,
page_texts,
rasters,
formulas,
_raw_page_paragraphs,
preprocessing,
ocr_confidence,
) = Box::pin(run_ocr_pipeline_for_page(
content,
images,
#[cfg(feature = "layout-detection")]
layout_detections,
config,
pipeline,
path,
0,
false,
None,
0,
))
.await?;
Ok((
text,
tables,
elements,
doc,
usage,
page_texts,
rasters,
formulas,
preprocessing,
ocr_confidence,
))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_ocr_pipeline_for_page(
content: Option<&[u8]>,
images: Option<&[image::DynamicImage]>,
#[cfg(feature = "layout-detection")] layout_detections: Option<&[crate::layout::DetectionResult]>,
config: &ExtractionConfig,
pipeline: &crate::core::config::OcrPipelineConfig,
path: Option<&std::path::Path>,
page_rotation_degrees: u32,
skip_document_global_heuristic: bool,
points_per_pixel_override: Option<f32>,
page_index_offset: usize,
) -> crate::Result<(
String,
Vec<crate::types::Table>,
Vec<crate::types::OcrElement>,
Option<crate::types::internal::InternalDocument>,
Vec<crate::types::LlmUsage>,
Vec<String>,
Option<Vec<crate::types::ExtractedImage>>,
Vec<crate::types::Formula>,
Vec<Vec<crate::pdf::structure::types::PdfParagraph>>,
ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
ahash::AHashMap<u32, crate::types::page::PageOcrConfidence>,
)> {
use crate::plugins::registry::get_ocr_backend_registry;
crate::plugins::ensure_ocr_backends_initialized();
let default_ocr_config = crate::core::config::OcrConfig::default();
let ocr_config = config.ocr.as_ref().unwrap_or(&default_ocr_config);
let selection = ocr_config.pipeline_selection();
let mut stages = pipeline.stages.clone();
stages.sort_by_key(|b| std::cmp::Reverse(b.priority));
let requested_backends: Vec<String> = stages.iter().map(|s| s.backend.clone()).collect();
let (available_stages, unavailable_backends): (Vec<_>, Vec<_>) = {
let registry = get_ocr_backend_registry();
let registry = registry.read();
stages
.into_iter()
.partition(|stage| registry.get(&stage.backend).is_ok())
};
let unavailable_backends = unavailable_backends
.into_iter()
.map(|stage| stage.backend)
.collect::<Vec<_>>();
if available_stages.is_empty() {
return Err(crate::XbergError::Parsing {
message: format!(
"No available OCR backends for pipeline (requested: {})",
requested_backends.join(", ")
),
source: None,
});
}
#[allow(clippy::type_complexity)]
let mut best_result: Option<(
String,
f64,
Vec<crate::types::Table>,
Vec<crate::types::OcrElement>,
Option<crate::types::internal::InternalDocument>,
Vec<String>,
Option<Vec<crate::types::ExtractedImage>>,
Vec<crate::types::Formula>,
Vec<Vec<crate::pdf::structure::types::PdfParagraph>>,
ahash::AHashMap<u32, crate::types::ImagePreprocessingMetadata>,
ahash::AHashMap<u32, crate::types::page::PageOcrConfidence>,
)> = None;
let mut accumulated_usage: Vec<crate::types::LlmUsage> = Vec::new();
let mut stage_failures: Vec<(String, String)> = Vec::new();
for stage in &available_stages {
let mut stage_ocr = ocr_config.clone();
stage_ocr.backend = stage.backend.clone();
if let Some(ref lang) = stage.language {
stage_ocr.language = lang.clone();
}
if let Some(ref tc) = stage.tesseract_config {
stage_ocr.tesseract_config = Some(tc.clone());
}
if let Some(ref pc) = stage.paddle_ocr_config {
stage_ocr.paddle_ocr_config = Some(pc.clone());
}
stage_ocr.quality_thresholds = Some(pipeline.quality_thresholds.clone());
stage_ocr.vlm_config = stage.vlm_config.clone();
stage_ocr.backend_options = stage.backend_options.clone();
let stage_config = ExtractionConfig {
ocr: Some(stage_ocr),
..config.clone()
};
tracing::debug!(
backend = %stage.backend,
priority = stage.priority,
"Pipeline: trying OCR backend"
);
let result = Box::pin(extract_with_ocr_for_page(
content,
images,
#[cfg(feature = "layout-detection")]
layout_detections,
&stage_config,
path,
page_rotation_degrees,
skip_document_global_heuristic,
points_per_pixel_override,
page_index_offset,
))
.await;
match result {
Ok((
text,
mean_conf,
stage_tables,
stage_ocr_elements,
stage_doc,
stage_llm_usage,
stage_page_texts,
stage_rasters,
stage_formulas,
stage_raw_paragraphs,
stage_preprocessing,
stage_ocr_confidence,
stage_recognition_noise_verdicts,
)) => {
let text_score = compute_quality_score(&text, &pipeline.quality_thresholds);
let score = pipeline_stage_score(text_score, mean_conf);
tracing::debug!(
backend = %stage.backend,
score,
text_score,
mean_text_conf = ?mean_conf,
threshold = pipeline.quality_thresholds.pipeline_min_quality,
"Pipeline: backend produced result"
);
for verdict in &stage_recognition_noise_verdicts {
tracing::debug!(
backend = %stage.backend,
page = verdict.page_index + 1,
fragmented_word_ratio = verdict.fragmented_word_ratio,
word_count = verdict.word_count,
mean_confidence = verdict.mean_confidence,
low_confidence = verdict.low_confidence,
fragmented_noise = verdict.fragmented_noise,
dictionary_noise = verdict.dictionary_noise,
dict_invalid_word_ratio = verdict.dict_invalid_word_ratio,
discarded = verdict.discarded,
"Pipeline: OCR recognition-noise verdict in scope at accept decision"
);
}
accumulated_usage.extend(stage_llm_usage);
if score >= pipeline.quality_thresholds.pipeline_min_quality {
let stage_doc =
attach_ocr_pipeline_stage_warnings(stage_doc, &text, &unavailable_backends, &stage_failures);
return Ok((
text,
stage_tables,
stage_ocr_elements,
stage_doc,
accumulated_usage,
stage_page_texts,
stage_rasters,
stage_formulas,
stage_raw_paragraphs,
stage_preprocessing,
stage_ocr_confidence,
));
}
let best_score = best_result.as_ref().map(|(_, best_score, ..)| *best_score);
let best_text = best_result.as_ref().map(|(text, ..)| text.as_str());
if should_replace_best_effort_result(
selection,
best_score,
best_text,
&text,
score,
&pipeline.quality_thresholds,
) {
best_result = Some((
text,
score,
stage_tables,
stage_ocr_elements,
stage_doc,
stage_page_texts,
stage_rasters,
stage_formulas,
stage_raw_paragraphs,
stage_preprocessing,
stage_ocr_confidence,
));
}
}
Err(e) => {
tracing::warn!(
backend = %stage.backend,
error = %e,
"Pipeline: backend failed, trying next"
);
stage_failures.push((stage.backend.clone(), e.to_string()));
}
}
}
match best_result {
Some((
text,
score,
tables,
elements,
doc,
page_texts,
rasters,
formulas,
raw_page_paragraphs,
preprocessing,
ocr_confidence,
)) => {
let threshold = pipeline.quality_thresholds.pipeline_min_quality;
tracing::warn!(
score,
threshold,
selection = ?selection,
"All OCR pipeline backends produced suboptimal quality, using best-effort result \
selected per the pipeline's selection policy"
);
let mut doc = doc.unwrap_or_else(|| {
let mut d = crate::types::internal::InternalDocument::new("pdf");
let text = crate::extraction::transform::normalize_line_endings(&text);
for paragraph in text.split("\n\n") {
let trimmed = paragraph.trim();
if !trimmed.is_empty() {
d.push_element(crate::types::internal::InternalElement::text(
crate::types::internal::ElementKind::Paragraph,
trimmed,
0,
));
}
}
d
});
doc.processing_warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("ocr_pipeline"),
message: std::borrow::Cow::Owned(format!(
"All OCR pipeline backends scored below the configured quality threshold \
(best score {score:.3} < {threshold:.3}); returning the best-effort result \
chosen by the pipeline's {:?} selection policy, which may be inaccurate or \
incomplete.",
selection
)),
});
let doc = attach_ocr_pipeline_stage_warnings(Some(doc), &text, &unavailable_backends, &stage_failures);
Ok((
text,
tables,
elements,
doc,
accumulated_usage,
page_texts,
rasters,
formulas,
raw_page_paragraphs,
preprocessing,
ocr_confidence,
))
}
None => {
#[cfg(feature = "pdf")]
if let Some(first_stage) = available_stages.first()
&& let Some(mut recovery) = Box::pin(recover_pipeline_document_from_image_xobjects(
content,
config,
ocr_config,
&first_stage.backend,
))
.await?
{
accumulated_usage.append(&mut recovery.llm_usage);
let doc =
attach_ocr_pipeline_stage_warnings(None, &recovery.text, &unavailable_backends, &stage_failures);
let doc = attach_ocr_fallback_warnings(doc, &recovery.text, recovery.warnings);
let capture_rasters = config.images.as_ref().is_some_and(|c| c.include_page_rasters);
return Ok((
recovery.text,
recovery.tables,
Vec::new(),
doc,
accumulated_usage,
recovery.page_texts,
if capture_rasters { Some(recovery.images) } else { None },
recovery.formulas,
Vec::new(),
recovery.preprocessing,
ahash::AHashMap::new(),
));
}
let detail = if stage_failures.is_empty() {
String::new()
} else {
let causes = stage_failures
.iter()
.map(|(backend, error)| format!("{backend}: {error}"))
.collect::<Vec<_>>()
.join("; ");
format!(" ({causes})")
};
Err(crate::XbergError::Parsing {
message: format!("All OCR pipeline backends failed{detail}"),
source: None,
})
}
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn ensure_elements_enabled(
config: &crate::core::config::ocr::OcrConfig,
) -> crate::core::config::ocr::OcrConfig {
let mut config = config.clone();
match config.element_config.as_mut() {
Some(ec) => {
ec.include_elements = true;
ec.min_level = crate::types::OcrElementLevel::Word;
}
None => {
config.element_config = Some(crate::types::OcrElementConfig {
include_elements: true,
min_level: crate::types::OcrElementLevel::Word,
..Default::default()
});
}
}
config
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn filter_public_ocr_elements(
elements: &[crate::types::OcrElement],
config: &crate::core::config::ocr::OcrConfig,
) -> Vec<crate::types::OcrElement> {
let Some(element_config) = config.element_config.as_ref() else {
return Vec::new();
};
element_config.select_elements(elements)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn discard_ocr_elements_from_rejected_pages(
elements: &mut Vec<crate::types::OcrElement>,
rejected_pages: &[bool],
page_index_offset: usize,
) {
elements.retain(|element| !ocr_page_is_rejected(element.page_number, rejected_pages, page_index_offset));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn ocr_page_is_rejected(page_number: u32, rejected_pages: &[bool], page_index_offset: usize) -> bool {
page_number
.checked_sub(1)
.and_then(|index| usize::try_from(index).ok())
.and_then(|index| index.checked_sub(page_index_offset))
.and_then(|local_index| rejected_pages.get(local_index))
.copied()
.unwrap_or(false)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn discard_rejected_ocr_page_payloads(
tables: &mut Vec<crate::types::Table>,
formulas: &mut Vec<crate::types::Formula>,
rejected_pages: &[bool],
page_index_offset: usize,
) {
tables.retain(|table| !ocr_page_is_rejected(table.page_number, rejected_pages, page_index_offset));
formulas.retain(|formula| {
formula
.page
.is_none_or(|page_number| !ocr_page_is_rejected(page_number, rejected_pages, page_index_offset))
});
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn retain_ocr_formulas_for_accepted_pages(
formulas: &mut Vec<crate::types::Formula>,
accepted_pages: &ahash::AHashMap<u32, String>,
) {
formulas.retain(|formula| {
formula
.page
.is_none_or(|page_number| accepted_pages.contains_key(&page_number))
});
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(super) fn ocr_config_with_page_rotation_hint(
config: &crate::core::config::ocr::OcrConfig,
page_rotation_degrees: u32,
source_dpi: Option<f64>,
) -> Cow<'_, crate::core::config::ocr::OcrConfig> {
let source_dpi = source_dpi.and_then(serde_json::Number::from_f64);
if page_rotation_degrees == 0 && source_dpi.is_none() {
return Cow::Borrowed(config);
}
let mut config = config.clone();
let mut opts = config.backend_options.take().unwrap_or_else(|| serde_json::json!({}));
if !opts.is_object() {
opts = serde_json::json!({});
}
if let Some(obj) = opts.as_object_mut() {
if page_rotation_degrees != 0 {
obj.insert(
"page_rotation_degrees".to_string(),
serde_json::Value::Number(page_rotation_degrees.into()),
);
}
if let Some(source_dpi) = source_dpi {
obj.insert(
crate::core::config::ocr::SOURCE_DPI_BACKEND_OPTION.to_string(),
serde_json::Value::Number(source_dpi),
);
}
}
config.backend_options = Some(opts);
Cow::Owned(config)
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(super) fn rendered_page_source_dpi(
doc: &xberg_native_pdf::PdfDocument,
page_index: usize,
rendered_width_px: u32,
) -> Option<f64> {
let (page_width_pt, _) = page_dimensions_pt(doc, page_index);
crate::pdf::render::rendered_page_dpi(rendered_width_px, page_width_pt)
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(super) fn upright_raster_for_backend(
data: &std::sync::Arc<Vec<u8>>,
width: u32,
height: u32,
page_rotation_degrees: u32,
orientation_handling: crate::plugins::PageOrientationHandling,
security_limits: Option<&crate::extractors::security::SecurityLimits>,
) -> crate::Result<(std::sync::Arc<Vec<u8>>, u32, u32, u32)> {
if page_rotation_degrees == 0 || orientation_handling != crate::plugins::PageOrientationHandling::RequiresUpright {
return Ok((std::sync::Arc::clone(data), width, height, 0));
}
let correction_degrees = page_rotation_degrees % 360;
let default_security_limits = crate::extractors::security::SecurityLimits::default();
let security_limits = security_limits.unwrap_or(&default_security_limits);
let (rotated, new_width, new_height) = crate::pdf::render::rotate_png_page_if_needed_with_security_limits(
(**data).clone(),
width,
height,
correction_degrees,
security_limits,
)?;
Ok((std::sync::Arc::new(rotated), new_width, new_height, correction_degrees))
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(super) fn undo_upright_raster_correction(
result: &mut crate::types::ExtractedDocument,
correction_degrees: u32,
upright_width: u32,
upright_height: u32,
) {
if correction_degrees == 0 {
return;
}
let correction_degrees = correction_degrees as u16;
let (processed_width, processed_height) = (f64::from(upright_width), f64::from(upright_height));
let undo_point =
|x: f64, y: f64| undo_auto_rotate_point(x, y, correction_degrees, processed_width, processed_height);
let undo_bbox = |bbox: &mut crate::types::extraction::BoundingBox| {
let (x0, y0) = undo_point(bbox.x0, bbox.y0);
let (x1, y1) = undo_point(bbox.x1, bbox.y1);
bbox.x0 = x0.min(x1);
bbox.x1 = x0.max(x1);
bbox.y0 = y0.min(y1);
bbox.y1 = y0.max(y1);
};
if let Some(doc) = result.ocr_internal_document.as_mut() {
for element in &mut doc.elements {
if let Some(bbox) = element.bbox.as_mut() {
undo_bbox(bbox);
}
}
}
for table in &mut result.tables {
if let Some(bbox) = table.bounding_box.as_mut() {
undo_bbox(bbox);
}
}
for formula in &mut result.formulas {
if let Some(bbox) = formula.bbox.as_mut() {
undo_bbox(bbox);
}
}
if let Some(elements) = result.ocr_elements.as_mut() {
for element in elements {
undo_ocr_element_geometry(&mut element.geometry, undo_point);
}
}
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(super) fn undo_ocr_element_geometry(
geometry: &mut crate::types::ocr_elements::OcrBoundingGeometry,
undo_point: impl Fn(f64, f64) -> (f64, f64),
) {
use crate::types::ocr_elements::OcrBoundingGeometry;
match geometry {
OcrBoundingGeometry::Rectangle {
left,
top,
width,
height,
} => {
let (x0, y0) = (f64::from(*left), f64::from(*top));
let (x1, y1) = (x0 + f64::from(*width), y0 + f64::from(*height));
let (nx0, ny0) = undo_point(x0, y0);
let (nx1, ny1) = undo_point(x1, y1);
let min_x = nx0.min(nx1);
let min_y = ny0.min(ny1);
let max_x = nx0.max(nx1);
let max_y = ny0.max(ny1);
*left = min_x.round().max(0.0) as u32;
*top = min_y.round().max(0.0) as u32;
*width = (max_x - min_x).round().max(0.0) as u32;
*height = (max_y - min_y).round().max(0.0) as u32;
}
OcrBoundingGeometry::Quadrilateral { points } => {
for point in points.iter_mut() {
let (x, y) = undo_point(f64::from(point.x), f64::from(point.y));
point.x = x.round().max(0.0) as u32;
point.y = y.round().max(0.0) as u32;
}
}
}
}
#[cfg(all(feature = "ocr", feature = "layout-detection"))]
pub(super) fn inject_layout_config_to_backend(
config: &crate::core::config::ocr::OcrConfig,
extraction_config: &ExtractionConfig,
) -> crate::core::config::ocr::OcrConfig {
let mut config = config.clone();
if let Some(layout_cfg) = &extraction_config.layout {
let mut opts = config.backend_options.take().unwrap_or_else(|| serde_json::json!({}));
if !opts.is_object() {
if !opts.is_null() {
tracing::warn!(
backend_options = %opts,
"backend_options was not a JSON object; replacing with new object to inject enable_chart_understanding"
);
}
opts = serde_json::json!({});
}
if let Some(obj) = opts.as_object_mut() {
obj.insert(
"enable_chart_understanding".to_string(),
serde_json::Value::Bool(layout_cfg.enable_chart_understanding),
);
}
config.backend_options = Some(opts);
}
config
}