use std::borrow::Cow;
use std::sync::LazyLock;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use regex::Regex;
use crate::core::config::OcrConfig;
use crate::types::{ExtractedDocument, FormatMetadata, Formula, Metadata, OcrMetadata, ProcessingWarning, Table};
use crate::ocr_metadata_keys::OCR_PROCESSED_IMAGE_HEIGHT_METADATA_KEY as PROCESSED_HEIGHT_KEY;
use crate::ocr_metadata_keys::OCR_PROCESSED_IMAGE_WIDTH_METADATA_KEY as PROCESSED_WIDTH_KEY;
const DEFAULT_LANGUAGE: &str = "eng";
const TESSERACT_LANGUAGE_SEPARATOR: char = '+';
fn effective_languages(config: &OcrConfig) -> Vec<String> {
let langs: Vec<String> = config
.language
.iter()
.flat_map(|lang| lang.split(TESSERACT_LANGUAGE_SEPARATOR))
.map(str::trim)
.filter(|lang| !lang.is_empty())
.map(str::to_string)
.collect();
if langs.is_empty() {
vec![DEFAULT_LANGUAGE.to_string()]
} else {
langs
}
}
pub(crate) struct OcrDocumentContext {
pub(crate) mime_type: Cow<'static, str>,
pub(crate) backend_name: &'static str,
pub(crate) plain_text_task: bool,
}
pub(crate) fn build_ocr_document(
content: String,
formulas: Vec<Formula>,
image_bytes: &[u8],
config: &OcrConfig,
context: OcrDocumentContext,
) -> ExtractedDocument {
let OcrDocumentContext {
mime_type,
backend_name,
plain_text_task,
} = context;
let (content, line_filter) = filter_implausible_lines(&content, config, plain_text_task);
let tables = extract_gfm_tables(&content);
let metadata = build_metadata(image_bytes, tables.len() as u32);
let mut processing_warnings = Vec::new();
if let Some(warning) = auto_rotate_unsupported_warning(config, backend_name) {
processing_warnings.push(warning);
}
if let Some(warning) = line_filter.into_warning(backend_name) {
processing_warnings.push(warning);
}
ExtractedDocument {
content,
formulas,
mime_type,
metadata,
tables,
detected_languages: Some(effective_languages(config)),
processing_warnings,
..Default::default()
}
}
fn auto_rotate_unsupported_warning(config: &OcrConfig, backend_name: &'static str) -> Option<ProcessingWarning> {
if !config.auto_rotate {
return None;
}
Some(crate::core::diagnostics::warning(
backend_name,
format!(
"auto_rotate was requested but the `{backend_name}` backend has no orientation \
detection or correction step; the image was OCR'd in its original orientation"
),
))
}
fn build_metadata(image_bytes: &[u8], table_count: u32) -> Metadata {
let mut metadata = Metadata {
format: Some(FormatMetadata::Ocr(OcrMetadata {
table_count,
..Default::default()
})),
ocr_used: true,
..Default::default()
};
if let Some((width, height)) = probe_image_dimensions(image_bytes) {
metadata
.additional
.insert(Cow::Borrowed(PROCESSED_WIDTH_KEY), serde_json::json!(width));
metadata
.additional
.insert(Cow::Borrowed(PROCESSED_HEIGHT_KEY), serde_json::json!(height));
}
metadata
}
fn probe_image_dimensions(image_bytes: &[u8]) -> Option<(u32, u32)> {
crate::extraction::image_decode::probe_standard_image_with_default_security_limits(image_bytes)
.ok()
.map(|(width, height, _)| (width, height))
}
fn extract_gfm_tables(content: &str) -> Vec<Table> {
let mut tables = Vec::new();
let mut in_table = false;
let mut rows: Vec<Vec<String>> = Vec::new();
let mut current_row: Vec<String> = Vec::new();
let mut current_cell = String::new();
let mut in_cell = false;
for event in Parser::new_ext(content, Options::ENABLE_TABLES) {
match event {
Event::Start(Tag::Table(_)) => {
in_table = true;
rows.clear();
}
Event::End(TagEnd::Table) if in_table => {
in_table = false;
if !rows.is_empty() {
let cells = std::mem::take(&mut rows);
let markdown = crate::rendering::common::render_table_markdown(&cells);
tables.push(Table {
cells,
markdown,
page_number: 1,
..Default::default()
});
}
}
Event::Start(Tag::TableHead | Tag::TableRow) if in_table => {
current_row.clear();
}
Event::End(TagEnd::TableHead | TagEnd::TableRow) if in_table && !current_row.is_empty() => {
rows.push(std::mem::take(&mut current_row));
}
Event::Start(Tag::TableCell) if in_table => {
in_cell = true;
current_cell.clear();
}
Event::End(TagEnd::TableCell) if in_table => {
in_cell = false;
current_row.push(current_cell.trim().to_string());
current_cell.clear();
}
Event::Text(text) | Event::Code(text) if in_table && in_cell => {
current_cell.push_str(&text);
}
_ => {}
}
}
tables
}
const IMPLAUSIBLE_SCRIPT_MIN_LETTERS: usize = 3;
const IMPLAUSIBLE_SCRIPT_MIN_FRACTION: f64 = 0.5;
const LATEX_LINE_MIN_COMMANDS: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Script {
Latin,
Cyrillic,
Greek,
Arabic,
Hebrew,
Devanagari,
Thai,
Han,
Kana,
Hangul,
}
fn classify_char(ch: char) -> Option<Script> {
match ch as u32 {
0x0041..=0x005A | 0x0061..=0x007A | 0x00C0..=0x024F => Some(Script::Latin),
0x0370..=0x03FF => Some(Script::Greek),
0x0400..=0x04FF => Some(Script::Cyrillic),
0x0590..=0x05FF => Some(Script::Hebrew),
0x0600..=0x06FF | 0x0750..=0x077F => Some(Script::Arabic),
0x0900..=0x097F => Some(Script::Devanagari),
0x0E00..=0x0E7F => Some(Script::Thai),
0x3040..=0x30FF => Some(Script::Kana),
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF => Some(Script::Han),
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7A3 => Some(Script::Hangul),
_ => None,
}
}
fn script_for_language(lang: &str) -> Option<&'static [Script]> {
match lang.trim().to_ascii_lowercase().as_str() {
"en" | "eng" | "de" | "deu" | "ger" | "fr" | "fra" | "fre" | "es" | "spa" | "it" | "ita" | "pt" | "por"
| "nl" | "nld" | "dut" | "pl" | "pol" | "sv" | "swe" | "tr" | "tur" | "vi" | "vie" | "id" | "ind" => {
Some(&[Script::Latin])
}
"ru" | "rus" | "uk" | "ukr" | "bg" | "bul" | "sr" | "srp" => Some(&[Script::Cyrillic]),
"el" | "ell" | "gre" => Some(&[Script::Greek]),
"ar" | "ara" | "fa" | "fas" | "per" | "ur" | "urd" => Some(&[Script::Arabic]),
"he" | "heb" | "yi" | "yid" => Some(&[Script::Hebrew]),
"hi" | "hin" | "mr" | "mar" | "ne" | "nep" => Some(&[Script::Devanagari]),
"th" | "tha" => Some(&[Script::Thai]),
"zh" | "zho" | "chi" | "chi_sim" | "chi_tra" => Some(&[Script::Han]),
"ja" | "jpn" => Some(&[Script::Han, Script::Kana]),
"ko" | "kor" => Some(&[Script::Hangul, Script::Han]),
_ => None,
}
}
fn allowed_scripts(config: &OcrConfig) -> Option<Vec<Script>> {
let mut scripts = vec![Script::Latin];
for lang in effective_languages(config) {
let scripts_for_lang = script_for_language(&lang)?;
for script in scripts_for_lang {
if !scripts.contains(script) {
scripts.push(*script);
}
}
}
Some(scripts)
}
fn line_script_counts(line: &str, allowed: &[Script]) -> (usize, usize) {
let mut disallowed = 0usize;
let mut classified = 0usize;
for ch in line.chars().filter(|ch| ch.is_alphabetic()) {
if let Some(script) = classify_char(ch) {
classified += 1;
if !allowed.contains(&script) {
disallowed += 1;
}
}
}
(disallowed, classified)
}
fn line_has_implausible_script(line: &str, allowed: &[Script]) -> bool {
let (disallowed, classified) = line_script_counts(line, allowed);
if classified == 0 || disallowed < IMPLAUSIBLE_SCRIPT_MIN_LETTERS {
return false;
}
(disallowed as f64 / classified as f64) >= IMPLAUSIBLE_SCRIPT_MIN_FRACTION
}
static LATEX_COMMAND_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\\[A-Za-z]+").expect("static LaTeX command pattern is valid"));
fn is_bare_alphabetic_word(word: &str) -> bool {
!word.is_empty() && word.chars().all(char::is_alphabetic)
}
fn line_is_bare_latex(line: &str) -> bool {
let command_count = LATEX_COMMAND_PATTERN.find_iter(line).count();
if command_count < LATEX_LINE_MIN_COMMANDS {
return false;
}
!line.split_whitespace().any(is_bare_alphabetic_word)
}
fn is_fence_boundary(trimmed_line: &str) -> bool {
trimmed_line == "$$" || trimmed_line.starts_with("```")
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ImplausibleLineFilter {
dropped_script_lines: usize,
dropped_latex_lines: usize,
}
impl ImplausibleLineFilter {
fn is_empty(&self) -> bool {
self.dropped_script_lines == 0 && self.dropped_latex_lines == 0
}
fn into_warning(self, backend_name: &'static str) -> Option<ProcessingWarning> {
if self.is_empty() {
return None;
}
Some(crate::core::diagnostics::warning(
backend_name,
format!(
"dropped {} line(s) with a script implausible for the configured OCR language(s) \
and {} line(s) of bare LaTeX markup on a plain-text OCR task",
self.dropped_script_lines, self.dropped_latex_lines
),
))
}
}
pub(crate) fn filter_implausible_lines(
content: &str,
config: &OcrConfig,
plain_text_task: bool,
) -> (String, ImplausibleLineFilter) {
let allowed_scripts = allowed_scripts(config);
let mut outcome = ImplausibleLineFilter::default();
let mut in_fence = false;
let mut kept_lines: Vec<&str> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if is_fence_boundary(trimmed) {
in_fence = !in_fence;
kept_lines.push(line);
continue;
}
if let Some(allowed) = &allowed_scripts
&& line_has_implausible_script(line, allowed)
{
outcome.dropped_script_lines += 1;
continue;
}
if plain_text_task && !in_fence && line_is_bare_latex(line) {
outcome.dropped_latex_lines += 1;
continue;
}
kept_lines.push(line);
}
(kept_lines.join("\n"), outcome)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_extract_no_tables_from_plain_text() {
let tables = extract_gfm_tables("Just some OCR'd prose with no tables.");
assert!(tables.is_empty(), "expected no tables; got: {tables:?}");
}
#[test]
fn should_extract_single_gfm_table_from_content() {
let content = "Some text before.\n\n\
| Name | Age |\n\
|------|-----|\n\
| Alice | 30 |\n\
| Bob | 25 |\n\n\
Some text after.";
let tables = extract_gfm_tables(content);
assert_eq!(tables.len(), 1, "expected exactly one table; got: {tables:?}");
assert_eq!(
tables[0].cells,
vec![
vec!["Name".to_string(), "Age".to_string()],
vec!["Alice".to_string(), "30".to_string()],
vec!["Bob".to_string(), "25".to_string()],
]
);
assert_eq!(tables[0].page_number, 1);
assert!(tables[0].markdown.contains("Name"));
assert!(tables[0].markdown.contains("Alice"));
}
#[test]
fn should_extract_multiple_gfm_tables_from_content() {
let content = "| A |\n|---|\n| 1 |\n\ntext between\n\n| B |\n|---|\n| 2 |";
let tables = extract_gfm_tables(content);
assert_eq!(tables.len(), 2, "expected two tables; got: {tables:?}");
assert_eq!(tables[0].cells, vec![vec!["A".to_string()], vec!["1".to_string()]]);
assert_eq!(tables[1].cells, vec![vec!["B".to_string()], vec!["2".to_string()]]);
}
#[test]
fn should_build_ocr_document_with_metadata_tables_and_languages() {
let png_1x1: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x18,
0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let content = "| H |\n|---|\n| v |".to_string();
let config = OcrConfig {
language: vec!["deu".to_string()],
..Default::default()
};
let doc = build_ocr_document(
content.clone(),
Vec::new(),
png_1x1,
&config,
OcrDocumentContext {
mime_type: Cow::Borrowed("text/markdown"),
backend_name: "candle-trocr",
plain_text_task: true,
},
);
assert_eq!(doc.content, content);
assert!(doc.metadata.ocr_used);
let Some(FormatMetadata::Ocr(ocr_metadata)) = &doc.metadata.format else {
panic!("expected FormatMetadata::Ocr; got: {:?}", doc.metadata.format);
};
assert_eq!(ocr_metadata.table_count, 1);
assert_eq!(
doc.metadata.additional.get(PROCESSED_WIDTH_KEY),
Some(&serde_json::json!(1))
);
assert_eq!(
doc.metadata.additional.get(PROCESSED_HEIGHT_KEY),
Some(&serde_json::json!(1))
);
assert_eq!(doc.tables.len(), 1);
assert_eq!(doc.detected_languages, Some(vec!["deu".to_string()]));
assert!(
doc.processing_warnings.is_empty(),
"auto_rotate defaults to false; no warning should be emitted, got: {:?}",
doc.processing_warnings
);
}
#[test]
fn should_warn_when_auto_rotate_requested_of_a_backend_with_no_rotation_support() {
let config = OcrConfig {
auto_rotate: true,
..Default::default()
};
let doc = build_ocr_document(
"text".to_string(),
Vec::new(),
&[],
&config,
OcrDocumentContext {
mime_type: Cow::Borrowed("text/plain"),
backend_name: "candle-trocr",
plain_text_task: true,
},
);
assert_eq!(
doc.processing_warnings.len(),
1,
"expected exactly one auto_rotate warning, got: {:?}",
doc.processing_warnings
);
assert_eq!(doc.processing_warnings[0].source, "candle-trocr");
assert_eq!(
doc.processing_warnings[0].message,
"auto_rotate was requested but the `candle-trocr` backend has no orientation \
detection or correction step; the image was OCR'd in its original orientation"
);
}
#[test]
fn should_not_warn_when_auto_rotate_is_not_requested() {
let config = OcrConfig::default();
assert!(
!config.auto_rotate,
"test assumes the OcrConfig default is auto_rotate: false"
);
let doc = build_ocr_document(
"text".to_string(),
Vec::new(),
&[],
&config,
OcrDocumentContext {
mime_type: Cow::Borrowed("text/plain"),
backend_name: "candle-glm-ocr",
plain_text_task: true,
},
);
assert!(
doc.processing_warnings.is_empty(),
"auto_rotate: false must not produce a warning, got: {:?}",
doc.processing_warnings
);
}
#[test]
fn should_drop_cjk_line_under_english_only_language_config() {
let config = OcrConfig {
language: vec!["eng".to_string()],
..Default::default()
};
let content = "Printed heading.\n这是一个测试\nMore printed prose.".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(
filtered, "Printed heading.\nMore printed prose.",
"expected the CJK line to be dropped, got: {filtered:?}"
);
assert_eq!(outcome.dropped_script_lines, 1);
assert_eq!(outcome.dropped_latex_lines, 0);
let warning = outcome
.into_warning("candle-paddleocr-vl")
.expect("a dropped line must produce a warning");
assert_eq!(warning.source, "candle-paddleocr-vl");
assert!(
warning.message.contains('1'),
"warning should name the dropped-line count, got: {}",
warning.message
);
}
#[test]
fn should_keep_cjk_line_when_japanese_is_configured() {
let config = OcrConfig {
language: vec!["eng".to_string(), "jpn".to_string()],
..Default::default()
};
let content = "这是一个测试".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, content);
assert_eq!(outcome.dropped_script_lines, 0);
}
#[test]
fn should_split_plus_joined_language_list_before_building_the_allowlist() {
let config = OcrConfig {
language: vec!["eng+deu".to_string()],
..Default::default()
};
let content = "这是一个测试".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, "");
assert_eq!(outcome.dropped_script_lines, 1);
assert_eq!(effective_languages(&config), vec!["eng".to_string(), "deu".to_string()]);
}
#[test]
fn should_drop_cyrillic_line_under_english_only_language_config() {
let config = OcrConfig {
language: vec!["eng".to_string()],
..Default::default()
};
let content = "Привет мир".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, "");
assert_eq!(outcome.dropped_script_lines, 1);
}
#[test]
fn should_keep_cyrillic_line_when_russian_is_configured() {
let config = OcrConfig {
language: vec!["rus".to_string()],
..Default::default()
};
let content = "Привет мир".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, content);
assert_eq!(outcome.dropped_script_lines, 0);
}
#[test]
fn should_keep_implausible_script_line_when_language_code_is_unrecognized() {
let config = OcrConfig {
language: vec!["xx-unrecognized".to_string()],
..Default::default()
};
let content = "这是一个测试".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, content, "rule (a) must be disabled for an unmapped language");
assert_eq!(outcome.dropped_script_lines, 0);
}
#[test]
fn should_keep_line_with_umlauts() {
let config = OcrConfig {
language: vec!["eng".to_string()],
..Default::default()
};
let content = "Über die Brücke gehen wir jetzt äöüß".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, content);
assert_eq!(outcome.dropped_script_lines, 0);
}
#[test]
fn should_drop_bare_latex_line_on_plain_text_task() {
let config = OcrConfig::default();
let content = "\\alpha \\beta \\gamma".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, "");
assert_eq!(outcome.dropped_latex_lines, 1);
}
#[test]
fn should_keep_bare_latex_line_on_formula_task() {
let config = OcrConfig::default();
let content = "\\alpha \\beta \\gamma".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, false);
assert_eq!(filtered, content);
assert_eq!(outcome.dropped_latex_lines, 0);
}
#[test]
fn should_keep_latex_line_inside_dollar_fence_on_plain_text_task() {
let config = OcrConfig::default();
let content = "$$\n\\alpha \\beta \\gamma\n$$".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, content);
assert_eq!(outcome.dropped_latex_lines, 0);
}
#[test]
fn should_keep_currency_amount_line() {
let config = OcrConfig::default();
let content = "Total due: $100.00".to_string();
let (filtered, outcome) = filter_implausible_lines(&content, &config, true);
assert_eq!(filtered, content);
assert_eq!(outcome.dropped_latex_lines, 0);
}
#[test]
fn should_count_tables_from_filtered_content_not_raw_content() {
let raw_content = "这是|一个测试\n----|----\n乱码|数据\n\nSome real prose.\n\n\
| Name | Age |\n|------|-----|\n| Alice | 30 |"
.to_string();
let raw_tables = extract_gfm_tables(&raw_content);
assert_eq!(
raw_tables.len(),
2,
"fixture must contain two GFM tables before filtering, got: {raw_tables:?}"
);
let config = OcrConfig {
language: vec!["eng".to_string()],
..Default::default()
};
let doc = build_ocr_document(
raw_content,
Vec::new(),
&[],
&config,
OcrDocumentContext {
mime_type: Cow::Borrowed("text/markdown"),
backend_name: "candle-paddleocr-vl",
plain_text_task: true,
},
);
assert_eq!(doc.tables.len(), 1, "expected only the real table to survive filtering");
assert_eq!(
doc.tables[0].cells[0],
vec!["Name".to_string(), "Age".to_string()],
"the real table's header must be intact, got: {:?}",
doc.tables[0].cells
);
let Some(FormatMetadata::Ocr(ocr_metadata)) = &doc.metadata.format else {
panic!("expected FormatMetadata::Ocr; got: {:?}", doc.metadata.format);
};
assert_eq!(ocr_metadata.table_count, 1);
}
}