use std::borrow::Cow;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
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";
fn effective_languages(config: &OcrConfig) -> Vec<String> {
let langs: Vec<String> = config
.language
.iter()
.map(|lang| lang.trim())
.filter(|lang| !lang.is_empty())
.map(str::to_string)
.collect();
if langs.is_empty() {
vec![DEFAULT_LANGUAGE.to_string()]
} else {
langs
}
}
pub(crate) fn build_ocr_document(
content: String,
formulas: Vec<Formula>,
mime_type: Cow<'static, str>,
image_bytes: &[u8],
config: &OcrConfig,
backend_name: &'static str,
) -> ExtractedDocument {
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);
}
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
}
#[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(),
Cow::Borrowed("text/markdown"),
png_1x1,
&config,
"candle-trocr",
);
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(),
Cow::Borrowed("text/plain"),
&[],
&config,
"candle-trocr",
);
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(),
Cow::Borrowed("text/plain"),
&[],
&config,
"candle-glm-ocr",
);
assert!(
doc.processing_warnings.is_empty(),
"auto_rotate: false must not produce a warning, got: {:?}",
doc.processing_warnings
);
}
}