use xberg_native_pdf::PdfDocument;
use xberg_native_pdf::document::ReadingOrder;
use xberg_native_pdf::extractors::auto::{ImageCodecClass, ProducerPrior};
use xberg_native_pdf::fonts::MappingProvenance;
use xberg_native_pdf::layout::TextSpan;
#[cfg(test)]
use crate::core::config::DEFAULT_SCANNED_MIN_CONFIDENCE;
#[cfg(test)]
thread_local! {
pub(crate) static FABRICATED_PROVENANCE_SECOND_PASS_CALLS: std::sync::atomic::AtomicUsize =
const { std::sync::atomic::AtomicUsize::new(0) };
}
const IMAGE_COVERAGE_MIN: f32 = 0.15;
const IMAGE_COVERAGE_FULL: f32 = 0.80;
const INVISIBLE_TEXT_MIN: f32 = 0.50;
const TEXT_AREA_HEADER_MAX: f32 = 0.02;
const SCORE_FULL_PAGE_RASTER: f32 = 0.50;
const SCORE_NO_VISIBLE_TEXT: f32 = 0.35;
const SCORE_BILEVEL_CODEC: f32 = 0.10;
const SCORE_SCANNER_PRODUCER: f32 = 0.05;
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PageScanSignals {
pub image_coverage: f32,
pub invisible_text_ratio: f32,
pub glyph_count: usize,
pub text_area_ratio: f32,
pub codec: ImageCodecClass,
pub producer_prior: ProducerPrior,
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ScanDetection {
pub confidence: f32,
pub page_confidence: Vec<f32>,
}
impl ScanDetection {
pub(crate) fn scanned_page_indices(&self, min_confidence: f32) -> Vec<usize> {
let threshold = min_confidence.clamp(0.0, 1.0);
self.page_confidence
.iter()
.enumerate()
.filter(|(_, score)| **score >= threshold)
.map(|(index, _)| index)
.collect()
}
}
fn is_header_sized_text(signals: &PageScanSignals) -> bool {
signals.glyph_count == 0 || signals.text_area_ratio <= TEXT_AREA_HEADER_MAX
}
pub(crate) fn score_page(signals: &PageScanSignals) -> f32 {
if signals.image_coverage < IMAGE_COVERAGE_MIN {
return 0.0;
}
let full_page = signals.image_coverage >= IMAGE_COVERAGE_FULL;
let header_sized_text = is_header_sized_text(signals);
if !full_page && !header_sized_text {
return 0.0;
}
let mut score = SCORE_FULL_PAGE_RASTER;
if header_sized_text || signals.invisible_text_ratio >= INVISIBLE_TEXT_MIN {
score += SCORE_NO_VISIBLE_TEXT;
}
if matches!(signals.codec, ImageCodecClass::Ccitt | ImageCodecClass::Jbig2) {
score += SCORE_BILEVEL_CODEC;
}
if signals.producer_prior == ProducerPrior::Scanner {
score += SCORE_SCANNER_PRODUCER;
}
score.clamp(0.0, 1.0)
}
const POINTS_PER_INCH: f64 = 72.0;
fn image_coverage(doc: &PdfDocument, page_index: usize) -> Option<f32> {
page_raster_geometry(doc, page_index).map(|(coverage, _)| coverage)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
const OCR_SCAN_COVERAGE_MIN: f32 = 0.25;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
const OCR_SCAN_MAX_GLYPHS: usize = 400;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
pub(crate) fn full_page_raster_density(doc: &PdfDocument, page_index: usize) -> Option<f64> {
let (coverage, density) = page_raster_geometry(doc, page_index)?;
if coverage >= IMAGE_COVERAGE_FULL {
return density;
}
if coverage < OCR_SCAN_COVERAGE_MIN {
return None;
}
let page_text = super::native::guard_native_panic(
|| {
doc.extract_page_text_with_options(page_index, ReadingOrder::ColumnAware)
.map_err(|error| error.to_string())
},
|message| message,
)
.ok()?;
(readable_glyph_count(&page_text.spans) <= OCR_SCAN_MAX_GLYPHS)
.then_some(density)
.flatten()
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
fn readable_glyph_count(spans: &[TextSpan]) -> usize {
spans
.iter()
.filter(|span| span.provenance != Some(MappingProvenance::Fallback))
.map(|span| span.text.chars().count())
.sum()
}
fn page_raster_geometry(doc: &PdfDocument, page_index: usize) -> Option<(f32, Option<f64>)> {
let (x0, y0, x1, y1) = doc.get_page_media_box(page_index).ok()?;
let page_area = ((x1 - x0) * (y1 - y0)).abs();
if page_area <= f32::EPSILON {
return None;
}
let (left, right) = (x0.min(x1), x0.max(x1));
let (bottom, top) = (y0.min(y1), y0.max(y1));
let handles = doc.page_image_handles(page_index).ok()?;
let mut covered = 0.0f32;
let mut largest: Option<(f32, f64)> = None;
for handle in &handles {
let bbox = &handle.bbox;
let width = ((bbox.x + bbox.width).min(right) - bbox.x.max(left)).max(0.0);
let height = ((bbox.y + bbox.height).min(top) - bbox.y.max(bottom)).max(0.0);
let visible = width * height;
covered += visible;
let painted = f64::from(bbox.width.abs()) * f64::from(bbox.height.abs());
if painted > 0.0 && largest.is_none_or(|(area, _)| visible > area) {
let pixels = f64::from(handle.width) * f64::from(handle.height);
largest = Some((visible, (pixels / painted).sqrt() * POINTS_PER_INCH));
}
}
Some((
(covered / page_area).clamp(0.0, 1.0),
largest.map(|(_, density)| density),
))
}
fn page_signals(doc: &PdfDocument, page_index: usize) -> Option<PageScanSignals> {
let coverage = image_coverage(doc, page_index)?;
if coverage < IMAGE_COVERAGE_MIN {
return None;
}
let classified = super::native::guard_native_panic(
|| doc.classify_page(page_index).map_err(|error| error.to_string()),
|message| message,
)
.ok()?;
let signals = classified.signals;
Some(PageScanSignals {
image_coverage: coverage,
invisible_text_ratio: signals.invisible_text_ratio,
glyph_count: signals.text_glyph_count,
text_area_ratio: signals.text_area_ratio,
codec: signals.codec,
producer_prior: signals.producer_prior,
})
}
fn map_pages<T, F>(page_count: usize, page_work: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync + Send,
{
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
(0..page_count).into_par_iter().map(page_work).collect()
}
#[cfg(target_arch = "wasm32")]
{
(0..page_count).map(page_work).collect()
}
}
pub(crate) fn detect(doc: &PdfDocument) -> Option<ScanDetection> {
let page_count = doc.page_count().ok()?;
let page_confidence = map_pages(page_count, |page_index| {
page_signals(doc, page_index).as_ref().map_or(0.0, score_page)
});
let confidence = page_confidence.iter().copied().fold(0.0_f32, f32::max);
Some(ScanDetection {
confidence,
page_confidence,
})
}
pub(crate) fn fabricated_char_counts(spans: &[TextSpan]) -> (usize, usize) {
let mut fabricated = 0usize;
let mut total = 0usize;
for span in spans {
let non_whitespace = span.text.chars().filter(|c| !c.is_whitespace()).count();
total += non_whitespace;
if span.provenance == Some(MappingProvenance::Fallback) {
fabricated += non_whitespace;
}
}
(fabricated, total)
}
fn counts_meet_fabricated_threshold(fabricated: usize, total: usize, min_ratio: f64, min_chars: usize) -> bool {
if total < min_chars {
return false;
}
(fabricated as f64 / total as f64) >= min_ratio
}
fn page_has_fabricated_text(doc: &PdfDocument, page_index: usize, min_ratio: f64, min_chars: usize) -> bool {
let page_text = match super::native::guard_native_panic(
|| {
doc.extract_page_text_with_options(page_index, ReadingOrder::ColumnAware)
.map_err(|error| error.to_string())
},
|message| message,
) {
Ok(page_text) => page_text,
Err(_) => return false,
};
let (fabricated, total) = fabricated_char_counts(&page_text.spans);
counts_meet_fabricated_threshold(fabricated, total, min_ratio, min_chars)
}
pub(crate) fn fabricated_provenance_page_indices(doc: &PdfDocument, min_ratio: f64, min_chars: usize) -> Vec<usize> {
#[cfg(test)]
FABRICATED_PROVENANCE_SECOND_PASS_CALLS.with(|counter| counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
let Ok(page_count) = doc.page_count() else {
return Vec::new();
};
let fabricated = map_pages(page_count, |page_index| {
page_has_fabricated_text(doc, page_index, min_ratio, min_chars)
});
fabricated
.into_iter()
.enumerate()
.filter_map(|(page_index, is_fabricated)| is_fabricated.then_some(page_index))
.collect()
}
pub(crate) fn fabricated_provenance_page_indices_from_counts(
counts: &[(usize, usize)],
min_ratio: f64,
min_chars: usize,
) -> Vec<usize> {
counts
.iter()
.enumerate()
.filter_map(|(page_index, &(fabricated, total))| {
counts_meet_fabricated_threshold(fabricated, total, min_ratio, min_chars).then_some(page_index)
})
.collect()
}
#[cfg(test)]
mod tests;
#[cfg(test)]
#[path = "scan_detect_type3_tests.rs"]
mod type3_tests;