cloudiful_docling_convert/
facade.rs1use 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 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 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 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#[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 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 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}