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}