Skip to main content

oxidize_pdf/operations/
extract_images.rs

1//! PDF image extraction functionality
2//!
3//! This module provides functionality to extract images from PDF documents with
4//! advanced preprocessing for scanned documents.
5
6use 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/// PDF transformation matrix (a, b, c, d, e, f)
19///
20/// Represents a 3x3 matrix: `[a c e; b d f; 0 0 1]` that transforms point `(x,y)` to `(a*x + c*y + e, b*x + d*y + f)`
21#[derive(Debug, Clone)]
22pub struct TransformMatrix {
23    pub a: f64, // x scaling
24    pub b: f64, // y skewing
25    pub c: f64, // x skewing
26    pub d: f64, // y scaling
27    pub e: f64, // x translation
28    pub f: f64, // y translation
29}
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    /// Check if this matrix represents a 90-degree rotation
38    #[allow(dead_code)]
39    fn is_90_degree_rotation(&self) -> bool {
40        // For 90-degree rotation: a ≈ 0, d ≈ 0, b and c are non-zero
41        self.a.abs() < 0.001 && self.d.abs() < 0.001 && self.b.abs() > 0.001 && self.c.abs() > 0.001
42    }
43
44    /// Check if this matrix represents a simple scaling
45    #[allow(dead_code)]
46    fn is_simple_scale(&self) -> bool {
47        // For scaling: b ≈ 0, c ≈ 0, a and d are scaling factors
48        self.b.abs() < 0.001 && self.c.abs() < 0.001 && self.a.abs() > 0.001 && self.d.abs() > 0.001
49    }
50
51    /// Check if this is a matrix that needs rotation for proper OCR
52    #[allow(dead_code)]
53    fn is_fis2_like_matrix(&self) -> bool {
54        // Some PDFs use 841.68 x 595.08 which are A4 dimensions (landscape fitting in portrait)
55        // This indicates the image is landscape but being fit into portrait page
56        (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/// Preprocessing options for extracted images
64#[derive(Debug, Clone)]
65pub struct ImagePreprocessingOptions {
66    /// Auto-detect and correct rotation
67    pub auto_correct_rotation: bool,
68    /// Enhance contrast for better OCR
69    pub enhance_contrast: bool,
70    /// Apply noise reduction
71    pub denoise: bool,
72    /// Upscale small images using bicubic interpolation
73    pub upscale_small_images: bool,
74    /// Minimum size to trigger upscaling
75    pub upscale_threshold: u32,
76    /// Upscale factor (2x, 3x, etc.)
77    pub upscale_factor: u32,
78    /// Convert to grayscale for better OCR on text documents
79    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/// Options for image extraction
97#[derive(Debug, Clone)]
98pub struct ExtractImagesOptions {
99    /// Output directory for extracted images
100    pub output_dir: PathBuf,
101    /// File name pattern for extracted images
102    /// Supports placeholders: {page}, {index}, {format}
103    pub name_pattern: String,
104    /// Whether to extract inline images
105    pub extract_inline: bool,
106    /// Minimum size (width or height) to extract
107    pub min_size: Option<u32>,
108    /// Whether to create output directory if it doesn't exist
109    pub create_dir: bool,
110    /// Preprocessing options for extracted images
111    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/// Result of image extraction
128#[derive(Debug)]
129pub struct ExtractedImage {
130    /// Page number (0-indexed)
131    pub page_number: usize,
132    /// Image index on the page
133    pub image_index: usize,
134    /// Output file path
135    pub file_path: PathBuf,
136    /// Image dimensions
137    pub width: u32,
138    pub height: u32,
139    /// Image format
140    pub format: ImageFormat,
141}
142
143/// An extracted image whose encoded bytes remain in memory.
144#[derive(Debug, Clone)]
145pub struct ExtractedImageData {
146    /// Zero-based page containing the image.
147    pub page_number: usize,
148    /// Zero-based image index within the page.
149    pub image_index: usize,
150    /// Image width in pixels.
151    pub width: u32,
152    /// Image height in pixels.
153    pub height: u32,
154    /// Encoding of `data`.
155    pub format: ImageFormat,
156    /// Encoded image bytes after optional preprocessing.
157    pub data: Vec<u8>,
158}
159
160/// Resource bounds applied while extracting images in memory.
161#[derive(Debug, Clone, Copy)]
162pub struct ImageExtractionLimits {
163    /// Maximum number of images delivered to the consumer.
164    pub max_images: usize,
165    /// Maximum encoded size of one delivered image.
166    pub max_encoded_bytes_per_image: usize,
167    /// Maximum total encoded size delivered during the operation.
168    pub max_total_encoded_bytes: usize,
169    /// Maximum width multiplied by height for one image.
170    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/// Errors produced by bounded in-memory image extraction.
185#[derive(Debug, thiserror::Error)]
186pub enum ImageExtractionError {
187    /// An existing PDF operation or image-processing step failed.
188    #[error(transparent)]
189    Operation(#[from] OperationError),
190    /// A configured extraction bound would be exceeded.
191    #[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
201/// Result type for bounded in-memory image extraction.
202pub 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
285/// Image extractor
286pub struct ImageExtractor<R: Read + Seek> {
287    document: PdfDocument<R>,
288    options: ExtractImagesOptions,
289    /// Cache for already processed images
290    processed_images: HashMap<String, PathBuf>,
291}
292
293impl<R: Read + Seek> ImageExtractor<R> {
294    /// Create a new image extractor
295    pub fn new(document: PdfDocument<R>, options: ExtractImagesOptions) -> Self {
296        Self {
297            document,
298            options,
299            processed_images: HashMap::new(),
300        }
301    }
302
303    /// Visit every extracted image without writing to the filesystem.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error when the document cannot be parsed, a configured limit
308    /// would be exceeded, image processing fails, or the visitor returns an error.
309    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    /// Extract every image as encoded bytes without filesystem writes.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error when parsing or image processing fails, or when a
333    /// configured extraction limit would be exceeded.
334    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    /// Extract one page's images as encoded bytes without filesystem writes.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error when the page cannot be read, image processing fails,
351    /// or a configured extraction limit would be exceeded.
352    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    /// Extract all images from the document
559    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    /// Extract images from a specific page
576    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    /// Detect image format from raw data by examining magic bytes
634    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        // Check for PNG signature (needs 8 bytes)
642        if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
643            return Ok(ImageFormat::Png);
644        }
645
646        // Check for TIFF signatures (needs 4 bytes)
647        if data.len() >= 4 {
648            if &data[0..2] == b"II" && &data[2..4] == b"\x2A\x00" {
649                return Ok(ImageFormat::Tiff); // Little endian TIFF
650            }
651            if &data[0..2] == b"MM" && &data[2..4] == b"\x00\x2A" {
652                return Ok(ImageFormat::Tiff); // Big endian TIFF
653            }
654        }
655
656        // Check for JPEG signature (needs 2 bytes)
657        if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
658            return Ok(ImageFormat::Jpeg);
659        }
660
661        // If data is too short for any meaningful detection
662        if data.len() < 2 {
663            return Err(OperationError::ParseError(
664                "Image data too short to detect format".to_string(),
665            ));
666        }
667
668        // Default to PNG for FlateDecode if no other format detected
669        // This is a fallback since FlateDecode is commonly used for PNG in PDFs
670        Ok(ImageFormat::Png)
671    }
672
673    /// Apply rotation transformation
674    #[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        // Determine rotation direction based on matrix values
682        // For 90-degree clockwise: a=0, b=1, c=-1, d=0
683        // For 90-degree counter-clockwise: a=0, b=-1, c=1, d=0
684
685        if matrix.b > 0.0 && matrix.c < 0.0 {
686            Ok(img.rotate90()) // 90 degrees clockwise
687        } else if matrix.b < 0.0 && matrix.c > 0.0 {
688            Ok(img.rotate270()) // 90 degrees counter-clockwise (270 clockwise)
689        } else {
690            // Default to 90-degree rotation for landscape-in-portrait cases
691            Ok(img.rotate90())
692        }
693    }
694
695    /// Apply scaling transformation
696    #[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        // Calculate new dimensions based on scaling factors
706        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            // If scaling results in invalid dimensions, return original
713            Ok(img)
714        }
715    }
716
717    /// Parse inline image dictionary to extract width and height
718    fn parse_inline_image_dict(&self, dict_str: &str) -> (u32, u32) {
719        let mut width = 100; // Default width
720        let mut height = 100; // Default height
721
722        // Simple parsing - look for /W and /H parameters
723        for line in dict_str.lines() {
724            let line = line.trim();
725
726            // Parse width: /W 123 or /Width 123
727            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            // Parse height: /H 123 or /Height 123
736            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    /// Decode an image stream, resolving an indirect `/DecodeParms` (or `/DP`)
749    /// first so that filter predictors are actually applied (issue #286).
750    ///
751    /// `PdfStream::decode` only sees the stream's own dictionary; when the
752    /// decode parameters are stored as an indirect reference the predictor is
753    /// silently skipped, leaving the per-row predictor bytes in the output.
754    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    /// Whether an object is, or directly contains, an indirect reference.
788    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    /// Resolve indirect references inside a `/DecodeParms` value (the value
800    /// itself, or each element of a per-filter array).
801    fn resolve_decode_params(&self, obj: &PdfObject) -> PdfObject {
802        let resolved = self.document.resolve(obj).unwrap_or_else(|e| {
803            // Falling back to the unresolved reference means the predictor is
804            // skipped and the image decodes to garbage — the original #286
805            // symptom. Surface it instead of failing silently.
806            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    /// If `color_space` is an `[/Indexed base hival lookup]` array, resolve it
821    /// into `(resolved_base, hival, palette_bytes)`.
822    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    /// Resolve the Indexed lookup table into palette bytes (it may be a string
838    /// literal or an indirect stream).
839    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    /// Resolve the `/N` (component count) of an `[/ICCBased stream]` colour space.
848    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        // /N is 1, 3 or 4 for valid ICC profiles. Clamp so a malformed value
861        // can't truncate (e.g. -1 → 255) and blow up a downstream allocation.
862        Some(n.clamp(1, 4) as u8)
863    }
864
865    /// Convert raw image sample data to PNG format.
866    ///
867    /// Handles Indexed colour spaces (one palette index per pixel, expanded to
868    /// the base colour) and computes the component count from the colour space
869    /// (issue #286 — Indexed was previously treated as 3-component RGB).
870    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        // Resolve an indirect ColorSpace reference up front.
880        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        // Indexed colour space: the data carries a single palette index per
884        // pixel. Expand to the base colour space so the PNG is a real picture
885        // rather than indices misread as grayscale.
886        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            // 8-bit indices are already one byte per pixel — borrow directly
889            // instead of cloning; only sub-byte depths need unpacking.
890            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        // Non-indexed: component count from the colour space.
915        let icc_n = self.icc_components(cs);
916        let components = image_sample_components(cs, icc_n);
917
918        // Calculate expected data size. Use usize arithmetic so large images
919        // do not overflow the intermediate product (a u32 multiply wraps near
920        // 4 GB and would let truncated data pass the check below).
921        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        // Validate data size
928        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        // Convert to PNG format using simple PNG encoding
937        self.encode_png_maybe_alpha(
938            data,
939            width,
940            height,
941            components,
942            bits_per_component,
943            smask_alpha,
944        )
945    }
946
947    /// Number of colour components for a (resolved) colour space, resolving an
948    /// `/ICCBased` `/N` when needed.
949    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    /// Decode an image's `/SMask` into a per-pixel 8-bit alpha buffer sized to
955    /// `width`×`height` (nearest-neighbour resized if the mask resolution
956    /// differs). Returns `None` when there is no soft mask, or when the mask
957    /// is not a plain 8-bit grayscale raster we can interpret (e.g. a DCT or
958    /// 16-bit mask), in which case the image is emitted without alpha.
959    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        // Validate sign before casting: a negative /Width or /Height would cast
973        // to a huge u32 and (on 32-bit targets) wrap the `sw * sh` product,
974        // producing a corrupt mask. Reject non-positive dimensions outright.
975        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; // only 8-bit masks are supported
988        }
989
990        let gray = self.decode_image_stream(stream).ok()?;
991        let expected = (sw as usize) * (sh as usize);
992        // A shorter buffer means the mask is not a plain gray raster (e.g. DCT);
993        // bail rather than misread it.
994        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        // Nearest-neighbour resize to the base image's dimensions.
1003        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    /// Encode `samples` as PNG. When `alpha` is present and the samples are
1016    /// 8-bit grayscale or RGB, composite it as the alpha channel and emit an
1017    /// RGBA PNG (grayscale is expanded to RGB first); otherwise emit the image
1018    /// as-is. Images that are 16-bit, DCT-encoded, or have 4 components (CMYK or
1019    /// an already-RGBA base) are emitted without alpha (the soft mask is dropped).
1020    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                // Callers guarantee these: the non-indexed path validates
1033                // `data.len() >= width*height*components`, the indexed path feeds
1034                // exactly `pixel_count*components` expanded bytes, and `alpha` is
1035                // sized to the base image. The `unwrap_or` below stay as a release
1036                // safety net; the asserts surface a broken contract in tests.
1037                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                    // Missing mask samples default to opaque.
1058                    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    /// Create PNG from raw pixel data
1074    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        // Simple PNG creation - create a basic PNG structure
1083        let mut png_data = Vec::new();
1084
1085        // PNG signature
1086        png_data.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
1087
1088        // IHDR chunk
1089        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        // Color type: 0 = grayscale, 2 = RGB, 6 = RGBA
1095        let color_type = match components {
1096            1 => 0, // Grayscale
1097            3 => 2, // RGB
1098            4 => 6, // RGBA
1099            _ => 2, // Default to RGB
1100        };
1101        ihdr.push(color_type);
1102        ihdr.push(0); // Compression method
1103        ihdr.push(0); // Filter method
1104        ihdr.push(0); // Interlace method
1105
1106        self.write_png_chunk(&mut png_data, b"IHDR", &ihdr);
1107
1108        // IDAT chunk - compress the image data
1109        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        // IEND chunk
1113        self.write_png_chunk(&mut png_data, b"IEND", &[]);
1114
1115        Ok(png_data)
1116    }
1117
1118    /// Write a PNG chunk with proper CRC
1119    fn write_png_chunk(&self, output: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
1120        // Length (4 bytes, big endian)
1121        output.extend_from_slice(&(data.len() as u32).to_be_bytes());
1122
1123        // Chunk type (4 bytes)
1124        output.extend_from_slice(chunk_type);
1125
1126        // Data
1127        output.extend_from_slice(data);
1128
1129        // CRC (4 bytes, big endian)
1130        let crc = self.calculate_crc32(chunk_type, data);
1131        output.extend_from_slice(&crc.to_be_bytes());
1132    }
1133
1134    /// Simple CRC32 calculation for PNG
1135    fn calculate_crc32(&self, chunk_type: &[u8; 4], data: &[u8]) -> u32 {
1136        // Simple CRC32 - in a real implementation we'd use a proper CRC library
1137        let mut crc: u32 = 0xFFFFFFFF;
1138
1139        // Process chunk type
1140        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        // Process data
1152        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    /// Compress image data for PNG IDAT chunk
1167    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        // PNG requires scanline filtering - add filter byte (0 = None) to each row
1181        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            // Filter byte (0 = no filter)
1186            encoder.write_all(&[0])?;
1187
1188            // Row data
1189            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    /// Convert CCITT Fax decoded data to PNG (for scanned documents)
1202    fn convert_ccitt_to_png(
1203        &self,
1204        data: &[u8],
1205        width: u32,
1206        height: u32,
1207    ) -> OperationResult<Vec<u8>> {
1208        // CCITT is typically 1-bit monochrome
1209        // Convert 1-bit to 8-bit grayscale
1210        let mut rgb_data = Vec::new();
1211
1212        // Calculate potential row strides - try multiple alignments
1213        let bits_per_row = width as usize;
1214        let min_bytes_per_row = bits_per_row.div_ceil(8);
1215
1216        // Try different row stride alignments (1, 2, 4, 8, 16 byte alignment)
1217        let possible_strides = [
1218            min_bytes_per_row,              // No padding
1219            (min_bytes_per_row + 1) & !1,   // 2-byte aligned
1220            (min_bytes_per_row + 3) & !3,   // 4-byte aligned
1221            (min_bytes_per_row + 7) & !7,   // 8-byte aligned
1222            (min_bytes_per_row + 15) & !15, // 16-byte aligned
1223        ];
1224
1225        // Try to detect the correct stride by checking data patterns
1226        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                    // CCITT: 0 = black, 1 = white
1239                    let gray_value = if bit == 0 { 0 } else { 255 };
1240                    rgb_data.push(gray_value);
1241                } else {
1242                    rgb_data.push(255); // White for missing data
1243                }
1244            }
1245        }
1246
1247        // Create PNG from grayscale data
1248        self.create_png_from_raw_data(&rgb_data, width, height, 1, 8)
1249    }
1250
1251    /// Detect the correct row stride by analyzing data patterns
1252    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 we don't have enough data for analysis, use minimum stride
1263        if data.len() < min_bytes_per_row * 3 {
1264            return Ok(min_bytes_per_row);
1265        }
1266
1267        // Calculate expected total size for each stride
1268        for &stride in possible_strides {
1269            let expected_size = stride * height as usize;
1270
1271            // If this stride gives us a size close to actual data length, use it
1272            if expected_size <= data.len() && (data.len() - expected_size) < stride * 2 {
1273                // Allow some tolerance
1274
1275                return Ok(stride);
1276            }
1277        }
1278
1279        // If no stride fits perfectly, calculate from data length
1280        let calculated_stride = data.len() / height as usize;
1281        if calculated_stride >= min_bytes_per_row {
1282            return Ok(calculated_stride);
1283        }
1284
1285        // Fallback to minimum
1286        Ok(min_bytes_per_row)
1287    }
1288
1289    /// Check if preprocessing should be applied
1290    #[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    /// Apply image preprocessing
1300    #[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        // Load image using the image crate
1309        let img_format = match format {
1310            ImageFormat::Jpeg => ImageLibFormat::Jpeg,
1311            ImageFormat::Png => ImageLibFormat::Png,
1312            ImageFormat::Tiff => ImageLibFormat::Tiff,
1313            ImageFormat::Raw => {
1314                // For raw data, create a simple RGB image
1315                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        // Apply preprocessing steps
1325        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        // Encode back to bytes
1335        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    /// Preprocess raw image data
1344    #[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        // Create a simple grayscale image from raw data
1352        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        // Apply preprocessing
1369        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        // Encode to PNG
1375        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    /// Auto-detect and correct rotation
1384    #[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        // Simple rotation detection based on aspect ratio and content analysis
1391        let (width, height) = img.dimensions();
1392
1393        // If image is wider than it is tall but contains mostly vertical text,
1394        // it might need rotation. This is a simplified heuristic.
1395        if width > height * 2 {
1396            // Likely rotated 90 degrees - try rotating
1397            return Ok(img.rotate90());
1398        }
1399
1400        // For now, return as-is. In a more sophisticated implementation,
1401        // we could use OCR or edge detection to determine optimal rotation.
1402        Ok(img)
1403    }
1404
1405    /// Enhance contrast for better OCR
1406    #[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        // Apply histogram equalization by adjusting brightness and contrast
1413        let enhanced = img.adjust_contrast(20.0); // Increase contrast by 20%
1414        Ok(enhanced.brighten(10)) // Slightly brighten
1415    }
1416
1417    /// Apply noise reduction
1418    #[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        // Simple blur to reduce noise
1425        Ok(img.blur(0.5))
1426    }
1427
1428    /// Upscale small images for better OCR
1429    #[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
1456/// Extract all images from a PDF file
1457pub 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
1468/// Extract images from specific pages
1469pub 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
1488/// Number of colour samples per pixel carried by the *image data* for a colour
1489/// space.
1490///
1491/// For `Indexed` the data carries a single palette index per pixel (1). For
1492/// `ICCBased`, pass the profile's resolved `/N` via `icc_n` (defaults to 3 when
1493/// unknown). `DeviceN` reports the number of named colorants.
1494fn 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
1528/// Expand one-index-per-pixel data into `base_components`-byte pixels using the
1529/// `lookup` palette (`(hival + 1) * base_components` bytes).
1530///
1531/// Indices greater than `hival` are clamped; a short palette is zero-padded so
1532/// the output length is always `indices.len() * base_components`.
1533fn 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
1545/// Unpack packed samples (1/2/4/8 bits per component) into one byte per sample,
1546/// honouring PDF row alignment (each scanline starts on a byte boundary).
1547///
1548/// For `bits_per_component >= 8` the data is returned unchanged.
1549fn unpack_indices(data: &[u8], width: u32, height: u32, bits_per_component: u8) -> Vec<u8> {
1550    // Only the spec-valid packed depths {1, 2, 4} are unpacked. 8 (and the
1551    // defensive 0) pass through unchanged; any other value (e.g. a malformed
1552    // 3/5/6/7) would make the scanline shift underflow, so it also passes
1553    // through and the caller's size check rejects it cleanly.
1554    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        // Unknown name / missing colour space default to RGB (legacy behaviour).
1594        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        // Falls back to RGB when /N is unknown.
1615        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        // 3-entry RGB palette: red, green, blue.
1632        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]; // 2 entries, hival = 1
1641                                                    // Index 5 is past hival -> clamped to the last entry.
1642        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        // One row of 2 pixels at 4 bpc packed into a single byte 0xA3 -> [0xA, 0x3].
1655        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        // One row of 4 pixels at 2 bpc packed into a byte 0b11_10_01_00 -> [3,2,1,0].
1662        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        // A malformed 3 bpc must not panic (shift underflow); it passes through.
1669        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        // 3 pixels per row at 1 bpc => each row occupies 1 byte (padded).
1676        // Row 0: 0b101_00000 -> 1,0,1 ; Row 1: 0b011_00000 -> 0,1,1
1677        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    // Helper function to create minimal valid PDF for testing
1788    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        // Create a minimal valid PDF document for testing
1812        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        // PNG magic bytes
1820        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        // JPEG magic bytes
1835        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        // TIFF little endian magic bytes
1850        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        // TIFF big endian magic bytes
1865        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        // Unknown format - should default to PNG
1880        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); // Default fallback
1885    }
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        // Too short data (less than 2 bytes)
1897        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        // In real usage, ImageExtractor would create this directory
1946        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"); // No placeholders replaced
2044    }
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        // Empty data
2056        let empty_data = b"";
2057        assert!(extractor.detect_image_format_from_data(empty_data).is_err());
2058
2059        // Data exactly 8 bytes (minimum for PNG check)
2060        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        // Data exactly 4 bytes (minimum for TIFF check)
2065        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        // Data exactly 2 bytes (minimum for JPEG check)
2070        let exact_2 = b"\xFF\xD8";
2071        let format = extractor.detect_image_format_from_data(exact_2).unwrap();
2072        assert_eq!(format, ImageFormat::Jpeg); // JPEG only needs 2 bytes
2073    }
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        // Test that page numbers and indices work correctly
2120        let image1 = ExtractedImage {
2121            page_number: 0, // 0-indexed
2122            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,  // Large page number
2131            image_index: 255, // Large index
2132            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;