use std::sync::mpsc::{sync_channel, Receiver};
use std::thread::JoinHandle;
use docling_core::{ImageMode, MarkdownStreamer};
use crate::converter::DocumentConverter;
use crate::error::ConversionError;
use crate::format::InputFormat;
use crate::source::SourceDocument;
const CHANNEL_DEPTH: usize = 8;
pub struct MarkdownStream {
rx: Option<Receiver<Result<String, ConversionError>>>,
handle: Option<JoinHandle<()>>,
}
impl Iterator for MarkdownStream {
type Item = Result<String, ConversionError>;
fn next(&mut self) -> Option<Self::Item> {
match self.rx.as_ref()?.recv() {
Ok(item) => Some(item),
Err(_) => {
let panicked = self.handle.take().is_some_and(|h| h.join().is_err());
self.rx = None;
panicked.then(|| {
Err(ConversionError::Panic(
"the conversion worker panicked (its message and backtrace are on stderr)"
.into(),
))
})
}
}
}
}
impl Drop for MarkdownStream {
fn drop(&mut self) {
self.rx = None;
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
pub(crate) struct StreamSettings {
pub strict: bool,
pub no_table_former: bool,
pub no_text_panels: bool,
pub no_ocr: bool,
pub skip_ocr: bool,
pub force_full_page_ocr: bool,
pub enrich: docling_pdf::EnrichmentOptions,
pub page_range: Option<(usize, usize)>,
pub ocr_lang: Option<docling_pdf::OcrLang>,
pub ocr_mode: Option<docling_pdf::OcrMode>,
pub ocr_scale: Option<f32>,
pub artifacts_dir: String,
}
pub(crate) fn spawn(
converter: DocumentConverter,
source: SourceDocument,
image_mode: ImageMode,
) -> MarkdownStream {
let (tx, rx) = sync_channel::<Result<String, ConversionError>>(CHANNEL_DEPTH);
let handle = std::thread::spawn(move || match source.format {
InputFormat::Pdf if !converter.heading_hierarchy_enabled() => {
run_pdf(converter.stream_settings(), &source, image_mode, &tx)
}
_ => run_buffered(converter, source, image_mode, &tx),
});
MarkdownStream {
rx: Some(rx),
handle: Some(handle),
}
}
fn write_artifacts(artifacts: Vec<(String, Vec<u8>)>) -> Result<(), ConversionError> {
for (rel, bytes) in artifacts {
let path = std::path::Path::new(&rel);
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.map_err(|e| ConversionError::Streaming(format!("creating {parent:?}: {e}")))?;
}
std::fs::write(path, bytes)
.map_err(|e| ConversionError::Streaming(format!("writing image {rel}: {e}")))?;
}
Ok(())
}
fn run_pdf(
settings: StreamSettings,
source: &SourceDocument,
image_mode: ImageMode,
tx: &std::sync::mpsc::SyncSender<Result<String, ConversionError>>,
) {
let mut streamer = MarkdownStreamer::with_artifacts(
settings.strict,
image_mode,
false,
&settings.artifacts_dir,
);
let mut pipeline = match docling_pdf::Pipeline::new().map(|p| {
p.no_table_former(settings.no_table_former)
.no_text_panels(settings.no_text_panels)
.no_ocr(settings.no_ocr)
.skip_ocr(settings.skip_ocr)
.force_full_page_ocr(settings.force_full_page_ocr)
.ocr_lang(settings.ocr_lang)
.ocr_mode(settings.ocr_mode)
.ocr_scale(settings.ocr_scale)
.enrichments(settings.enrich)
.pages(settings.page_range)
}) {
Ok(p) => p,
Err(e) => {
let _ = tx.send(Err(ConversionError::Parse(e.to_string())));
return;
}
};
let result = pipeline.convert_streaming(&source.bytes, None, &source.name, |nodes, links| {
let chunk = streamer.push(&nodes, &links);
if let Err(e) = write_artifacts(streamer.take_artifacts()) {
return Err(docling_pdf::PdfError::Pdfium(e.to_string()));
}
if !chunk.is_empty() && tx.send(Ok(chunk)).is_err() {
return Err(docling_pdf::PdfError::Pdfium(
"markdown stream consumer dropped".into(),
));
}
Ok(())
});
match result {
Ok(()) => {
let tail = streamer.finish();
if !tail.is_empty() {
let _ = tx.send(Ok(tail));
}
}
Err(e) => {
let _ = tx.send(Err(ConversionError::Parse(e.to_string())));
}
}
}
fn run_buffered(
converter: DocumentConverter,
source: SourceDocument,
image_mode: ImageMode,
tx: &std::sync::mpsc::SyncSender<Result<String, ConversionError>>,
) {
let settings = converter.stream_settings();
let doc = match converter.convert(source) {
Ok(result) => result.document,
Err(e) => {
let _ = tx.send(Err(e));
return;
}
};
let mut streamer = MarkdownStreamer::with_artifacts(
settings.strict,
image_mode,
doc.compact_tables,
&settings.artifacts_dir,
);
let chunk = streamer.push(&doc.nodes, &doc.links);
if let Err(e) = write_artifacts(streamer.take_artifacts()) {
let _ = tx.send(Err(e));
return;
}
if !chunk.is_empty() && tx.send(Ok(chunk)).is_err() {
return;
}
let tail = streamer.finish();
if !tail.is_empty() {
let _ = tx.send(Ok(tail));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panicking_producer_ends_the_stream_with_an_error() {
let (tx, rx) = sync_channel::<Result<String, ConversionError>>(CHANNEL_DEPTH);
let handle = std::thread::spawn(move || {
tx.send(Ok("a chunk".into())).unwrap();
panic!("a backend bug");
});
let mut stream = MarkdownStream {
rx: Some(rx),
handle: Some(handle),
};
assert_eq!(stream.next().unwrap().unwrap(), "a chunk");
let err = stream
.next()
.expect("the panic is reported, not swallowed")
.expect_err("as an error");
assert!(matches!(err, ConversionError::Panic(_)), "got {err}");
assert!(
stream.next().is_none(),
"the stream ends after reporting the panic"
);
}
#[test]
fn a_finished_producer_just_ends() {
let (tx, rx) = sync_channel::<Result<String, ConversionError>>(CHANNEL_DEPTH);
let handle = std::thread::spawn(move || {
tx.send(Ok("only chunk".into())).unwrap();
});
let mut stream = MarkdownStream {
rx: Some(rx),
handle: Some(handle),
};
assert_eq!(stream.next().unwrap().unwrap(), "only chunk");
assert!(stream.next().is_none());
}
}