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    pub async fn convert_bytes(
124        &self,
125        filename: impl Into<String>,
126        bytes: impl Into<Bytes>,
127    ) -> Result<ConvertedDocument> {
128        let filename = filename.into();
129        let input_kind =
130            InputKind::from_filename_and_media_type(&filename, None).ok_or_else(|| {
131                PdfConvertError::validation_error(
132                    "filename",
133                    format!("unsupported input type for '{}'", filename),
134                )
135            })?;
136
137        self.convert_input(InputDocument::new(
138            filename.clone(),
139            input_kind.canonical_media_type(&filename, None),
140            bytes,
141        ))
142        .await
143    }
144
145    pub async fn convert_bytes_with_input_kind(
146        &self,
147        filename: impl Into<String>,
148        bytes: impl Into<Bytes>,
149        input_kind: InputKind,
150    ) -> Result<ConvertedDocument> {
151        let filename = filename.into();
152
153        self.convert_input(
154            InputDocument::new(
155                filename.clone(),
156                input_kind.canonical_media_type(&filename, None),
157                bytes,
158            )
159            .with_input_kind(input_kind),
160        )
161        .await
162    }
163}
164
165/// A resumable handle to a whole-document asynchronous Docling conversion.
166///
167/// The caller controls the polling cadence and deadline; the underlying remote
168/// task keeps running in Docling while this handle is idle, so the handle can
169/// be serialized by its [`task_id`](Self::task_id) and resumed after a restart
170/// by submitting a new handle for the same remote task through the client API.
171#[derive(Clone)]
172pub struct DoclingTaskHandle {
173    converter: DocumentConverter,
174    input: InputDocument,
175    task_id: String,
176}
177
178impl DoclingTaskHandle {
179    pub fn task_id(&self) -> &str {
180        &self.task_id
181    }
182
183    /// Poll the remote task status. Uses Docling's long-polling endpoint, which
184    /// blocks server-side for a bounded period before returning the status.
185    pub async fn poll_status(&self) -> Result<TaskStatusResponse> {
186        self.converter
187            .docling_client
188            .poll_task_status(&self.task_id)
189            .await
190    }
191
192    /// Fetch and parse the completed remote result. Requires the task to have
193    /// reached a terminal status; call [`Self::poll_status`] first.
194    pub async fn fetch_result(&self) -> Result<ConvertedDocument> {
195        let status = self
196            .converter
197            .docling_client
198            .poll_task_status(&self.task_id)
199            .await?;
200        let task_result = self
201            .converter
202            .docling_client
203            .fetch_task_result(&self.task_id, &status)
204            .await?;
205        DocumentConverter::document_from_task_result(&self.input, task_result)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn builder_defaults_to_markdown_output() {
215        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
216            docling_base_url: "http://127.0.0.1:5001/v1".into(),
217            openai_base_url: "https://example.com/v1".into(),
218            vlm_pipeline_model: "test-model".into(),
219            picture_description_model: "test-model".into(),
220            code_formula_model: "test-model".into(),
221            api_key: Some("key".into()),
222            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
223        })
224        .build()
225        .unwrap();
226
227        let request = converter
228            .request_for_input(InputDocument::new(
229                "notes.md",
230                "text/markdown",
231                Bytes::from("# hi"),
232            ))
233            .unwrap();
234
235        assert_eq!(request.output_formats, vec![OutputFormat::Md]);
236    }
237
238    #[test]
239    fn convert_bytes_rejects_unknown_extensions() {
240        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
241            docling_base_url: "http://127.0.0.1:5001/v1".into(),
242            openai_base_url: "https://example.com/v1".into(),
243            vlm_pipeline_model: "test-model".into(),
244            picture_description_model: "test-model".into(),
245            code_formula_model: "test-model".into(),
246            api_key: Some("key".into()),
247            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
248        })
249        .build()
250        .unwrap();
251
252        let error = tokio::runtime::Runtime::new()
253            .unwrap()
254            .block_on(converter.convert_bytes("notes.bin", Bytes::from_static(b"test")))
255            .unwrap_err();
256
257        assert!(error.to_string().contains("unsupported input type"));
258    }
259
260    #[test]
261    fn convert_bytes_with_input_kind_accepts_ambiguous_sources() {
262        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
263            docling_base_url: "http://127.0.0.1:5001/v1".into(),
264            openai_base_url: "https://example.com/v1".into(),
265            vlm_pipeline_model: "test-model".into(),
266            picture_description_model: "test-model".into(),
267            code_formula_model: "test-model".into(),
268            api_key: Some("key".into()),
269            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
270        })
271        .build()
272        .unwrap();
273
274        let request = converter
275            .request_for_input(
276                InputDocument::new("paper.xml", "application/xml", Bytes::from("<article />"))
277                    .with_input_kind(InputKind::XmlJats),
278            )
279            .unwrap();
280
281        assert_eq!(request.input.kind().unwrap(), InputKind::XmlJats);
282    }
283
284    #[test]
285    fn builder_result_body_limit_zero_fails_build() {
286        let error = match ConverterBuilder::new(DoclingRuntimeConfig {
287            docling_base_url: "http://127.0.0.1:5001/v1".into(),
288            openai_base_url: "https://example.com/v1".into(),
289            vlm_pipeline_model: "test-model".into(),
290            picture_description_model: "test-model".into(),
291            code_formula_model: "test-model".into(),
292            api_key: Some("key".into()),
293            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
294        })
295        .result_body_limit(0)
296        .build()
297        {
298            Ok(_) => panic!("build should fail"),
299            Err(error) => error,
300        };
301
302        assert!(error.to_string().contains("result_body_limit"));
303        assert!(error.to_string().contains("greater than 0"));
304    }
305
306    #[test]
307    fn builder_result_body_limit_builds_with_default_outputs() {
308        let converter = ConverterBuilder::new(DoclingRuntimeConfig {
309            docling_base_url: "http://127.0.0.1:5001/v1".into(),
310            openai_base_url: "https://example.com/v1".into(),
311            vlm_pipeline_model: "test-model".into(),
312            picture_description_model: "test-model".into(),
313            code_formula_model: "test-model".into(),
314            api_key: Some("key".into()),
315            ..DoclingRuntimeConfig::without_vlm("http://127.0.0.1:5001/v1")
316        })
317        .result_body_limit(1024)
318        .build()
319        .unwrap();
320
321        let request = converter
322            .request_for_input(InputDocument::new(
323                "notes.md",
324                "text/markdown",
325                Bytes::from("# hi"),
326            ))
327            .unwrap();
328
329        assert_eq!(request.output_formats, vec![OutputFormat::Md]);
330    }
331}