use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractMode {
TextOnly,
#[default]
Auto,
ForceOcr,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
#[non_exhaustive]
pub struct AutoExtractOptions {
pub mode: ExtractMode,
pub reconstruct_image_tables: bool,
pub emit_placeholders: bool,
pub min_text_confidence: Option<f32>,
pub table_confidence: Option<f32>,
pub force_ocr_pages: Vec<usize>,
}
impl Default for AutoExtractOptions {
fn default() -> Self {
Self::balanced()
}
}
impl AutoExtractOptions {
#[must_use]
pub fn fast() -> Self {
Self {
mode: ExtractMode::TextOnly,
reconstruct_image_tables: false,
emit_placeholders: true,
min_text_confidence: None,
table_confidence: None,
force_ocr_pages: Vec::new(),
}
}
#[must_use]
pub fn balanced() -> Self {
Self {
mode: ExtractMode::Auto,
reconstruct_image_tables: true,
emit_placeholders: true,
min_text_confidence: None,
table_confidence: None,
force_ocr_pages: Vec::new(),
}
}
#[must_use]
pub fn high_fidelity() -> Self {
Self {
mode: ExtractMode::Auto,
reconstruct_image_tables: true,
emit_placeholders: true,
min_text_confidence: Some(0.55),
table_confidence: Some(0.45),
force_ocr_pages: Vec::new(),
}
}
#[must_use]
pub fn builder() -> AutoExtractOptionsBuilder {
AutoExtractOptionsBuilder::new()
}
}
#[derive(Debug, Clone, Default)]
pub struct AutoExtractOptionsBuilder {
opts: AutoExtractOptions,
}
impl AutoExtractOptionsBuilder {
#[must_use]
pub fn new() -> Self {
Self {
opts: AutoExtractOptions::balanced(),
}
}
#[must_use]
pub fn mode(mut self, mode: ExtractMode) -> Self {
self.opts.mode = mode;
self
}
#[must_use]
pub fn reconstruct_image_tables(mut self, yes: bool) -> Self {
self.opts.reconstruct_image_tables = yes;
self
}
#[must_use]
pub fn emit_placeholders(mut self, yes: bool) -> Self {
self.opts.emit_placeholders = yes;
self
}
#[must_use]
pub fn min_text_confidence(mut self, c: f32) -> Self {
self.opts.min_text_confidence = Some(c.clamp(0.0, 1.0));
self
}
#[must_use]
pub fn table_confidence(mut self, c: f32) -> Self {
self.opts.table_confidence = Some(c.clamp(0.0, 1.0));
self
}
#[must_use]
pub fn force_ocr_pages<I: IntoIterator<Item = usize>>(mut self, pages: I) -> Self {
self.opts.force_ocr_pages = pages.into_iter().collect();
self
}
#[must_use]
pub fn build(self) -> AutoExtractOptions {
self.opts
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PageKind {
TextLayer,
Scanned,
ImageText,
Mixed,
Empty,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReasonCode {
Ok,
NativeTextHighConfidence,
NoTextLayerPresent,
TextLayerBelowThreshold,
GlyphMappingMissing,
EncryptedNoExtractPermission,
ImageTableReconstructed,
ImageTableNoStructure,
ChartNotTranscribed,
OcrRequestedButUnavailable,
OcrLowConfidenceFallback,
Empty,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractSource {
NativeText,
Ocr,
ImageTableRecovery,
Fallback,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RegionKind {
Text,
Heading,
Table,
Figure,
Chart,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Quad {
pub points: [[f32; 2]; 4],
}
impl Quad {
#[must_use]
pub fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Self {
Self {
points: [[x, y + h], [x + w, y + h], [x + w, y], [x, y]],
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct TableData {
pub rows: Vec<Vec<String>>,
pub has_header: bool,
pub markdown: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct Region {
pub bbox: Quad,
pub kind: RegionKind,
pub text: String,
pub table: Option<TableData>,
pub confidence: f32,
pub source: ExtractSource,
pub reason: ReasonCode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractionStatus {
Complete,
PartialSuccess,
NoTextRecovered,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct PageExtraction {
pub page: usize,
pub kind: PageKind,
pub text: String,
pub regions: Vec<Region>,
pub confidence: f32,
pub reason: ReasonCode,
pub ocr_used: bool,
pub status: ExtractionStatus,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct DocumentExtraction {
pub pages: Vec<PageExtraction>,
pub status: ExtractionStatus,
pub pages_needing_ocr: Vec<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct DocumentClassification {
pub pages: Vec<PageKind>,
pub pages_needing_ocr: Vec<usize>,
pub summary: DocumentSummary,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DocumentSummary {
MostlyText,
MostlyScanned,
Mixed,
Empty,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ImageCodecClass {
None,
Ccitt,
Jbig2,
Dct,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProducerPrior {
Scanner,
Authoring,
Unknown,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct PageSignals {
pub text_glyph_count: usize,
pub text_area_ratio: f32,
pub image_area_ratio: f32,
pub codec: ImageCodecClass,
pub invisible_text_ratio: f32,
pub garbled_ratio: f32,
pub fragmented_word_ratio: f32,
pub consecutive_repeat_ratio: f32,
pub vector_path_density: f32,
pub has_reliable_structure: bool,
pub producer_prior: ProducerPrior,
pub page_is_empty: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct PageClassification {
pub page: usize,
pub kind: PageKind,
pub confidence: f32,
pub reason: ReasonCode,
pub signals: PageSignals,
}
#[must_use]
pub fn is_cjk_dominant_text(text: &str) -> bool {
let mut total = 0usize;
let mut cjk = 0usize;
for c in text.chars() {
if c.is_whitespace() {
continue;
}
total += 1;
let cp = c as u32;
if (0x4E00..=0x9FFF).contains(&cp) || (0x3400..=0x4DBF).contains(&cp) || (0x3040..=0x309F).contains(&cp) || (0x30A0..=0x30FF).contains(&cp) || (0xAC00..=0xD7A3).contains(&cp)
{
cjk += 1;
}
}
total > 0 && (cjk as f32 / total as f32) > 0.5
}
#[must_use]
pub fn text_quality_gate(text: &str) -> Option<ReasonCode> {
let chars: Vec<char> = text.chars().collect();
let total = chars.len();
if total < 16 {
return None;
}
let bad = chars
.iter()
.filter(|&&c| {
c == '\u{FFFD}'
|| ('\u{0}'..'\u{9}').contains(&c)
|| ('\u{E}'..'\u{20}').contains(&c)
|| ('\u{E000}'..='\u{F8FF}').contains(&c) })
.count();
let garbled_ratio = bad as f32 / total as f32;
if garbled_ratio > 0.20 {
return Some(ReasonCode::GlyphMappingMissing);
}
let words: Vec<&str> = text.split_whitespace().collect();
if words.len() >= 8 {
let frag = words.iter().filter(|w| w.chars().count() <= 2).count() as f32 / words.len() as f32;
let cjk_dominant = is_cjk_dominant_text(text);
if frag > 0.80 && !cjk_dominant {
return Some(ReasonCode::GlyphMappingMissing);
}
let mut repeats = 0usize;
for w in words.windows(2) {
if w[0] == w[1] {
repeats += 1;
}
}
let repeat_ratio = repeats as f32 / words.len() as f32;
let avg_word_len = words.iter().map(|w| w.chars().count()).sum::<usize>() as f32 / words.len() as f32;
if repeat_ratio > 0.30 || (frag > 0.55 && avg_word_len < 2.5 && !cjk_dominant) {
return Some(ReasonCode::TextLayerBelowThreshold);
}
}
let alnum = chars.iter().filter(|c| c.is_alphanumeric()).count();
if (alnum as f32 / total as f32) < 0.20 {
return Some(ReasonCode::TextLayerBelowThreshold);
}
None
}
#[must_use]
pub fn classify_from_signals(s: &PageSignals, cfg: &AutoExtractOptions) -> (PageKind, f32, ReasonCode) {
let min_text_conf = cfg.min_text_confidence.unwrap_or(0.70);
let scan_cover_min = 0.80_f32;
let sparse_text_max = 0.10_f32;
let min_glyphs = 24_usize;
if s.page_is_empty {
return (PageKind::Empty, 0.99, ReasonCode::Empty);
}
let usable_text = s.text_glyph_count >= min_glyphs && s.garbled_ratio <= 0.20 && s.fragmented_word_ratio <= 0.80;
if !usable_text && s.vector_path_density > 0.60 && s.image_area_ratio < 0.20 {
return (PageKind::Scanned, 0.80, ReasonCode::NoTextLayerPresent);
}
if let Some(verdict) = scan_dominant_verdict(s, usable_text, scan_cover_min, sparse_text_max) {
return verdict;
}
if usable_text && s.image_area_ratio > 0.05 && s.image_area_ratio < scan_cover_min {
return (PageKind::ImageText, 0.75, ReasonCode::Ok);
}
if usable_text {
let mut conf = min_text_conf.max(0.80);
if s.has_reliable_structure {
conf = (conf + 0.10).min(0.99);
}
return (PageKind::TextLayer, conf, ReasonCode::NativeTextHighConfidence);
}
if s.text_glyph_count > 0 {
return low_glyph_count_verdict(s);
}
let conf = match s.producer_prior {
ProducerPrior::Scanner => 0.85,
_ => 0.70,
};
(PageKind::Scanned, conf, ReasonCode::NoTextLayerPresent)
}
fn scan_dominant_verdict(
s: &PageSignals,
usable_text: bool,
scan_cover_min: f32,
sparse_text_max: f32,
) -> Option<(PageKind, f32, ReasonCode)> {
if s.image_area_ratio < scan_cover_min {
return None;
}
if usable_text && s.invisible_text_ratio >= 0.50 {
return Some((PageKind::TextLayer, 0.85, ReasonCode::NativeTextHighConfidence));
}
if s.text_area_ratio < sparse_text_max || !usable_text {
let conf = if s.codec == ImageCodecClass::Ccitt || s.codec == ImageCodecClass::Jbig2 {
0.95
} else {
0.85
};
let reason = if usable_text {
ReasonCode::TextLayerBelowThreshold
} else {
ReasonCode::NoTextLayerPresent
};
return Some((PageKind::Scanned, conf, reason));
}
None
}
fn low_glyph_count_verdict(s: &PageSignals) -> (PageKind, f32, ReasonCode) {
let clean = s.garbled_ratio <= 0.20 && s.fragmented_word_ratio <= 0.80;
if clean && s.image_area_ratio < 0.05 {
return (PageKind::TextLayer, 0.60, ReasonCode::NativeTextHighConfidence);
}
(PageKind::Scanned, 0.80, ReasonCode::GlyphMappingMissing)
}
mod summary;
pub use summary::summarise;
use crate::document::PdfDocument;
#[derive(Debug, Clone)]
pub struct AutoExtractor {
opts: AutoExtractOptions,
}
impl Default for AutoExtractor {
fn default() -> Self {
Self::new()
}
}
impl AutoExtractor {
#[must_use]
pub fn new() -> Self {
Self {
opts: AutoExtractOptions::balanced(),
}
}
#[must_use]
pub fn text_only() -> Self {
Self {
opts: AutoExtractOptions::fast(),
}
}
#[must_use]
pub fn with(opts: AutoExtractOptions) -> Self {
Self { opts }
}
#[must_use]
pub fn options(&self) -> &AutoExtractOptions {
&self.opts
}
pub fn classify(&self, doc: &PdfDocument) -> crate::Result<DocumentClassification> {
doc.classify_document()
}
pub fn extract_text(&self, doc: &PdfDocument, page: usize) -> crate::Result<String> {
if matches!(self.opts.mode, ExtractMode::TextOnly) {
return doc.extract_text(page);
}
let cls = doc.classify_page(page)?;
Ok(self.route(doc, page, &cls)?.0)
}
fn route(
&self,
doc: &PdfDocument,
page: usize,
cls: &PageClassification,
) -> crate::Result<(String, ExtractSource, ReasonCode)> {
let force = matches!(self.opts.mode, ExtractMode::ForceOcr) || self.opts.force_ocr_pages.contains(&page);
let needs_ocr = force || matches!(cls.kind, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed);
if !needs_ocr {
return Ok((doc.extract_text(page)?, ExtractSource::NativeText, cls.reason));
}
tracing::warn!(
"auto-extract: OCR unavailable (no OCR backend compiled in) \
for page {page} (kind={:?}); falling back to native \
text (reason OcrRequestedButUnavailable)",
cls.kind
);
let native = doc.extract_text(page)?;
if !native.trim().is_empty() && text_quality_gate(&native).is_none() {
Ok((native, ExtractSource::NativeText, ReasonCode::NativeTextHighConfidence))
} else {
Ok((native, ExtractSource::Fallback, ReasonCode::OcrRequestedButUnavailable))
}
}
pub fn extract_document_text(&self, doc: &PdfDocument) -> crate::Result<String> {
let n = doc.page_count()?;
let mut out = String::new();
for p in 0..n {
if p > 0 {
out.push_str("\n\n");
}
out.push_str(&self.extract_text(doc, p)?);
}
Ok(out)
}
pub fn extract_page(&self, doc: &PdfDocument, page: usize) -> crate::Result<PageExtraction> {
let cls = doc.classify_page(page)?;
let (text, source, reason) = if matches!(self.opts.mode, ExtractMode::TextOnly) {
(doc.extract_text(page)?, ExtractSource::NativeText, cls.reason)
} else {
self.route(doc, page, &cls)?
};
let ocr_used = source == ExtractSource::Ocr;
let bbox = doc
.get_page_media_box(page)
.map(|(x0, y0, x1, y1)| Quad::from_xywh(x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()))
.unwrap_or(Quad::from_xywh(0.0, 0.0, 0.0, 0.0));
let status = if text.trim().is_empty() {
ExtractionStatus::NoTextRecovered
} else if matches!(reason, ReasonCode::Ok | ReasonCode::NativeTextHighConfidence) {
ExtractionStatus::Complete
} else {
ExtractionStatus::PartialSuccess
};
let region = Region {
bbox,
kind: RegionKind::Text,
text: text.clone(),
table: None,
confidence: cls.confidence,
source,
reason,
};
Ok(PageExtraction {
page,
kind: cls.kind,
text,
regions: vec![region],
confidence: cls.confidence,
reason,
ocr_used,
status,
})
}
pub fn extract_document(&self, doc: &PdfDocument) -> crate::Result<DocumentExtraction> {
let n = doc.page_count()?;
let mut pages = Vec::with_capacity(n);
let mut need = Vec::new();
for p in 0..n {
let pe = self.extract_page(doc, p)?;
if matches!(pe.kind, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed) {
need.push(p);
}
pages.push(pe);
}
let any_text = pages.iter().any(|p| !p.text.trim().is_empty());
let all_ok = pages
.iter()
.all(|p| matches!(p.reason, ReasonCode::Ok | ReasonCode::NativeTextHighConfidence));
let status = if !any_text {
ExtractionStatus::NoTextRecovered
} else if all_ok {
ExtractionStatus::Complete
} else {
ExtractionStatus::PartialSuccess
};
Ok(DocumentExtraction {
pages,
status,
pages_needing_ocr: need,
})
}
}
#[cfg(test)]
mod tests;