1use super::{OperationError, OperationResult};
7use crate::graphics::ImageFormat;
8use crate::parser::objects::{PdfArray, PdfName, PdfObject, PdfStream};
9use crate::parser::{PdfDocument, PdfReader};
10use std::collections::HashMap;
11use std::fs::{self, File};
12use std::io::{Read, Seek, Write};
13use std::path::{Path, PathBuf};
14
15#[cfg(feature = "external-images")]
16use image::{DynamicImage, GenericImageView, ImageBuffer, ImageFormat as ImageLibFormat, Luma};
17
18#[derive(Debug, Clone)]
22pub struct TransformMatrix {
23 pub a: f64, pub b: f64, pub c: f64, pub d: f64, pub e: f64, pub f: f64, }
30
31impl TransformMatrix {
32 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 #[allow(dead_code)]
38 fn is_90_degree_rotation(&self) -> bool {
39 self.a.abs() < 0.001 && self.d.abs() < 0.001 && self.b.abs() > 0.001 && self.c.abs() > 0.001
41 }
42
43 #[allow(dead_code)]
45 fn is_simple_scale(&self) -> bool {
46 self.b.abs() < 0.001 && self.c.abs() < 0.001 && self.a.abs() > 0.001 && self.d.abs() > 0.001
48 }
49
50 #[allow(dead_code)]
52 fn is_fis2_like_matrix(&self) -> bool {
53 (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#[derive(Debug, Clone)]
64pub struct ImagePreprocessingOptions {
65 pub auto_correct_rotation: bool,
67 pub enhance_contrast: bool,
69 pub denoise: bool,
71 pub upscale_small_images: bool,
73 pub upscale_threshold: u32,
75 pub upscale_factor: u32,
77 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#[derive(Debug, Clone)]
97pub struct ExtractImagesOptions {
98 pub output_dir: PathBuf,
100 pub name_pattern: String,
103 pub extract_inline: bool,
105 pub min_size: Option<u32>,
107 pub create_dir: bool,
109 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#[derive(Debug)]
128pub struct ExtractedImage {
129 pub page_number: usize,
131 pub image_index: usize,
133 pub file_path: PathBuf,
135 pub width: u32,
137 pub height: u32,
138 pub format: ImageFormat,
140}
141
142pub struct ImageExtractor<R: Read + Seek> {
144 document: PdfDocument<R>,
145 options: ExtractImagesOptions,
146 processed_images: HashMap<String, PathBuf>,
148}
149
150impl<R: Read + Seek> ImageExtractor<R> {
151 pub fn new(document: PdfDocument<R>, options: ExtractImagesOptions) -> Self {
153 Self {
154 document,
155 options,
156 processed_images: HashMap::new(),
157 }
158 }
159
160 pub fn extract_all(&mut self) -> OperationResult<Vec<ExtractedImage>> {
162 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 pub fn extract_from_page(
183 &mut self,
184 page_number: usize,
185 ) -> OperationResult<Vec<ExtractedImage>> {
186 let mut extracted = Vec::new();
187
188 let page = self
190 .document
191 .get_page(page_number as u32)
192 .map_err(|e| OperationError::ParseError(e.to_string()))?;
193
194 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 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 extracted.is_empty() {
233 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 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 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 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 fn extract_image_xobject(
288 &mut self,
289 stream: &PdfStream,
290 page_number: usize,
291 image_index: usize,
292 ) -> OperationResult<Option<ExtractedImage>> {
293 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 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 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, };
317
318 let mut data = self.decode_image_stream(stream)?;
321
322 let smask_alpha = self.extract_smask_alpha(&stream.dict, width, height);
328
329 let format = match stream.dict.0.get(&PdfName("Filter".to_string())) {
331 Some(PdfObject::Name(filter)) => match filter.0.as_str() {
332 "DCTDecode" => {
333 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 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 data = self.convert_ccitt_to_png(&data, width, height)?;
358 ImageFormat::Png
359 }
360 "LZWDecode" => {
361 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 if let Some(PdfObject::Name(filter)) = filters.0.first() {
380 match filter.0.as_str() {
381 "DCTDecode" => {
382 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 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 let image_key = format!("{:x}", md5::compute(&data));
442
443 let allow_deduplication = !self.options.name_pattern.contains("{page}");
447
448 if allow_deduplication {
450 if let Some(existing_path) = self.processed_images.get(&image_key) {
451 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 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 #[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 let mut file = File::create(&output_path)?;
493 file.write_all(&processed_data)?;
494
495 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 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 if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
518 return Ok(ImageFormat::Png);
519 }
520
521 if data.len() >= 4 {
523 if &data[0..2] == b"II" && &data[2..4] == b"\x2A\x00" {
524 return Ok(ImageFormat::Tiff); }
526 if &data[0..2] == b"MM" && &data[2..4] == b"\x00\x2A" {
527 return Ok(ImageFormat::Tiff); }
529 }
530
531 if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
533 return Ok(ImageFormat::Jpeg);
534 }
535
536 if data.len() < 2 {
538 return Err(OperationError::ParseError(
539 "Image data too short to detect format".to_string(),
540 ));
541 }
542
543 Ok(ImageFormat::Png)
546 }
547
548 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 let stream_str = String::from_utf8_lossy(stream_data);
559
560 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 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 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 let dict_section = &stream_str[absolute_bi_pos + 2..absolute_id_pos].trim();
575
576 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 let (width, height) = self.parse_inline_image_dict(dict_section);
585
586 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 pos = absolute_ei_pos + 2;
601 } else {
602 break; }
604 } else {
605 break; }
607 }
608
609 Ok(inline_images)
610 }
611
612 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 let content = String::from_utf8_lossy(stream_data);
623
624 tracing::debug!(" Content: {}", content);
625
626 let image_with_transform = self.parse_images_with_transformations(&content)?;
629
630 for (image_name, transform_matrix) in image_with_transform {
631 if let Some(mut extracted_image) =
633 self.find_and_extract_xobject_by_name(&image_name, page_number, *image_index)?
634 {
635 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 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 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 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 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 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 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 if line.ends_with(" cm") {
723 let parts: Vec<&str> = line.split_whitespace().collect();
724 if parts.len() == 7 && parts[6] == "cm" {
725 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 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(); results.push((image_name, current_matrix.clone()));
746 }
747 }
748 }
749
750 if line.trim() == "Q" {
752 current_matrix = None;
753 }
754 }
755
756 Ok(results)
757 }
758
759 #[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 let image_data = std::fs::read(&extracted_image.file_path)?;
770
771 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 let transformed_img =
778 self.fix_stride_problem(img, extracted_image.width, extracted_image.height)?;
779
780 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 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 #[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 if matrix.b > 0.0 && matrix.c < 0.0 {
847 Ok(img.rotate90()) } else if matrix.b < 0.0 && matrix.c > 0.0 {
849 Ok(img.rotate270()) } else {
851 Ok(img.rotate90())
853 }
854 }
855
856 #[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 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 Ok(img)
875 }
876 }
877
878 #[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 let gray_img = img.to_luma8();
888 let pixel_data = gray_img.as_raw();
889
890 let bytes_per_row = original_width as usize;
892 let min_bytes_per_row = bytes_per_row;
893
894 let possible_strides = [
896 min_bytes_per_row, (min_bytes_per_row + 1) & !1, (min_bytes_per_row + 3) & !3, (min_bytes_per_row + 7) & !7, (min_bytes_per_row + 15) & !15, min_bytes_per_row + 1, min_bytes_per_row + 2, min_bytes_per_row + 4, ];
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 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 corrected_data.resize(corrected_data.len() + bytes_per_row, 255);
921 }
922 }
923
924 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 fn parse_inline_image_dict(&self, dict_str: &str) -> (u32, u32) {
943 let mut width = 100; let mut height = 100; for line in dict_str.lines() {
948 let line = line.trim();
949
950 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 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 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 let image_key = format!("{:x}", md5::compute(data));
983
984 let allow_deduplication = !self.options.name_pattern.contains("{page}");
986
987 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, });
998 }
999 }
1000
1001 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 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 fs::write(&file_path, data)?;
1023
1024 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 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 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 fn resolve_decode_params(&self, obj: &PdfObject) -> PdfObject {
1091 let resolved = self.document.resolve(obj).unwrap_or_else(|e| {
1092 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 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 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 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 Some(n.clamp(1, 4) as u8)
1152 }
1153
1154 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 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 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 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 let icc_n = self.icc_components(cs);
1205 let components = image_sample_components(cs, icc_n);
1206
1207 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 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 self.encode_png_maybe_alpha(
1227 data,
1228 width,
1229 height,
1230 components,
1231 bits_per_component,
1232 smask_alpha,
1233 )
1234 }
1235
1236 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 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 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; }
1278
1279 let gray = self.decode_image_stream(stream).ok()?;
1280 let expected = (sw as usize) * (sh as usize);
1281 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 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 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 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 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 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 let mut png_data = Vec::new();
1373
1374 png_data.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
1376
1377 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 let color_type = match components {
1385 1 => 0, 3 => 2, 4 => 6, _ => 2, };
1390 ihdr.push(color_type);
1391 ihdr.push(0); ihdr.push(0); ihdr.push(0); self.write_png_chunk(&mut png_data, b"IHDR", &ihdr);
1396
1397 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 self.write_png_chunk(&mut png_data, b"IEND", &[]);
1403
1404 Ok(png_data)
1405 }
1406
1407 fn write_png_chunk(&self, output: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
1409 output.extend_from_slice(&(data.len() as u32).to_be_bytes());
1411
1412 output.extend_from_slice(chunk_type);
1414
1415 output.extend_from_slice(data);
1417
1418 let crc = self.calculate_crc32(chunk_type, data);
1420 output.extend_from_slice(&crc.to_be_bytes());
1421 }
1422
1423 fn calculate_crc32(&self, chunk_type: &[u8; 4], data: &[u8]) -> u32 {
1425 let mut crc: u32 = 0xFFFFFFFF;
1427
1428 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 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 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 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 encoder.write_all(&[0])?;
1476
1477 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 fn convert_ccitt_to_png(
1492 &self,
1493 data: &[u8],
1494 width: u32,
1495 height: u32,
1496 ) -> OperationResult<Vec<u8>> {
1497 let mut rgb_data = Vec::new();
1500
1501 let bits_per_row = width as usize;
1503 let min_bytes_per_row = bits_per_row.div_ceil(8);
1504
1505 let possible_strides = [
1507 min_bytes_per_row, (min_bytes_per_row + 1) & !1, (min_bytes_per_row + 3) & !3, (min_bytes_per_row + 7) & !7, (min_bytes_per_row + 15) & !15, ];
1513
1514 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 let gray_value = if bit == 0 { 0 } else { 255 };
1529 rgb_data.push(gray_value);
1530 } else {
1531 rgb_data.push(255); }
1533 }
1534 }
1535
1536 self.create_png_from_raw_data(&rgb_data, width, height, 1, 8)
1538 }
1539
1540 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 data.len() < min_bytes_per_row * 3 {
1553 return Ok(min_bytes_per_row);
1554 }
1555
1556 for &stride in possible_strides {
1558 let expected_size = stride * height as usize;
1559
1560 if expected_size <= data.len() && (data.len() - expected_size) < stride * 2 {
1562 return Ok(stride);
1565 }
1566 }
1567
1568 let calculated_stride = data.len() / height as usize;
1570 if calculated_stride >= min_bytes_per_row {
1571 return Ok(calculated_stride);
1572 }
1573
1574 Ok(min_bytes_per_row)
1576 }
1577
1578 #[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 #[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 let img_format = match format {
1599 ImageFormat::Jpeg => ImageLibFormat::Jpeg,
1600 ImageFormat::Png => ImageLibFormat::Png,
1601 ImageFormat::Tiff => ImageLibFormat::Tiff,
1602 ImageFormat::Raw => {
1603 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 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 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 #[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 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 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 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 #[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 let (width, height) = img.dimensions();
1681
1682 if width > height * 2 {
1685 return Ok(img.rotate90());
1687 }
1688
1689 Ok(img)
1692 }
1693
1694 #[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 let enhanced = img.adjust_contrast(20.0); Ok(enhanced.brighten(10)) }
1705
1706 #[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 Ok(img.blur(0.5))
1715 }
1716
1717 #[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
1745pub 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
1757pub 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
1777fn 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
1817fn 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
1834fn unpack_indices(data: &[u8], width: u32, height: u32, bits_per_component: u8) -> Vec<u8> {
1839 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 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 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 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]; 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 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 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 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 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 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 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 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 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 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 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 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); }
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 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 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"); }
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 let empty_data = b"";
2346 assert!(extractor.detect_image_format_from_data(empty_data).is_err());
2347
2348 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 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 let exact_2 = b"\xFF\xD8";
2360 let format = extractor.detect_image_format_from_data(exact_2).unwrap();
2361 assert_eq!(format, ImageFormat::Jpeg); }
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 let image1 = ExtractedImage {
2410 page_number: 0, 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, image_index: 255, 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;