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 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#[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 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 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}