use async_trait::async_trait;
use crate::error::EngineError;
use crate::types::{BoundingBox, DocumentFormat, ExtractedDocument, Input, Span, WordBox};
impl From<DocumentFormat> for anydoc::Format {
fn from(format: DocumentFormat) -> Self {
match format {
DocumentFormat::Doc => anydoc::Format::Doc,
DocumentFormat::Docx => anydoc::Format::Docx,
DocumentFormat::Odt => anydoc::Format::Odt,
DocumentFormat::Ppt => anydoc::Format::Ppt,
DocumentFormat::Pptx => anydoc::Format::Pptx,
DocumentFormat::Rtf => anydoc::Format::Rtf,
DocumentFormat::Epub => anydoc::Format::Epub,
DocumentFormat::Excel => anydoc::Format::Excel,
DocumentFormat::Ods => anydoc::Format::Ods,
DocumentFormat::Odp => anydoc::Format::Odp,
DocumentFormat::Csv => anydoc::Format::Csv,
}
}
}
async fn ingest_document(
bytes: &[u8],
format: Option<DocumentFormat>,
) -> Result<ExtractedDocument, EngineError> {
let markdown = anydoc::to_markdown_bytes(bytes, format.map(anydoc::Format::from))
.map_err(|e| EngineError::Ingest(e.to_string()))?;
Ok(ExtractedDocument {
text: markdown.clone(),
markdown: Some(markdown),
..Default::default()
})
}
#[async_trait]
pub trait Ingestor: Send + Sync {
async fn ingest(
&self,
input: &Input,
needs_boxes: bool,
) -> Result<ExtractedDocument, EngineError>;
}
#[derive(Default)]
pub struct DefaultIngestor {
liteparse: LiteparseIngestor,
}
impl DefaultIngestor {
pub fn with_liteparse_config(config: liteparse::config::LiteParseConfig) -> Self {
Self {
liteparse: LiteparseIngestor::with_config(config),
}
}
}
#[async_trait]
impl Ingestor for DefaultIngestor {
async fn ingest(
&self,
input: &Input,
needs_boxes: bool,
) -> Result<ExtractedDocument, EngineError> {
match input {
Input::Text(text) => Ok(ExtractedDocument {
text: text.clone(),
..Default::default()
}),
Input::Pdf(bytes) => {
if needs_boxes {
return self.liteparse.ingest_pdf(bytes).await;
}
match anydoc::to_markdown_bytes(bytes, anydoc::Format::Pdf) {
Ok(markdown) if !markdown.trim().is_empty() => Ok(ExtractedDocument {
text: markdown.clone(),
markdown: Some(markdown),
..Default::default()
}),
_ => self.liteparse.ingest_pdf(bytes).await,
}
}
Input::Image(bytes) => self.liteparse.ingest_image(bytes).await,
Input::Document { bytes, format } => ingest_document(bytes, *format).await,
}
}
}
pub struct AnydocIngestor;
#[async_trait]
impl Ingestor for AnydocIngestor {
async fn ingest(
&self,
input: &Input,
needs_boxes: bool,
) -> Result<ExtractedDocument, EngineError> {
let (bytes, format): (&[u8], Option<anydoc::Format>) = match input {
Input::Text(text) => {
return Ok(ExtractedDocument {
text: text.clone(),
..Default::default()
});
}
Input::Pdf(bytes) => {
if needs_boxes {
return Err(EngineError::Unsupported(
"AnydocIngestor cannot produce bounding boxes; it must not be used \
when the output needs pixel-level redaction"
.into(),
));
}
(bytes, Some(anydoc::Format::Pdf))
}
Input::Image(_) => {
return Err(EngineError::Unsupported(
"anydoc does not read raster images; use the liteparse ingest path".into(),
));
}
Input::Document { bytes, format } => {
return ingest_document(bytes, *format).await;
}
};
let markdown = anydoc::to_markdown_bytes(bytes, format)
.map_err(|e| EngineError::Ingest(e.to_string()))?;
Ok(ExtractedDocument {
text: markdown.clone(),
markdown: Some(markdown),
..Default::default()
})
}
}
#[derive(Default)]
pub struct LiteparseIngestor {
config: liteparse::config::LiteParseConfig,
}
impl LiteparseIngestor {
pub fn with_config(config: liteparse::config::LiteParseConfig) -> Self {
Self { config }
}
pub async fn ingest_pdf(&self, bytes: &[u8]) -> Result<ExtractedDocument, EngineError> {
self.parse(bytes).await
}
pub async fn ingest_image(&self, bytes: &[u8]) -> Result<ExtractedDocument, EngineError> {
self.parse(bytes).await
}
async fn parse(&self, bytes: &[u8]) -> Result<ExtractedDocument, EngineError> {
use liteparse::types::PdfInput;
use liteparse::LiteParse;
let parser = LiteParse::new(self.config.clone());
let result = parser
.parse_input(PdfInput::Bytes(bytes.to_vec()))
.await
.map_err(|e| EngineError::Ingest(e.to_string()))?;
let mut text = String::new();
let mut word_boxes =
Vec::with_capacity(result.pages.iter().map(|p| p.text_items.len()).sum());
for page in &result.pages {
for item in &page.text_items {
if !text.is_empty() {
text.push(' ');
}
let start = text.len();
text.push_str(&item.text);
let end = text.len();
word_boxes.push(WordBox {
span: Span { start, end },
bbox: BoundingBox {
page: page.page_number as u32,
x: item.x,
y: item.y,
width: item.width,
height: item.height,
},
});
}
}
Ok(ExtractedDocument {
text,
markdown: None,
word_boxes,
page_count: result.pages.len() as u32,
})
}
}
#[async_trait]
impl Ingestor for LiteparseIngestor {
async fn ingest(
&self,
input: &Input,
_needs_boxes: bool,
) -> Result<ExtractedDocument, EngineError> {
match input {
Input::Text(text) => Ok(ExtractedDocument {
text: text.clone(),
..Default::default()
}),
Input::Pdf(bytes) => self.ingest_pdf(bytes).await,
Input::Image(bytes) => self.ingest_image(bytes).await,
Input::Document { .. } => Err(EngineError::Unsupported(
"LiteparseIngestor doesn't handle office-document formats (DOCX/XLSX/PPTX/...) — \
that's deliberately anydoc's job (see AnydocIngestor); this pipeline never routes \
through liteparse's LibreOffice-based conversion"
.into(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::DocumentFormat;
fn block_on<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(f)
}
#[test]
fn anydoc_extracts_real_csv() {
let csv = b"name,amount\nAlice,10\nBob,20\n".to_vec();
let result = block_on(AnydocIngestor.ingest(
&Input::Document {
bytes: csv,
format: Some(DocumentFormat::Csv),
},
false,
))
.expect("anydoc parses well-formed CSV");
assert!(result.markdown.is_some());
let markdown = result.markdown.unwrap();
assert!(markdown.contains("Alice"));
assert!(markdown.contains("Bob"));
assert!(result.word_boxes.is_empty()); }
#[test]
fn anydoc_refuses_pdf_when_boxes_are_needed() {
let result = block_on(AnydocIngestor.ingest(&Input::Pdf(vec![0u8; 4]), true));
assert!(matches!(result, Err(EngineError::Unsupported(_))));
}
#[test]
fn anydoc_refuses_images() {
let result = block_on(AnydocIngestor.ingest(&Input::Image(vec![0u8; 4]), false));
assert!(matches!(result, Err(EngineError::Unsupported(_))));
}
#[test]
fn anydoc_text_is_a_passthrough() {
let result =
block_on(AnydocIngestor.ingest(&Input::Text("hello world".into()), false)).unwrap();
assert_eq!(result.text, "hello world");
assert!(result.markdown.is_none()); }
#[test]
fn default_ingestor_text_is_a_passthrough() {
let result =
block_on(DefaultIngestor::default().ingest(&Input::Text("hello world".into()), false))
.unwrap();
assert_eq!(result.text, "hello world");
}
#[test]
fn default_ingestor_routes_document_through_anydoc() {
let csv = b"a,b\n1,2\n".to_vec();
let result = block_on(DefaultIngestor::default().ingest(
&Input::Document {
bytes: csv,
format: Some(DocumentFormat::Csv),
},
false,
))
.unwrap();
assert!(result.markdown.unwrap().contains('1'));
}
#[test]
fn liteparse_ingestor_refuses_document_input() {
let result = block_on(LiteparseIngestor::default().ingest(
&Input::Document {
bytes: vec![],
format: Some(DocumentFormat::Csv),
},
false,
));
assert!(matches!(result, Err(EngineError::Unsupported(_))));
}
}