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