Skip to main content

cloudiful_docling_convert/
document.rs

1use std::path::Path;
2use std::str::FromStr;
3
4use bytes::Bytes;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::error::{PdfConvertError, Result};
9use crate::models::{Bookmark, ChunkMetadata};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum OutputFormat {
13    Json,
14    Md,
15    Text,
16}
17
18impl OutputFormat {
19    pub fn as_api_value(self) -> &'static str {
20        match self {
21            Self::Json => "json",
22            Self::Md => "md",
23            Self::Text => "text",
24        }
25    }
26
27    pub fn extension(self) -> &'static str {
28        self.as_api_value()
29    }
30}
31
32impl std::fmt::Display for OutputFormat {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.write_str(self.as_api_value())
35    }
36}
37
38impl FromStr for OutputFormat {
39    type Err = PdfConvertError;
40
41    fn from_str(value: &str) -> Result<Self> {
42        match value {
43            "json" => Ok(Self::Json),
44            "md" => Ok(Self::Md),
45            "text" => Ok(Self::Text),
46            other => Err(PdfConvertError::validation_error(
47                "format",
48                format!("unsupported output format: {}", other),
49            )),
50        }
51    }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum InputKind {
57    Pdf,
58    Docx,
59    Markdown,
60    Text,
61}
62
63impl InputKind {
64    pub fn from_path(path: &Path) -> Option<Self> {
65        let file_name = path.file_name().and_then(|name| name.to_str())?;
66        Self::from_filename_and_media_type(file_name, None)
67    }
68
69    pub fn from_filename_and_media_type(filename: &str, media_type: Option<&str>) -> Option<Self> {
70        let ext = Path::new(filename)
71            .extension()
72            .and_then(|ext| ext.to_str())
73            .map(|ext| ext.to_ascii_lowercase());
74        let media_type = media_type.map(|value| {
75            value
76                .split(';')
77                .next()
78                .unwrap_or(value)
79                .trim()
80                .to_ascii_lowercase()
81        });
82
83        match (ext.as_deref(), media_type.as_deref()) {
84            (Some("pdf"), _) | (_, Some("application/pdf")) => Some(Self::Pdf),
85            (Some("docx"), _)
86            | (
87                _,
88                Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
89            ) => Some(Self::Docx),
90            (Some("md"), _)
91            | (Some("markdown"), _)
92            | (_, Some("text/markdown"))
93            | (_, Some("text/x-markdown")) => Some(Self::Markdown),
94            (Some("txt"), _) | (_, Some("text/plain")) => Some(Self::Text),
95            _ => None,
96        }
97    }
98
99    pub fn media_type(self) -> &'static str {
100        match self {
101            Self::Pdf => "application/pdf",
102            Self::Docx => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
103            Self::Markdown => "text/markdown",
104            Self::Text => "text/plain",
105        }
106    }
107
108    pub fn default_extension(self) -> &'static str {
109        match self {
110            Self::Pdf => "pdf",
111            Self::Docx => "docx",
112            Self::Markdown => "md",
113            Self::Text => "txt",
114        }
115    }
116
117    pub fn reading_label(self) -> &'static str {
118        match self {
119            Self::Pdf => "Reading PDF...",
120            Self::Docx => "Reading DOCX...",
121            Self::Markdown => "Reading Markdown...",
122            Self::Text => "Reading text file...",
123        }
124    }
125
126    pub fn from_formats_value(self) -> &'static str {
127        match self {
128            Self::Pdf => "pdf",
129            Self::Docx => "docx",
130            Self::Markdown => "md",
131            Self::Text => "text",
132        }
133    }
134}
135
136#[derive(Debug, Clone)]
137pub struct InputDocument {
138    pub filename: String,
139    pub media_type: String,
140    pub bytes: Bytes,
141}
142
143impl InputDocument {
144    pub fn new(
145        filename: impl Into<String>,
146        media_type: impl Into<String>,
147        bytes: impl Into<Bytes>,
148    ) -> Self {
149        Self {
150            filename: filename.into(),
151            media_type: media_type.into(),
152            bytes: bytes.into(),
153        }
154    }
155
156    pub fn from_path_and_bytes(path: &Path, bytes: impl Into<Bytes>) -> Result<Self> {
157        let filename = path
158            .file_name()
159            .and_then(|name| name.to_str())
160            .ok_or_else(|| {
161                PdfConvertError::validation_error(
162                    "input_path",
163                    format!("path '{}' does not have a valid file name", path.display()),
164                )
165            })?;
166        let kind = InputKind::from_path(path).ok_or_else(|| {
167            PdfConvertError::validation_error(
168                "input_path",
169                format!("unsupported file type for '{}'", path.display()),
170            )
171        })?;
172
173        Ok(Self::new(filename, kind.media_type(), bytes))
174    }
175
176    pub fn kind(&self) -> Result<InputKind> {
177        InputKind::from_filename_and_media_type(&self.filename, Some(&self.media_type)).ok_or_else(
178            || {
179                PdfConvertError::validation_error(
180                    "input",
181                    format!(
182                        "unsupported input type for '{}' ({})",
183                        self.filename, self.media_type
184                    ),
185                )
186            },
187        )
188    }
189}
190
191#[derive(Debug, Clone)]
192pub struct ConvertRequest {
193    pub input: InputDocument,
194    pub output_formats: Vec<OutputFormat>,
195    pub options: ConvertOptions,
196}
197
198impl ConvertRequest {
199    pub fn validate(&self) -> Result<InputKind> {
200        let kind = self.input.kind()?;
201        match (&kind, &self.options) {
202            (InputKind::Pdf, ConvertOptions::Pdf(_))
203            | (InputKind::Docx, ConvertOptions::Generic(_))
204            | (InputKind::Markdown, ConvertOptions::Generic(_))
205            | (InputKind::Text, ConvertOptions::Text(_)) => {}
206            (InputKind::Pdf, _) => {
207                return Err(PdfConvertError::validation_error(
208                    "options",
209                    "PDF input requires Pdf convert options",
210                ));
211            }
212            (InputKind::Docx | InputKind::Markdown, _) => {
213                return Err(PdfConvertError::validation_error(
214                    "options",
215                    "docx/md input requires GenericFileConvertOptions",
216                ));
217            }
218            (InputKind::Text, _) => {
219                return Err(PdfConvertError::validation_error(
220                    "options",
221                    "txt input requires TextConvertOptions",
222                ));
223            }
224        }
225
226        if self.output_formats.is_empty() {
227            return Err(PdfConvertError::validation_error(
228                "output_formats",
229                "at least one output format is required",
230            ));
231        }
232
233        if let ConvertOptions::Pdf(options) = &self.options {
234            options.validate()?;
235        }
236
237        Ok(kind)
238    }
239}
240
241#[derive(Debug, Clone)]
242pub enum ConvertOptions {
243    Pdf(PdfConvertOptions),
244    Generic(GenericFileConvertOptions),
245    Text(TextConvertOptions),
246}
247
248#[derive(Debug, Clone)]
249pub struct PdfConvertOptions {
250    pub pages_per_file: u32,
251    pub split_input: bool,
252    pub split_by_bookmark: bool,
253    pub chunking: bool,
254    pub batch_size: usize,
255}
256
257impl Default for PdfConvertOptions {
258    fn default() -> Self {
259        Self {
260            pages_per_file: 5,
261            split_input: true,
262            split_by_bookmark: false,
263            chunking: false,
264            batch_size: 2,
265        }
266    }
267}
268
269impl PdfConvertOptions {
270    pub fn validate(&self) -> Result<()> {
271        if self.pages_per_file == 0 {
272            return Err(PdfConvertError::validation_error(
273                "pages_per_file",
274                "value must be 1 or greater",
275            ));
276        }
277
278        if self.batch_size == 0 {
279            return Err(PdfConvertError::validation_error(
280                "batch_size",
281                "value must be 1 or greater",
282            ));
283        }
284
285        Ok(())
286    }
287}
288
289#[derive(Debug, Clone, Default)]
290pub struct GenericFileConvertOptions {
291    pub chunking: bool,
292}
293
294#[derive(Debug, Clone)]
295pub struct TextConvertOptions {
296    pub normalize_line_endings: bool,
297    pub trim_utf8_bom: bool,
298}
299
300impl Default for TextConvertOptions {
301    fn default() -> Self {
302        Self {
303            normalize_line_endings: true,
304            trim_utf8_bom: true,
305        }
306    }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ConvertedChunk {
311    pub metadata: Option<ChunkMetadata>,
312    pub markdown: Option<String>,
313    pub text: Option<String>,
314    pub json: Option<Value>,
315    pub raw_result: Value,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ConvertedDocumentMetadata {
320    pub input_kind: InputKind,
321    pub media_type: String,
322    pub page_count: Option<u32>,
323    pub outlines: Vec<Bookmark>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct ConvertedDocument {
328    pub filename: String,
329    pub markdown: Option<String>,
330    pub text: Option<String>,
331    pub json: Option<Value>,
332    pub chunks: Vec<ConvertedChunk>,
333    pub metadata: ConvertedDocumentMetadata,
334    pub errors: Vec<String>,
335}
336
337#[derive(Debug, Clone)]
338pub struct FileConvertRequest {
339    pub request: ConvertRequest,
340    pub output_dir: std::path::PathBuf,
341    pub selected_output: OutputFormat,
342    pub overwrite: bool,
343}
344
345#[derive(Debug, Clone)]
346pub struct ConvertedFile {
347    pub document: ConvertedDocument,
348    pub output_paths: Vec<std::path::PathBuf>,
349}
350
351pub fn supported_input_kind(path: &Path) -> bool {
352    InputKind::from_path(path).is_some()
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn detects_supported_input_kinds() {
361        assert_eq!(
362            InputKind::from_path(Path::new("a.pdf")),
363            Some(InputKind::Pdf)
364        );
365        assert_eq!(
366            InputKind::from_path(Path::new("a.docx")),
367            Some(InputKind::Docx)
368        );
369        assert_eq!(
370            InputKind::from_path(Path::new("a.md")),
371            Some(InputKind::Markdown)
372        );
373        assert_eq!(
374            InputKind::from_path(Path::new("a.txt")),
375            Some(InputKind::Text)
376        );
377        assert_eq!(InputKind::from_path(Path::new("a.csv")), None);
378    }
379
380    #[test]
381    fn request_validation_rejects_mismatched_options() {
382        let request = ConvertRequest {
383            input: InputDocument::new("a.txt", "text/plain", Bytes::from_static(b"hello")),
384            output_formats: vec![OutputFormat::Text],
385            options: ConvertOptions::Generic(GenericFileConvertOptions::default()),
386        };
387
388        let err = request.validate().unwrap_err();
389        assert!(err.to_string().contains("TextConvertOptions"));
390    }
391
392    #[test]
393    fn request_validation_rejects_zero_pages_per_file() {
394        let request = ConvertRequest {
395            input: InputDocument::new("a.pdf", "application/pdf", Bytes::from_static(b"%PDF")),
396            output_formats: vec![OutputFormat::Text],
397            options: ConvertOptions::Pdf(PdfConvertOptions {
398                pages_per_file: 0,
399                ..PdfConvertOptions::default()
400            }),
401        };
402
403        let err = request.validate().unwrap_err();
404        assert!(err.to_string().contains("pages_per_file"));
405    }
406
407    #[test]
408    fn request_validation_rejects_zero_batch_size() {
409        let request = ConvertRequest {
410            input: InputDocument::new("a.pdf", "application/pdf", Bytes::from_static(b"%PDF")),
411            output_formats: vec![OutputFormat::Text],
412            options: ConvertOptions::Pdf(PdfConvertOptions {
413                batch_size: 0,
414                ..PdfConvertOptions::default()
415            }),
416        };
417
418        let err = request.validate().unwrap_err();
419        assert!(err.to_string().contains("batch_size"));
420    }
421}