Skip to main content

cloudiful_docling_convert/
facade.rs

1use bytes::Bytes;
2
3use crate::api::DoclingConfig;
4use crate::conversion::{
5    ConversionBehavior, DoclingRuntimeConfig, build_convert_options, build_docling_client,
6};
7use crate::document::{ConvertRequest, ConvertedDocument, InputDocument, InputKind, OutputFormat};
8use crate::error::{PdfConvertError, Result};
9use crate::models::TaskStatusResponse;
10use crate::processor::DocumentConverter;
11
12pub struct ConverterBuilder {
13    config: DoclingRuntimeConfig,
14    behavior: ConversionBehavior,
15    output_formats: Vec<OutputFormat>,
16    result_body_limit: Option<usize>,
17}
18
19impl ConverterBuilder {
20    pub fn new(config: DoclingRuntimeConfig) -> Self {
21        Self {
22            config,
23            behavior: ConversionBehavior::default(),
24            output_formats: vec![OutputFormat::Md],
25            result_body_limit: None,
26        }
27    }
28
29    pub fn behavior(mut self, behavior: ConversionBehavior) -> Self {
30        self.behavior = behavior;
31        self
32    }
33
34    pub fn output_formats(mut self, output_formats: Vec<OutputFormat>) -> Self {
35        self.output_formats = output_formats;
36        self
37    }
38
39    pub fn result_body_limit(mut self, result_body_limit: usize) -> Self {
40        self.result_body_limit = Some(result_body_limit);
41        self
42    }
43
44    pub fn build(self) -> Result<PdfConvert> {
45        let output_formats = if self.output_formats.is_empty() {
46            vec![OutputFormat::Md]
47        } else {
48            self.output_formats
49        };
50
51        let client = match self.result_body_limit {
52            Some(limit) => crate::DoclingClient::new_with_result_body_limit(
53                self.config.into_docling_config(),
54                limit,
55            )?,
56            None => build_docling_client(self.config)?,
57        };
58        Ok(PdfConvert {
59            converter: DocumentConverter::new(client),
60            behavior: self.behavior,
61            output_formats,
62        })
63    }
64}
65
66pub struct PdfConvert {
67    converter: DocumentConverter,
68    behavior: ConversionBehavior,
69    output_formats: Vec<OutputFormat>,
70}
71
72impl PdfConvert {
73    pub fn builder(config: DoclingRuntimeConfig) -> ConverterBuilder {
74        ConverterBuilder::new(config)
75    }
76
77    pub fn from_runtime_config(config: DoclingRuntimeConfig) -> Result<Self> {
78        Self::builder(config).build()
79    }
80
81    pub fn from_docling_config(config: DoclingConfig) -> Result<Self> {
82        Ok(Self {
83            converter: DocumentConverter::new(crate::DoclingClient::new(config)?),
84            behavior: ConversionBehavior::default(),
85            output_formats: vec![OutputFormat::Md],
86        })
87    }
88
89    pub fn request_for_input(&self, input: InputDocument) -> Result<ConvertRequest> {
90        let input_kind = input.kind()?;
91
92        Ok(ConvertRequest {
93            input,
94            output_formats: self.output_formats.clone(),
95            options: build_convert_options(input_kind, &self.behavior)?,
96        })
97    }
98
99    pub async fn convert_input(&self, input: InputDocument) -> Result<ConvertedDocument> {
100        self.converter.convert(self.request_for_input(input)?).await
101    }
102
103    pub async fn convert_input_async(&self, input: InputDocument) -> Result<ConvertedDocument> {
104        self.converter
105            .convert_async(self.request_for_input(input)?)
106            .await
107    }
108
109    /// Submit a whole-document asynchronous conversion and return a resumable
110    /// [`DoclingTaskHandle`]. Unlike [`Self::convert_input_async`], the remote
111    /// task id is returned to the caller so polling can be paused and resumed,
112    /// even across process restarts.
113    pub async fn submit_async(&self, input: InputDocument) -> Result<DoclingTaskHandle> {
114        let request = self.request_for_input(input)?;
115        let task_id = self.converter.submit_async(&request).await?;
116        Ok(DoclingTaskHandle {
117            converter: self.converter.clone(),
118            input: request.input,
119            task_id,
120        })
121    }
122
123    /// Poll a remote task by its id. Uses Docling's long-polling endpoint.
124    pub async fn poll_remote(&self, task_id: &str) -> Result<TaskStatusResponse> {
125        self.converter
126            .docling_client
127            .poll_task_status(task_id)
128            .await
129    }
130
131    /// Fetch and parse a completed remote task by its id. The task must have
132    /// reached a terminal status; poll with [`Self::poll_remote`] first.
133    pub async fn fetch_remote(
134        &self,
135        input: InputDocument,
136        task_id: &str,
137    ) -> Result<ConvertedDocument> {
138        let status = self
139            .converter
140            .docling_client
141            .poll_task_status(task_id)
142            .await?;
143        let task_result = self
144            .converter
145            .docling_client
146            .fetch_task_result(task_id, &status)
147            .await?;
148        DocumentConverter::document_from_task_result(&input, task_result)
149    }
150
151    pub async fn convert_bytes(
152        &self,
153        filename: impl Into<String>,
154        bytes: impl Into<Bytes>,
155    ) -> Result<ConvertedDocument> {
156        let filename = filename.into();
157        let input_kind =
158            InputKind::from_filename_and_media_type(&filename, None).ok_or_else(|| {
159                PdfConvertError::validation_error(
160                    "filename",
161                    format!("unsupported input type for '{}'", filename),
162                )
163            })?;
164
165        self.convert_input(InputDocument::new(
166            filename.clone(),
167            input_kind.canonical_media_type(&filename, None),
168            bytes,
169        ))
170        .await
171    }
172
173    pub async fn convert_bytes_with_input_kind(
174        &self,
175        filename: impl Into<String>,
176        bytes: impl Into<Bytes>,
177        input_kind: InputKind,
178    ) -> Result<ConvertedDocument> {
179        let filename = filename.into();
180
181        self.convert_input(
182            InputDocument::new(
183                filename.clone(),
184                input_kind.canonical_media_type(&filename, None),
185                bytes,
186            )
187            .with_input_kind(input_kind),
188        )
189        .await
190    }
191}
192
193/// A resumable handle to a whole-document asynchronous Docling conversion.
194///
195/// The caller controls the polling cadence and deadline; the underlying remote
196/// task keeps running in Docling while this handle is idle, so the handle can
197/// be serialized by its [`task_id`](Self::task_id) and resumed after a restart
198/// by submitting a new handle for the same remote task through the client API.
199#[derive(Clone)]
200pub struct DoclingTaskHandle {
201    converter: DocumentConverter,
202    input: InputDocument,
203    task_id: String,
204}
205
206impl DoclingTaskHandle {
207    pub fn task_id(&self) -> &str {
208        &self.task_id
209    }
210
211    /// Poll the remote task status. Uses Docling's long-polling endpoint, which
212    /// blocks server-side for a bounded period before returning the status.
213    pub async fn poll_status(&self) -> Result<TaskStatusResponse> {
214        self.converter
215            .docling_client
216            .poll_task_status(&self.task_id)
217            .await
218    }
219
220    /// Fetch and parse the completed remote result. Requires the task to have
221    /// reached a terminal status; call [`Self::poll_status`] first.
222    pub async fn fetch_result(&self) -> Result<ConvertedDocument> {
223        let status = self
224            .converter
225            .docling_client
226            .poll_task_status(&self.task_id)
227            .await?;
228        let task_result = self
229            .converter
230            .docling_client
231            .fetch_task_result(&self.task_id, &status)
232            .await?;
233        DocumentConverter::document_from_task_result(&self.input, task_result)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn builder_defaults_to_markdown_output() {
243        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
244            docling_base_url: "http://127.0.0.1:5001/v1".into(),
245            openai_base_url: "https://example.com/v1".into(),
246            vlm_pipeline_model: "test-model".into(),
247            picture_description_model: "test-model".into(),
248            code_formula_model: "test-model".into(),
249            api_key: Some("key".into()),
250            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
251        })
252        .build()
253        .unwrap();
254
255        let request = converter
256            .request_for_input(InputDocument::new(
257                "notes.md",
258                "text/markdown",
259                Bytes::from("# hi"),
260            ))
261            .unwrap();
262
263        assert_eq!(request.output_formats, vec![OutputFormat::Md]);
264    }
265
266    #[test]
267    fn convert_bytes_rejects_unknown_extensions() {
268        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
269            docling_base_url: "http://127.0.0.1:5001/v1".into(),
270            openai_base_url: "https://example.com/v1".into(),
271            vlm_pipeline_model: "test-model".into(),
272            picture_description_model: "test-model".into(),
273            code_formula_model: "test-model".into(),
274            api_key: Some("key".into()),
275            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
276        })
277        .build()
278        .unwrap();
279
280        let error = tokio::runtime::Runtime::new()
281            .unwrap()
282            .block_on(converter.convert_bytes("notes.bin", Bytes::from_static(b"test")))
283            .unwrap_err();
284
285        assert!(error.to_string().contains("unsupported input type"));
286    }
287
288    #[test]
289    fn convert_bytes_with_input_kind_accepts_ambiguous_sources() {
290        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
291            docling_base_url: "http://127.0.0.1:5001/v1".into(),
292            openai_base_url: "https://example.com/v1".into(),
293            vlm_pipeline_model: "test-model".into(),
294            picture_description_model: "test-model".into(),
295            code_formula_model: "test-model".into(),
296            api_key: Some("key".into()),
297            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
298        })
299        .build()
300        .unwrap();
301
302        let request = converter
303            .request_for_input(
304                InputDocument::new("paper.xml", "application/xml", Bytes::from("<article />"))
305                    .with_input_kind(InputKind::XmlJats),
306            )
307            .unwrap();
308
309        assert_eq!(request.input.kind().unwrap(), InputKind::XmlJats);
310    }
311
312    #[test]
313    fn builder_result_body_limit_zero_fails_build() {
314        let error = match ConverterBuilder::new(DoclingRuntimeConfig {
315            docling_base_url: "http://127.0.0.1:5001/v1".into(),
316            openai_base_url: "https://example.com/v1".into(),
317            vlm_pipeline_model: "test-model".into(),
318            picture_description_model: "test-model".into(),
319            code_formula_model: "test-model".into(),
320            api_key: Some("key".into()),
321            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
322        })
323        .result_body_limit(0)
324        .build()
325        {
326            Ok(_) => panic!("build should fail"),
327            Err(error) => error,
328        };
329
330        assert!(error.to_string().contains("result_body_limit"));
331        assert!(error.to_string().contains("greater than 0"));
332    }
333
334    #[test]
335    fn builder_result_body_limit_builds_with_default_outputs() {
336        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
337            docling_base_url: "http://127.0.0.1:5001/v1".into(),
338            openai_base_url: "https://example.com/v1".into(),
339            vlm_pipeline_model: "test-model".into(),
340            picture_description_model: "test-model".into(),
341            code_formula_model: "test-model".into(),
342            api_key: Some("key".into()),
343            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
344        })
345        .result_body_limit(1024)
346        .build()
347        .unwrap();
348
349        let request = converter
350            .request_for_input(InputDocument::new(
351                "notes.md",
352                "text/markdown",
353                Bytes::from("# hi"),
354            ))
355            .unwrap();
356
357        assert_eq!(request.output_formats, vec![OutputFormat::Md]);
358    }
359}