1use async_trait::async_trait;
6use flate2::read::ZlibDecoder;
7use lopdf::Document;
8use ragfs_core::{
9 ContentElement, ContentExtractor, ContentMetadataInfo, ExtractError, ExtractedContent,
10 ExtractedImage,
11};
12use std::io::Read;
13use std::path::Path;
14use tracing::{debug, warn};
15
16pub struct PdfExtractor;
18
19impl PdfExtractor {
20 #[must_use]
22 pub fn new() -> Self {
23 Self
24 }
25}
26
27impl Default for PdfExtractor {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33#[async_trait]
34impl ContentExtractor for PdfExtractor {
35 fn supported_types(&self) -> &[&str] {
36 &["application/pdf"]
37 }
38
39 fn can_extract_by_extension(&self, path: &Path) -> bool {
40 path.extension()
41 .and_then(|ext| ext.to_str())
42 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
43 }
44
45 async fn extract(&self, path: &Path) -> Result<ExtractedContent, ExtractError> {
46 debug!("Extracting PDF: {:?}", path);
47
48 let bytes = tokio::fs::read(path).await?;
50
51 let text = tokio::task::spawn_blocking({
53 let bytes = bytes.clone();
54 move || extract_pdf_text(&bytes)
55 })
56 .await
57 .map_err(|e| ExtractError::Failed(format!("Task join error: {e}")))?
58 .map_err(|e| ExtractError::Failed(format!("PDF extraction failed: {e}")))?;
59
60 let images = tokio::task::spawn_blocking(move || extract_pdf_images(&bytes))
62 .await
63 .map_err(|e| ExtractError::Failed(format!("Image extraction task error: {e}")))?;
64
65 let elements = build_elements(&text);
67
68 let page_count = estimate_page_count(&text);
70
71 Ok(ExtractedContent {
72 text,
73 elements,
74 images,
75 metadata: ContentMetadataInfo {
76 page_count: Some(page_count),
77 ..Default::default()
78 },
79 })
80 }
81}
82
83fn extract_pdf_text(bytes: &[u8]) -> Result<String, String> {
85 pdf_extract::extract_text_from_mem(bytes).map_err(|e| e.to_string())
86}
87
88const MAX_IMAGES: usize = 100;
90const MAX_TOTAL_BYTES: usize = 50 * 1024 * 1024; const MIN_DIMENSION: u32 = 50; fn extract_pdf_images(bytes: &[u8]) -> Vec<ExtractedImage> {
95 let doc = match Document::load_mem(bytes) {
96 Ok(d) => d,
97 Err(e) => {
98 warn!("Failed to load PDF for image extraction: {}", e);
99 return vec![];
100 }
101 };
102
103 let mut images = Vec::new();
104 let mut total_bytes = 0usize;
105
106 let pages = doc.get_pages();
107 for (page_num, page_id) in pages {
108 if images.len() >= MAX_IMAGES {
109 debug!(
110 "Reached maximum image count ({}), stopping extraction",
111 MAX_IMAGES
112 );
113 break;
114 }
115
116 match doc.get_page_images(page_id) {
117 Ok(page_images) => {
118 for pdf_image in page_images {
119 if images.len() >= MAX_IMAGES || total_bytes >= MAX_TOTAL_BYTES {
120 break;
121 }
122
123 if pdf_image.width < i64::from(MIN_DIMENSION)
125 || pdf_image.height < i64::from(MIN_DIMENSION)
126 {
127 debug!(
128 "Skipping small image: {}x{}",
129 pdf_image.width, pdf_image.height
130 );
131 continue;
132 }
133
134 let budget = MAX_TOTAL_BYTES - total_bytes;
137 if let Some(extracted) = decode_pdf_image(&pdf_image, page_num, budget) {
138 total_bytes += extracted.data.len();
139 images.push(extracted);
140 }
141 }
142 }
143 Err(e) => {
144 debug!("Failed to get images from page {}: {}", page_num, e);
145 }
146 }
147 }
148
149 debug!(
150 "Extracted {} images from PDF ({} bytes total)",
151 images.len(),
152 total_bytes
153 );
154 images
155}
156
157fn decode_pdf_image(
162 pdf_image: &lopdf::xobject::PdfImage<'_>,
163 page_num: u32,
164 budget: usize,
165) -> Option<ExtractedImage> {
166 let filters = pdf_image.filters.as_ref()?;
167
168 let (data, mime_type) = if filters.iter().any(|f| f == "DCTDecode") {
170 (pdf_image.content.to_vec(), "image/jpeg".to_string())
172 } else if filters.iter().any(|f| f == "FlateDecode") {
173 match decode_flate_image(pdf_image, budget) {
175 Ok((data, mime)) => (data, mime),
176 Err(e) => {
177 debug!("Failed to decode FlateDecode image: {}", e);
178 return None;
179 }
180 }
181 } else if filters.iter().any(|f| f == "JPXDecode") {
182 (pdf_image.content.to_vec(), "image/jp2".to_string())
184 } else {
185 debug!("Unsupported image filter: {:?}", filters);
187 return None;
188 };
189
190 if data.len() > budget {
193 debug!(
194 "Skipping image: {} bytes exceeds the remaining budget of {} bytes",
195 data.len(),
196 budget
197 );
198 return None;
199 }
200
201 Some(ExtractedImage {
202 data,
203 mime_type,
204 caption: None, page: Some(page_num),
206 })
207}
208
209fn decode_flate_image(
211 pdf_image: &lopdf::xobject::PdfImage<'_>,
212 budget: usize,
213) -> Result<(Vec<u8>, String), String> {
214 let mut decoder = ZlibDecoder::new(pdf_image.content);
218 let mut decompressed = Vec::new();
219 decoder
220 .by_ref()
221 .take(budget as u64 + 1)
222 .read_to_end(&mut decompressed)
223 .map_err(|e| format!("Decompression failed: {e}"))?;
224
225 if decompressed.len() > budget {
226 return Err(format!(
227 "Decompressed image exceeds the remaining budget of {budget} bytes"
228 ));
229 }
230
231 let color_space = pdf_image.color_space.as_deref().unwrap_or("DeviceRGB");
233 let width = pdf_image.width as u32;
234 let height = pdf_image.height as u32;
235
236 let img = match color_space {
237 "DeviceRGB" | "RGB" => image::RgbImage::from_raw(width, height, decompressed)
238 .map(image::DynamicImage::ImageRgb8),
239 "DeviceGray" | "Gray" => image::GrayImage::from_raw(width, height, decompressed)
240 .map(image::DynamicImage::ImageLuma8),
241 "DeviceCMYK" | "CMYK" => {
242 let rgb_data = cmyk_to_rgb(&decompressed);
244 image::RgbImage::from_raw(width, height, rgb_data).map(image::DynamicImage::ImageRgb8)
245 }
246 _ => {
247 debug!("Unknown color space '{}', attempting RGB", color_space);
249 image::RgbImage::from_raw(width, height, decompressed)
250 .map(image::DynamicImage::ImageRgb8)
251 }
252 };
253
254 let img = img.ok_or_else(|| "Failed to create image from raw data".to_string())?;
255
256 let mut png_data = Vec::new();
258 img.write_to(
259 &mut std::io::Cursor::new(&mut png_data),
260 image::ImageFormat::Png,
261 )
262 .map_err(|e| format!("PNG encoding failed: {e}"))?;
263
264 Ok((png_data, "image/png".to_string()))
265}
266
267#[allow(clippy::many_single_char_names)]
269fn cmyk_to_rgb(cmyk: &[u8]) -> Vec<u8> {
270 let mut rgb = Vec::with_capacity((cmyk.len() / 4) * 3);
271 for chunk in cmyk.as_chunks::<4>().0 {
272 let c = f32::from(chunk[0]) / 255.0;
273 let m = f32::from(chunk[1]) / 255.0;
274 let y = f32::from(chunk[2]) / 255.0;
275 let k = f32::from(chunk[3]) / 255.0;
276
277 let r = 255.0 * (1.0 - c) * (1.0 - k);
278 let g = 255.0 * (1.0 - m) * (1.0 - k);
279 let b = 255.0 * (1.0 - y) * (1.0 - k);
280
281 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
282 {
283 rgb.push(r as u8);
284 rgb.push(g as u8);
285 rgb.push(b as u8);
286 }
287 }
288 rgb
289}
290
291fn build_elements(text: &str) -> Vec<ContentElement> {
293 let mut elements = Vec::new();
294 let mut current_offset = 0u64;
295
296 for paragraph in text.split("\n\n") {
298 let trimmed = paragraph.trim();
299 if trimmed.is_empty() {
300 current_offset += paragraph.len() as u64 + 2; continue;
302 }
303
304 if looks_like_heading(trimmed) {
306 elements.push(ContentElement::Heading {
307 level: 1,
308 text: trimmed.to_string(),
309 byte_offset: current_offset,
310 });
311 } else {
312 elements.push(ContentElement::Paragraph {
313 text: trimmed.to_string(),
314 byte_offset: current_offset,
315 });
316 }
317
318 current_offset += paragraph.len() as u64 + 2;
319 }
320
321 elements
322}
323
324fn looks_like_heading(text: &str) -> bool {
326 if text.len() > 100 {
328 return false;
329 }
330
331 if text.ends_with('.') {
333 return false;
334 }
335
336 if text.contains('\n') {
338 return false;
339 }
340
341 let words: Vec<&str> = text.split_whitespace().collect();
343 if words.len() <= 8 {
344 let caps_count = words
346 .iter()
347 .filter(|w| w.chars().next().is_some_and(char::is_uppercase))
348 .count();
349 return caps_count >= words.len() / 2;
350 }
351
352 false
353}
354
355fn estimate_page_count(text: &str) -> u32 {
357 let form_feeds = text.matches('\x0C').count();
359 if form_feeds > 0 {
360 return (form_feeds + 1) as u32;
361 }
362
363 let chars = text.len();
365 std::cmp::max(1, (chars / 3000) as u32)
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_looks_like_heading() {
374 assert!(looks_like_heading("Chapter 1"));
375 assert!(looks_like_heading("INTRODUCTION"));
376 assert!(looks_like_heading("The Quick Brown Fox"));
377 assert!(!looks_like_heading("This is a normal sentence."));
378 assert!(!looks_like_heading(
379 "This is a very long paragraph that goes on and on and definitely is not a heading"
380 ));
381 }
382
383 #[test]
384 fn test_estimate_page_count() {
385 assert_eq!(estimate_page_count("short"), 1);
386 assert_eq!(estimate_page_count(&"x".repeat(6000)), 2);
387 assert_eq!(estimate_page_count("page1\x0Cpage2\x0Cpage3"), 3);
388 }
389
390 #[test]
391 fn test_build_elements() {
392 let text = "Title\n\nFirst paragraph here.\n\nSecond paragraph.";
393 let elements = build_elements(text);
394 assert_eq!(elements.len(), 3);
395 }
396
397 #[test]
400 fn test_decode_flate_image_respects_budget() {
401 use flate2::Compression;
402 use flate2::write::ZlibEncoder;
403 use std::io::Write;
404
405 let pixels = vec![7u8; 48];
407 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
408 encoder.write_all(&pixels).unwrap();
409 let compressed = encoder.finish().unwrap();
410
411 let dict = lopdf::Dictionary::new();
412 let image = lopdf::xobject::PdfImage {
413 id: (1, 0),
414 width: 4,
415 height: 4,
416 color_space: Some("DeviceRGB".to_string()),
417 filters: Some(vec!["FlateDecode".to_string()]),
418 bits_per_component: Some(8),
419 content: &compressed,
420 origin_dict: &dict,
421 };
422
423 assert!(decode_flate_image(&image, 16).is_err());
425
426 let (png, mime) = decode_flate_image(&image, 4096).unwrap();
428 assert_eq!(mime, "image/png");
429 assert!(!png.is_empty());
430 }
431}