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    fn new(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> Self {
33        Self { a, b, c, d, e, f }
34    }
35
36    /// Check if this matrix represents a 90-degree rotation
37    #[allow(dead_code)]
38    fn is_90_degree_rotation(&self) -> bool {
39        // For 90-degree rotation: a ≈ 0, d ≈ 0, b and c are non-zero
40        self.a.abs() < 0.001 && self.d.abs() < 0.001 && self.b.abs() > 0.001 && self.c.abs() > 0.001
41    }
42
43    /// Check if this matrix represents a simple scaling
44    #[allow(dead_code)]
45    fn is_simple_scale(&self) -> bool {
46        // For scaling: b ≈ 0, c ≈ 0, a and d are scaling factors
47        self.b.abs() < 0.001 && self.c.abs() < 0.001 && self.a.abs() > 0.001 && self.d.abs() > 0.001
48    }
49
50    /// Check if this is a matrix that needs rotation for proper OCR
51    #[allow(dead_code)]
52    fn is_fis2_like_matrix(&self) -> bool {
53        // Some PDFs use 841.68 x 595.08 which are A4 dimensions (landscape fitting in portrait)
54        // This indicates the image is landscape but being fit into portrait page
55        (self.a - 841.68).abs() < 1.0
56            && (self.d - 595.08).abs() < 1.0
57            && self.b.abs() < 0.001
58            && self.c.abs() < 0.001
59    }
60}
61
62/// Preprocessing options for extracted images
63#[derive(Debug, Clone)]
64pub struct ImagePreprocessingOptions {
65    /// Auto-detect and correct rotation
66    pub auto_correct_rotation: bool,
67    /// Enhance contrast for better OCR
68    pub enhance_contrast: bool,
69    /// Apply noise reduction
70    pub denoise: bool,
71    /// Upscale small images using bicubic interpolation
72    pub upscale_small_images: bool,
73    /// Minimum size to trigger upscaling
74    pub upscale_threshold: u32,
75    /// Upscale factor (2x, 3x, etc.)
76    pub upscale_factor: u32,
77    /// Convert to grayscale for better OCR on text documents
78    pub force_grayscale: bool,
79}
80
81impl Default for ImagePreprocessingOptions {
82    fn default() -> Self {
83        Self {
84            auto_correct_rotation: true,
85            enhance_contrast: true,
86            denoise: true,
87            upscale_small_images: true,
88            upscale_threshold: 300,
89            upscale_factor: 2,
90            force_grayscale: false,
91        }
92    }
93}
94
95/// Options for image extraction
96#[derive(Debug, Clone)]
97pub struct ExtractImagesOptions {
98    /// Output directory for extracted images
99    pub output_dir: PathBuf,
100    /// File name pattern for extracted images
101    /// Supports placeholders: {page}, {index}, {format}
102    pub name_pattern: String,
103    /// Whether to extract inline images
104    pub extract_inline: bool,
105    /// Minimum size (width or height) to extract
106    pub min_size: Option<u32>,
107    /// Whether to create output directory if it doesn't exist
108    pub create_dir: bool,
109    /// Preprocessing options for extracted images
110    pub preprocessing: ImagePreprocessingOptions,
111}
112
113impl Default for ExtractImagesOptions {
114    fn default() -> Self {
115        Self {
116            output_dir: PathBuf::from("."),
117            name_pattern: "page_{page}_image_{index}.{format}".to_string(),
118            extract_inline: true,
119            min_size: Some(10),
120            create_dir: true,
121            preprocessing: ImagePreprocessingOptions::default(),
122        }
123    }
124}
125
126/// Result of image extraction
127#[derive(Debug)]
128pub struct ExtractedImage {
129    /// Page number (0-indexed)
130    pub page_number: usize,
131    /// Image index on the page
132    pub image_index: usize,
133    /// Output file path
134    pub file_path: PathBuf,
135    /// Image dimensions
136    pub width: u32,
137    pub height: u32,
138    /// Image format
139    pub format: ImageFormat,
140}
141
142/// Image extractor
143pub struct ImageExtractor<R: Read + Seek> {
144    document: PdfDocument<R>,
145    options: ExtractImagesOptions,
146    /// Cache for already processed images
147    processed_images: HashMap<String, PathBuf>,
148}
149
150impl<R: Read + Seek> ImageExtractor<R> {
151    /// Create a new image extractor
152    pub fn new(document: PdfDocument<R>, options: ExtractImagesOptions) -> Self {
153        Self {
154            document,
155            options,
156            processed_images: HashMap::new(),
157        }
158    }
159
160    /// Extract all images from the document
161    pub fn extract_all(&mut self) -> OperationResult<Vec<ExtractedImage>> {
162        // Create output directory if needed
163        if self.options.create_dir && !self.options.output_dir.exists() {
164            fs::create_dir_all(&self.options.output_dir)?;
165        }
166
167        let mut extracted_images = Vec::new();
168        let page_count = self
169            .document
170            .page_count()
171            .map_err(|e| OperationError::ParseError(e.to_string()))?;
172
173        for page_idx in 0..page_count {
174            let page_images = self.extract_from_page(page_idx as usize)?;
175            extracted_images.extend(page_images);
176        }
177
178        Ok(extracted_images)
179    }
180
181    /// Extract images from a specific page
182    pub fn extract_from_page(
183        &mut self,
184        page_number: usize,
185    ) -> OperationResult<Vec<ExtractedImage>> {
186        let mut extracted = Vec::new();
187
188        // Get the page
189        let page = self
190            .document
191            .get_page(page_number as u32)
192            .map_err(|e| OperationError::ParseError(e.to_string()))?;
193
194        // Get page resources and collect XObject references
195        let xobject_refs: Vec<(String, u32, u16)> = {
196            let resources = self
197                .document
198                .get_page_resources(&page)
199                .map_err(|e| OperationError::ParseError(e.to_string()))?;
200
201            let mut refs = Vec::new();
202
203            if let Some(resources) = resources {
204                if let Some(PdfObject::Dictionary(xobjects)) =
205                    resources.0.get(&PdfName("XObject".to_string()))
206                {
207                    for (name, obj_ref) in &xobjects.0 {
208                        if let PdfObject::Reference(obj_num, gen_num) = obj_ref {
209                            refs.push((name.0.clone(), *obj_num, *gen_num));
210                        }
211                    }
212                }
213            }
214
215            refs
216        };
217
218        // Process each XObject reference
219        let mut image_index = 0;
220        for (name, obj_num, gen_num) in xobject_refs {
221            if let Ok(xobject) = self.document.get_object(obj_num, gen_num) {
222                if let Some(extracted_image) =
223                    self.process_xobject(&xobject, page_number, image_index, &name)?
224                {
225                    extracted.push(extracted_image);
226                    image_index += 1;
227                }
228            }
229        }
230
231        // If no XObjects found via resources, try alternative method
232        if extracted.is_empty() {
233            // Analyze content streams for image references
234            if let Ok(content_streams) = self.document.get_page_content_streams(&page) {
235                for stream_data in &content_streams {
236                    let referenced_images = self.extract_referenced_images_from_content(
237                        stream_data,
238                        page_number,
239                        &mut image_index,
240                    )?;
241                    extracted.extend(referenced_images);
242                }
243            }
244        }
245
246        // Extract inline images from content stream if requested
247        if self.options.extract_inline {
248            if let Ok(parsed_page) = self.document.get_page(page_number as u32) {
249                if let Ok(content_streams) = self.document.get_page_content_streams(&parsed_page) {
250                    for stream_data in &content_streams {
251                        let inline_images = self.extract_inline_images_from_stream(
252                            stream_data,
253                            page_number,
254                            &mut image_index,
255                        )?;
256                        extracted.extend(inline_images);
257                    }
258                }
259            }
260        }
261
262        Ok(extracted)
263    }
264
265    /// Process an XObject to see if it's an image
266    fn process_xobject(
267        &mut self,
268        xobject: &PdfObject,
269        page_number: usize,
270        image_index: usize,
271        _name: &str,
272    ) -> OperationResult<Option<ExtractedImage>> {
273        if let PdfObject::Stream(stream) = xobject {
274            // Check if it's an image XObject
275            if let Some(PdfObject::Name(subtype)) =
276                stream.dict.0.get(&PdfName("Subtype".to_string()))
277            {
278                if subtype.0 == "Image" {
279                    return self.extract_image_xobject(stream, page_number, image_index);
280                }
281            }
282        }
283        Ok(None)
284    }
285
286    /// Extract an image XObject
287    fn extract_image_xobject(
288        &mut self,
289        stream: &PdfStream,
290        page_number: usize,
291        image_index: usize,
292    ) -> OperationResult<Option<ExtractedImage>> {
293        // Get image properties
294        let width = match stream.dict.0.get(&PdfName("Width".to_string())) {
295            Some(PdfObject::Integer(w)) => *w as u32,
296            _ => return Ok(None),
297        };
298
299        let height = match stream.dict.0.get(&PdfName("Height".to_string())) {
300            Some(PdfObject::Integer(h)) => *h as u32,
301            _ => return Ok(None),
302        };
303
304        // Check minimum size
305        if let Some(min_size) = self.options.min_size {
306            if width < min_size || height < min_size {
307                return Ok(None);
308            }
309        }
310
311        // Get color space information
312        let color_space = stream.dict.0.get(&PdfName("ColorSpace".to_string()));
313        let bits_per_component = match stream.dict.0.get(&PdfName("BitsPerComponent".to_string())) {
314            Some(PdfObject::Integer(bits)) => *bits as u8,
315            _ => 8, // Default to 8 bits per component
316        };
317
318        // Get the decoded image data (resolving an indirect /DecodeParms so
319        // predictors are applied — issue #286).
320        let mut data = self.decode_image_stream(stream)?;
321
322        // Soft mask (/SMask): a grayscale image whose samples are the per-pixel
323        // alpha of this image. Decoded once here (resized to this image's
324        // dimensions) and composited into an RGBA PNG by the raw→PNG paths below
325        // (issue #286: images whose visible shape lives entirely in the SMask
326        // otherwise extract as opaque, often near-black, rectangles).
327        let smask_alpha = self.extract_smask_alpha(&stream.dict, width, height);
328
329        // Determine format from filter and process data accordingly
330        let format = match stream.dict.0.get(&PdfName("Filter".to_string())) {
331            Some(PdfObject::Name(filter)) => match filter.0.as_str() {
332                "DCTDecode" => {
333                    // JPEG data is already in correct format - use raw stream data
334                    // DCTDecode streams contain complete JPEG data, don't decode
335                    if smask_alpha.is_some() {
336                        tracing::debug!(
337                            "image has an /SMask but is DCT-encoded; alpha not composited into JPEG output"
338                        );
339                    }
340                    data = stream.data.clone();
341                    ImageFormat::Jpeg
342                }
343                "FlateDecode" => {
344                    // FlateDecode contains raw pixel data - need to convert to image format
345                    data = self.convert_raw_image_data_to_png(
346                        &data,
347                        width,
348                        height,
349                        color_space,
350                        bits_per_component,
351                        smask_alpha.as_deref(),
352                    )?;
353                    ImageFormat::Png
354                }
355                "CCITTFaxDecode" => {
356                    // CCITT data for scanned documents - convert to PNG
357                    data = self.convert_ccitt_to_png(&data, width, height)?;
358                    ImageFormat::Png
359                }
360                "LZWDecode" => {
361                    // LZW compressed raw data - convert to PNG
362                    data = self.convert_raw_image_data_to_png(
363                        &data,
364                        width,
365                        height,
366                        color_space,
367                        bits_per_component,
368                        smask_alpha.as_deref(),
369                    )?;
370                    ImageFormat::Png
371                }
372                _ => {
373                    tracing::debug!("Unsupported image filter: {}", filter.0);
374                    return Ok(None);
375                }
376            },
377            Some(PdfObject::Array(filters)) => {
378                // Handle filter arrays - use the first filter
379                if let Some(PdfObject::Name(filter)) = filters.0.first() {
380                    match filter.0.as_str() {
381                        "DCTDecode" => {
382                            // JPEG data is already in correct format - use raw stream data
383                            if smask_alpha.is_some() {
384                                tracing::debug!(
385                                    "image has an /SMask but is DCT-encoded; alpha not composited into JPEG output"
386                                );
387                            }
388                            data = stream.data.clone();
389                            ImageFormat::Jpeg
390                        }
391                        "FlateDecode" => {
392                            data = self.convert_raw_image_data_to_png(
393                                &data,
394                                width,
395                                height,
396                                color_space,
397                                bits_per_component,
398                                smask_alpha.as_deref(),
399                            )?;
400                            ImageFormat::Png
401                        }
402                        "CCITTFaxDecode" => {
403                            data = self.convert_ccitt_to_png(&data, width, height)?;
404                            ImageFormat::Png
405                        }
406                        "LZWDecode" => {
407                            data = self.convert_raw_image_data_to_png(
408                                &data,
409                                width,
410                                height,
411                                color_space,
412                                bits_per_component,
413                                smask_alpha.as_deref(),
414                            )?;
415                            ImageFormat::Png
416                        }
417                        _ => {
418                            tracing::debug!("Unsupported image filter: {}", filter.0);
419                            return Ok(None);
420                        }
421                    }
422                } else {
423                    return Ok(None);
424                }
425            }
426            _ => {
427                // No filter - raw image data
428                data = self.convert_raw_image_data_to_png(
429                    &data,
430                    width,
431                    height,
432                    color_space,
433                    bits_per_component,
434                    smask_alpha.as_deref(),
435                )?;
436                ImageFormat::Png
437            }
438        };
439
440        // Generate unique key for this image data
441        let image_key = format!("{:x}", md5::compute(&data));
442
443        // For scanned PDFs where all pages reference the same image object,
444        // we need to create separate files per page for OCR processing
445        // Don't deduplicate if we're extracting for OCR purposes
446        let allow_deduplication = !self.options.name_pattern.contains("{page}");
447
448        // Check if we've already extracted this image (only if deduplication is allowed)
449        if allow_deduplication {
450            if let Some(existing_path) = self.processed_images.get(&image_key) {
451                // Return reference to already extracted image
452                return Ok(Some(ExtractedImage {
453                    page_number,
454                    image_index,
455                    file_path: existing_path.clone(),
456                    width,
457                    height,
458                    format,
459                }));
460            }
461        }
462
463        // Generate output filename
464        let extension = match format {
465            ImageFormat::Jpeg => "jpg",
466            ImageFormat::Png => "png",
467            ImageFormat::Tiff => "tiff",
468            ImageFormat::Raw => "rgb",
469        };
470
471        let filename = self
472            .options
473            .name_pattern
474            .replace("{page}", &(page_number + 1).to_string())
475            .replace("{index}", &(image_index + 1).to_string())
476            .replace("{format}", extension);
477
478        let output_path = self.options.output_dir.join(filename);
479
480        // Apply preprocessing if enabled
481        #[cfg(feature = "external-images")]
482        let processed_data = if self.should_preprocess() {
483            self.preprocess_image_data(&data, width, height, format)?
484        } else {
485            data
486        };
487
488        #[cfg(not(feature = "external-images"))]
489        let processed_data = data;
490
491        // Write image data
492        let mut file = File::create(&output_path)?;
493        file.write_all(&processed_data)?;
494
495        // Cache the path
496        self.processed_images.insert(image_key, output_path.clone());
497
498        Ok(Some(ExtractedImage {
499            page_number,
500            image_index,
501            file_path: output_path,
502            width,
503            height,
504            format,
505        }))
506    }
507
508    /// Detect image format from raw data by examining magic bytes
509    fn detect_image_format_from_data(&self, data: &[u8]) -> OperationResult<ImageFormat> {
510        if data.is_empty() {
511            return Err(OperationError::ParseError(
512                "Image data too short to detect format".to_string(),
513            ));
514        }
515
516        // Check for PNG signature (needs 8 bytes)
517        if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
518            return Ok(ImageFormat::Png);
519        }
520
521        // Check for TIFF signatures (needs 4 bytes)
522        if data.len() >= 4 {
523            if &data[0..2] == b"II" && &data[2..4] == b"\x2A\x00" {
524                return Ok(ImageFormat::Tiff); // Little endian TIFF
525            }
526            if &data[0..2] == b"MM" && &data[2..4] == b"\x00\x2A" {
527                return Ok(ImageFormat::Tiff); // Big endian TIFF
528            }
529        }
530
531        // Check for JPEG signature (needs 2 bytes)
532        if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
533            return Ok(ImageFormat::Jpeg);
534        }
535
536        // If data is too short for any meaningful detection
537        if data.len() < 2 {
538            return Err(OperationError::ParseError(
539                "Image data too short to detect format".to_string(),
540            ));
541        }
542
543        // Default to PNG for FlateDecode if no other format detected
544        // This is a fallback since FlateDecode is commonly used for PNG in PDFs
545        Ok(ImageFormat::Png)
546    }
547
548    /// Extract inline images from a content stream
549    fn extract_inline_images_from_stream(
550        &mut self,
551        stream_data: &[u8],
552        page_number: usize,
553        image_index: &mut usize,
554    ) -> OperationResult<Vec<ExtractedImage>> {
555        let mut inline_images = Vec::new();
556
557        // Convert bytes to string for parsing
558        let stream_str = String::from_utf8_lossy(stream_data);
559
560        // Find inline image operators: BI (Begin Image), ID (Image Data), EI (End Image)
561        let mut pos = 0;
562        while let Some(bi_pos) = stream_str[pos..].find("BI") {
563            let absolute_bi_pos = pos + bi_pos;
564
565            // Find the ID operator after BI
566            if let Some(relative_id_pos) = stream_str[absolute_bi_pos..].find("ID") {
567                let absolute_id_pos = absolute_bi_pos + relative_id_pos;
568
569                // Find the EI operator after ID
570                if let Some(relative_ei_pos) = stream_str[absolute_id_pos..].find("EI") {
571                    let absolute_ei_pos = absolute_id_pos + relative_ei_pos;
572
573                    // Extract image dictionary (between BI and ID)
574                    let dict_section = &stream_str[absolute_bi_pos + 2..absolute_id_pos].trim();
575
576                    // Extract image data (between ID and EI)
577                    let data_start = absolute_id_pos + 2;
578                    let data_end = absolute_ei_pos;
579
580                    if data_start < data_end && data_end <= stream_data.len() {
581                        let image_data = &stream_data[data_start..data_end];
582
583                        // Parse basic image properties from dictionary
584                        let (width, height) = self.parse_inline_image_dict(dict_section);
585
586                        // Create extracted image
587                        if let Ok(extracted_image) = self.save_inline_image(
588                            image_data,
589                            page_number,
590                            *image_index,
591                            width,
592                            height,
593                        ) {
594                            inline_images.push(extracted_image);
595                            *image_index += 1;
596                        }
597                    }
598
599                    // Continue searching after this EI
600                    pos = absolute_ei_pos + 2;
601                } else {
602                    break; // No matching EI found
603                }
604            } else {
605                break; // No matching ID found
606            }
607        }
608
609        Ok(inline_images)
610    }
611
612    /// Extract images referenced in content streams when resources are not available
613    fn extract_referenced_images_from_content(
614        &mut self,
615        stream_data: &[u8],
616        page_number: usize,
617        image_index: &mut usize,
618    ) -> OperationResult<Vec<ExtractedImage>> {
619        let mut extracted = Vec::new();
620
621        // Convert to string for parsing
622        let content = String::from_utf8_lossy(stream_data);
623
624        tracing::debug!("       Content: {}", content);
625
626        // Parse transformation matrices and image references together
627        // Pattern: look for cm matrices followed by Do operators
628        let image_with_transform = self.parse_images_with_transformations(&content)?;
629
630        for (image_name, transform_matrix) in image_with_transform {
631            // Try to find this object by scanning all objects in the document
632            if let Some(mut extracted_image) =
633                self.find_and_extract_xobject_by_name(&image_name, page_number, *image_index)?
634            {
635                // Apply transformation if one was found
636                if let Some(matrix) = transform_matrix {
637                    extracted_image =
638                        self.apply_transformation_to_image(extracted_image, &matrix)?;
639                }
640
641                extracted.push(extracted_image);
642                *image_index += 1;
643            }
644        }
645
646        Ok(extracted)
647    }
648
649    /// Find an XObject by name by scanning through the document
650    fn find_and_extract_xobject_by_name(
651        &mut self,
652        name: &str,
653        page_number: usize,
654        image_index: usize,
655    ) -> OperationResult<Option<ExtractedImage>> {
656        // This is a brute force approach - scan through objects looking for image streams
657        // In a real implementation, we would have better object mapping, but for now
658        // this should work for common landscape-in-portrait cases
659
660        // Try some common object numbers that might contain images
661        // We'll scan a range and look for stream objects that look like images
662        for obj_num in 1..1000 {
663            if let Ok(obj) = self.document.get_object(obj_num, 0) {
664                if let Some(extracted) =
665                    self.try_extract_image_from_object(&obj, page_number, image_index, name)?
666                {
667                    return Ok(Some(extracted));
668                }
669            }
670        }
671
672        Ok(None)
673    }
674
675    /// Try to extract an image from any PDF object
676    fn try_extract_image_from_object(
677        &mut self,
678        obj: &PdfObject,
679        page_number: usize,
680        image_index: usize,
681        _expected_name: &str,
682    ) -> OperationResult<Option<ExtractedImage>> {
683        if let PdfObject::Stream(stream) = obj {
684            // Check if this stream looks like an image
685            if let Some(PdfObject::Name(subtype)) =
686                stream.dict.0.get(&PdfName("Subtype".to_string()))
687            {
688                if subtype.0 == "Image" {
689                    return self.extract_image_xobject(stream, page_number, image_index);
690                }
691            }
692
693            // Also check for streams that might be images but don't have proper Subtype
694            if let Some(PdfObject::Integer(_width)) =
695                stream.dict.0.get(&PdfName("Width".to_string()))
696            {
697                if let Some(PdfObject::Integer(_height)) =
698                    stream.dict.0.get(&PdfName("Height".to_string()))
699                {
700                    return self.extract_image_xobject(stream, page_number, image_index);
701                }
702            }
703        }
704
705        Ok(None)
706    }
707
708    /// Parse content stream to find images with their transformation matrices
709    fn parse_images_with_transformations(
710        &self,
711        content: &str,
712    ) -> OperationResult<Vec<(String, Option<TransformMatrix>)>> {
713        let mut results = Vec::new();
714        let lines: Vec<&str> = content.lines().collect();
715
716        let mut current_matrix: Option<TransformMatrix> = None;
717
718        for line in lines {
719            let line = line.trim();
720
721            // Look for transformation matrices: "a b c d e f cm"
722            if line.ends_with(" cm") {
723                let parts: Vec<&str> = line.split_whitespace().collect();
724                if parts.len() == 7 && parts[6] == "cm" {
725                    // Parse the 6 matrix values
726                    if let (Ok(a), Ok(b), Ok(c), Ok(d), Ok(e), Ok(f)) = (
727                        parts[0].parse::<f64>(),
728                        parts[1].parse::<f64>(),
729                        parts[2].parse::<f64>(),
730                        parts[3].parse::<f64>(),
731                        parts[4].parse::<f64>(),
732                        parts[5].parse::<f64>(),
733                    ) {
734                        current_matrix = Some(TransformMatrix::new(a, b, c, d, e, f));
735                    }
736                }
737            }
738
739            // Look for image draw commands: "/ImageName Do"
740            if line.contains(" Do") {
741                let parts: Vec<&str> = line.split_whitespace().collect();
742                for part in parts {
743                    if part.starts_with('/') && !part.contains("Do") {
744                        let image_name = part[1..].to_string(); // Remove the '/'
745                        results.push((image_name, current_matrix.clone()));
746                    }
747                }
748            }
749
750            // Reset matrix on graphics state restore
751            if line.trim() == "Q" {
752                current_matrix = None;
753            }
754        }
755
756        Ok(results)
757    }
758
759    /// Apply transformation matrix to an extracted image
760    #[allow(unused_mut)]
761    fn apply_transformation_to_image(
762        &self,
763        mut extracted_image: ExtractedImage,
764        _matrix: &TransformMatrix,
765    ) -> OperationResult<ExtractedImage> {
766        #[cfg(feature = "external-images")]
767        {
768            // Read the extracted image file
769            let image_data = std::fs::read(&extracted_image.file_path)?;
770
771            // Load with image crate
772            let img = image::load_from_memory(&image_data).map_err(|e| {
773                OperationError::ParseError(format!("Failed to load image for transformation: {e}"))
774            })?;
775
776            // IGNORE TRANSFORMATION FOR NOW - FOCUS ON STRIDE PROBLEM
777            let transformed_img =
778                self.fix_stride_problem(img, extracted_image.width, extracted_image.height)?;
779
780            // Save the transformed image
781            let output_filename = extracted_image
782                .file_path
783                .file_stem()
784                .and_then(|s| s.to_str())
785                .ok_or_else(|| OperationError::InvalidPath {
786                    reason: format!(
787                        "Image path has no valid filename: {:?}",
788                        extracted_image.file_path
789                    ),
790                })?;
791            let output_extension = extracted_image
792                .file_path
793                .extension()
794                .and_then(|s| s.to_str())
795                .ok_or_else(|| OperationError::InvalidPath {
796                    reason: format!(
797                        "Image path has no valid extension: {:?}",
798                        extracted_image.file_path
799                    ),
800                })?;
801
802            let parent_dir =
803                extracted_image
804                    .file_path
805                    .parent()
806                    .ok_or_else(|| OperationError::InvalidPath {
807                        reason: format!(
808                            "Image path has no parent directory: {:?}",
809                            extracted_image.file_path
810                        ),
811                    })?;
812            let transformed_path = parent_dir.join(format!(
813                "{}_transformed.{}",
814                output_filename, output_extension
815            ));
816
817            transformed_img.save(&transformed_path).map_err(|e| {
818                OperationError::ParseError(format!("Failed to save transformed image: {e}"))
819            })?;
820
821            // Update the extracted image info
822            let (new_width, new_height) = transformed_img.dimensions();
823            extracted_image.file_path = transformed_path;
824            extracted_image.width = new_width;
825            extracted_image.height = new_height;
826        }
827
828        #[cfg(not(feature = "external-images"))]
829        {}
830
831        Ok(extracted_image)
832    }
833
834    /// Apply rotation transformation
835    #[cfg(feature = "external-images")]
836    #[allow(dead_code)]
837    fn apply_rotation_transformation(
838        &self,
839        img: DynamicImage,
840        matrix: &TransformMatrix,
841    ) -> OperationResult<DynamicImage> {
842        // Determine rotation direction based on matrix values
843        // For 90-degree clockwise: a=0, b=1, c=-1, d=0
844        // For 90-degree counter-clockwise: a=0, b=-1, c=1, d=0
845
846        if matrix.b > 0.0 && matrix.c < 0.0 {
847            Ok(img.rotate90()) // 90 degrees clockwise
848        } else if matrix.b < 0.0 && matrix.c > 0.0 {
849            Ok(img.rotate270()) // 90 degrees counter-clockwise (270 clockwise)
850        } else {
851            // Default to 90-degree rotation for landscape-in-portrait cases
852            Ok(img.rotate90())
853        }
854    }
855
856    /// Apply scaling transformation
857    #[cfg(feature = "external-images")]
858    #[allow(dead_code)]
859    fn apply_scale_transformation(
860        &self,
861        img: DynamicImage,
862        matrix: &TransformMatrix,
863    ) -> OperationResult<DynamicImage> {
864        let (current_width, current_height) = img.dimensions();
865
866        // Calculate new dimensions based on scaling factors
867        let new_width = (current_width as f64 * matrix.a.abs()) as u32;
868        let new_height = (current_height as f64 * matrix.d.abs()) as u32;
869
870        if new_width > 0 && new_height > 0 {
871            Ok(img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3))
872        } else {
873            // If scaling results in invalid dimensions, return original
874            Ok(img)
875        }
876    }
877
878    /// Fix stride/row alignment problems in image data
879    #[cfg(feature = "external-images")]
880    fn fix_stride_problem(
881        &self,
882        img: DynamicImage,
883        original_width: u32,
884        original_height: u32,
885    ) -> OperationResult<DynamicImage> {
886        // Convert to raw grayscale data
887        let gray_img = img.to_luma8();
888        let pixel_data = gray_img.as_raw();
889
890        // Try different row strides to fix misalignment
891        let bytes_per_row = original_width as usize;
892        let min_bytes_per_row = bytes_per_row;
893
894        // Possible stride alignments
895        let possible_strides = [
896            min_bytes_per_row,              // No padding
897            (min_bytes_per_row + 1) & !1,   // 2-byte aligned
898            (min_bytes_per_row + 3) & !3,   // 4-byte aligned
899            (min_bytes_per_row + 7) & !7,   // 8-byte aligned
900            (min_bytes_per_row + 15) & !15, // 16-byte aligned
901            min_bytes_per_row + 1,          // +1 padding
902            min_bytes_per_row + 2,          // +2 padding
903            min_bytes_per_row + 4,          // +4 padding
904        ];
905
906        for (_i, &stride) in possible_strides.iter().enumerate() {
907            let expected_total = stride * original_height as usize;
908
909            if expected_total <= pixel_data.len() {
910                // Extract using this stride
911                let mut corrected_data = Vec::new();
912                for row in 0..original_height {
913                    let row_start = row as usize * stride;
914                    let row_end = row_start + bytes_per_row;
915
916                    if row_end <= pixel_data.len() {
917                        corrected_data.extend_from_slice(&pixel_data[row_start..row_end]);
918                    } else {
919                        // Fill with white if we run out of data
920                        corrected_data.resize(corrected_data.len() + bytes_per_row, 255);
921                    }
922                }
923
924                // Create corrected image
925                if corrected_data.len() == (original_width * original_height) as usize {
926                    if let Some(corrected_img) = ImageBuffer::<Luma<u8>, Vec<u8>>::from_raw(
927                        original_width,
928                        original_height,
929                        corrected_data,
930                    ) {
931                        return Ok(DynamicImage::ImageLuma8(corrected_img));
932                    }
933                }
934            } else {
935            }
936        }
937
938        Ok(img)
939    }
940
941    /// Parse inline image dictionary to extract width and height
942    fn parse_inline_image_dict(&self, dict_str: &str) -> (u32, u32) {
943        let mut width = 100; // Default width
944        let mut height = 100; // Default height
945
946        // Simple parsing - look for /W and /H parameters
947        for line in dict_str.lines() {
948            let line = line.trim();
949
950            // Parse width: /W 123 or /Width 123
951            if line.starts_with("/W ") || line.starts_with("/Width ") {
952                if let Some(value_str) = line.split_whitespace().nth(1) {
953                    if let Ok(w) = value_str.parse::<u32>() {
954                        width = w;
955                    }
956                }
957            }
958
959            // Parse height: /H 123 or /Height 123
960            if line.starts_with("/H ") || line.starts_with("/Height ") {
961                if let Some(value_str) = line.split_whitespace().nth(1) {
962                    if let Ok(h) = value_str.parse::<u32>() {
963                        height = h;
964                    }
965                }
966            }
967        }
968
969        (width, height)
970    }
971
972    /// Save an inline image to disk
973    fn save_inline_image(
974        &mut self,
975        data: &[u8],
976        page_number: usize,
977        image_index: usize,
978        width: u32,
979        height: u32,
980    ) -> OperationResult<ExtractedImage> {
981        // Generate unique key for deduplication
982        let image_key = format!("{:x}", md5::compute(data));
983
984        // Don't deduplicate if we're extracting for OCR purposes (pattern contains {page})
985        let allow_deduplication = !self.options.name_pattern.contains("{page}");
986
987        // Check if we've already extracted this image (only if deduplication is allowed)
988        if allow_deduplication {
989            if let Some(existing_path) = self.processed_images.get(&image_key) {
990                return Ok(ExtractedImage {
991                    page_number,
992                    image_index,
993                    file_path: existing_path.clone(),
994                    width,
995                    height,
996                    format: ImageFormat::Raw, // Inline images are often raw
997                });
998            }
999        }
1000
1001        // Determine format and extension
1002        let format = self
1003            .detect_image_format_from_data(data)
1004            .unwrap_or(ImageFormat::Raw);
1005        let extension = match format {
1006            ImageFormat::Jpeg => "jpg",
1007            ImageFormat::Png => "png",
1008            ImageFormat::Tiff => "tif",
1009            ImageFormat::Raw => "raw",
1010        };
1011
1012        // Generate filename
1013        let filename = format!(
1014            "inline_page_{}_{:03}.{}",
1015            page_number + 1,
1016            image_index + 1,
1017            extension
1018        );
1019        let file_path = self.options.output_dir.join(filename);
1020
1021        // Write image data to file
1022        fs::write(&file_path, data)?;
1023
1024        // Cache the extracted image
1025        self.processed_images.insert(image_key, file_path.clone());
1026
1027        Ok(ExtractedImage {
1028            page_number,
1029            image_index,
1030            file_path,
1031            width,
1032            height,
1033            format,
1034        })
1035    }
1036
1037    /// Decode an image stream, resolving an indirect `/DecodeParms` (or `/DP`)
1038    /// first so that filter predictors are actually applied (issue #286).
1039    ///
1040    /// `PdfStream::decode` only sees the stream's own dictionary; when the
1041    /// decode parameters are stored as an indirect reference the predictor is
1042    /// silently skipped, leaving the per-row predictor bytes in the output.
1043    fn decode_image_stream(&self, stream: &PdfStream) -> OperationResult<Vec<u8>> {
1044        let parse_options = self.document.options();
1045
1046        let needs_resolution = ["DecodeParms", "DP"].into_iter().any(|key| {
1047            stream
1048                .dict
1049                .0
1050                .get(&PdfName(key.to_string()))
1051                .map(Self::contains_reference)
1052                .unwrap_or(false)
1053        });
1054
1055        let decode_result = if needs_resolution {
1056            let mut dict = stream.dict.clone();
1057            for key in ["DecodeParms", "DP"] {
1058                if let Some(obj) = dict.0.get(&PdfName(key.to_string())).cloned() {
1059                    let resolved = self.resolve_decode_params(&obj);
1060                    dict.0.insert(PdfName(key.to_string()), resolved);
1061                }
1062            }
1063            PdfStream {
1064                dict,
1065                data: stream.data.clone(),
1066            }
1067            .decode(&parse_options)
1068        } else {
1069            stream.decode(&parse_options)
1070        };
1071
1072        decode_result
1073            .map_err(|e| OperationError::ParseError(format!("Failed to decode image stream: {e}")))
1074    }
1075
1076    /// Whether an object is, or directly contains, an indirect reference.
1077    fn contains_reference(obj: &PdfObject) -> bool {
1078        match obj {
1079            PdfObject::Reference(_, _) => true,
1080            PdfObject::Array(arr) => arr
1081                .0
1082                .iter()
1083                .any(|e| matches!(e, PdfObject::Reference(_, _))),
1084            _ => false,
1085        }
1086    }
1087
1088    /// Resolve indirect references inside a `/DecodeParms` value (the value
1089    /// itself, or each element of a per-filter array).
1090    fn resolve_decode_params(&self, obj: &PdfObject) -> PdfObject {
1091        let resolved = self.document.resolve(obj).unwrap_or_else(|e| {
1092            // Falling back to the unresolved reference means the predictor is
1093            // skipped and the image decodes to garbage — the original #286
1094            // symptom. Surface it instead of failing silently.
1095            tracing::warn!("Failed to resolve /DecodeParms reference: {e}");
1096            obj.clone()
1097        });
1098        match resolved {
1099            PdfObject::Array(arr) => PdfObject::Array(PdfArray(
1100                arr.0
1101                    .iter()
1102                    .map(|e| self.document.resolve(e).unwrap_or_else(|_| e.clone()))
1103                    .collect(),
1104            )),
1105            other => other,
1106        }
1107    }
1108
1109    /// If `color_space` is an `[/Indexed base hival lookup]` array, resolve it
1110    /// into `(resolved_base, hival, palette_bytes)`.
1111    fn try_resolve_indexed(
1112        &self,
1113        color_space: Option<&PdfObject>,
1114    ) -> Option<(PdfObject, usize, Vec<u8>)> {
1115        let array = color_space?.as_array()?;
1116        let first = array.0.first()?.as_name()?;
1117        if first.0 != "Indexed" && first.0 != "I" {
1118            return None;
1119        }
1120        let base = self.document.resolve(array.0.get(1)?).ok()?;
1121        let hival = array.0.get(2)?.as_integer()?.max(0) as usize;
1122        let lookup = self.resolve_lookup_bytes(array.0.get(3)?)?;
1123        Some((base, hival, lookup))
1124    }
1125
1126    /// Resolve the Indexed lookup table into palette bytes (it may be a string
1127    /// literal or an indirect stream).
1128    fn resolve_lookup_bytes(&self, lookup: &PdfObject) -> Option<Vec<u8>> {
1129        match self.document.resolve(lookup).ok()? {
1130            PdfObject::String(s) => Some(s.0),
1131            PdfObject::Stream(s) => s.decode(&self.document.options()).ok(),
1132            _ => None,
1133        }
1134    }
1135
1136    /// Resolve the `/N` (component count) of an `[/ICCBased stream]` colour space.
1137    fn icc_components(&self, color_space: Option<&PdfObject>) -> Option<u8> {
1138        let array = color_space?.as_array()?;
1139        if array.0.first()?.as_name()?.0 != "ICCBased" {
1140            return None;
1141        }
1142        let stream = self.document.resolve(array.0.get(1)?).ok()?;
1143        let n = stream
1144            .as_stream()?
1145            .dict
1146            .0
1147            .get(&PdfName("N".to_string()))?
1148            .as_integer()?;
1149        // /N is 1, 3 or 4 for valid ICC profiles. Clamp so a malformed value
1150        // can't truncate (e.g. -1 → 255) and blow up a downstream allocation.
1151        Some(n.clamp(1, 4) as u8)
1152    }
1153
1154    /// Convert raw image sample data to PNG format.
1155    ///
1156    /// Handles Indexed colour spaces (one palette index per pixel, expanded to
1157    /// the base colour) and computes the component count from the colour space
1158    /// (issue #286 — Indexed was previously treated as 3-component RGB).
1159    fn convert_raw_image_data_to_png(
1160        &self,
1161        data: &[u8],
1162        width: u32,
1163        height: u32,
1164        color_space: Option<&PdfObject>,
1165        bits_per_component: u8,
1166        smask_alpha: Option<&[u8]>,
1167    ) -> OperationResult<Vec<u8>> {
1168        // Resolve an indirect ColorSpace reference up front.
1169        let resolved_cs = color_space.and_then(|cs| self.document.resolve(cs).ok());
1170        let cs = resolved_cs.as_ref().or(color_space);
1171
1172        // Indexed colour space: the data carries a single palette index per
1173        // pixel. Expand to the base colour space so the PNG is a real picture
1174        // rather than indices misread as grayscale.
1175        if let Some((base, hival, palette)) = self.try_resolve_indexed(cs) {
1176            let base_components = self.color_space_component_count(Some(&base)) as usize;
1177            // 8-bit indices are already one byte per pixel — borrow directly
1178            // instead of cloning; only sub-byte depths need unpacking.
1179            let indices: std::borrow::Cow<[u8]> = if bits_per_component == 8 {
1180                std::borrow::Cow::Borrowed(data)
1181            } else {
1182                std::borrow::Cow::Owned(unpack_indices(data, width, height, bits_per_component))
1183            };
1184            let pixel_count = (width as usize) * (height as usize);
1185            if indices.len() < pixel_count {
1186                return Err(OperationError::ParseError(format!(
1187                    "Indexed image data too small: expected {} indices, got {}",
1188                    pixel_count,
1189                    indices.len()
1190                )));
1191            }
1192            let rgb = expand_indexed(&indices[..pixel_count], &palette, base_components, hival);
1193            return self.encode_png_maybe_alpha(
1194                &rgb,
1195                width,
1196                height,
1197                base_components as u8,
1198                8,
1199                smask_alpha,
1200            );
1201        }
1202
1203        // Non-indexed: component count from the colour space.
1204        let icc_n = self.icc_components(cs);
1205        let components = image_sample_components(cs, icc_n);
1206
1207        // Calculate expected data size. Use usize arithmetic so large images
1208        // do not overflow the intermediate product (a u32 multiply wraps near
1209        // 4 GB and would let truncated data pass the check below).
1210        let bytes_per_sample = if bits_per_component <= 8 { 1 } else { 2 };
1211        let expected_size = (width as usize)
1212            * (height as usize)
1213            * (components as usize)
1214            * (bytes_per_sample as usize);
1215
1216        // Validate data size
1217        if data.len() < expected_size {
1218            return Err(OperationError::ParseError(format!(
1219                "Image data too small: expected {}, got {}",
1220                expected_size,
1221                data.len()
1222            )));
1223        }
1224
1225        // Convert to PNG format using simple PNG encoding
1226        self.encode_png_maybe_alpha(
1227            data,
1228            width,
1229            height,
1230            components,
1231            bits_per_component,
1232            smask_alpha,
1233        )
1234    }
1235
1236    /// Number of colour components for a (resolved) colour space, resolving an
1237    /// `/ICCBased` `/N` when needed.
1238    fn color_space_component_count(&self, color_space: Option<&PdfObject>) -> u8 {
1239        let icc_n = self.icc_components(color_space);
1240        image_sample_components(color_space, icc_n)
1241    }
1242
1243    /// Decode an image's `/SMask` into a per-pixel 8-bit alpha buffer sized to
1244    /// `width`×`height` (nearest-neighbour resized if the mask resolution
1245    /// differs). Returns `None` when there is no soft mask, or when the mask
1246    /// is not a plain 8-bit grayscale raster we can interpret (e.g. a DCT or
1247    /// 16-bit mask), in which case the image is emitted without alpha.
1248    fn extract_smask_alpha(
1249        &self,
1250        image_dict: &crate::parser::objects::PdfDictionary,
1251        width: u32,
1252        height: u32,
1253    ) -> Option<Vec<u8>> {
1254        let smask = image_dict.0.get(&PdfName("SMask".to_string()))?;
1255        let resolved = self.document.resolve(smask).ok()?;
1256        let stream = match &resolved {
1257            PdfObject::Stream(s) => s,
1258            _ => return None,
1259        };
1260        let dict = &stream.dict.0;
1261        // Validate sign before casting: a negative /Width or /Height would cast
1262        // to a huge u32 and (on 32-bit targets) wrap the `sw * sh` product,
1263        // producing a corrupt mask. Reject non-positive dimensions outright.
1264        let sw_i = dict.get(&PdfName("Width".to_string()))?.as_integer()?;
1265        let sh_i = dict.get(&PdfName("Height".to_string()))?.as_integer()?;
1266        if sw_i <= 0 || sh_i <= 0 {
1267            return None;
1268        }
1269        let sw = sw_i as u32;
1270        let sh = sh_i as u32;
1271        let sbpc = dict
1272            .get(&PdfName("BitsPerComponent".to_string()))
1273            .and_then(|b| b.as_integer())
1274            .unwrap_or(8);
1275        if sbpc != 8 {
1276            return None; // only 8-bit masks are supported
1277        }
1278
1279        let gray = self.decode_image_stream(stream).ok()?;
1280        let expected = (sw as usize) * (sh as usize);
1281        // A shorter buffer means the mask is not a plain gray raster (e.g. DCT);
1282        // bail rather than misread it.
1283        if gray.len() < expected {
1284            return None;
1285        }
1286        let gray = &gray[..expected];
1287
1288        if sw == width && sh == height {
1289            return Some(gray.to_vec());
1290        }
1291        // Nearest-neighbour resize to the base image's dimensions.
1292        let mut out = Vec::with_capacity((width as usize) * (height as usize));
1293        for y in 0..height {
1294            let sy = ((y as u64 * sh as u64) / height as u64) as usize;
1295            let row = sy * sw as usize;
1296            for x in 0..width {
1297                let sx = ((x as u64 * sw as u64) / width as u64) as usize;
1298                out.push(gray[row + sx]);
1299            }
1300        }
1301        Some(out)
1302    }
1303
1304    /// Encode `samples` as PNG. When `alpha` is present and the samples are
1305    /// 8-bit grayscale or RGB, composite it as the alpha channel and emit an
1306    /// RGBA PNG (grayscale is expanded to RGB first); otherwise emit the image
1307    /// as-is. Images that are 16-bit, DCT-encoded, or have 4 components (CMYK or
1308    /// an already-RGBA base) are emitted without alpha (the soft mask is dropped).
1309    fn encode_png_maybe_alpha(
1310        &self,
1311        samples: &[u8],
1312        width: u32,
1313        height: u32,
1314        components: u8,
1315        bits_per_component: u8,
1316        alpha: Option<&[u8]>,
1317    ) -> OperationResult<Vec<u8>> {
1318        match alpha {
1319            Some(a) if bits_per_component == 8 && (components == 1 || components == 3) => {
1320                let pixel_count = (width as usize) * (height as usize);
1321                // Callers guarantee these: the non-indexed path validates
1322                // `data.len() >= width*height*components`, the indexed path feeds
1323                // exactly `pixel_count*components` expanded bytes, and `alpha` is
1324                // sized to the base image. The `unwrap_or` below stay as a release
1325                // safety net; the asserts surface a broken contract in tests.
1326                debug_assert!(
1327                    samples.len() >= pixel_count * components as usize,
1328                    "sample buffer too short: {} < {}",
1329                    samples.len(),
1330                    pixel_count * components as usize
1331                );
1332                debug_assert_eq!(a.len(), pixel_count, "alpha length must match pixel count");
1333                let mut rgba = Vec::with_capacity(pixel_count * 4);
1334                for i in 0..pixel_count {
1335                    let (r, g, b) = if components == 3 {
1336                        let p = i * 3;
1337                        (
1338                            *samples.get(p).unwrap_or(&0),
1339                            *samples.get(p + 1).unwrap_or(&0),
1340                            *samples.get(p + 2).unwrap_or(&0),
1341                        )
1342                    } else {
1343                        let v = *samples.get(i).unwrap_or(&0);
1344                        (v, v, v)
1345                    };
1346                    // Missing mask samples default to opaque.
1347                    let al = *a.get(i).unwrap_or(&255);
1348                    rgba.extend_from_slice(&[r, g, b, al]);
1349                }
1350                self.create_png_from_raw_data(&rgba, width, height, 4, 8)
1351            }
1352            _ => self.create_png_from_raw_data(
1353                samples,
1354                width,
1355                height,
1356                components,
1357                bits_per_component,
1358            ),
1359        }
1360    }
1361
1362    /// Create PNG from raw pixel data
1363    fn create_png_from_raw_data(
1364        &self,
1365        data: &[u8],
1366        width: u32,
1367        height: u32,
1368        components: u8,
1369        bits_per_component: u8,
1370    ) -> OperationResult<Vec<u8>> {
1371        // Simple PNG creation - create a basic PNG structure
1372        let mut png_data = Vec::new();
1373
1374        // PNG signature
1375        png_data.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
1376
1377        // IHDR chunk
1378        let mut ihdr = Vec::new();
1379        ihdr.extend_from_slice(&width.to_be_bytes());
1380        ihdr.extend_from_slice(&height.to_be_bytes());
1381        ihdr.push(bits_per_component);
1382
1383        // Color type: 0 = grayscale, 2 = RGB, 6 = RGBA
1384        let color_type = match components {
1385            1 => 0, // Grayscale
1386            3 => 2, // RGB
1387            4 => 6, // RGBA
1388            _ => 2, // Default to RGB
1389        };
1390        ihdr.push(color_type);
1391        ihdr.push(0); // Compression method
1392        ihdr.push(0); // Filter method
1393        ihdr.push(0); // Interlace method
1394
1395        self.write_png_chunk(&mut png_data, b"IHDR", &ihdr);
1396
1397        // IDAT chunk - compress the image data
1398        let compressed_data = self.compress_image_data(data, width, height, components)?;
1399        self.write_png_chunk(&mut png_data, b"IDAT", &compressed_data);
1400
1401        // IEND chunk
1402        self.write_png_chunk(&mut png_data, b"IEND", &[]);
1403
1404        Ok(png_data)
1405    }
1406
1407    /// Write a PNG chunk with proper CRC
1408    fn write_png_chunk(&self, output: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
1409        // Length (4 bytes, big endian)
1410        output.extend_from_slice(&(data.len() as u32).to_be_bytes());
1411
1412        // Chunk type (4 bytes)
1413        output.extend_from_slice(chunk_type);
1414
1415        // Data
1416        output.extend_from_slice(data);
1417
1418        // CRC (4 bytes, big endian)
1419        let crc = self.calculate_crc32(chunk_type, data);
1420        output.extend_from_slice(&crc.to_be_bytes());
1421    }
1422
1423    /// Simple CRC32 calculation for PNG
1424    fn calculate_crc32(&self, chunk_type: &[u8; 4], data: &[u8]) -> u32 {
1425        // Simple CRC32 - in a real implementation we'd use a proper CRC library
1426        let mut crc: u32 = 0xFFFFFFFF;
1427
1428        // Process chunk type
1429        for &byte in chunk_type {
1430            crc ^= byte as u32;
1431            for _ in 0..8 {
1432                if crc & 1 != 0 {
1433                    crc = (crc >> 1) ^ 0xEDB88320;
1434                } else {
1435                    crc >>= 1;
1436                }
1437            }
1438        }
1439
1440        // Process data
1441        for &byte in data {
1442            crc ^= byte as u32;
1443            for _ in 0..8 {
1444                if crc & 1 != 0 {
1445                    crc = (crc >> 1) ^ 0xEDB88320;
1446                } else {
1447                    crc >>= 1;
1448                }
1449            }
1450        }
1451
1452        crc ^ 0xFFFFFFFF
1453    }
1454
1455    /// Compress image data for PNG IDAT chunk
1456    fn compress_image_data(
1457        &self,
1458        data: &[u8],
1459        width: u32,
1460        height: u32,
1461        components: u8,
1462    ) -> OperationResult<Vec<u8>> {
1463        use flate2::write::ZlibEncoder;
1464        use flate2::Compression;
1465        use std::io::Write;
1466
1467        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1468
1469        // PNG requires scanline filtering - add filter byte (0 = None) to each row
1470        let bytes_per_pixel = components as usize;
1471        let bytes_per_row = width as usize * bytes_per_pixel;
1472
1473        for row in 0..height {
1474            // Filter byte (0 = no filter)
1475            encoder.write_all(&[0])?;
1476
1477            // Row data
1478            let start = row as usize * bytes_per_row;
1479            let end = start + bytes_per_row;
1480            if end <= data.len() {
1481                encoder.write_all(&data[start..end])?;
1482            }
1483        }
1484
1485        encoder
1486            .finish()
1487            .map_err(|e| OperationError::ParseError(format!("Failed to compress PNG data: {e}")))
1488    }
1489
1490    /// Convert CCITT Fax decoded data to PNG (for scanned documents)
1491    fn convert_ccitt_to_png(
1492        &self,
1493        data: &[u8],
1494        width: u32,
1495        height: u32,
1496    ) -> OperationResult<Vec<u8>> {
1497        // CCITT is typically 1-bit monochrome
1498        // Convert 1-bit to 8-bit grayscale
1499        let mut rgb_data = Vec::new();
1500
1501        // Calculate potential row strides - try multiple alignments
1502        let bits_per_row = width as usize;
1503        let min_bytes_per_row = bits_per_row.div_ceil(8);
1504
1505        // Try different row stride alignments (1, 2, 4, 8, 16 byte alignment)
1506        let possible_strides = [
1507            min_bytes_per_row,              // No padding
1508            (min_bytes_per_row + 1) & !1,   // 2-byte aligned
1509            (min_bytes_per_row + 3) & !3,   // 4-byte aligned
1510            (min_bytes_per_row + 7) & !7,   // 8-byte aligned
1511            (min_bytes_per_row + 15) & !15, // 16-byte aligned
1512        ];
1513
1514        // Try to detect the correct stride by checking data patterns
1515        let correct_stride =
1516            self.detect_correct_row_stride(data, width, height, &possible_strides)?;
1517
1518        for row in 0..height {
1519            let row_start = row as usize * correct_stride;
1520
1521            for col in 0..width {
1522                let byte_idx = row_start + (col as usize / 8);
1523                let bit_idx = 7 - (col as usize % 8);
1524
1525                if byte_idx < data.len() {
1526                    let bit = (data[byte_idx] >> bit_idx) & 1;
1527                    // CCITT: 0 = black, 1 = white
1528                    let gray_value = if bit == 0 { 0 } else { 255 };
1529                    rgb_data.push(gray_value);
1530                } else {
1531                    rgb_data.push(255); // White for missing data
1532                }
1533            }
1534        }
1535
1536        // Create PNG from grayscale data
1537        self.create_png_from_raw_data(&rgb_data, width, height, 1, 8)
1538    }
1539
1540    /// Detect the correct row stride by analyzing data patterns
1541    fn detect_correct_row_stride(
1542        &self,
1543        data: &[u8],
1544        width: u32,
1545        height: u32,
1546        possible_strides: &[usize],
1547    ) -> OperationResult<usize> {
1548        let bits_per_row = width as usize;
1549        let min_bytes_per_row = bits_per_row.div_ceil(8);
1550
1551        // If we don't have enough data for analysis, use minimum stride
1552        if data.len() < min_bytes_per_row * 3 {
1553            return Ok(min_bytes_per_row);
1554        }
1555
1556        // Calculate expected total size for each stride
1557        for &stride in possible_strides {
1558            let expected_size = stride * height as usize;
1559
1560            // If this stride gives us a size close to actual data length, use it
1561            if expected_size <= data.len() && (data.len() - expected_size) < stride * 2 {
1562                // Allow some tolerance
1563
1564                return Ok(stride);
1565            }
1566        }
1567
1568        // If no stride fits perfectly, calculate from data length
1569        let calculated_stride = data.len() / height as usize;
1570        if calculated_stride >= min_bytes_per_row {
1571            return Ok(calculated_stride);
1572        }
1573
1574        // Fallback to minimum
1575        Ok(min_bytes_per_row)
1576    }
1577
1578    /// Check if preprocessing should be applied
1579    #[allow(dead_code)]
1580    fn should_preprocess(&self) -> bool {
1581        self.options.preprocessing.auto_correct_rotation
1582            || self.options.preprocessing.enhance_contrast
1583            || self.options.preprocessing.denoise
1584            || self.options.preprocessing.upscale_small_images
1585            || self.options.preprocessing.force_grayscale
1586    }
1587
1588    /// Apply image preprocessing
1589    #[cfg(feature = "external-images")]
1590    fn preprocess_image_data(
1591        &self,
1592        data: &[u8],
1593        width: u32,
1594        height: u32,
1595        format: ImageFormat,
1596    ) -> OperationResult<Vec<u8>> {
1597        // Load image using the image crate
1598        let img_format = match format {
1599            ImageFormat::Jpeg => ImageLibFormat::Jpeg,
1600            ImageFormat::Png => ImageLibFormat::Png,
1601            ImageFormat::Tiff => ImageLibFormat::Tiff,
1602            ImageFormat::Raw => {
1603                // For raw data, create a simple RGB image
1604                return self.preprocess_raw_image_data(data, width, height);
1605            }
1606        };
1607
1608        let img = image::load_from_memory_with_format(data, img_format)
1609            .map_err(|e| OperationError::ParseError(format!("Failed to load image: {e}")))?;
1610
1611        let mut processed_img = img;
1612
1613        // Apply preprocessing steps
1614        processed_img = self.apply_rotation_correction(processed_img)?;
1615        processed_img = self.apply_contrast_enhancement(processed_img)?;
1616        processed_img = self.apply_noise_reduction(processed_img)?;
1617        processed_img = self.apply_upscaling(processed_img, width, height)?;
1618
1619        if self.options.preprocessing.force_grayscale {
1620            processed_img = DynamicImage::ImageLuma8(processed_img.to_luma8());
1621        }
1622
1623        // Encode back to bytes
1624        let mut output = Vec::new();
1625        processed_img
1626            .write_to(&mut std::io::Cursor::new(&mut output), img_format)
1627            .map_err(|e| OperationError::ParseError(format!("Failed to encode image: {e}")))?;
1628
1629        Ok(output)
1630    }
1631
1632    /// Preprocess raw image data
1633    #[cfg(feature = "external-images")]
1634    fn preprocess_raw_image_data(
1635        &self,
1636        data: &[u8],
1637        width: u32,
1638        height: u32,
1639    ) -> OperationResult<Vec<u8>> {
1640        // Create a simple grayscale image from raw data
1641        if data.len() < (width * height) as usize {
1642            return Err(OperationError::ParseError(
1643                "Raw image data too small".to_string(),
1644            ));
1645        }
1646
1647        let img_buffer = ImageBuffer::<Luma<u8>, Vec<u8>>::from_raw(
1648            width,
1649            height,
1650            data[..(width * height) as usize].to_vec(),
1651        )
1652        .ok_or_else(|| OperationError::ParseError("Failed to create image buffer".to_string()))?;
1653
1654        let img = DynamicImage::ImageLuma8(img_buffer);
1655        let mut processed_img = img;
1656
1657        // Apply preprocessing
1658        processed_img = self.apply_rotation_correction(processed_img)?;
1659        processed_img = self.apply_contrast_enhancement(processed_img)?;
1660        processed_img = self.apply_noise_reduction(processed_img)?;
1661        processed_img = self.apply_upscaling(processed_img, width, height)?;
1662
1663        // Encode to PNG
1664        let mut output = Vec::new();
1665        processed_img
1666            .write_to(&mut std::io::Cursor::new(&mut output), ImageLibFormat::Png)
1667            .map_err(|e| OperationError::ParseError(format!("Failed to encode image: {e}")))?;
1668
1669        Ok(output)
1670    }
1671
1672    /// Auto-detect and correct rotation
1673    #[cfg(feature = "external-images")]
1674    fn apply_rotation_correction(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
1675        if !self.options.preprocessing.auto_correct_rotation {
1676            return Ok(img);
1677        }
1678
1679        // Simple rotation detection based on aspect ratio and content analysis
1680        let (width, height) = img.dimensions();
1681
1682        // If image is wider than it is tall but contains mostly vertical text,
1683        // it might need rotation. This is a simplified heuristic.
1684        if width > height * 2 {
1685            // Likely rotated 90 degrees - try rotating
1686            return Ok(img.rotate90());
1687        }
1688
1689        // For now, return as-is. In a more sophisticated implementation,
1690        // we could use OCR or edge detection to determine optimal rotation.
1691        Ok(img)
1692    }
1693
1694    /// Enhance contrast for better OCR
1695    #[cfg(feature = "external-images")]
1696    fn apply_contrast_enhancement(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
1697        if !self.options.preprocessing.enhance_contrast {
1698            return Ok(img);
1699        }
1700
1701        // Apply histogram equalization by adjusting brightness and contrast
1702        let enhanced = img.adjust_contrast(20.0); // Increase contrast by 20%
1703        Ok(enhanced.brighten(10)) // Slightly brighten
1704    }
1705
1706    /// Apply noise reduction
1707    #[cfg(feature = "external-images")]
1708    fn apply_noise_reduction(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
1709        if !self.options.preprocessing.denoise {
1710            return Ok(img);
1711        }
1712
1713        // Simple blur to reduce noise
1714        Ok(img.blur(0.5))
1715    }
1716
1717    /// Upscale small images for better OCR
1718    #[cfg(feature = "external-images")]
1719    fn apply_upscaling(
1720        &self,
1721        img: DynamicImage,
1722        original_width: u32,
1723        original_height: u32,
1724    ) -> OperationResult<DynamicImage> {
1725        if !self.options.preprocessing.upscale_small_images {
1726            return Ok(img);
1727        }
1728
1729        let min_dimension = original_width.min(original_height);
1730        if min_dimension < self.options.preprocessing.upscale_threshold {
1731            let new_width = original_width * self.options.preprocessing.upscale_factor;
1732            let new_height = original_height * self.options.preprocessing.upscale_factor;
1733
1734            return Ok(img.resize(
1735                new_width,
1736                new_height,
1737                image::imageops::FilterType::CatmullRom,
1738            ));
1739        }
1740
1741        Ok(img)
1742    }
1743}
1744
1745/// Extract all images from a PDF file
1746pub fn extract_images_from_pdf<P: AsRef<Path>>(
1747    input_path: P,
1748    options: ExtractImagesOptions,
1749) -> OperationResult<Vec<ExtractedImage>> {
1750    let document = PdfReader::open_document(input_path)
1751        .map_err(|e| OperationError::ParseError(e.to_string()))?;
1752
1753    let mut extractor = ImageExtractor::new(document, options);
1754    extractor.extract_all()
1755}
1756
1757/// Extract images from specific pages
1758pub fn extract_images_from_pages<P: AsRef<Path>>(
1759    input_path: P,
1760    pages: &[usize],
1761    options: ExtractImagesOptions,
1762) -> OperationResult<Vec<ExtractedImage>> {
1763    let document = PdfReader::open_document(input_path)
1764        .map_err(|e| OperationError::ParseError(e.to_string()))?;
1765
1766    let mut extractor = ImageExtractor::new(document, options);
1767    let mut all_images = Vec::new();
1768
1769    for &page_num in pages {
1770        let page_images = extractor.extract_from_page(page_num)?;
1771        all_images.extend(page_images);
1772    }
1773
1774    Ok(all_images)
1775}
1776
1777/// Number of colour samples per pixel carried by the *image data* for a colour
1778/// space.
1779///
1780/// For `Indexed` the data carries a single palette index per pixel (1). For
1781/// `ICCBased`, pass the profile's resolved `/N` via `icc_n` (defaults to 3 when
1782/// unknown). `DeviceN` reports the number of named colorants.
1783fn image_sample_components(color_space: Option<&PdfObject>, icc_n: Option<u8>) -> u8 {
1784    match color_space {
1785        Some(PdfObject::Name(cs)) => match cs.0.as_str() {
1786            "DeviceGray" | "G" | "CalGray" => 1,
1787            "DeviceRGB" | "RGB" | "CalRGB" | "Lab" => 3,
1788            "DeviceCMYK" | "CMYK" => 4,
1789            _ => 3,
1790        },
1791        Some(PdfObject::Array(array)) => {
1792            match array
1793                .0
1794                .first()
1795                .and_then(|o| o.as_name())
1796                .map(|n| n.0.as_str())
1797            {
1798                Some("Indexed") | Some("I") => 1,
1799                Some("Separation") => 1,
1800                Some("DeviceN") => array
1801                    .0
1802                    .get(1)
1803                    .and_then(|o| o.as_array())
1804                    .map(|names| names.0.len().max(1) as u8)
1805                    .unwrap_or(1),
1806                Some("ICCBased") => icc_n.unwrap_or(3),
1807                Some("CalGray") | Some("DeviceGray") => 1,
1808                Some("DeviceCMYK") => 4,
1809                Some("CalRGB") | Some("Lab") | Some("DeviceRGB") => 3,
1810                _ => 3,
1811            }
1812        }
1813        _ => 3,
1814    }
1815}
1816
1817/// Expand one-index-per-pixel data into `base_components`-byte pixels using the
1818/// `lookup` palette (`(hival + 1) * base_components` bytes).
1819///
1820/// Indices greater than `hival` are clamped; a short palette is zero-padded so
1821/// the output length is always `indices.len() * base_components`.
1822fn expand_indexed(indices: &[u8], lookup: &[u8], base_components: usize, hival: usize) -> Vec<u8> {
1823    let mut out = Vec::with_capacity(indices.len() * base_components);
1824    for &idx in indices {
1825        let entry = (idx as usize).min(hival);
1826        let start = entry * base_components;
1827        for c in 0..base_components {
1828            out.push(lookup.get(start + c).copied().unwrap_or(0));
1829        }
1830    }
1831    out
1832}
1833
1834/// Unpack packed samples (1/2/4/8 bits per component) into one byte per sample,
1835/// honouring PDF row alignment (each scanline starts on a byte boundary).
1836///
1837/// For `bits_per_component >= 8` the data is returned unchanged.
1838fn unpack_indices(data: &[u8], width: u32, height: u32, bits_per_component: u8) -> Vec<u8> {
1839    // Only the spec-valid packed depths {1, 2, 4} are unpacked. 8 (and the
1840    // defensive 0) pass through unchanged; any other value (e.g. a malformed
1841    // 3/5/6/7) would make the scanline shift underflow, so it also passes
1842    // through and the caller's size check rejects it cleanly.
1843    if !matches!(bits_per_component, 1 | 2 | 4) {
1844        return data.to_vec();
1845    }
1846    let bpc = bits_per_component as usize;
1847    let width = width as usize;
1848    let height = height as usize;
1849    let row_bytes = (width * bpc).div_ceil(8);
1850    let mask = (1u16 << bpc) - 1;
1851    let mut out = Vec::with_capacity(width * height);
1852    for row in 0..height {
1853        let row_start = row * row_bytes;
1854        for col in 0..width {
1855            let bit_index = col * bpc;
1856            let byte = row_start + bit_index / 8;
1857            let shift = 8 - bpc - (bit_index % 8);
1858            let value = data
1859                .get(byte)
1860                .map(|b| ((*b as u16) >> shift) & mask)
1861                .unwrap_or(0);
1862            out.push(value as u8);
1863        }
1864    }
1865    out
1866}
1867
1868#[cfg(test)]
1869mod tests {
1870    use super::*;
1871    use tempfile::TempDir;
1872
1873    fn name(s: &str) -> PdfObject {
1874        PdfObject::Name(PdfName(s.to_string()))
1875    }
1876
1877    #[test]
1878    fn test_image_sample_components_device_color_spaces() {
1879        assert_eq!(image_sample_components(Some(&name("DeviceGray")), None), 1);
1880        assert_eq!(image_sample_components(Some(&name("DeviceRGB")), None), 3);
1881        assert_eq!(image_sample_components(Some(&name("DeviceCMYK")), None), 4);
1882        // Unknown name / missing colour space default to RGB (legacy behaviour).
1883        assert_eq!(image_sample_components(Some(&name("Weird")), None), 3);
1884        assert_eq!(image_sample_components(None, None), 3);
1885    }
1886
1887    #[test]
1888    fn test_image_sample_components_indexed_is_one() {
1889        let indexed = PdfObject::Array(PdfArray(vec![
1890            name("Indexed"),
1891            name("DeviceRGB"),
1892            PdfObject::Integer(23),
1893            PdfObject::String(crate::parser::objects::PdfString(vec![0u8; 72])),
1894        ]));
1895        assert_eq!(image_sample_components(Some(&indexed), None), 1);
1896    }
1897
1898    #[test]
1899    fn test_image_sample_components_iccbased_uses_n() {
1900        let icc = PdfObject::Array(PdfArray(vec![name("ICCBased"), PdfObject::Reference(5, 0)]));
1901        assert_eq!(image_sample_components(Some(&icc), Some(1)), 1);
1902        assert_eq!(image_sample_components(Some(&icc), Some(4)), 4);
1903        // Falls back to RGB when /N is unknown.
1904        assert_eq!(image_sample_components(Some(&icc), None), 3);
1905    }
1906
1907    #[test]
1908    fn test_image_sample_components_devicen_counts_colorants() {
1909        let devicen = PdfObject::Array(PdfArray(vec![
1910            name("DeviceN"),
1911            PdfObject::Array(PdfArray(vec![name("Cyan"), name("Magenta")])),
1912            name("DeviceCMYK"),
1913            PdfObject::Reference(9, 0),
1914        ]));
1915        assert_eq!(image_sample_components(Some(&devicen), None), 2);
1916    }
1917
1918    #[test]
1919    fn test_expand_indexed_maps_indices_to_palette_rgb() {
1920        // 3-entry RGB palette: red, green, blue.
1921        let palette = vec![255, 0, 0, 0, 255, 0, 0, 0, 255];
1922        let indices = [0u8, 2, 1];
1923        let rgb = expand_indexed(&indices, &palette, 3, 2);
1924        assert_eq!(rgb, vec![255, 0, 0, 0, 0, 255, 0, 255, 0]);
1925    }
1926
1927    #[test]
1928    fn test_expand_indexed_clamps_out_of_range_index() {
1929        let palette = vec![10, 20, 30, 40, 50, 60]; // 2 entries, hival = 1
1930                                                    // Index 5 is past hival -> clamped to the last entry.
1931        let rgb = expand_indexed(&[5u8], &palette, 3, 1);
1932        assert_eq!(rgb, vec![40, 50, 60]);
1933    }
1934
1935    #[test]
1936    fn test_unpack_indices_passthrough_for_8bit() {
1937        let data = vec![1, 2, 3, 4];
1938        assert_eq!(unpack_indices(&data, 2, 2, 8), data);
1939    }
1940
1941    #[test]
1942    fn test_unpack_indices_4bit_two_pixels_per_byte() {
1943        // One row of 2 pixels at 4 bpc packed into a single byte 0xA3 -> [0xA, 0x3].
1944        let data = vec![0xA3];
1945        assert_eq!(unpack_indices(&data, 2, 1, 4), vec![0x0A, 0x03]);
1946    }
1947
1948    #[test]
1949    fn test_unpack_indices_2bit_four_pixels_per_byte() {
1950        // One row of 4 pixels at 2 bpc packed into a byte 0b11_10_01_00 -> [3,2,1,0].
1951        let data = vec![0b1110_0100];
1952        assert_eq!(unpack_indices(&data, 4, 1, 2), vec![3, 2, 1, 0]);
1953    }
1954
1955    #[test]
1956    fn test_unpack_indices_passthrough_for_unsupported_bpc() {
1957        // A malformed 3 bpc must not panic (shift underflow); it passes through.
1958        let data = vec![0xAB, 0xCD];
1959        assert_eq!(unpack_indices(&data, 4, 1, 3), data);
1960    }
1961
1962    #[test]
1963    fn test_unpack_indices_1bit_respects_row_byte_alignment() {
1964        // 3 pixels per row at 1 bpc => each row occupies 1 byte (padded).
1965        // Row 0: 0b101_00000 -> 1,0,1 ; Row 1: 0b011_00000 -> 0,1,1
1966        let data = vec![0b1010_0000, 0b0110_0000];
1967        assert_eq!(unpack_indices(&data, 3, 2, 1), vec![1, 0, 1, 0, 1, 1]);
1968    }
1969
1970    #[test]
1971    fn test_extract_options_default() {
1972        let options = ExtractImagesOptions::default();
1973        assert_eq!(options.output_dir, PathBuf::from("."));
1974        assert!(options.extract_inline);
1975        assert_eq!(options.min_size, Some(10));
1976        assert!(options.create_dir);
1977    }
1978
1979    #[test]
1980    fn test_filename_pattern() {
1981        let options = ExtractImagesOptions {
1982            name_pattern: "img_{page}_{index}.{format}".to_string(),
1983            ..Default::default()
1984        };
1985
1986        let pattern = options
1987            .name_pattern
1988            .replace("{page}", "1")
1989            .replace("{index}", "2")
1990            .replace("{format}", "jpg");
1991
1992        assert_eq!(pattern, "img_1_2.jpg");
1993    }
1994
1995    #[test]
1996    fn test_extract_options_custom() {
1997        let temp_dir = TempDir::new().unwrap();
1998        let options = ExtractImagesOptions {
1999            output_dir: temp_dir.path().to_path_buf(),
2000            name_pattern: "custom_{page}_{index}.{format}".to_string(),
2001            extract_inline: false,
2002            min_size: Some(50),
2003            create_dir: false,
2004            preprocessing: ImagePreprocessingOptions::default(),
2005        };
2006
2007        assert_eq!(options.output_dir, temp_dir.path());
2008        assert_eq!(options.name_pattern, "custom_{page}_{index}.{format}");
2009        assert!(!options.extract_inline);
2010        assert_eq!(options.min_size, Some(50));
2011        assert!(!options.create_dir);
2012    }
2013
2014    #[test]
2015    fn test_extract_options_debug_clone() {
2016        let options = ExtractImagesOptions {
2017            output_dir: PathBuf::from("/test/path"),
2018            name_pattern: "test.{format}".to_string(),
2019            extract_inline: true,
2020            min_size: None,
2021            create_dir: true,
2022            preprocessing: ImagePreprocessingOptions::default(),
2023        };
2024
2025        let debug_str = format!("{options:?}");
2026        assert!(debug_str.contains("ExtractImagesOptions"));
2027        assert!(debug_str.contains("/test/path"));
2028
2029        let cloned = options.clone();
2030        assert_eq!(cloned.output_dir, options.output_dir);
2031        assert_eq!(cloned.name_pattern, options.name_pattern);
2032        assert_eq!(cloned.extract_inline, options.extract_inline);
2033        assert_eq!(cloned.min_size, options.min_size);
2034        assert_eq!(cloned.create_dir, options.create_dir);
2035    }
2036
2037    #[test]
2038    fn test_extracted_image_struct() {
2039        let image = ExtractedImage {
2040            page_number: 0,
2041            image_index: 1,
2042            file_path: PathBuf::from("/test/image.jpg"),
2043            width: 100,
2044            height: 200,
2045            format: ImageFormat::Jpeg,
2046        };
2047
2048        assert_eq!(image.page_number, 0);
2049        assert_eq!(image.image_index, 1);
2050        assert_eq!(image.file_path, PathBuf::from("/test/image.jpg"));
2051        assert_eq!(image.width, 100);
2052        assert_eq!(image.height, 200);
2053        assert_eq!(image.format, ImageFormat::Jpeg);
2054    }
2055
2056    #[test]
2057    fn test_extracted_image_debug() {
2058        let image = ExtractedImage {
2059            page_number: 5,
2060            image_index: 3,
2061            file_path: PathBuf::from("output.png"),
2062            width: 512,
2063            height: 768,
2064            format: ImageFormat::Png,
2065        };
2066
2067        let debug_str = format!("{image:?}");
2068        assert!(debug_str.contains("ExtractedImage"));
2069        assert!(debug_str.contains("5"));
2070        assert!(debug_str.contains("3"));
2071        assert!(debug_str.contains("output.png"));
2072        assert!(debug_str.contains("512"));
2073        assert!(debug_str.contains("768"));
2074    }
2075
2076    // Helper function to create minimal valid PDF for testing
2077    fn create_minimal_pdf(temp_file: &std::path::Path) {
2078        let minimal_pdf = b"%PDF-1.7\n\
20791 0 obj\n\
2080<< /Type /Catalog /Pages 2 0 R >>\n\
2081endobj\n\
20822 0 obj\n\
2083<< /Type /Pages /Kids [] /Count 0 >>\n\
2084endobj\n\
2085xref\n\
20860 3\n\
20870000000000 65535 f \n\
20880000000009 00000 n \n\
20890000000055 00000 n \n\
2090trailer\n\
2091<< /Size 3 /Root 1 0 R >>\n\
2092startxref\n\
2093105\n\
2094%%EOF";
2095        std::fs::write(temp_file, minimal_pdf).unwrap();
2096    }
2097
2098    #[test]
2099    fn test_detect_image_format_png() {
2100        // Create a minimal valid PDF document for testing
2101        let temp_dir = TempDir::new().unwrap();
2102        let temp_file = temp_dir.path().join("test.pdf");
2103        create_minimal_pdf(&temp_file);
2104
2105        let document = PdfReader::open_document(&temp_file).unwrap();
2106        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2107
2108        // PNG magic bytes
2109        let png_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0DIHDR";
2110        let format = extractor.detect_image_format_from_data(png_data).unwrap();
2111        assert_eq!(format, ImageFormat::Png);
2112    }
2113
2114    #[test]
2115    fn test_detect_image_format_jpeg() {
2116        let temp_dir = TempDir::new().unwrap();
2117        let temp_file = temp_dir.path().join("test.pdf");
2118        create_minimal_pdf(&temp_file);
2119
2120        let document = PdfReader::open_document(&temp_file).unwrap();
2121        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2122
2123        // JPEG magic bytes
2124        let jpeg_data = b"\xFF\xD8\xFF\xE0\x00\x10JFIF";
2125        let format = extractor.detect_image_format_from_data(jpeg_data).unwrap();
2126        assert_eq!(format, ImageFormat::Jpeg);
2127    }
2128
2129    #[test]
2130    fn test_detect_image_format_tiff_little_endian() {
2131        let temp_dir = TempDir::new().unwrap();
2132        let temp_file = temp_dir.path().join("test.pdf");
2133        create_minimal_pdf(&temp_file);
2134
2135        let document = PdfReader::open_document(&temp_file).unwrap();
2136        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2137
2138        // TIFF little endian magic bytes
2139        let tiff_data = b"II\x2A\x00\x08\x00\x00\x00";
2140        let format = extractor.detect_image_format_from_data(tiff_data).unwrap();
2141        assert_eq!(format, ImageFormat::Tiff);
2142    }
2143
2144    #[test]
2145    fn test_detect_image_format_tiff_big_endian() {
2146        let temp_dir = TempDir::new().unwrap();
2147        let temp_file = temp_dir.path().join("test.pdf");
2148        create_minimal_pdf(&temp_file);
2149
2150        let document = PdfReader::open_document(&temp_file).unwrap();
2151        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2152
2153        // TIFF big endian magic bytes
2154        let tiff_data = b"MM\x00\x2A\x00\x00\x00\x08";
2155        let format = extractor.detect_image_format_from_data(tiff_data).unwrap();
2156        assert_eq!(format, ImageFormat::Tiff);
2157    }
2158
2159    #[test]
2160    fn test_detect_image_format_unknown() {
2161        let temp_dir = TempDir::new().unwrap();
2162        let temp_file = temp_dir.path().join("test.pdf");
2163        create_minimal_pdf(&temp_file);
2164
2165        let document = PdfReader::open_document(&temp_file).unwrap();
2166        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2167
2168        // Unknown format - should default to PNG
2169        let unknown_data = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08";
2170        let format = extractor
2171            .detect_image_format_from_data(unknown_data)
2172            .unwrap();
2173        assert_eq!(format, ImageFormat::Png); // Default fallback
2174    }
2175
2176    #[test]
2177    fn test_detect_image_format_short_data() {
2178        let temp_dir = TempDir::new().unwrap();
2179        let temp_file = temp_dir.path().join("test.pdf");
2180        create_minimal_pdf(&temp_file);
2181
2182        let document = PdfReader::open_document(&temp_file).unwrap();
2183        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2184
2185        // Too short data (less than 2 bytes)
2186        let short_data = b"\xFF";
2187        let result = extractor.detect_image_format_from_data(short_data);
2188        assert!(result.is_err());
2189        match result {
2190            Err(OperationError::ParseError(msg)) => {
2191                assert!(msg.contains("too short"));
2192            }
2193            _ => panic!("Expected ParseError"),
2194        }
2195    }
2196
2197    #[test]
2198    fn test_filename_pattern_replacements() {
2199        let options = ExtractImagesOptions {
2200            name_pattern: "page_{page}_img_{index}_{format}.{format}".to_string(),
2201            ..Default::default()
2202        };
2203
2204        let pattern = options
2205            .name_pattern
2206            .replace("{page}", "10")
2207            .replace("{index}", "5")
2208            .replace("{format}", "png");
2209
2210        assert_eq!(pattern, "page_10_img_5_png.png");
2211    }
2212
2213    #[test]
2214    fn test_extract_options_no_min_size() {
2215        let options = ExtractImagesOptions {
2216            min_size: None,
2217            ..Default::default()
2218        };
2219
2220        assert_eq!(options.min_size, None);
2221    }
2222
2223    #[test]
2224    fn test_create_output_directory() {
2225        let temp_dir = TempDir::new().unwrap();
2226        let output_dir = temp_dir.path().join("new_dir");
2227
2228        let options = ExtractImagesOptions {
2229            output_dir: output_dir.clone(),
2230            create_dir: true,
2231            ..Default::default()
2232        };
2233
2234        // In real usage, ImageExtractor would create this directory
2235        assert!(!output_dir.exists());
2236        assert_eq!(options.output_dir, output_dir);
2237        assert!(options.create_dir);
2238    }
2239
2240    #[test]
2241    fn test_pattern_with_special_chars() {
2242        let options = ExtractImagesOptions {
2243            name_pattern: "img-{page}_{index}.{format}".to_string(),
2244            ..Default::default()
2245        };
2246
2247        let pattern = options
2248            .name_pattern
2249            .replace("{page}", "1")
2250            .replace("{index}", "1")
2251            .replace("{format}", "jpg");
2252
2253        assert_eq!(pattern, "img-1_1.jpg");
2254    }
2255
2256    #[test]
2257    fn test_multiple_format_extensions() {
2258        let formats = vec![
2259            (ImageFormat::Jpeg, "jpg"),
2260            (ImageFormat::Png, "png"),
2261            (ImageFormat::Tiff, "tiff"),
2262        ];
2263
2264        for (format, expected_ext) in formats {
2265            let extension = match format {
2266                ImageFormat::Jpeg => "jpg",
2267                ImageFormat::Png => "png",
2268                ImageFormat::Tiff => "tiff",
2269                ImageFormat::Raw => "raw",
2270            };
2271            assert_eq!(extension, expected_ext);
2272        }
2273    }
2274
2275    #[test]
2276    fn test_extract_inline_option() {
2277        let mut options = ExtractImagesOptions::default();
2278        assert!(options.extract_inline);
2279
2280        options.extract_inline = false;
2281        assert!(!options.extract_inline);
2282    }
2283
2284    #[test]
2285    fn test_min_size_filtering() {
2286        let options_with_min = ExtractImagesOptions {
2287            min_size: Some(100),
2288            ..Default::default()
2289        };
2290
2291        let options_no_min = ExtractImagesOptions {
2292            min_size: None,
2293            ..Default::default()
2294        };
2295
2296        assert_eq!(options_with_min.min_size, Some(100));
2297        assert_eq!(options_no_min.min_size, None);
2298    }
2299
2300    #[test]
2301    fn test_output_path_combinations() {
2302        let base_dir = PathBuf::from("/output");
2303        let options = ExtractImagesOptions {
2304            output_dir: base_dir,
2305            name_pattern: "img_{page}_{index}.{format}".to_string(),
2306            ..Default::default()
2307        };
2308
2309        let filename = options
2310            .name_pattern
2311            .replace("{page}", "1")
2312            .replace("{index}", "2")
2313            .replace("{format}", "png");
2314
2315        let full_path = options.output_dir.join(filename);
2316        assert_eq!(full_path, PathBuf::from("/output/img_1_2.png"));
2317    }
2318
2319    #[test]
2320    fn test_pattern_without_placeholders() {
2321        let options = ExtractImagesOptions {
2322            name_pattern: "static_name.jpg".to_string(),
2323            ..Default::default()
2324        };
2325
2326        let pattern = options
2327            .name_pattern
2328            .replace("{page}", "1")
2329            .replace("{index}", "2")
2330            .replace("{format}", "png");
2331
2332        assert_eq!(pattern, "static_name.jpg"); // No placeholders replaced
2333    }
2334
2335    #[test]
2336    fn test_detect_format_edge_cases() {
2337        let temp_dir = TempDir::new().unwrap();
2338        let temp_file = temp_dir.path().join("test.pdf");
2339        create_minimal_pdf(&temp_file);
2340
2341        let document = PdfReader::open_document(&temp_file).unwrap();
2342        let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
2343
2344        // Empty data
2345        let empty_data = b"";
2346        assert!(extractor.detect_image_format_from_data(empty_data).is_err());
2347
2348        // Data exactly 8 bytes (minimum for PNG check)
2349        let exact_8 = b"\x89PNG\r\n\x1a\n";
2350        let format = extractor.detect_image_format_from_data(exact_8).unwrap();
2351        assert_eq!(format, ImageFormat::Png);
2352
2353        // Data exactly 4 bytes (minimum for TIFF check)
2354        let exact_4 = b"II\x2A\x00";
2355        let format = extractor.detect_image_format_from_data(exact_4).unwrap();
2356        assert_eq!(format, ImageFormat::Tiff);
2357
2358        // Data exactly 2 bytes (minimum for JPEG check)
2359        let exact_2 = b"\xFF\xD8";
2360        let format = extractor.detect_image_format_from_data(exact_2).unwrap();
2361        assert_eq!(format, ImageFormat::Jpeg); // JPEG only needs 2 bytes
2362    }
2363
2364    #[test]
2365    fn test_complex_filename_pattern() {
2366        let options = ExtractImagesOptions {
2367            name_pattern: "{format}/page{page}/image_{index}_{page}.{format}".to_string(),
2368            ..Default::default()
2369        };
2370
2371        let pattern = options
2372            .name_pattern
2373            .replace("{page}", "5")
2374            .replace("{index}", "3")
2375            .replace("{format}", "jpeg");
2376
2377        assert_eq!(pattern, "jpeg/page5/image_3_5.jpeg");
2378    }
2379
2380    #[test]
2381    fn test_image_dimensions() {
2382        let small_image = ExtractedImage {
2383            page_number: 0,
2384            image_index: 0,
2385            file_path: PathBuf::from("small.jpg"),
2386            width: 5,
2387            height: 5,
2388            format: ImageFormat::Jpeg,
2389        };
2390
2391        let large_image = ExtractedImage {
2392            page_number: 0,
2393            image_index: 1,
2394            file_path: PathBuf::from("large.jpg"),
2395            width: 2000,
2396            height: 3000,
2397            format: ImageFormat::Jpeg,
2398        };
2399
2400        assert_eq!(small_image.width, 5);
2401        assert_eq!(small_image.height, 5);
2402        assert_eq!(large_image.width, 2000);
2403        assert_eq!(large_image.height, 3000);
2404    }
2405
2406    #[test]
2407    fn test_page_and_index_numbering() {
2408        // Test that page numbers and indices work correctly
2409        let image1 = ExtractedImage {
2410            page_number: 0, // 0-indexed
2411            image_index: 0,
2412            file_path: PathBuf::from("first.jpg"),
2413            width: 100,
2414            height: 100,
2415            format: ImageFormat::Jpeg,
2416        };
2417
2418        let image2 = ExtractedImage {
2419            page_number: 99,  // Large page number
2420            image_index: 255, // Large index
2421            file_path: PathBuf::from("last.jpg"),
2422            width: 100,
2423            height: 100,
2424            format: ImageFormat::Jpeg,
2425        };
2426
2427        assert_eq!(image1.page_number, 0);
2428        assert_eq!(image1.image_index, 0);
2429        assert_eq!(image2.page_number, 99);
2430        assert_eq!(image2.image_index, 255);
2431    }
2432}
2433
2434#[cfg(test)]
2435#[path = "extract_images_tests.rs"]
2436mod extract_images_tests;