1use super::{OperationError, OperationResult};
7use crate::graphics::ImageFormat;
8use crate::parser::objects::{PdfArray, PdfName, PdfObject, PdfStream};
9use crate::parser::{PdfDocument, PdfReader};
10use std::collections::HashMap;
11use std::fs::{self, File};
12use std::io::{Read, Seek, Write};
13use std::path::{Path, PathBuf};
14
15#[cfg(feature = "external-images")]
16use image::{DynamicImage, GenericImageView, ImageBuffer, ImageFormat as ImageLibFormat, Luma};
17
18#[derive(Debug, Clone)]
22pub struct TransformMatrix {
23 pub a: f64, pub b: f64, pub c: f64, pub d: f64, pub e: f64, pub f: f64, }
30
31impl TransformMatrix {
32 #[allow(dead_code)]
33 fn new(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> Self {
34 Self { a, b, c, d, e, f }
35 }
36
37 #[allow(dead_code)]
39 fn is_90_degree_rotation(&self) -> bool {
40 self.a.abs() < 0.001 && self.d.abs() < 0.001 && self.b.abs() > 0.001 && self.c.abs() > 0.001
42 }
43
44 #[allow(dead_code)]
46 fn is_simple_scale(&self) -> bool {
47 self.b.abs() < 0.001 && self.c.abs() < 0.001 && self.a.abs() > 0.001 && self.d.abs() > 0.001
49 }
50
51 #[allow(dead_code)]
53 fn is_fis2_like_matrix(&self) -> bool {
54 (self.a - 841.68).abs() < 1.0
57 && (self.d - 595.08).abs() < 1.0
58 && self.b.abs() < 0.001
59 && self.c.abs() < 0.001
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct ImagePreprocessingOptions {
66 pub auto_correct_rotation: bool,
68 pub enhance_contrast: bool,
70 pub denoise: bool,
72 pub upscale_small_images: bool,
74 pub upscale_threshold: u32,
76 pub upscale_factor: u32,
78 pub force_grayscale: bool,
80}
81
82impl Default for ImagePreprocessingOptions {
83 fn default() -> Self {
84 Self {
85 auto_correct_rotation: true,
86 enhance_contrast: true,
87 denoise: true,
88 upscale_small_images: true,
89 upscale_threshold: 300,
90 upscale_factor: 2,
91 force_grayscale: false,
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct ExtractImagesOptions {
99 pub output_dir: PathBuf,
101 pub name_pattern: String,
104 pub extract_inline: bool,
106 pub min_size: Option<u32>,
108 pub create_dir: bool,
110 pub preprocessing: ImagePreprocessingOptions,
112}
113
114impl Default for ExtractImagesOptions {
115 fn default() -> Self {
116 Self {
117 output_dir: PathBuf::from("."),
118 name_pattern: "page_{page}_image_{index}.{format}".to_string(),
119 extract_inline: true,
120 min_size: Some(10),
121 create_dir: true,
122 preprocessing: ImagePreprocessingOptions::default(),
123 }
124 }
125}
126
127#[derive(Debug)]
129pub struct ExtractedImage {
130 pub page_number: usize,
132 pub image_index: usize,
134 pub file_path: PathBuf,
136 pub width: u32,
138 pub height: u32,
139 pub format: ImageFormat,
141}
142
143#[derive(Debug, Clone)]
145pub struct ExtractedImageData {
146 pub page_number: usize,
148 pub image_index: usize,
150 pub width: u32,
152 pub height: u32,
154 pub format: ImageFormat,
156 pub data: Vec<u8>,
158}
159
160#[derive(Debug, Clone, Copy)]
162pub struct ImageExtractionLimits {
163 pub max_images: usize,
165 pub max_encoded_bytes_per_image: usize,
167 pub max_total_encoded_bytes: usize,
169 pub max_decoded_pixels_per_image: u64,
171}
172
173impl Default for ImageExtractionLimits {
174 fn default() -> Self {
175 Self {
176 max_images: usize::MAX,
177 max_encoded_bytes_per_image: usize::MAX,
178 max_total_encoded_bytes: usize::MAX,
179 max_decoded_pixels_per_image: u64::MAX,
180 }
181 }
182}
183
184#[derive(Debug, thiserror::Error)]
186pub enum ImageExtractionError {
187 #[error(transparent)]
189 Operation(#[from] OperationError),
190 #[error(
192 "Image extraction limit exceeded for {limit}: maximum {maximum}, attempted {attempted}"
193 )]
194 LimitExceeded {
195 limit: &'static str,
196 maximum: u64,
197 attempted: u64,
198 },
199}
200
201pub type ImageExtractionResult<T> = Result<T, ImageExtractionError>;
203
204impl ImageExtractionError {
205 fn into_operation_error(self) -> OperationError {
206 match self {
207 Self::Operation(error) => error,
208 error @ Self::LimitExceeded { .. } => {
209 OperationError::ProcessingError(error.to_string())
210 }
211 }
212 }
213}
214
215struct ImageExtractionBudget {
216 limits: ImageExtractionLimits,
217 images: usize,
218 encoded_bytes: usize,
219}
220
221impl ImageExtractionBudget {
222 fn new(limits: ImageExtractionLimits) -> Self {
223 Self {
224 limits,
225 images: 0,
226 encoded_bytes: 0,
227 }
228 }
229
230 fn check_pixels(&self, width: u32, height: u32) -> ImageExtractionResult<()> {
231 let pixels = u64::from(width) * u64::from(height);
232 if pixels > self.limits.max_decoded_pixels_per_image {
233 return Err(ImageExtractionError::LimitExceeded {
234 limit: "decoded pixels per image",
235 maximum: self.limits.max_decoded_pixels_per_image,
236 attempted: pixels,
237 });
238 }
239 Ok(())
240 }
241
242 fn check_image_slot(&self) -> ImageExtractionResult<()> {
243 let attempted = self.images.checked_add(1).unwrap_or(usize::MAX);
244 if attempted > self.limits.max_images {
245 return Err(ImageExtractionError::LimitExceeded {
246 limit: "image count",
247 maximum: self.limits.max_images as u64,
248 attempted: attempted as u64,
249 });
250 }
251 Ok(())
252 }
253
254 fn consume(&mut self, bytes: usize) -> ImageExtractionResult<()> {
255 if bytes > self.limits.max_encoded_bytes_per_image {
256 return Err(ImageExtractionError::LimitExceeded {
257 limit: "encoded bytes per image",
258 maximum: self.limits.max_encoded_bytes_per_image as u64,
259 attempted: bytes as u64,
260 });
261 }
262 self.check_image_slot()?;
263 let next_images = self.images + 1;
264 let next_bytes =
265 self.encoded_bytes
266 .checked_add(bytes)
267 .ok_or(ImageExtractionError::LimitExceeded {
268 limit: "total encoded bytes",
269 maximum: self.limits.max_total_encoded_bytes as u64,
270 attempted: u64::MAX,
271 })?;
272 if next_bytes > self.limits.max_total_encoded_bytes {
273 return Err(ImageExtractionError::LimitExceeded {
274 limit: "total encoded bytes",
275 maximum: self.limits.max_total_encoded_bytes as u64,
276 attempted: next_bytes as u64,
277 });
278 }
279 self.images = next_images;
280 self.encoded_bytes = next_bytes;
281 Ok(())
282 }
283}
284
285pub struct ImageExtractor<R: Read + Seek> {
287 document: PdfDocument<R>,
288 options: ExtractImagesOptions,
289 processed_images: HashMap<String, PathBuf>,
291}
292
293impl<R: Read + Seek> ImageExtractor<R> {
294 pub fn new(document: PdfDocument<R>, options: ExtractImagesOptions) -> Self {
296 Self {
297 document,
298 options,
299 processed_images: HashMap::new(),
300 }
301 }
302
303 pub fn visit_images<F>(
310 &mut self,
311 limits: ImageExtractionLimits,
312 mut visitor: F,
313 ) -> ImageExtractionResult<()>
314 where
315 F: FnMut(ExtractedImageData) -> ImageExtractionResult<()>,
316 {
317 let page_count = self
318 .document
319 .page_count()
320 .map_err(|error| OperationError::ParseError(error.to_string()))?;
321 let mut budget = ImageExtractionBudget::new(limits);
322 for page_number in 0..page_count as usize {
323 self.visit_page_images(page_number, &mut budget, &mut visitor)?;
324 }
325 Ok(())
326 }
327
328 pub fn extract_all_in_memory(
335 &mut self,
336 limits: ImageExtractionLimits,
337 ) -> ImageExtractionResult<Vec<ExtractedImageData>> {
338 let mut images = Vec::new();
339 self.visit_images(limits, |image| {
340 images.push(image);
341 Ok(())
342 })?;
343 Ok(images)
344 }
345
346 pub fn extract_from_page_in_memory(
353 &mut self,
354 page_number: usize,
355 limits: ImageExtractionLimits,
356 ) -> ImageExtractionResult<Vec<ExtractedImageData>> {
357 let mut images = Vec::new();
358 let mut budget = ImageExtractionBudget::new(limits);
359 self.visit_page_images(page_number, &mut budget, &mut |image| {
360 images.push(image);
361 Ok(())
362 })?;
363 Ok(images)
364 }
365
366 fn visit_page_images<F>(
367 &mut self,
368 page_number: usize,
369 budget: &mut ImageExtractionBudget,
370 visitor: &mut F,
371 ) -> ImageExtractionResult<()>
372 where
373 F: FnMut(ExtractedImageData) -> ImageExtractionResult<()>,
374 {
375 let page = self
376 .document
377 .get_page(page_number as u32)
378 .map_err(|error| OperationError::ParseError(error.to_string()))?;
379 let resources = self
380 .document
381 .get_page_resources(&page)
382 .map_err(|error| OperationError::ParseError(error.to_string()))?;
383 let mut references = Vec::new();
384 if let Some(resources) = resources {
385 if let Some(PdfObject::Dictionary(xobjects)) = resources.get("XObject") {
386 for object in xobjects.0.values() {
387 if let PdfObject::Reference(number, generation) = object {
388 references.push((*number, *generation));
389 }
390 }
391 }
392 }
393
394 let mut image_index = 0;
395 for (number, generation) in references {
396 let object = self
397 .document
398 .get_object(number, generation)
399 .map_err(|error| OperationError::ParseError(error.to_string()))?;
400 let PdfObject::Stream(stream) = object else {
401 continue;
402 };
403 if !matches!(stream.dict.get("Subtype"), Some(PdfObject::Name(name)) if name.0 == "Image")
404 {
405 continue;
406 }
407 budget.check_image_slot()?;
408 if let Some(image) =
409 self.prepare_image_data(&stream, page_number, image_index, budget)?
410 {
411 budget.consume(image.data.len())?;
412 visitor(image)?;
413 image_index += 1;
414 }
415 }
416
417 if self.options.extract_inline {
418 for content in self
419 .document
420 .get_page_content_streams(&page)
421 .map_err(|error| OperationError::ParseError(error.to_string()))?
422 {
423 self.visit_inline_images(&content, page_number, &mut image_index, budget, visitor)?;
424 }
425 }
426 Ok(())
427 }
428
429 fn prepare_image_data(
430 &self,
431 stream: &PdfStream,
432 page_number: usize,
433 image_index: usize,
434 budget: &ImageExtractionBudget,
435 ) -> ImageExtractionResult<Option<ExtractedImageData>> {
436 let Some(PdfObject::Integer(width @ 1..)) = stream.dict.get("Width") else {
437 return Ok(None);
438 };
439 let Some(PdfObject::Integer(height @ 1..)) = stream.dict.get("Height") else {
440 return Ok(None);
441 };
442 let (Ok(width), Ok(height)) = (u32::try_from(*width), u32::try_from(*height)) else {
443 return Ok(None);
444 };
445 if self
446 .options
447 .min_size
448 .is_some_and(|minimum| width < minimum || height < minimum)
449 {
450 return Ok(None);
451 }
452 budget.check_pixels(width, height)?;
453 let color_space = stream.dict.get("ColorSpace");
454 let bits = match stream.dict.get("BitsPerComponent") {
455 Some(PdfObject::Integer(bits)) => *bits as u8,
456 _ => 8,
457 };
458 let smask = self.extract_smask_alpha(&stream.dict, width, height);
459 let first_filter = match stream.dict.get("Filter") {
460 Some(PdfObject::Name(name)) => Some(name.0.as_str()),
461 Some(PdfObject::Array(filters)) => filters.0.first().and_then(|filter| match filter {
462 PdfObject::Name(name) => Some(name.0.as_str()),
463 _ => None,
464 }),
465 _ => None,
466 };
467 let (data, format) = match first_filter {
468 Some("DCTDecode") => (stream.data.clone(), ImageFormat::Jpeg),
469 Some("FlateDecode" | "LZWDecode") | None => {
470 let decoded = self.decode_image_stream(stream)?;
471 (
472 self.convert_raw_image_data_to_png(
473 &decoded,
474 width,
475 height,
476 color_space,
477 bits,
478 smask.as_deref(),
479 )?,
480 ImageFormat::Png,
481 )
482 }
483 Some("CCITTFaxDecode") => {
484 let decoded = self.decode_image_stream(stream)?;
485 (
486 self.convert_ccitt_to_png(&decoded, width, height)?,
487 ImageFormat::Png,
488 )
489 }
490 Some(_) => return Ok(None),
491 };
492 #[cfg(feature = "external-images")]
493 let data = if self.should_preprocess() {
494 self.preprocess_image_data(&data, width, height, format)?
495 } else {
496 data
497 };
498 Ok(Some(ExtractedImageData {
499 page_number,
500 image_index,
501 width,
502 height,
503 format,
504 data,
505 }))
506 }
507
508 fn visit_inline_images<F>(
509 &self,
510 stream_data: &[u8],
511 page_number: usize,
512 image_index: &mut usize,
513 budget: &mut ImageExtractionBudget,
514 visitor: &mut F,
515 ) -> ImageExtractionResult<()>
516 where
517 F: FnMut(ExtractedImageData) -> ImageExtractionResult<()>,
518 {
519 let mut position = 0;
520 while let Some(begin) = Self::find_bytes(stream_data, b"BI", position) {
521 let Some(id) = Self::find_bytes(stream_data, b"ID", begin + 2) else {
522 break;
523 };
524 let Some(end) = Self::find_bytes(stream_data, b"EI", id + 2) else {
525 break;
526 };
527 budget.check_image_slot()?;
528 let dictionary = String::from_utf8_lossy(&stream_data[begin + 2..id]);
529 let (width, height) = self.parse_inline_image_dict(dictionary.trim());
530 budget.check_pixels(width, height)?;
531 let data = stream_data[id + 2..end].to_vec();
532 budget.consume(data.len())?;
533 let format = self
534 .detect_image_format_from_data(&data)
535 .unwrap_or(ImageFormat::Raw);
536 visitor(ExtractedImageData {
537 page_number,
538 image_index: *image_index,
539 width,
540 height,
541 format,
542 data,
543 })?;
544 *image_index += 1;
545 position = end + 2;
546 }
547 Ok(())
548 }
549
550 fn find_bytes(haystack: &[u8], needle: &[u8], start: usize) -> Option<usize> {
551 haystack
552 .get(start..)?
553 .windows(needle.len())
554 .position(|window| window == needle)
555 .map(|position| start + position)
556 }
557
558 pub fn extract_all(&mut self) -> OperationResult<Vec<ExtractedImage>> {
560 if self.options.create_dir && !self.options.output_dir.exists() {
561 fs::create_dir_all(&self.options.output_dir)?;
562 }
563 let options = self.options.clone();
564 let mut cache = std::mem::take(&mut self.processed_images);
565 let mut images = Vec::new();
566 let result = self.visit_images(ImageExtractionLimits::default(), |image| {
567 images.push(Self::persist_image_data(&options, &mut cache, image)?);
568 Ok(())
569 });
570 self.processed_images = cache;
571 result.map_err(ImageExtractionError::into_operation_error)?;
572 Ok(images)
573 }
574
575 pub fn extract_from_page(
577 &mut self,
578 page_number: usize,
579 ) -> OperationResult<Vec<ExtractedImage>> {
580 if self.options.create_dir && !self.options.output_dir.exists() {
581 fs::create_dir_all(&self.options.output_dir)?;
582 }
583 let options = self.options.clone();
584 let mut cache = std::mem::take(&mut self.processed_images);
585 let mut images = Vec::new();
586 let mut budget = ImageExtractionBudget::new(ImageExtractionLimits::default());
587 let result = self.visit_page_images(page_number, &mut budget, &mut |image| {
588 images.push(Self::persist_image_data(&options, &mut cache, image)?);
589 Ok(())
590 });
591 self.processed_images = cache;
592 result.map_err(ImageExtractionError::into_operation_error)?;
593 Ok(images)
594 }
595
596 fn persist_image_data(
597 options: &ExtractImagesOptions,
598 cache: &mut HashMap<String, PathBuf>,
599 image: ExtractedImageData,
600 ) -> OperationResult<ExtractedImage> {
601 let key = format!("{:x}", md5::compute(&image.data));
602 let allow_deduplication = !options.name_pattern.contains("{page}");
603 let extension = match image.format {
604 ImageFormat::Jpeg => "jpg",
605 ImageFormat::Png => "png",
606 ImageFormat::Tiff => "tiff",
607 ImageFormat::Raw => "rgb",
608 };
609 let filename = options
610 .name_pattern
611 .replace("{page}", &(image.page_number + 1).to_string())
612 .replace("{index}", &(image.image_index + 1).to_string())
613 .replace("{format}", extension);
614 let existing = allow_deduplication
615 .then(|| cache.get(&key).cloned())
616 .flatten();
617 let path = existing.unwrap_or_else(|| options.output_dir.join(filename));
618 if !allow_deduplication || !cache.contains_key(&key) {
619 let mut file = File::create(&path)?;
620 file.write_all(&image.data)?;
621 cache.insert(key, path.clone());
622 }
623 Ok(ExtractedImage {
624 page_number: image.page_number,
625 image_index: image.image_index,
626 file_path: path,
627 width: image.width,
628 height: image.height,
629 format: image.format,
630 })
631 }
632
633 fn detect_image_format_from_data(&self, data: &[u8]) -> OperationResult<ImageFormat> {
635 if data.is_empty() {
636 return Err(OperationError::ParseError(
637 "Image data too short to detect format".to_string(),
638 ));
639 }
640
641 if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
643 return Ok(ImageFormat::Png);
644 }
645
646 if data.len() >= 4 {
648 if &data[0..2] == b"II" && &data[2..4] == b"\x2A\x00" {
649 return Ok(ImageFormat::Tiff); }
651 if &data[0..2] == b"MM" && &data[2..4] == b"\x00\x2A" {
652 return Ok(ImageFormat::Tiff); }
654 }
655
656 if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
658 return Ok(ImageFormat::Jpeg);
659 }
660
661 if data.len() < 2 {
663 return Err(OperationError::ParseError(
664 "Image data too short to detect format".to_string(),
665 ));
666 }
667
668 Ok(ImageFormat::Png)
671 }
672
673 #[cfg(feature = "external-images")]
675 #[allow(dead_code)]
676 fn apply_rotation_transformation(
677 &self,
678 img: DynamicImage,
679 matrix: &TransformMatrix,
680 ) -> OperationResult<DynamicImage> {
681 if matrix.b > 0.0 && matrix.c < 0.0 {
686 Ok(img.rotate90()) } else if matrix.b < 0.0 && matrix.c > 0.0 {
688 Ok(img.rotate270()) } else {
690 Ok(img.rotate90())
692 }
693 }
694
695 #[cfg(feature = "external-images")]
697 #[allow(dead_code)]
698 fn apply_scale_transformation(
699 &self,
700 img: DynamicImage,
701 matrix: &TransformMatrix,
702 ) -> OperationResult<DynamicImage> {
703 let (current_width, current_height) = img.dimensions();
704
705 let new_width = (current_width as f64 * matrix.a.abs()) as u32;
707 let new_height = (current_height as f64 * matrix.d.abs()) as u32;
708
709 if new_width > 0 && new_height > 0 {
710 Ok(img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3))
711 } else {
712 Ok(img)
714 }
715 }
716
717 fn parse_inline_image_dict(&self, dict_str: &str) -> (u32, u32) {
719 let mut width = 100; let mut height = 100; for line in dict_str.lines() {
724 let line = line.trim();
725
726 if line.starts_with("/W ") || line.starts_with("/Width ") {
728 if let Some(value_str) = line.split_whitespace().nth(1) {
729 if let Ok(w) = value_str.parse::<u32>() {
730 width = w;
731 }
732 }
733 }
734
735 if line.starts_with("/H ") || line.starts_with("/Height ") {
737 if let Some(value_str) = line.split_whitespace().nth(1) {
738 if let Ok(h) = value_str.parse::<u32>() {
739 height = h;
740 }
741 }
742 }
743 }
744
745 (width, height)
746 }
747
748 fn decode_image_stream(&self, stream: &PdfStream) -> OperationResult<Vec<u8>> {
755 let parse_options = self.document.options();
756
757 let needs_resolution = ["DecodeParms", "DP"].into_iter().any(|key| {
758 stream
759 .dict
760 .0
761 .get(&PdfName(key.to_string()))
762 .map(Self::contains_reference)
763 .unwrap_or(false)
764 });
765
766 let decode_result = if needs_resolution {
767 let mut dict = stream.dict.clone();
768 for key in ["DecodeParms", "DP"] {
769 if let Some(obj) = dict.0.get(&PdfName(key.to_string())).cloned() {
770 let resolved = self.resolve_decode_params(&obj);
771 dict.0.insert(PdfName(key.to_string()), resolved);
772 }
773 }
774 PdfStream {
775 dict,
776 data: stream.data.clone(),
777 }
778 .decode(&parse_options)
779 } else {
780 stream.decode(&parse_options)
781 };
782
783 decode_result
784 .map_err(|e| OperationError::ParseError(format!("Failed to decode image stream: {e}")))
785 }
786
787 fn contains_reference(obj: &PdfObject) -> bool {
789 match obj {
790 PdfObject::Reference(_, _) => true,
791 PdfObject::Array(arr) => arr
792 .0
793 .iter()
794 .any(|e| matches!(e, PdfObject::Reference(_, _))),
795 _ => false,
796 }
797 }
798
799 fn resolve_decode_params(&self, obj: &PdfObject) -> PdfObject {
802 let resolved = self.document.resolve(obj).unwrap_or_else(|e| {
803 tracing::warn!("Failed to resolve /DecodeParms reference: {e}");
807 obj.clone()
808 });
809 match resolved {
810 PdfObject::Array(arr) => PdfObject::Array(PdfArray(
811 arr.0
812 .iter()
813 .map(|e| self.document.resolve(e).unwrap_or_else(|_| e.clone()))
814 .collect(),
815 )),
816 other => other,
817 }
818 }
819
820 fn try_resolve_indexed(
823 &self,
824 color_space: Option<&PdfObject>,
825 ) -> Option<(PdfObject, usize, Vec<u8>)> {
826 let array = color_space?.as_array()?;
827 let first = array.0.first()?.as_name()?;
828 if first.0 != "Indexed" && first.0 != "I" {
829 return None;
830 }
831 let base = self.document.resolve(array.0.get(1)?).ok()?;
832 let hival = array.0.get(2)?.as_integer()?.max(0) as usize;
833 let lookup = self.resolve_lookup_bytes(array.0.get(3)?)?;
834 Some((base, hival, lookup))
835 }
836
837 fn resolve_lookup_bytes(&self, lookup: &PdfObject) -> Option<Vec<u8>> {
840 match self.document.resolve(lookup).ok()? {
841 PdfObject::String(s) => Some(s.0),
842 PdfObject::Stream(s) => s.decode(&self.document.options()).ok(),
843 _ => None,
844 }
845 }
846
847 fn icc_components(&self, color_space: Option<&PdfObject>) -> Option<u8> {
849 let array = color_space?.as_array()?;
850 if array.0.first()?.as_name()?.0 != "ICCBased" {
851 return None;
852 }
853 let stream = self.document.resolve(array.0.get(1)?).ok()?;
854 let n = stream
855 .as_stream()?
856 .dict
857 .0
858 .get(&PdfName("N".to_string()))?
859 .as_integer()?;
860 Some(n.clamp(1, 4) as u8)
863 }
864
865 fn convert_raw_image_data_to_png(
871 &self,
872 data: &[u8],
873 width: u32,
874 height: u32,
875 color_space: Option<&PdfObject>,
876 bits_per_component: u8,
877 smask_alpha: Option<&[u8]>,
878 ) -> OperationResult<Vec<u8>> {
879 let resolved_cs = color_space.and_then(|cs| self.document.resolve(cs).ok());
881 let cs = resolved_cs.as_ref().or(color_space);
882
883 if let Some((base, hival, palette)) = self.try_resolve_indexed(cs) {
887 let base_components = self.color_space_component_count(Some(&base)) as usize;
888 let indices: std::borrow::Cow<[u8]> = if bits_per_component == 8 {
891 std::borrow::Cow::Borrowed(data)
892 } else {
893 std::borrow::Cow::Owned(unpack_indices(data, width, height, bits_per_component))
894 };
895 let pixel_count = (width as usize) * (height as usize);
896 if indices.len() < pixel_count {
897 return Err(OperationError::ParseError(format!(
898 "Indexed image data too small: expected {} indices, got {}",
899 pixel_count,
900 indices.len()
901 )));
902 }
903 let rgb = expand_indexed(&indices[..pixel_count], &palette, base_components, hival);
904 return self.encode_png_maybe_alpha(
905 &rgb,
906 width,
907 height,
908 base_components as u8,
909 8,
910 smask_alpha,
911 );
912 }
913
914 let icc_n = self.icc_components(cs);
916 let components = image_sample_components(cs, icc_n);
917
918 let bytes_per_sample = if bits_per_component <= 8 { 1 } else { 2 };
922 let expected_size = (width as usize)
923 * (height as usize)
924 * (components as usize)
925 * (bytes_per_sample as usize);
926
927 if data.len() < expected_size {
929 return Err(OperationError::ParseError(format!(
930 "Image data too small: expected {}, got {}",
931 expected_size,
932 data.len()
933 )));
934 }
935
936 self.encode_png_maybe_alpha(
938 data,
939 width,
940 height,
941 components,
942 bits_per_component,
943 smask_alpha,
944 )
945 }
946
947 fn color_space_component_count(&self, color_space: Option<&PdfObject>) -> u8 {
950 let icc_n = self.icc_components(color_space);
951 image_sample_components(color_space, icc_n)
952 }
953
954 fn extract_smask_alpha(
960 &self,
961 image_dict: &crate::parser::objects::PdfDictionary,
962 width: u32,
963 height: u32,
964 ) -> Option<Vec<u8>> {
965 let smask = image_dict.0.get(&PdfName("SMask".to_string()))?;
966 let resolved = self.document.resolve(smask).ok()?;
967 let stream = match &resolved {
968 PdfObject::Stream(s) => s,
969 _ => return None,
970 };
971 let dict = &stream.dict.0;
972 let sw_i = dict.get(&PdfName("Width".to_string()))?.as_integer()?;
976 let sh_i = dict.get(&PdfName("Height".to_string()))?.as_integer()?;
977 if sw_i <= 0 || sh_i <= 0 {
978 return None;
979 }
980 let sw = sw_i as u32;
981 let sh = sh_i as u32;
982 let sbpc = dict
983 .get(&PdfName("BitsPerComponent".to_string()))
984 .and_then(|b| b.as_integer())
985 .unwrap_or(8);
986 if sbpc != 8 {
987 return None; }
989
990 let gray = self.decode_image_stream(stream).ok()?;
991 let expected = (sw as usize) * (sh as usize);
992 if gray.len() < expected {
995 return None;
996 }
997 let gray = &gray[..expected];
998
999 if sw == width && sh == height {
1000 return Some(gray.to_vec());
1001 }
1002 let mut out = Vec::with_capacity((width as usize) * (height as usize));
1004 for y in 0..height {
1005 let sy = ((y as u64 * sh as u64) / height as u64) as usize;
1006 let row = sy * sw as usize;
1007 for x in 0..width {
1008 let sx = ((x as u64 * sw as u64) / width as u64) as usize;
1009 out.push(gray[row + sx]);
1010 }
1011 }
1012 Some(out)
1013 }
1014
1015 fn encode_png_maybe_alpha(
1021 &self,
1022 samples: &[u8],
1023 width: u32,
1024 height: u32,
1025 components: u8,
1026 bits_per_component: u8,
1027 alpha: Option<&[u8]>,
1028 ) -> OperationResult<Vec<u8>> {
1029 match alpha {
1030 Some(a) if bits_per_component == 8 && (components == 1 || components == 3) => {
1031 let pixel_count = (width as usize) * (height as usize);
1032 debug_assert!(
1038 samples.len() >= pixel_count * components as usize,
1039 "sample buffer too short: {} < {}",
1040 samples.len(),
1041 pixel_count * components as usize
1042 );
1043 debug_assert_eq!(a.len(), pixel_count, "alpha length must match pixel count");
1044 let mut rgba = Vec::with_capacity(pixel_count * 4);
1045 for i in 0..pixel_count {
1046 let (r, g, b) = if components == 3 {
1047 let p = i * 3;
1048 (
1049 *samples.get(p).unwrap_or(&0),
1050 *samples.get(p + 1).unwrap_or(&0),
1051 *samples.get(p + 2).unwrap_or(&0),
1052 )
1053 } else {
1054 let v = *samples.get(i).unwrap_or(&0);
1055 (v, v, v)
1056 };
1057 let al = *a.get(i).unwrap_or(&255);
1059 rgba.extend_from_slice(&[r, g, b, al]);
1060 }
1061 self.create_png_from_raw_data(&rgba, width, height, 4, 8)
1062 }
1063 _ => self.create_png_from_raw_data(
1064 samples,
1065 width,
1066 height,
1067 components,
1068 bits_per_component,
1069 ),
1070 }
1071 }
1072
1073 fn create_png_from_raw_data(
1075 &self,
1076 data: &[u8],
1077 width: u32,
1078 height: u32,
1079 components: u8,
1080 bits_per_component: u8,
1081 ) -> OperationResult<Vec<u8>> {
1082 let mut png_data = Vec::new();
1084
1085 png_data.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
1087
1088 let mut ihdr = Vec::new();
1090 ihdr.extend_from_slice(&width.to_be_bytes());
1091 ihdr.extend_from_slice(&height.to_be_bytes());
1092 ihdr.push(bits_per_component);
1093
1094 let color_type = match components {
1096 1 => 0, 3 => 2, 4 => 6, _ => 2, };
1101 ihdr.push(color_type);
1102 ihdr.push(0); ihdr.push(0); ihdr.push(0); self.write_png_chunk(&mut png_data, b"IHDR", &ihdr);
1107
1108 let compressed_data = self.compress_image_data(data, width, height, components)?;
1110 self.write_png_chunk(&mut png_data, b"IDAT", &compressed_data);
1111
1112 self.write_png_chunk(&mut png_data, b"IEND", &[]);
1114
1115 Ok(png_data)
1116 }
1117
1118 fn write_png_chunk(&self, output: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
1120 output.extend_from_slice(&(data.len() as u32).to_be_bytes());
1122
1123 output.extend_from_slice(chunk_type);
1125
1126 output.extend_from_slice(data);
1128
1129 let crc = self.calculate_crc32(chunk_type, data);
1131 output.extend_from_slice(&crc.to_be_bytes());
1132 }
1133
1134 fn calculate_crc32(&self, chunk_type: &[u8; 4], data: &[u8]) -> u32 {
1136 let mut crc: u32 = 0xFFFFFFFF;
1138
1139 for &byte in chunk_type {
1141 crc ^= byte as u32;
1142 for _ in 0..8 {
1143 if crc & 1 != 0 {
1144 crc = (crc >> 1) ^ 0xEDB88320;
1145 } else {
1146 crc >>= 1;
1147 }
1148 }
1149 }
1150
1151 for &byte in data {
1153 crc ^= byte as u32;
1154 for _ in 0..8 {
1155 if crc & 1 != 0 {
1156 crc = (crc >> 1) ^ 0xEDB88320;
1157 } else {
1158 crc >>= 1;
1159 }
1160 }
1161 }
1162
1163 crc ^ 0xFFFFFFFF
1164 }
1165
1166 fn compress_image_data(
1168 &self,
1169 data: &[u8],
1170 width: u32,
1171 height: u32,
1172 components: u8,
1173 ) -> OperationResult<Vec<u8>> {
1174 use flate2::write::ZlibEncoder;
1175 use flate2::Compression;
1176 use std::io::Write;
1177
1178 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1179
1180 let bytes_per_pixel = components as usize;
1182 let bytes_per_row = width as usize * bytes_per_pixel;
1183
1184 for row in 0..height {
1185 encoder.write_all(&[0])?;
1187
1188 let start = row as usize * bytes_per_row;
1190 let end = start + bytes_per_row;
1191 if end <= data.len() {
1192 encoder.write_all(&data[start..end])?;
1193 }
1194 }
1195
1196 encoder
1197 .finish()
1198 .map_err(|e| OperationError::ParseError(format!("Failed to compress PNG data: {e}")))
1199 }
1200
1201 fn convert_ccitt_to_png(
1203 &self,
1204 data: &[u8],
1205 width: u32,
1206 height: u32,
1207 ) -> OperationResult<Vec<u8>> {
1208 let mut rgb_data = Vec::new();
1211
1212 let bits_per_row = width as usize;
1214 let min_bytes_per_row = bits_per_row.div_ceil(8);
1215
1216 let possible_strides = [
1218 min_bytes_per_row, (min_bytes_per_row + 1) & !1, (min_bytes_per_row + 3) & !3, (min_bytes_per_row + 7) & !7, (min_bytes_per_row + 15) & !15, ];
1224
1225 let correct_stride =
1227 self.detect_correct_row_stride(data, width, height, &possible_strides)?;
1228
1229 for row in 0..height {
1230 let row_start = row as usize * correct_stride;
1231
1232 for col in 0..width {
1233 let byte_idx = row_start + (col as usize / 8);
1234 let bit_idx = 7 - (col as usize % 8);
1235
1236 if byte_idx < data.len() {
1237 let bit = (data[byte_idx] >> bit_idx) & 1;
1238 let gray_value = if bit == 0 { 0 } else { 255 };
1240 rgb_data.push(gray_value);
1241 } else {
1242 rgb_data.push(255); }
1244 }
1245 }
1246
1247 self.create_png_from_raw_data(&rgb_data, width, height, 1, 8)
1249 }
1250
1251 fn detect_correct_row_stride(
1253 &self,
1254 data: &[u8],
1255 width: u32,
1256 height: u32,
1257 possible_strides: &[usize],
1258 ) -> OperationResult<usize> {
1259 let bits_per_row = width as usize;
1260 let min_bytes_per_row = bits_per_row.div_ceil(8);
1261
1262 if data.len() < min_bytes_per_row * 3 {
1264 return Ok(min_bytes_per_row);
1265 }
1266
1267 for &stride in possible_strides {
1269 let expected_size = stride * height as usize;
1270
1271 if expected_size <= data.len() && (data.len() - expected_size) < stride * 2 {
1273 return Ok(stride);
1276 }
1277 }
1278
1279 let calculated_stride = data.len() / height as usize;
1281 if calculated_stride >= min_bytes_per_row {
1282 return Ok(calculated_stride);
1283 }
1284
1285 Ok(min_bytes_per_row)
1287 }
1288
1289 #[allow(dead_code)]
1291 fn should_preprocess(&self) -> bool {
1292 self.options.preprocessing.auto_correct_rotation
1293 || self.options.preprocessing.enhance_contrast
1294 || self.options.preprocessing.denoise
1295 || self.options.preprocessing.upscale_small_images
1296 || self.options.preprocessing.force_grayscale
1297 }
1298
1299 #[cfg(feature = "external-images")]
1301 fn preprocess_image_data(
1302 &self,
1303 data: &[u8],
1304 width: u32,
1305 height: u32,
1306 format: ImageFormat,
1307 ) -> OperationResult<Vec<u8>> {
1308 let img_format = match format {
1310 ImageFormat::Jpeg => ImageLibFormat::Jpeg,
1311 ImageFormat::Png => ImageLibFormat::Png,
1312 ImageFormat::Tiff => ImageLibFormat::Tiff,
1313 ImageFormat::Raw => {
1314 return self.preprocess_raw_image_data(data, width, height);
1316 }
1317 };
1318
1319 let img = image::load_from_memory_with_format(data, img_format)
1320 .map_err(|e| OperationError::ParseError(format!("Failed to load image: {e}")))?;
1321
1322 let mut processed_img = img;
1323
1324 processed_img = self.apply_rotation_correction(processed_img)?;
1326 processed_img = self.apply_contrast_enhancement(processed_img)?;
1327 processed_img = self.apply_noise_reduction(processed_img)?;
1328 processed_img = self.apply_upscaling(processed_img, width, height)?;
1329
1330 if self.options.preprocessing.force_grayscale {
1331 processed_img = DynamicImage::ImageLuma8(processed_img.to_luma8());
1332 }
1333
1334 let mut output = Vec::new();
1336 processed_img
1337 .write_to(&mut std::io::Cursor::new(&mut output), img_format)
1338 .map_err(|e| OperationError::ParseError(format!("Failed to encode image: {e}")))?;
1339
1340 Ok(output)
1341 }
1342
1343 #[cfg(feature = "external-images")]
1345 fn preprocess_raw_image_data(
1346 &self,
1347 data: &[u8],
1348 width: u32,
1349 height: u32,
1350 ) -> OperationResult<Vec<u8>> {
1351 if data.len() < (width * height) as usize {
1353 return Err(OperationError::ParseError(
1354 "Raw image data too small".to_string(),
1355 ));
1356 }
1357
1358 let img_buffer = ImageBuffer::<Luma<u8>, Vec<u8>>::from_raw(
1359 width,
1360 height,
1361 data[..(width * height) as usize].to_vec(),
1362 )
1363 .ok_or_else(|| OperationError::ParseError("Failed to create image buffer".to_string()))?;
1364
1365 let img = DynamicImage::ImageLuma8(img_buffer);
1366 let mut processed_img = img;
1367
1368 processed_img = self.apply_rotation_correction(processed_img)?;
1370 processed_img = self.apply_contrast_enhancement(processed_img)?;
1371 processed_img = self.apply_noise_reduction(processed_img)?;
1372 processed_img = self.apply_upscaling(processed_img, width, height)?;
1373
1374 let mut output = Vec::new();
1376 processed_img
1377 .write_to(&mut std::io::Cursor::new(&mut output), ImageLibFormat::Png)
1378 .map_err(|e| OperationError::ParseError(format!("Failed to encode image: {e}")))?;
1379
1380 Ok(output)
1381 }
1382
1383 #[cfg(feature = "external-images")]
1385 fn apply_rotation_correction(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
1386 if !self.options.preprocessing.auto_correct_rotation {
1387 return Ok(img);
1388 }
1389
1390 let (width, height) = img.dimensions();
1392
1393 if width > height * 2 {
1396 return Ok(img.rotate90());
1398 }
1399
1400 Ok(img)
1403 }
1404
1405 #[cfg(feature = "external-images")]
1407 fn apply_contrast_enhancement(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
1408 if !self.options.preprocessing.enhance_contrast {
1409 return Ok(img);
1410 }
1411
1412 let enhanced = img.adjust_contrast(20.0); Ok(enhanced.brighten(10)) }
1416
1417 #[cfg(feature = "external-images")]
1419 fn apply_noise_reduction(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
1420 if !self.options.preprocessing.denoise {
1421 return Ok(img);
1422 }
1423
1424 Ok(img.blur(0.5))
1426 }
1427
1428 #[cfg(feature = "external-images")]
1430 fn apply_upscaling(
1431 &self,
1432 img: DynamicImage,
1433 original_width: u32,
1434 original_height: u32,
1435 ) -> OperationResult<DynamicImage> {
1436 if !self.options.preprocessing.upscale_small_images {
1437 return Ok(img);
1438 }
1439
1440 let min_dimension = original_width.min(original_height);
1441 if min_dimension < self.options.preprocessing.upscale_threshold {
1442 let new_width = original_width * self.options.preprocessing.upscale_factor;
1443 let new_height = original_height * self.options.preprocessing.upscale_factor;
1444
1445 return Ok(img.resize(
1446 new_width,
1447 new_height,
1448 image::imageops::FilterType::CatmullRom,
1449 ));
1450 }
1451
1452 Ok(img)
1453 }
1454}
1455
1456pub fn extract_images_from_pdf<P: AsRef<Path>>(
1458 input_path: P,
1459 options: ExtractImagesOptions,
1460) -> OperationResult<Vec<ExtractedImage>> {
1461 let document = PdfReader::open_document(input_path)
1462 .map_err(|e| OperationError::ParseError(e.to_string()))?;
1463
1464 let mut extractor = ImageExtractor::new(document, options);
1465 extractor.extract_all()
1466}
1467
1468pub fn extract_images_from_pages<P: AsRef<Path>>(
1470 input_path: P,
1471 pages: &[usize],
1472 options: ExtractImagesOptions,
1473) -> OperationResult<Vec<ExtractedImage>> {
1474 let document = PdfReader::open_document(input_path)
1475 .map_err(|e| OperationError::ParseError(e.to_string()))?;
1476
1477 let mut extractor = ImageExtractor::new(document, options);
1478 let mut all_images = Vec::new();
1479
1480 for &page_num in pages {
1481 let page_images = extractor.extract_from_page(page_num)?;
1482 all_images.extend(page_images);
1483 }
1484
1485 Ok(all_images)
1486}
1487
1488fn image_sample_components(color_space: Option<&PdfObject>, icc_n: Option<u8>) -> u8 {
1495 match color_space {
1496 Some(PdfObject::Name(cs)) => match cs.0.as_str() {
1497 "DeviceGray" | "G" | "CalGray" => 1,
1498 "DeviceRGB" | "RGB" | "CalRGB" | "Lab" => 3,
1499 "DeviceCMYK" | "CMYK" => 4,
1500 _ => 3,
1501 },
1502 Some(PdfObject::Array(array)) => {
1503 match array
1504 .0
1505 .first()
1506 .and_then(|o| o.as_name())
1507 .map(|n| n.0.as_str())
1508 {
1509 Some("Indexed") | Some("I") => 1,
1510 Some("Separation") => 1,
1511 Some("DeviceN") => array
1512 .0
1513 .get(1)
1514 .and_then(|o| o.as_array())
1515 .map(|names| names.0.len().max(1) as u8)
1516 .unwrap_or(1),
1517 Some("ICCBased") => icc_n.unwrap_or(3),
1518 Some("CalGray") | Some("DeviceGray") => 1,
1519 Some("DeviceCMYK") => 4,
1520 Some("CalRGB") | Some("Lab") | Some("DeviceRGB") => 3,
1521 _ => 3,
1522 }
1523 }
1524 _ => 3,
1525 }
1526}
1527
1528fn expand_indexed(indices: &[u8], lookup: &[u8], base_components: usize, hival: usize) -> Vec<u8> {
1534 let mut out = Vec::with_capacity(indices.len() * base_components);
1535 for &idx in indices {
1536 let entry = (idx as usize).min(hival);
1537 let start = entry * base_components;
1538 for c in 0..base_components {
1539 out.push(lookup.get(start + c).copied().unwrap_or(0));
1540 }
1541 }
1542 out
1543}
1544
1545fn unpack_indices(data: &[u8], width: u32, height: u32, bits_per_component: u8) -> Vec<u8> {
1550 if !matches!(bits_per_component, 1 | 2 | 4) {
1555 return data.to_vec();
1556 }
1557 let bpc = bits_per_component as usize;
1558 let width = width as usize;
1559 let height = height as usize;
1560 let row_bytes = (width * bpc).div_ceil(8);
1561 let mask = (1u16 << bpc) - 1;
1562 let mut out = Vec::with_capacity(width * height);
1563 for row in 0..height {
1564 let row_start = row * row_bytes;
1565 for col in 0..width {
1566 let bit_index = col * bpc;
1567 let byte = row_start + bit_index / 8;
1568 let shift = 8 - bpc - (bit_index % 8);
1569 let value = data
1570 .get(byte)
1571 .map(|b| ((*b as u16) >> shift) & mask)
1572 .unwrap_or(0);
1573 out.push(value as u8);
1574 }
1575 }
1576 out
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582 use tempfile::TempDir;
1583
1584 fn name(s: &str) -> PdfObject {
1585 PdfObject::Name(PdfName(s.to_string()))
1586 }
1587
1588 #[test]
1589 fn test_image_sample_components_device_color_spaces() {
1590 assert_eq!(image_sample_components(Some(&name("DeviceGray")), None), 1);
1591 assert_eq!(image_sample_components(Some(&name("DeviceRGB")), None), 3);
1592 assert_eq!(image_sample_components(Some(&name("DeviceCMYK")), None), 4);
1593 assert_eq!(image_sample_components(Some(&name("Weird")), None), 3);
1595 assert_eq!(image_sample_components(None, None), 3);
1596 }
1597
1598 #[test]
1599 fn test_image_sample_components_indexed_is_one() {
1600 let indexed = PdfObject::Array(PdfArray(vec![
1601 name("Indexed"),
1602 name("DeviceRGB"),
1603 PdfObject::Integer(23),
1604 PdfObject::String(crate::parser::objects::PdfString(vec![0u8; 72])),
1605 ]));
1606 assert_eq!(image_sample_components(Some(&indexed), None), 1);
1607 }
1608
1609 #[test]
1610 fn test_image_sample_components_iccbased_uses_n() {
1611 let icc = PdfObject::Array(PdfArray(vec![name("ICCBased"), PdfObject::Reference(5, 0)]));
1612 assert_eq!(image_sample_components(Some(&icc), Some(1)), 1);
1613 assert_eq!(image_sample_components(Some(&icc), Some(4)), 4);
1614 assert_eq!(image_sample_components(Some(&icc), None), 3);
1616 }
1617
1618 #[test]
1619 fn test_image_sample_components_devicen_counts_colorants() {
1620 let devicen = PdfObject::Array(PdfArray(vec![
1621 name("DeviceN"),
1622 PdfObject::Array(PdfArray(vec![name("Cyan"), name("Magenta")])),
1623 name("DeviceCMYK"),
1624 PdfObject::Reference(9, 0),
1625 ]));
1626 assert_eq!(image_sample_components(Some(&devicen), None), 2);
1627 }
1628
1629 #[test]
1630 fn test_expand_indexed_maps_indices_to_palette_rgb() {
1631 let palette = vec![255, 0, 0, 0, 255, 0, 0, 0, 255];
1633 let indices = [0u8, 2, 1];
1634 let rgb = expand_indexed(&indices, &palette, 3, 2);
1635 assert_eq!(rgb, vec![255, 0, 0, 0, 0, 255, 0, 255, 0]);
1636 }
1637
1638 #[test]
1639 fn test_expand_indexed_clamps_out_of_range_index() {
1640 let palette = vec![10, 20, 30, 40, 50, 60]; let rgb = expand_indexed(&[5u8], &palette, 3, 1);
1643 assert_eq!(rgb, vec![40, 50, 60]);
1644 }
1645
1646 #[test]
1647 fn test_unpack_indices_passthrough_for_8bit() {
1648 let data = vec![1, 2, 3, 4];
1649 assert_eq!(unpack_indices(&data, 2, 2, 8), data);
1650 }
1651
1652 #[test]
1653 fn test_unpack_indices_4bit_two_pixels_per_byte() {
1654 let data = vec![0xA3];
1656 assert_eq!(unpack_indices(&data, 2, 1, 4), vec![0x0A, 0x03]);
1657 }
1658
1659 #[test]
1660 fn test_unpack_indices_2bit_four_pixels_per_byte() {
1661 let data = vec![0b1110_0100];
1663 assert_eq!(unpack_indices(&data, 4, 1, 2), vec![3, 2, 1, 0]);
1664 }
1665
1666 #[test]
1667 fn test_unpack_indices_passthrough_for_unsupported_bpc() {
1668 let data = vec![0xAB, 0xCD];
1670 assert_eq!(unpack_indices(&data, 4, 1, 3), data);
1671 }
1672
1673 #[test]
1674 fn test_unpack_indices_1bit_respects_row_byte_alignment() {
1675 let data = vec![0b1010_0000, 0b0110_0000];
1678 assert_eq!(unpack_indices(&data, 3, 2, 1), vec![1, 0, 1, 0, 1, 1]);
1679 }
1680
1681 #[test]
1682 fn test_extract_options_default() {
1683 let options = ExtractImagesOptions::default();
1684 assert_eq!(options.output_dir, PathBuf::from("."));
1685 assert!(options.extract_inline);
1686 assert_eq!(options.min_size, Some(10));
1687 assert!(options.create_dir);
1688 }
1689
1690 #[test]
1691 fn test_filename_pattern() {
1692 let options = ExtractImagesOptions {
1693 name_pattern: "img_{page}_{index}.{format}".to_string(),
1694 ..Default::default()
1695 };
1696
1697 let pattern = options
1698 .name_pattern
1699 .replace("{page}", "1")
1700 .replace("{index}", "2")
1701 .replace("{format}", "jpg");
1702
1703 assert_eq!(pattern, "img_1_2.jpg");
1704 }
1705
1706 #[test]
1707 fn test_extract_options_custom() {
1708 let temp_dir = TempDir::new().unwrap();
1709 let options = ExtractImagesOptions {
1710 output_dir: temp_dir.path().to_path_buf(),
1711 name_pattern: "custom_{page}_{index}.{format}".to_string(),
1712 extract_inline: false,
1713 min_size: Some(50),
1714 create_dir: false,
1715 preprocessing: ImagePreprocessingOptions::default(),
1716 };
1717
1718 assert_eq!(options.output_dir, temp_dir.path());
1719 assert_eq!(options.name_pattern, "custom_{page}_{index}.{format}");
1720 assert!(!options.extract_inline);
1721 assert_eq!(options.min_size, Some(50));
1722 assert!(!options.create_dir);
1723 }
1724
1725 #[test]
1726 fn test_extract_options_debug_clone() {
1727 let options = ExtractImagesOptions {
1728 output_dir: PathBuf::from("/test/path"),
1729 name_pattern: "test.{format}".to_string(),
1730 extract_inline: true,
1731 min_size: None,
1732 create_dir: true,
1733 preprocessing: ImagePreprocessingOptions::default(),
1734 };
1735
1736 let debug_str = format!("{options:?}");
1737 assert!(debug_str.contains("ExtractImagesOptions"));
1738 assert!(debug_str.contains("/test/path"));
1739
1740 let cloned = options.clone();
1741 assert_eq!(cloned.output_dir, options.output_dir);
1742 assert_eq!(cloned.name_pattern, options.name_pattern);
1743 assert_eq!(cloned.extract_inline, options.extract_inline);
1744 assert_eq!(cloned.min_size, options.min_size);
1745 assert_eq!(cloned.create_dir, options.create_dir);
1746 }
1747
1748 #[test]
1749 fn test_extracted_image_struct() {
1750 let image = ExtractedImage {
1751 page_number: 0,
1752 image_index: 1,
1753 file_path: PathBuf::from("/test/image.jpg"),
1754 width: 100,
1755 height: 200,
1756 format: ImageFormat::Jpeg,
1757 };
1758
1759 assert_eq!(image.page_number, 0);
1760 assert_eq!(image.image_index, 1);
1761 assert_eq!(image.file_path, PathBuf::from("/test/image.jpg"));
1762 assert_eq!(image.width, 100);
1763 assert_eq!(image.height, 200);
1764 assert_eq!(image.format, ImageFormat::Jpeg);
1765 }
1766
1767 #[test]
1768 fn test_extracted_image_debug() {
1769 let image = ExtractedImage {
1770 page_number: 5,
1771 image_index: 3,
1772 file_path: PathBuf::from("output.png"),
1773 width: 512,
1774 height: 768,
1775 format: ImageFormat::Png,
1776 };
1777
1778 let debug_str = format!("{image:?}");
1779 assert!(debug_str.contains("ExtractedImage"));
1780 assert!(debug_str.contains("5"));
1781 assert!(debug_str.contains("3"));
1782 assert!(debug_str.contains("output.png"));
1783 assert!(debug_str.contains("512"));
1784 assert!(debug_str.contains("768"));
1785 }
1786
1787 fn create_minimal_pdf(temp_file: &std::path::Path) {
1789 let minimal_pdf = b"%PDF-1.7\n\
17901 0 obj\n\
1791<< /Type /Catalog /Pages 2 0 R >>\n\
1792endobj\n\
17932 0 obj\n\
1794<< /Type /Pages /Kids [] /Count 0 >>\n\
1795endobj\n\
1796xref\n\
17970 3\n\
17980000000000 65535 f \n\
17990000000009 00000 n \n\
18000000000055 00000 n \n\
1801trailer\n\
1802<< /Size 3 /Root 1 0 R >>\n\
1803startxref\n\
1804105\n\
1805%%EOF";
1806 std::fs::write(temp_file, minimal_pdf).unwrap();
1807 }
1808
1809 #[test]
1810 fn test_detect_image_format_png() {
1811 let temp_dir = TempDir::new().unwrap();
1813 let temp_file = temp_dir.path().join("test.pdf");
1814 create_minimal_pdf(&temp_file);
1815
1816 let document = PdfReader::open_document(&temp_file).unwrap();
1817 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
1818
1819 let png_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0DIHDR";
1821 let format = extractor.detect_image_format_from_data(png_data).unwrap();
1822 assert_eq!(format, ImageFormat::Png);
1823 }
1824
1825 #[test]
1826 fn test_detect_image_format_jpeg() {
1827 let temp_dir = TempDir::new().unwrap();
1828 let temp_file = temp_dir.path().join("test.pdf");
1829 create_minimal_pdf(&temp_file);
1830
1831 let document = PdfReader::open_document(&temp_file).unwrap();
1832 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
1833
1834 let jpeg_data = b"\xFF\xD8\xFF\xE0\x00\x10JFIF";
1836 let format = extractor.detect_image_format_from_data(jpeg_data).unwrap();
1837 assert_eq!(format, ImageFormat::Jpeg);
1838 }
1839
1840 #[test]
1841 fn test_detect_image_format_tiff_little_endian() {
1842 let temp_dir = TempDir::new().unwrap();
1843 let temp_file = temp_dir.path().join("test.pdf");
1844 create_minimal_pdf(&temp_file);
1845
1846 let document = PdfReader::open_document(&temp_file).unwrap();
1847 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
1848
1849 let tiff_data = b"II\x2A\x00\x08\x00\x00\x00";
1851 let format = extractor.detect_image_format_from_data(tiff_data).unwrap();
1852 assert_eq!(format, ImageFormat::Tiff);
1853 }
1854
1855 #[test]
1856 fn test_detect_image_format_tiff_big_endian() {
1857 let temp_dir = TempDir::new().unwrap();
1858 let temp_file = temp_dir.path().join("test.pdf");
1859 create_minimal_pdf(&temp_file);
1860
1861 let document = PdfReader::open_document(&temp_file).unwrap();
1862 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
1863
1864 let tiff_data = b"MM\x00\x2A\x00\x00\x00\x08";
1866 let format = extractor.detect_image_format_from_data(tiff_data).unwrap();
1867 assert_eq!(format, ImageFormat::Tiff);
1868 }
1869
1870 #[test]
1871 fn test_detect_image_format_unknown() {
1872 let temp_dir = TempDir::new().unwrap();
1873 let temp_file = temp_dir.path().join("test.pdf");
1874 create_minimal_pdf(&temp_file);
1875
1876 let document = PdfReader::open_document(&temp_file).unwrap();
1877 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
1878
1879 let unknown_data = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08";
1881 let format = extractor
1882 .detect_image_format_from_data(unknown_data)
1883 .unwrap();
1884 assert_eq!(format, ImageFormat::Png); }
1886
1887 #[test]
1888 fn test_detect_image_format_short_data() {
1889 let temp_dir = TempDir::new().unwrap();
1890 let temp_file = temp_dir.path().join("test.pdf");
1891 create_minimal_pdf(&temp_file);
1892
1893 let document = PdfReader::open_document(&temp_file).unwrap();
1894 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
1895
1896 let short_data = b"\xFF";
1898 let result = extractor.detect_image_format_from_data(short_data);
1899 assert!(result.is_err());
1900 match result {
1901 Err(OperationError::ParseError(msg)) => {
1902 assert!(msg.contains("too short"));
1903 }
1904 _ => panic!("Expected ParseError"),
1905 }
1906 }
1907
1908 #[test]
1909 fn test_filename_pattern_replacements() {
1910 let options = ExtractImagesOptions {
1911 name_pattern: "page_{page}_img_{index}_{format}.{format}".to_string(),
1912 ..Default::default()
1913 };
1914
1915 let pattern = options
1916 .name_pattern
1917 .replace("{page}", "10")
1918 .replace("{index}", "5")
1919 .replace("{format}", "png");
1920
1921 assert_eq!(pattern, "page_10_img_5_png.png");
1922 }
1923
1924 #[test]
1925 fn test_extract_options_no_min_size() {
1926 let options = ExtractImagesOptions {
1927 min_size: None,
1928 ..Default::default()
1929 };
1930
1931 assert_eq!(options.min_size, None);
1932 }
1933
1934 #[test]
1935 fn test_create_output_directory() {
1936 let temp_dir = TempDir::new().unwrap();
1937 let output_dir = temp_dir.path().join("new_dir");
1938
1939 let options = ExtractImagesOptions {
1940 output_dir: output_dir.clone(),
1941 create_dir: true,
1942 ..Default::default()
1943 };
1944
1945 assert!(!output_dir.exists());
1947 assert_eq!(options.output_dir, output_dir);
1948 assert!(options.create_dir);
1949 }
1950
1951 #[test]
1952 fn test_pattern_with_special_chars() {
1953 let options = ExtractImagesOptions {
1954 name_pattern: "img-{page}_{index}.{format}".to_string(),
1955 ..Default::default()
1956 };
1957
1958 let pattern = options
1959 .name_pattern
1960 .replace("{page}", "1")
1961 .replace("{index}", "1")
1962 .replace("{format}", "jpg");
1963
1964 assert_eq!(pattern, "img-1_1.jpg");
1965 }
1966
1967 #[test]
1968 fn test_multiple_format_extensions() {
1969 let formats = vec![
1970 (ImageFormat::Jpeg, "jpg"),
1971 (ImageFormat::Png, "png"),
1972 (ImageFormat::Tiff, "tiff"),
1973 ];
1974
1975 for (format, expected_ext) in formats {
1976 let extension = match format {
1977 ImageFormat::Jpeg => "jpg",
1978 ImageFormat::Png => "png",
1979 ImageFormat::Tiff => "tiff",
1980 ImageFormat::Raw => "raw",
1981 };
1982 assert_eq!(extension, expected_ext);
1983 }
1984 }
1985
1986 #[test]
1987 fn test_extract_inline_option() {
1988 let mut options = ExtractImagesOptions::default();
1989 assert!(options.extract_inline);
1990
1991 options.extract_inline = false;
1992 assert!(!options.extract_inline);
1993 }
1994
1995 #[test]
1996 fn test_min_size_filtering() {
1997 let options_with_min = ExtractImagesOptions {
1998 min_size: Some(100),
1999 ..Default::default()
2000 };
2001
2002 let options_no_min = ExtractImagesOptions {
2003 min_size: None,
2004 ..Default::default()
2005 };
2006
2007 assert_eq!(options_with_min.min_size, Some(100));
2008 assert_eq!(options_no_min.min_size, None);
2009 }
2010
2011 #[test]
2012 fn test_output_path_combinations() {
2013 let base_dir = PathBuf::from("/output");
2014 let options = ExtractImagesOptions {
2015 output_dir: base_dir,
2016 name_pattern: "img_{page}_{index}.{format}".to_string(),
2017 ..Default::default()
2018 };
2019
2020 let filename = options
2021 .name_pattern
2022 .replace("{page}", "1")
2023 .replace("{index}", "2")
2024 .replace("{format}", "png");
2025
2026 let full_path = options.output_dir.join(filename);
2027 assert_eq!(full_path, PathBuf::from("/output/img_1_2.png"));
2028 }
2029
2030 #[test]
2031 fn test_pattern_without_placeholders() {
2032 let options = ExtractImagesOptions {
2033 name_pattern: "static_name.jpg".to_string(),
2034 ..Default::default()
2035 };
2036
2037 let pattern = options
2038 .name_pattern
2039 .replace("{page}", "1")
2040 .replace("{index}", "2")
2041 .replace("{format}", "png");
2042
2043 assert_eq!(pattern, "static_name.jpg"); }
2045
2046 #[test]
2047 fn test_detect_format_edge_cases() {
2048 let temp_dir = TempDir::new().unwrap();
2049 let temp_file = temp_dir.path().join("test.pdf");
2050 create_minimal_pdf(&temp_file);
2051
2052 let document = PdfReader::open_document(&temp_file).unwrap();
2053 let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2054
2055 let empty_data = b"";
2057 assert!(extractor.detect_image_format_from_data(empty_data).is_err());
2058
2059 let exact_8 = b"\x89PNG\r\n\x1a\n";
2061 let format = extractor.detect_image_format_from_data(exact_8).unwrap();
2062 assert_eq!(format, ImageFormat::Png);
2063
2064 let exact_4 = b"II\x2A\x00";
2066 let format = extractor.detect_image_format_from_data(exact_4).unwrap();
2067 assert_eq!(format, ImageFormat::Tiff);
2068
2069 let exact_2 = b"\xFF\xD8";
2071 let format = extractor.detect_image_format_from_data(exact_2).unwrap();
2072 assert_eq!(format, ImageFormat::Jpeg); }
2074
2075 #[test]
2076 fn test_complex_filename_pattern() {
2077 let options = ExtractImagesOptions {
2078 name_pattern: "{format}/page{page}/image_{index}_{page}.{format}".to_string(),
2079 ..Default::default()
2080 };
2081
2082 let pattern = options
2083 .name_pattern
2084 .replace("{page}", "5")
2085 .replace("{index}", "3")
2086 .replace("{format}", "jpeg");
2087
2088 assert_eq!(pattern, "jpeg/page5/image_3_5.jpeg");
2089 }
2090
2091 #[test]
2092 fn test_image_dimensions() {
2093 let small_image = ExtractedImage {
2094 page_number: 0,
2095 image_index: 0,
2096 file_path: PathBuf::from("small.jpg"),
2097 width: 5,
2098 height: 5,
2099 format: ImageFormat::Jpeg,
2100 };
2101
2102 let large_image = ExtractedImage {
2103 page_number: 0,
2104 image_index: 1,
2105 file_path: PathBuf::from("large.jpg"),
2106 width: 2000,
2107 height: 3000,
2108 format: ImageFormat::Jpeg,
2109 };
2110
2111 assert_eq!(small_image.width, 5);
2112 assert_eq!(small_image.height, 5);
2113 assert_eq!(large_image.width, 2000);
2114 assert_eq!(large_image.height, 3000);
2115 }
2116
2117 #[test]
2118 fn test_page_and_index_numbering() {
2119 let image1 = ExtractedImage {
2121 page_number: 0, image_index: 0,
2123 file_path: PathBuf::from("first.jpg"),
2124 width: 100,
2125 height: 100,
2126 format: ImageFormat::Jpeg,
2127 };
2128
2129 let image2 = ExtractedImage {
2130 page_number: 99, image_index: 255, file_path: PathBuf::from("last.jpg"),
2133 width: 100,
2134 height: 100,
2135 format: ImageFormat::Jpeg,
2136 };
2137
2138 assert_eq!(image1.page_number, 0);
2139 assert_eq!(image1.image_index, 0);
2140 assert_eq!(image2.page_number, 99);
2141 assert_eq!(image2.image_index, 255);
2142 }
2143}
2144
2145#[cfg(test)]
2146#[path = "extract_images_tests.rs"]
2147mod extract_images_tests;