use super::{OperationError, OperationResult};
use crate::graphics::ImageFormat;
use crate::parser::objects::{PdfArray, PdfName, PdfObject, PdfStream};
use crate::parser::{PdfDocument, PdfReader};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
#[cfg(feature = "external-images")]
use image::{DynamicImage, GenericImageView, ImageBuffer, ImageFormat as ImageLibFormat, Luma};
#[derive(Debug, Clone)]
pub struct TransformMatrix {
pub a: f64, pub b: f64, pub c: f64, pub d: f64, pub e: f64, pub f: f64, }
impl TransformMatrix {
#[allow(dead_code)]
fn new(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> Self {
Self { a, b, c, d, e, f }
}
#[allow(dead_code)]
fn is_90_degree_rotation(&self) -> bool {
self.a.abs() < 0.001 && self.d.abs() < 0.001 && self.b.abs() > 0.001 && self.c.abs() > 0.001
}
#[allow(dead_code)]
fn is_simple_scale(&self) -> bool {
self.b.abs() < 0.001 && self.c.abs() < 0.001 && self.a.abs() > 0.001 && self.d.abs() > 0.001
}
#[allow(dead_code)]
fn is_fis2_like_matrix(&self) -> bool {
(self.a - 841.68).abs() < 1.0
&& (self.d - 595.08).abs() < 1.0
&& self.b.abs() < 0.001
&& self.c.abs() < 0.001
}
}
#[derive(Debug, Clone)]
pub struct ImagePreprocessingOptions {
pub auto_correct_rotation: bool,
pub enhance_contrast: bool,
pub denoise: bool,
pub upscale_small_images: bool,
pub upscale_threshold: u32,
pub upscale_factor: u32,
pub force_grayscale: bool,
}
impl Default for ImagePreprocessingOptions {
fn default() -> Self {
Self {
auto_correct_rotation: true,
enhance_contrast: true,
denoise: true,
upscale_small_images: true,
upscale_threshold: 300,
upscale_factor: 2,
force_grayscale: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ExtractImagesOptions {
pub output_dir: PathBuf,
pub name_pattern: String,
pub extract_inline: bool,
pub min_size: Option<u32>,
pub create_dir: bool,
pub preprocessing: ImagePreprocessingOptions,
}
impl Default for ExtractImagesOptions {
fn default() -> Self {
Self {
output_dir: PathBuf::from("."),
name_pattern: "page_{page}_image_{index}.{format}".to_string(),
extract_inline: true,
min_size: Some(10),
create_dir: true,
preprocessing: ImagePreprocessingOptions::default(),
}
}
}
#[derive(Debug)]
pub struct ExtractedImage {
pub page_number: usize,
pub image_index: usize,
pub file_path: PathBuf,
pub width: u32,
pub height: u32,
pub format: ImageFormat,
}
#[derive(Debug, Clone)]
pub struct ExtractedImageData {
pub page_number: usize,
pub image_index: usize,
pub width: u32,
pub height: u32,
pub format: ImageFormat,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Copy)]
pub struct ImageExtractionLimits {
pub max_images: usize,
pub max_encoded_bytes_per_image: usize,
pub max_total_encoded_bytes: usize,
pub max_decoded_pixels_per_image: u64,
}
impl Default for ImageExtractionLimits {
fn default() -> Self {
Self {
max_images: usize::MAX,
max_encoded_bytes_per_image: usize::MAX,
max_total_encoded_bytes: usize::MAX,
max_decoded_pixels_per_image: u64::MAX,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ImageExtractionError {
#[error(transparent)]
Operation(#[from] OperationError),
#[error(
"Image extraction limit exceeded for {limit}: maximum {maximum}, attempted {attempted}"
)]
LimitExceeded {
limit: &'static str,
maximum: u64,
attempted: u64,
},
}
pub type ImageExtractionResult<T> = Result<T, ImageExtractionError>;
impl ImageExtractionError {
fn into_operation_error(self) -> OperationError {
match self {
Self::Operation(error) => error,
error @ Self::LimitExceeded { .. } => {
OperationError::ProcessingError(error.to_string())
}
}
}
}
struct ImageExtractionBudget {
limits: ImageExtractionLimits,
images: usize,
encoded_bytes: usize,
}
impl ImageExtractionBudget {
fn new(limits: ImageExtractionLimits) -> Self {
Self {
limits,
images: 0,
encoded_bytes: 0,
}
}
fn check_pixels(&self, width: u32, height: u32) -> ImageExtractionResult<()> {
let pixels = u64::from(width) * u64::from(height);
if pixels > self.limits.max_decoded_pixels_per_image {
return Err(ImageExtractionError::LimitExceeded {
limit: "decoded pixels per image",
maximum: self.limits.max_decoded_pixels_per_image,
attempted: pixels,
});
}
Ok(())
}
fn check_image_slot(&self) -> ImageExtractionResult<()> {
let attempted = self.images.checked_add(1).unwrap_or(usize::MAX);
if attempted > self.limits.max_images {
return Err(ImageExtractionError::LimitExceeded {
limit: "image count",
maximum: self.limits.max_images as u64,
attempted: attempted as u64,
});
}
Ok(())
}
fn consume(&mut self, bytes: usize) -> ImageExtractionResult<()> {
if bytes > self.limits.max_encoded_bytes_per_image {
return Err(ImageExtractionError::LimitExceeded {
limit: "encoded bytes per image",
maximum: self.limits.max_encoded_bytes_per_image as u64,
attempted: bytes as u64,
});
}
self.check_image_slot()?;
let next_images = self.images + 1;
let next_bytes =
self.encoded_bytes
.checked_add(bytes)
.ok_or(ImageExtractionError::LimitExceeded {
limit: "total encoded bytes",
maximum: self.limits.max_total_encoded_bytes as u64,
attempted: u64::MAX,
})?;
if next_bytes > self.limits.max_total_encoded_bytes {
return Err(ImageExtractionError::LimitExceeded {
limit: "total encoded bytes",
maximum: self.limits.max_total_encoded_bytes as u64,
attempted: next_bytes as u64,
});
}
self.images = next_images;
self.encoded_bytes = next_bytes;
Ok(())
}
}
pub struct ImageExtractor<R: Read + Seek> {
document: PdfDocument<R>,
options: ExtractImagesOptions,
processed_images: HashMap<String, PathBuf>,
}
impl<R: Read + Seek> ImageExtractor<R> {
pub fn new(document: PdfDocument<R>, options: ExtractImagesOptions) -> Self {
Self {
document,
options,
processed_images: HashMap::new(),
}
}
pub fn visit_images<F>(
&mut self,
limits: ImageExtractionLimits,
mut visitor: F,
) -> ImageExtractionResult<()>
where
F: FnMut(ExtractedImageData) -> ImageExtractionResult<()>,
{
let page_count = self
.document
.page_count()
.map_err(|error| OperationError::ParseError(error.to_string()))?;
let mut budget = ImageExtractionBudget::new(limits);
for page_number in 0..page_count as usize {
self.visit_page_images(page_number, &mut budget, &mut visitor)?;
}
Ok(())
}
pub fn extract_all_in_memory(
&mut self,
limits: ImageExtractionLimits,
) -> ImageExtractionResult<Vec<ExtractedImageData>> {
let mut images = Vec::new();
self.visit_images(limits, |image| {
images.push(image);
Ok(())
})?;
Ok(images)
}
pub fn extract_from_page_in_memory(
&mut self,
page_number: usize,
limits: ImageExtractionLimits,
) -> ImageExtractionResult<Vec<ExtractedImageData>> {
let mut images = Vec::new();
let mut budget = ImageExtractionBudget::new(limits);
self.visit_page_images(page_number, &mut budget, &mut |image| {
images.push(image);
Ok(())
})?;
Ok(images)
}
fn visit_page_images<F>(
&mut self,
page_number: usize,
budget: &mut ImageExtractionBudget,
visitor: &mut F,
) -> ImageExtractionResult<()>
where
F: FnMut(ExtractedImageData) -> ImageExtractionResult<()>,
{
let page = self
.document
.get_page(page_number as u32)
.map_err(|error| OperationError::ParseError(error.to_string()))?;
let resources = self
.document
.get_page_resources(&page)
.map_err(|error| OperationError::ParseError(error.to_string()))?;
let mut references = Vec::new();
if let Some(resources) = resources {
if let Some(PdfObject::Dictionary(xobjects)) = resources.get("XObject") {
for object in xobjects.0.values() {
if let PdfObject::Reference(number, generation) = object {
references.push((*number, *generation));
}
}
}
}
let mut image_index = 0;
for (number, generation) in references {
let object = self
.document
.get_object(number, generation)
.map_err(|error| OperationError::ParseError(error.to_string()))?;
let PdfObject::Stream(stream) = object else {
continue;
};
if !matches!(stream.dict.get("Subtype"), Some(PdfObject::Name(name)) if name.0 == "Image")
{
continue;
}
budget.check_image_slot()?;
if let Some(image) =
self.prepare_image_data(&stream, page_number, image_index, budget)?
{
budget.consume(image.data.len())?;
visitor(image)?;
image_index += 1;
}
}
if self.options.extract_inline {
for content in self
.document
.get_page_content_streams(&page)
.map_err(|error| OperationError::ParseError(error.to_string()))?
{
self.visit_inline_images(&content, page_number, &mut image_index, budget, visitor)?;
}
}
Ok(())
}
fn prepare_image_data(
&self,
stream: &PdfStream,
page_number: usize,
image_index: usize,
budget: &ImageExtractionBudget,
) -> ImageExtractionResult<Option<ExtractedImageData>> {
let Some(PdfObject::Integer(width @ 1..)) = stream.dict.get("Width") else {
return Ok(None);
};
let Some(PdfObject::Integer(height @ 1..)) = stream.dict.get("Height") else {
return Ok(None);
};
let (Ok(width), Ok(height)) = (u32::try_from(*width), u32::try_from(*height)) else {
return Ok(None);
};
if self
.options
.min_size
.is_some_and(|minimum| width < minimum || height < minimum)
{
return Ok(None);
}
budget.check_pixels(width, height)?;
let color_space = stream.dict.get("ColorSpace");
let bits = match stream.dict.get("BitsPerComponent") {
Some(PdfObject::Integer(bits)) => *bits as u8,
_ => 8,
};
let smask = self.extract_smask_alpha(&stream.dict, width, height);
let first_filter = match stream.dict.get("Filter") {
Some(PdfObject::Name(name)) => Some(name.0.as_str()),
Some(PdfObject::Array(filters)) => filters.0.first().and_then(|filter| match filter {
PdfObject::Name(name) => Some(name.0.as_str()),
_ => None,
}),
_ => None,
};
let (data, format) = match first_filter {
Some("DCTDecode") => (stream.data.clone(), ImageFormat::Jpeg),
Some("FlateDecode" | "LZWDecode") | None => {
let decoded = self.decode_image_stream(stream)?;
(
self.convert_raw_image_data_to_png(
&decoded,
width,
height,
color_space,
bits,
smask.as_deref(),
)?,
ImageFormat::Png,
)
}
Some("CCITTFaxDecode") => {
let decoded = self.decode_image_stream(stream)?;
(
self.convert_ccitt_to_png(&decoded, width, height)?,
ImageFormat::Png,
)
}
Some(_) => return Ok(None),
};
#[cfg(feature = "external-images")]
let data = if self.should_preprocess() {
self.preprocess_image_data(&data, width, height, format)?
} else {
data
};
Ok(Some(ExtractedImageData {
page_number,
image_index,
width,
height,
format,
data,
}))
}
fn visit_inline_images<F>(
&self,
stream_data: &[u8],
page_number: usize,
image_index: &mut usize,
budget: &mut ImageExtractionBudget,
visitor: &mut F,
) -> ImageExtractionResult<()>
where
F: FnMut(ExtractedImageData) -> ImageExtractionResult<()>,
{
let mut position = 0;
while let Some(begin) = Self::find_bytes(stream_data, b"BI", position) {
let Some(id) = Self::find_bytes(stream_data, b"ID", begin + 2) else {
break;
};
let Some(end) = Self::find_bytes(stream_data, b"EI", id + 2) else {
break;
};
budget.check_image_slot()?;
let dictionary = String::from_utf8_lossy(&stream_data[begin + 2..id]);
let (width, height) = self.parse_inline_image_dict(dictionary.trim());
budget.check_pixels(width, height)?;
let data = stream_data[id + 2..end].to_vec();
budget.consume(data.len())?;
let format = self
.detect_image_format_from_data(&data)
.unwrap_or(ImageFormat::Raw);
visitor(ExtractedImageData {
page_number,
image_index: *image_index,
width,
height,
format,
data,
})?;
*image_index += 1;
position = end + 2;
}
Ok(())
}
fn find_bytes(haystack: &[u8], needle: &[u8], start: usize) -> Option<usize> {
haystack
.get(start..)?
.windows(needle.len())
.position(|window| window == needle)
.map(|position| start + position)
}
pub fn extract_all(&mut self) -> OperationResult<Vec<ExtractedImage>> {
if self.options.create_dir && !self.options.output_dir.exists() {
fs::create_dir_all(&self.options.output_dir)?;
}
let options = self.options.clone();
let mut cache = std::mem::take(&mut self.processed_images);
let mut images = Vec::new();
let result = self.visit_images(ImageExtractionLimits::default(), |image| {
images.push(Self::persist_image_data(&options, &mut cache, image)?);
Ok(())
});
self.processed_images = cache;
result.map_err(ImageExtractionError::into_operation_error)?;
Ok(images)
}
pub fn extract_from_page(
&mut self,
page_number: usize,
) -> OperationResult<Vec<ExtractedImage>> {
if self.options.create_dir && !self.options.output_dir.exists() {
fs::create_dir_all(&self.options.output_dir)?;
}
let options = self.options.clone();
let mut cache = std::mem::take(&mut self.processed_images);
let mut images = Vec::new();
let mut budget = ImageExtractionBudget::new(ImageExtractionLimits::default());
let result = self.visit_page_images(page_number, &mut budget, &mut |image| {
images.push(Self::persist_image_data(&options, &mut cache, image)?);
Ok(())
});
self.processed_images = cache;
result.map_err(ImageExtractionError::into_operation_error)?;
Ok(images)
}
fn persist_image_data(
options: &ExtractImagesOptions,
cache: &mut HashMap<String, PathBuf>,
image: ExtractedImageData,
) -> OperationResult<ExtractedImage> {
let key = format!("{:x}", md5::compute(&image.data));
let allow_deduplication = !options.name_pattern.contains("{page}");
let extension = match image.format {
ImageFormat::Jpeg => "jpg",
ImageFormat::Png => "png",
ImageFormat::Tiff => "tiff",
ImageFormat::Raw => "rgb",
};
let filename = options
.name_pattern
.replace("{page}", &(image.page_number + 1).to_string())
.replace("{index}", &(image.image_index + 1).to_string())
.replace("{format}", extension);
let existing = allow_deduplication
.then(|| cache.get(&key).cloned())
.flatten();
let path = existing.unwrap_or_else(|| options.output_dir.join(filename));
if !allow_deduplication || !cache.contains_key(&key) {
let mut file = File::create(&path)?;
file.write_all(&image.data)?;
cache.insert(key, path.clone());
}
Ok(ExtractedImage {
page_number: image.page_number,
image_index: image.image_index,
file_path: path,
width: image.width,
height: image.height,
format: image.format,
})
}
fn detect_image_format_from_data(&self, data: &[u8]) -> OperationResult<ImageFormat> {
if data.is_empty() {
return Err(OperationError::ParseError(
"Image data too short to detect format".to_string(),
));
}
if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
return Ok(ImageFormat::Png);
}
if data.len() >= 4 {
if &data[0..2] == b"II" && &data[2..4] == b"\x2A\x00" {
return Ok(ImageFormat::Tiff); }
if &data[0..2] == b"MM" && &data[2..4] == b"\x00\x2A" {
return Ok(ImageFormat::Tiff); }
}
if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
return Ok(ImageFormat::Jpeg);
}
if data.len() < 2 {
return Err(OperationError::ParseError(
"Image data too short to detect format".to_string(),
));
}
Ok(ImageFormat::Png)
}
#[cfg(feature = "external-images")]
#[allow(dead_code)]
fn apply_rotation_transformation(
&self,
img: DynamicImage,
matrix: &TransformMatrix,
) -> OperationResult<DynamicImage> {
if matrix.b > 0.0 && matrix.c < 0.0 {
Ok(img.rotate90()) } else if matrix.b < 0.0 && matrix.c > 0.0 {
Ok(img.rotate270()) } else {
Ok(img.rotate90())
}
}
#[cfg(feature = "external-images")]
#[allow(dead_code)]
fn apply_scale_transformation(
&self,
img: DynamicImage,
matrix: &TransformMatrix,
) -> OperationResult<DynamicImage> {
let (current_width, current_height) = img.dimensions();
let new_width = (current_width as f64 * matrix.a.abs()) as u32;
let new_height = (current_height as f64 * matrix.d.abs()) as u32;
if new_width > 0 && new_height > 0 {
Ok(img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3))
} else {
Ok(img)
}
}
fn parse_inline_image_dict(&self, dict_str: &str) -> (u32, u32) {
let mut width = 100; let mut height = 100;
for line in dict_str.lines() {
let line = line.trim();
if line.starts_with("/W ") || line.starts_with("/Width ") {
if let Some(value_str) = line.split_whitespace().nth(1) {
if let Ok(w) = value_str.parse::<u32>() {
width = w;
}
}
}
if line.starts_with("/H ") || line.starts_with("/Height ") {
if let Some(value_str) = line.split_whitespace().nth(1) {
if let Ok(h) = value_str.parse::<u32>() {
height = h;
}
}
}
}
(width, height)
}
fn decode_image_stream(&self, stream: &PdfStream) -> OperationResult<Vec<u8>> {
let parse_options = self.document.options();
let needs_resolution = ["DecodeParms", "DP"].into_iter().any(|key| {
stream
.dict
.0
.get(&PdfName(key.to_string()))
.map(Self::contains_reference)
.unwrap_or(false)
});
let decode_result = if needs_resolution {
let mut dict = stream.dict.clone();
for key in ["DecodeParms", "DP"] {
if let Some(obj) = dict.0.get(&PdfName(key.to_string())).cloned() {
let resolved = self.resolve_decode_params(&obj);
dict.0.insert(PdfName(key.to_string()), resolved);
}
}
PdfStream {
dict,
data: stream.data.clone(),
}
.decode(&parse_options)
} else {
stream.decode(&parse_options)
};
decode_result
.map_err(|e| OperationError::ParseError(format!("Failed to decode image stream: {e}")))
}
fn contains_reference(obj: &PdfObject) -> bool {
match obj {
PdfObject::Reference(_, _) => true,
PdfObject::Array(arr) => arr
.0
.iter()
.any(|e| matches!(e, PdfObject::Reference(_, _))),
_ => false,
}
}
fn resolve_decode_params(&self, obj: &PdfObject) -> PdfObject {
let resolved = self.document.resolve(obj).unwrap_or_else(|e| {
tracing::warn!("Failed to resolve /DecodeParms reference: {e}");
obj.clone()
});
match resolved {
PdfObject::Array(arr) => PdfObject::Array(PdfArray(
arr.0
.iter()
.map(|e| self.document.resolve(e).unwrap_or_else(|_| e.clone()))
.collect(),
)),
other => other,
}
}
fn try_resolve_indexed(
&self,
color_space: Option<&PdfObject>,
) -> Option<(PdfObject, usize, Vec<u8>)> {
let array = color_space?.as_array()?;
let first = array.0.first()?.as_name()?;
if first.0 != "Indexed" && first.0 != "I" {
return None;
}
let base = self.document.resolve(array.0.get(1)?).ok()?;
let hival = array.0.get(2)?.as_integer()?.max(0) as usize;
let lookup = self.resolve_lookup_bytes(array.0.get(3)?)?;
Some((base, hival, lookup))
}
fn resolve_lookup_bytes(&self, lookup: &PdfObject) -> Option<Vec<u8>> {
match self.document.resolve(lookup).ok()? {
PdfObject::String(s) => Some(s.0),
PdfObject::Stream(s) => s.decode(&self.document.options()).ok(),
_ => None,
}
}
fn icc_components(&self, color_space: Option<&PdfObject>) -> Option<u8> {
let array = color_space?.as_array()?;
if array.0.first()?.as_name()?.0 != "ICCBased" {
return None;
}
let stream = self.document.resolve(array.0.get(1)?).ok()?;
let n = stream
.as_stream()?
.dict
.0
.get(&PdfName("N".to_string()))?
.as_integer()?;
Some(n.clamp(1, 4) as u8)
}
fn convert_raw_image_data_to_png(
&self,
data: &[u8],
width: u32,
height: u32,
color_space: Option<&PdfObject>,
bits_per_component: u8,
smask_alpha: Option<&[u8]>,
) -> OperationResult<Vec<u8>> {
let resolved_cs = color_space.and_then(|cs| self.document.resolve(cs).ok());
let cs = resolved_cs.as_ref().or(color_space);
if let Some((base, hival, palette)) = self.try_resolve_indexed(cs) {
let base_components = self.color_space_component_count(Some(&base)) as usize;
let indices: std::borrow::Cow<[u8]> = if bits_per_component == 8 {
std::borrow::Cow::Borrowed(data)
} else {
std::borrow::Cow::Owned(unpack_indices(data, width, height, bits_per_component))
};
let pixel_count = (width as usize) * (height as usize);
if indices.len() < pixel_count {
return Err(OperationError::ParseError(format!(
"Indexed image data too small: expected {} indices, got {}",
pixel_count,
indices.len()
)));
}
let rgb = expand_indexed(&indices[..pixel_count], &palette, base_components, hival);
return self.encode_png_maybe_alpha(
&rgb,
width,
height,
base_components as u8,
8,
smask_alpha,
);
}
let icc_n = self.icc_components(cs);
let components = image_sample_components(cs, icc_n);
let bytes_per_sample = if bits_per_component <= 8 { 1 } else { 2 };
let expected_size = (width as usize)
* (height as usize)
* (components as usize)
* (bytes_per_sample as usize);
if data.len() < expected_size {
return Err(OperationError::ParseError(format!(
"Image data too small: expected {}, got {}",
expected_size,
data.len()
)));
}
self.encode_png_maybe_alpha(
data,
width,
height,
components,
bits_per_component,
smask_alpha,
)
}
fn color_space_component_count(&self, color_space: Option<&PdfObject>) -> u8 {
let icc_n = self.icc_components(color_space);
image_sample_components(color_space, icc_n)
}
fn extract_smask_alpha(
&self,
image_dict: &crate::parser::objects::PdfDictionary,
width: u32,
height: u32,
) -> Option<Vec<u8>> {
let smask = image_dict.0.get(&PdfName("SMask".to_string()))?;
let resolved = self.document.resolve(smask).ok()?;
let stream = match &resolved {
PdfObject::Stream(s) => s,
_ => return None,
};
let dict = &stream.dict.0;
let sw_i = dict.get(&PdfName("Width".to_string()))?.as_integer()?;
let sh_i = dict.get(&PdfName("Height".to_string()))?.as_integer()?;
if sw_i <= 0 || sh_i <= 0 {
return None;
}
let sw = sw_i as u32;
let sh = sh_i as u32;
let sbpc = dict
.get(&PdfName("BitsPerComponent".to_string()))
.and_then(|b| b.as_integer())
.unwrap_or(8);
if sbpc != 8 {
return None; }
let gray = self.decode_image_stream(stream).ok()?;
let expected = (sw as usize) * (sh as usize);
if gray.len() < expected {
return None;
}
let gray = &gray[..expected];
if sw == width && sh == height {
return Some(gray.to_vec());
}
let mut out = Vec::with_capacity((width as usize) * (height as usize));
for y in 0..height {
let sy = ((y as u64 * sh as u64) / height as u64) as usize;
let row = sy * sw as usize;
for x in 0..width {
let sx = ((x as u64 * sw as u64) / width as u64) as usize;
out.push(gray[row + sx]);
}
}
Some(out)
}
fn encode_png_maybe_alpha(
&self,
samples: &[u8],
width: u32,
height: u32,
components: u8,
bits_per_component: u8,
alpha: Option<&[u8]>,
) -> OperationResult<Vec<u8>> {
match alpha {
Some(a) if bits_per_component == 8 && (components == 1 || components == 3) => {
let pixel_count = (width as usize) * (height as usize);
debug_assert!(
samples.len() >= pixel_count * components as usize,
"sample buffer too short: {} < {}",
samples.len(),
pixel_count * components as usize
);
debug_assert_eq!(a.len(), pixel_count, "alpha length must match pixel count");
let mut rgba = Vec::with_capacity(pixel_count * 4);
for i in 0..pixel_count {
let (r, g, b) = if components == 3 {
let p = i * 3;
(
*samples.get(p).unwrap_or(&0),
*samples.get(p + 1).unwrap_or(&0),
*samples.get(p + 2).unwrap_or(&0),
)
} else {
let v = *samples.get(i).unwrap_or(&0);
(v, v, v)
};
let al = *a.get(i).unwrap_or(&255);
rgba.extend_from_slice(&[r, g, b, al]);
}
self.create_png_from_raw_data(&rgba, width, height, 4, 8)
}
_ => self.create_png_from_raw_data(
samples,
width,
height,
components,
bits_per_component,
),
}
}
fn create_png_from_raw_data(
&self,
data: &[u8],
width: u32,
height: u32,
components: u8,
bits_per_component: u8,
) -> OperationResult<Vec<u8>> {
let mut png_data = Vec::new();
png_data.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(bits_per_component);
let color_type = match components {
1 => 0, 3 => 2, 4 => 6, _ => 2, };
ihdr.push(color_type);
ihdr.push(0); ihdr.push(0); ihdr.push(0);
self.write_png_chunk(&mut png_data, b"IHDR", &ihdr);
let compressed_data = self.compress_image_data(data, width, height, components)?;
self.write_png_chunk(&mut png_data, b"IDAT", &compressed_data);
self.write_png_chunk(&mut png_data, b"IEND", &[]);
Ok(png_data)
}
fn write_png_chunk(&self, output: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
output.extend_from_slice(&(data.len() as u32).to_be_bytes());
output.extend_from_slice(chunk_type);
output.extend_from_slice(data);
let crc = self.calculate_crc32(chunk_type, data);
output.extend_from_slice(&crc.to_be_bytes());
}
fn calculate_crc32(&self, chunk_type: &[u8; 4], data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFFFFFF;
for &byte in chunk_type {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB88320;
} else {
crc >>= 1;
}
}
}
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB88320;
} else {
crc >>= 1;
}
}
}
crc ^ 0xFFFFFFFF
}
fn compress_image_data(
&self,
data: &[u8],
width: u32,
height: u32,
components: u8,
) -> OperationResult<Vec<u8>> {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
let bytes_per_pixel = components as usize;
let bytes_per_row = width as usize * bytes_per_pixel;
for row in 0..height {
encoder.write_all(&[0])?;
let start = row as usize * bytes_per_row;
let end = start + bytes_per_row;
if end <= data.len() {
encoder.write_all(&data[start..end])?;
}
}
encoder
.finish()
.map_err(|e| OperationError::ParseError(format!("Failed to compress PNG data: {e}")))
}
fn convert_ccitt_to_png(
&self,
data: &[u8],
width: u32,
height: u32,
) -> OperationResult<Vec<u8>> {
let mut rgb_data = Vec::new();
let bits_per_row = width as usize;
let min_bytes_per_row = bits_per_row.div_ceil(8);
let possible_strides = [
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, ];
let correct_stride =
self.detect_correct_row_stride(data, width, height, &possible_strides)?;
for row in 0..height {
let row_start = row as usize * correct_stride;
for col in 0..width {
let byte_idx = row_start + (col as usize / 8);
let bit_idx = 7 - (col as usize % 8);
if byte_idx < data.len() {
let bit = (data[byte_idx] >> bit_idx) & 1;
let gray_value = if bit == 0 { 0 } else { 255 };
rgb_data.push(gray_value);
} else {
rgb_data.push(255); }
}
}
self.create_png_from_raw_data(&rgb_data, width, height, 1, 8)
}
fn detect_correct_row_stride(
&self,
data: &[u8],
width: u32,
height: u32,
possible_strides: &[usize],
) -> OperationResult<usize> {
let bits_per_row = width as usize;
let min_bytes_per_row = bits_per_row.div_ceil(8);
if data.len() < min_bytes_per_row * 3 {
return Ok(min_bytes_per_row);
}
for &stride in possible_strides {
let expected_size = stride * height as usize;
if expected_size <= data.len() && (data.len() - expected_size) < stride * 2 {
return Ok(stride);
}
}
let calculated_stride = data.len() / height as usize;
if calculated_stride >= min_bytes_per_row {
return Ok(calculated_stride);
}
Ok(min_bytes_per_row)
}
#[allow(dead_code)]
fn should_preprocess(&self) -> bool {
self.options.preprocessing.auto_correct_rotation
|| self.options.preprocessing.enhance_contrast
|| self.options.preprocessing.denoise
|| self.options.preprocessing.upscale_small_images
|| self.options.preprocessing.force_grayscale
}
#[cfg(feature = "external-images")]
fn preprocess_image_data(
&self,
data: &[u8],
width: u32,
height: u32,
format: ImageFormat,
) -> OperationResult<Vec<u8>> {
let img_format = match format {
ImageFormat::Jpeg => ImageLibFormat::Jpeg,
ImageFormat::Png => ImageLibFormat::Png,
ImageFormat::Tiff => ImageLibFormat::Tiff,
ImageFormat::Raw => {
return self.preprocess_raw_image_data(data, width, height);
}
};
let img = image::load_from_memory_with_format(data, img_format)
.map_err(|e| OperationError::ParseError(format!("Failed to load image: {e}")))?;
let mut processed_img = img;
processed_img = self.apply_rotation_correction(processed_img)?;
processed_img = self.apply_contrast_enhancement(processed_img)?;
processed_img = self.apply_noise_reduction(processed_img)?;
processed_img = self.apply_upscaling(processed_img, width, height)?;
if self.options.preprocessing.force_grayscale {
processed_img = DynamicImage::ImageLuma8(processed_img.to_luma8());
}
let mut output = Vec::new();
processed_img
.write_to(&mut std::io::Cursor::new(&mut output), img_format)
.map_err(|e| OperationError::ParseError(format!("Failed to encode image: {e}")))?;
Ok(output)
}
#[cfg(feature = "external-images")]
fn preprocess_raw_image_data(
&self,
data: &[u8],
width: u32,
height: u32,
) -> OperationResult<Vec<u8>> {
if data.len() < (width * height) as usize {
return Err(OperationError::ParseError(
"Raw image data too small".to_string(),
));
}
let img_buffer = ImageBuffer::<Luma<u8>, Vec<u8>>::from_raw(
width,
height,
data[..(width * height) as usize].to_vec(),
)
.ok_or_else(|| OperationError::ParseError("Failed to create image buffer".to_string()))?;
let img = DynamicImage::ImageLuma8(img_buffer);
let mut processed_img = img;
processed_img = self.apply_rotation_correction(processed_img)?;
processed_img = self.apply_contrast_enhancement(processed_img)?;
processed_img = self.apply_noise_reduction(processed_img)?;
processed_img = self.apply_upscaling(processed_img, width, height)?;
let mut output = Vec::new();
processed_img
.write_to(&mut std::io::Cursor::new(&mut output), ImageLibFormat::Png)
.map_err(|e| OperationError::ParseError(format!("Failed to encode image: {e}")))?;
Ok(output)
}
#[cfg(feature = "external-images")]
fn apply_rotation_correction(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
if !self.options.preprocessing.auto_correct_rotation {
return Ok(img);
}
let (width, height) = img.dimensions();
if width > height * 2 {
return Ok(img.rotate90());
}
Ok(img)
}
#[cfg(feature = "external-images")]
fn apply_contrast_enhancement(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
if !self.options.preprocessing.enhance_contrast {
return Ok(img);
}
let enhanced = img.adjust_contrast(20.0); Ok(enhanced.brighten(10)) }
#[cfg(feature = "external-images")]
fn apply_noise_reduction(&self, img: DynamicImage) -> OperationResult<DynamicImage> {
if !self.options.preprocessing.denoise {
return Ok(img);
}
Ok(img.blur(0.5))
}
#[cfg(feature = "external-images")]
fn apply_upscaling(
&self,
img: DynamicImage,
original_width: u32,
original_height: u32,
) -> OperationResult<DynamicImage> {
if !self.options.preprocessing.upscale_small_images {
return Ok(img);
}
let min_dimension = original_width.min(original_height);
if min_dimension < self.options.preprocessing.upscale_threshold {
let new_width = original_width * self.options.preprocessing.upscale_factor;
let new_height = original_height * self.options.preprocessing.upscale_factor;
return Ok(img.resize(
new_width,
new_height,
image::imageops::FilterType::CatmullRom,
));
}
Ok(img)
}
}
pub fn extract_images_from_pdf<P: AsRef<Path>>(
input_path: P,
options: ExtractImagesOptions,
) -> OperationResult<Vec<ExtractedImage>> {
let document = PdfReader::open_document(input_path)
.map_err(|e| OperationError::ParseError(e.to_string()))?;
let mut extractor = ImageExtractor::new(document, options);
extractor.extract_all()
}
pub fn extract_images_from_pages<P: AsRef<Path>>(
input_path: P,
pages: &[usize],
options: ExtractImagesOptions,
) -> OperationResult<Vec<ExtractedImage>> {
let document = PdfReader::open_document(input_path)
.map_err(|e| OperationError::ParseError(e.to_string()))?;
let mut extractor = ImageExtractor::new(document, options);
let mut all_images = Vec::new();
for &page_num in pages {
let page_images = extractor.extract_from_page(page_num)?;
all_images.extend(page_images);
}
Ok(all_images)
}
fn image_sample_components(color_space: Option<&PdfObject>, icc_n: Option<u8>) -> u8 {
match color_space {
Some(PdfObject::Name(cs)) => match cs.0.as_str() {
"DeviceGray" | "G" | "CalGray" => 1,
"DeviceRGB" | "RGB" | "CalRGB" | "Lab" => 3,
"DeviceCMYK" | "CMYK" => 4,
_ => 3,
},
Some(PdfObject::Array(array)) => {
match array
.0
.first()
.and_then(|o| o.as_name())
.map(|n| n.0.as_str())
{
Some("Indexed") | Some("I") => 1,
Some("Separation") => 1,
Some("DeviceN") => array
.0
.get(1)
.and_then(|o| o.as_array())
.map(|names| names.0.len().max(1) as u8)
.unwrap_or(1),
Some("ICCBased") => icc_n.unwrap_or(3),
Some("CalGray") | Some("DeviceGray") => 1,
Some("DeviceCMYK") => 4,
Some("CalRGB") | Some("Lab") | Some("DeviceRGB") => 3,
_ => 3,
}
}
_ => 3,
}
}
fn expand_indexed(indices: &[u8], lookup: &[u8], base_components: usize, hival: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(indices.len() * base_components);
for &idx in indices {
let entry = (idx as usize).min(hival);
let start = entry * base_components;
for c in 0..base_components {
out.push(lookup.get(start + c).copied().unwrap_or(0));
}
}
out
}
fn unpack_indices(data: &[u8], width: u32, height: u32, bits_per_component: u8) -> Vec<u8> {
if !matches!(bits_per_component, 1 | 2 | 4) {
return data.to_vec();
}
let bpc = bits_per_component as usize;
let width = width as usize;
let height = height as usize;
let row_bytes = (width * bpc).div_ceil(8);
let mask = (1u16 << bpc) - 1;
let mut out = Vec::with_capacity(width * height);
for row in 0..height {
let row_start = row * row_bytes;
for col in 0..width {
let bit_index = col * bpc;
let byte = row_start + bit_index / 8;
let shift = 8 - bpc - (bit_index % 8);
let value = data
.get(byte)
.map(|b| ((*b as u16) >> shift) & mask)
.unwrap_or(0);
out.push(value as u8);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn name(s: &str) -> PdfObject {
PdfObject::Name(PdfName(s.to_string()))
}
#[test]
fn test_image_sample_components_device_color_spaces() {
assert_eq!(image_sample_components(Some(&name("DeviceGray")), None), 1);
assert_eq!(image_sample_components(Some(&name("DeviceRGB")), None), 3);
assert_eq!(image_sample_components(Some(&name("DeviceCMYK")), None), 4);
assert_eq!(image_sample_components(Some(&name("Weird")), None), 3);
assert_eq!(image_sample_components(None, None), 3);
}
#[test]
fn test_image_sample_components_indexed_is_one() {
let indexed = PdfObject::Array(PdfArray(vec![
name("Indexed"),
name("DeviceRGB"),
PdfObject::Integer(23),
PdfObject::String(crate::parser::objects::PdfString(vec![0u8; 72])),
]));
assert_eq!(image_sample_components(Some(&indexed), None), 1);
}
#[test]
fn test_image_sample_components_iccbased_uses_n() {
let icc = PdfObject::Array(PdfArray(vec![name("ICCBased"), PdfObject::Reference(5, 0)]));
assert_eq!(image_sample_components(Some(&icc), Some(1)), 1);
assert_eq!(image_sample_components(Some(&icc), Some(4)), 4);
assert_eq!(image_sample_components(Some(&icc), None), 3);
}
#[test]
fn test_image_sample_components_devicen_counts_colorants() {
let devicen = PdfObject::Array(PdfArray(vec![
name("DeviceN"),
PdfObject::Array(PdfArray(vec![name("Cyan"), name("Magenta")])),
name("DeviceCMYK"),
PdfObject::Reference(9, 0),
]));
assert_eq!(image_sample_components(Some(&devicen), None), 2);
}
#[test]
fn test_expand_indexed_maps_indices_to_palette_rgb() {
let palette = vec![255, 0, 0, 0, 255, 0, 0, 0, 255];
let indices = [0u8, 2, 1];
let rgb = expand_indexed(&indices, &palette, 3, 2);
assert_eq!(rgb, vec![255, 0, 0, 0, 0, 255, 0, 255, 0]);
}
#[test]
fn test_expand_indexed_clamps_out_of_range_index() {
let palette = vec![10, 20, 30, 40, 50, 60]; let rgb = expand_indexed(&[5u8], &palette, 3, 1);
assert_eq!(rgb, vec![40, 50, 60]);
}
#[test]
fn test_unpack_indices_passthrough_for_8bit() {
let data = vec![1, 2, 3, 4];
assert_eq!(unpack_indices(&data, 2, 2, 8), data);
}
#[test]
fn test_unpack_indices_4bit_two_pixels_per_byte() {
let data = vec![0xA3];
assert_eq!(unpack_indices(&data, 2, 1, 4), vec![0x0A, 0x03]);
}
#[test]
fn test_unpack_indices_2bit_four_pixels_per_byte() {
let data = vec![0b1110_0100];
assert_eq!(unpack_indices(&data, 4, 1, 2), vec![3, 2, 1, 0]);
}
#[test]
fn test_unpack_indices_passthrough_for_unsupported_bpc() {
let data = vec![0xAB, 0xCD];
assert_eq!(unpack_indices(&data, 4, 1, 3), data);
}
#[test]
fn test_unpack_indices_1bit_respects_row_byte_alignment() {
let data = vec![0b1010_0000, 0b0110_0000];
assert_eq!(unpack_indices(&data, 3, 2, 1), vec![1, 0, 1, 0, 1, 1]);
}
#[test]
fn test_extract_options_default() {
let options = ExtractImagesOptions::default();
assert_eq!(options.output_dir, PathBuf::from("."));
assert!(options.extract_inline);
assert_eq!(options.min_size, Some(10));
assert!(options.create_dir);
}
#[test]
fn test_filename_pattern() {
let options = ExtractImagesOptions {
name_pattern: "img_{page}_{index}.{format}".to_string(),
..Default::default()
};
let pattern = options
.name_pattern
.replace("{page}", "1")
.replace("{index}", "2")
.replace("{format}", "jpg");
assert_eq!(pattern, "img_1_2.jpg");
}
#[test]
fn test_extract_options_custom() {
let temp_dir = TempDir::new().unwrap();
let options = ExtractImagesOptions {
output_dir: temp_dir.path().to_path_buf(),
name_pattern: "custom_{page}_{index}.{format}".to_string(),
extract_inline: false,
min_size: Some(50),
create_dir: false,
preprocessing: ImagePreprocessingOptions::default(),
};
assert_eq!(options.output_dir, temp_dir.path());
assert_eq!(options.name_pattern, "custom_{page}_{index}.{format}");
assert!(!options.extract_inline);
assert_eq!(options.min_size, Some(50));
assert!(!options.create_dir);
}
#[test]
fn test_extract_options_debug_clone() {
let options = ExtractImagesOptions {
output_dir: PathBuf::from("/test/path"),
name_pattern: "test.{format}".to_string(),
extract_inline: true,
min_size: None,
create_dir: true,
preprocessing: ImagePreprocessingOptions::default(),
};
let debug_str = format!("{options:?}");
assert!(debug_str.contains("ExtractImagesOptions"));
assert!(debug_str.contains("/test/path"));
let cloned = options.clone();
assert_eq!(cloned.output_dir, options.output_dir);
assert_eq!(cloned.name_pattern, options.name_pattern);
assert_eq!(cloned.extract_inline, options.extract_inline);
assert_eq!(cloned.min_size, options.min_size);
assert_eq!(cloned.create_dir, options.create_dir);
}
#[test]
fn test_extracted_image_struct() {
let image = ExtractedImage {
page_number: 0,
image_index: 1,
file_path: PathBuf::from("/test/image.jpg"),
width: 100,
height: 200,
format: ImageFormat::Jpeg,
};
assert_eq!(image.page_number, 0);
assert_eq!(image.image_index, 1);
assert_eq!(image.file_path, PathBuf::from("/test/image.jpg"));
assert_eq!(image.width, 100);
assert_eq!(image.height, 200);
assert_eq!(image.format, ImageFormat::Jpeg);
}
#[test]
fn test_extracted_image_debug() {
let image = ExtractedImage {
page_number: 5,
image_index: 3,
file_path: PathBuf::from("output.png"),
width: 512,
height: 768,
format: ImageFormat::Png,
};
let debug_str = format!("{image:?}");
assert!(debug_str.contains("ExtractedImage"));
assert!(debug_str.contains("5"));
assert!(debug_str.contains("3"));
assert!(debug_str.contains("output.png"));
assert!(debug_str.contains("512"));
assert!(debug_str.contains("768"));
}
fn create_minimal_pdf(temp_file: &std::path::Path) {
let minimal_pdf = b"%PDF-1.7\n\
1 0 obj\n\
<< /Type /Catalog /Pages 2 0 R >>\n\
endobj\n\
2 0 obj\n\
<< /Type /Pages /Kids [] /Count 0 >>\n\
endobj\n\
xref\n\
0 3\n\
0000000000 65535 f \n\
0000000009 00000 n \n\
0000000055 00000 n \n\
trailer\n\
<< /Size 3 /Root 1 0 R >>\n\
startxref\n\
105\n\
%%EOF";
std::fs::write(temp_file, minimal_pdf).unwrap();
}
#[test]
fn test_detect_image_format_png() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let png_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0DIHDR";
let format = extractor.detect_image_format_from_data(png_data).unwrap();
assert_eq!(format, ImageFormat::Png);
}
#[test]
fn test_detect_image_format_jpeg() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let jpeg_data = b"\xFF\xD8\xFF\xE0\x00\x10JFIF";
let format = extractor.detect_image_format_from_data(jpeg_data).unwrap();
assert_eq!(format, ImageFormat::Jpeg);
}
#[test]
fn test_detect_image_format_tiff_little_endian() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let tiff_data = b"II\x2A\x00\x08\x00\x00\x00";
let format = extractor.detect_image_format_from_data(tiff_data).unwrap();
assert_eq!(format, ImageFormat::Tiff);
}
#[test]
fn test_detect_image_format_tiff_big_endian() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let tiff_data = b"MM\x00\x2A\x00\x00\x00\x08";
let format = extractor.detect_image_format_from_data(tiff_data).unwrap();
assert_eq!(format, ImageFormat::Tiff);
}
#[test]
fn test_detect_image_format_unknown() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let unknown_data = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08";
let format = extractor
.detect_image_format_from_data(unknown_data)
.unwrap();
assert_eq!(format, ImageFormat::Png); }
#[test]
fn test_detect_image_format_short_data() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let short_data = b"\xFF";
let result = extractor.detect_image_format_from_data(short_data);
assert!(result.is_err());
match result {
Err(OperationError::ParseError(msg)) => {
assert!(msg.contains("too short"));
}
_ => panic!("Expected ParseError"),
}
}
#[test]
fn test_filename_pattern_replacements() {
let options = ExtractImagesOptions {
name_pattern: "page_{page}_img_{index}_{format}.{format}".to_string(),
..Default::default()
};
let pattern = options
.name_pattern
.replace("{page}", "10")
.replace("{index}", "5")
.replace("{format}", "png");
assert_eq!(pattern, "page_10_img_5_png.png");
}
#[test]
fn test_extract_options_no_min_size() {
let options = ExtractImagesOptions {
min_size: None,
..Default::default()
};
assert_eq!(options.min_size, None);
}
#[test]
fn test_create_output_directory() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("new_dir");
let options = ExtractImagesOptions {
output_dir: output_dir.clone(),
create_dir: true,
..Default::default()
};
assert!(!output_dir.exists());
assert_eq!(options.output_dir, output_dir);
assert!(options.create_dir);
}
#[test]
fn test_pattern_with_special_chars() {
let options = ExtractImagesOptions {
name_pattern: "img-{page}_{index}.{format}".to_string(),
..Default::default()
};
let pattern = options
.name_pattern
.replace("{page}", "1")
.replace("{index}", "1")
.replace("{format}", "jpg");
assert_eq!(pattern, "img-1_1.jpg");
}
#[test]
fn test_multiple_format_extensions() {
let formats = vec![
(ImageFormat::Jpeg, "jpg"),
(ImageFormat::Png, "png"),
(ImageFormat::Tiff, "tiff"),
];
for (format, expected_ext) in formats {
let extension = match format {
ImageFormat::Jpeg => "jpg",
ImageFormat::Png => "png",
ImageFormat::Tiff => "tiff",
ImageFormat::Raw => "raw",
};
assert_eq!(extension, expected_ext);
}
}
#[test]
fn test_extract_inline_option() {
let mut options = ExtractImagesOptions::default();
assert!(options.extract_inline);
options.extract_inline = false;
assert!(!options.extract_inline);
}
#[test]
fn test_min_size_filtering() {
let options_with_min = ExtractImagesOptions {
min_size: Some(100),
..Default::default()
};
let options_no_min = ExtractImagesOptions {
min_size: None,
..Default::default()
};
assert_eq!(options_with_min.min_size, Some(100));
assert_eq!(options_no_min.min_size, None);
}
#[test]
fn test_output_path_combinations() {
let base_dir = PathBuf::from("/output");
let options = ExtractImagesOptions {
output_dir: base_dir,
name_pattern: "img_{page}_{index}.{format}".to_string(),
..Default::default()
};
let filename = options
.name_pattern
.replace("{page}", "1")
.replace("{index}", "2")
.replace("{format}", "png");
let full_path = options.output_dir.join(filename);
assert_eq!(full_path, PathBuf::from("/output/img_1_2.png"));
}
#[test]
fn test_pattern_without_placeholders() {
let options = ExtractImagesOptions {
name_pattern: "static_name.jpg".to_string(),
..Default::default()
};
let pattern = options
.name_pattern
.replace("{page}", "1")
.replace("{index}", "2")
.replace("{format}", "png");
assert_eq!(pattern, "static_name.jpg"); }
#[test]
fn test_detect_format_edge_cases() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.pdf");
create_minimal_pdf(&temp_file);
let document = PdfReader::open_document(&temp_file).unwrap();
let extractor = ImageExtractor::new(document, ExtractImagesOptions::default());
let empty_data = b"";
assert!(extractor.detect_image_format_from_data(empty_data).is_err());
let exact_8 = b"\x89PNG\r\n\x1a\n";
let format = extractor.detect_image_format_from_data(exact_8).unwrap();
assert_eq!(format, ImageFormat::Png);
let exact_4 = b"II\x2A\x00";
let format = extractor.detect_image_format_from_data(exact_4).unwrap();
assert_eq!(format, ImageFormat::Tiff);
let exact_2 = b"\xFF\xD8";
let format = extractor.detect_image_format_from_data(exact_2).unwrap();
assert_eq!(format, ImageFormat::Jpeg); }
#[test]
fn test_complex_filename_pattern() {
let options = ExtractImagesOptions {
name_pattern: "{format}/page{page}/image_{index}_{page}.{format}".to_string(),
..Default::default()
};
let pattern = options
.name_pattern
.replace("{page}", "5")
.replace("{index}", "3")
.replace("{format}", "jpeg");
assert_eq!(pattern, "jpeg/page5/image_3_5.jpeg");
}
#[test]
fn test_image_dimensions() {
let small_image = ExtractedImage {
page_number: 0,
image_index: 0,
file_path: PathBuf::from("small.jpg"),
width: 5,
height: 5,
format: ImageFormat::Jpeg,
};
let large_image = ExtractedImage {
page_number: 0,
image_index: 1,
file_path: PathBuf::from("large.jpg"),
width: 2000,
height: 3000,
format: ImageFormat::Jpeg,
};
assert_eq!(small_image.width, 5);
assert_eq!(small_image.height, 5);
assert_eq!(large_image.width, 2000);
assert_eq!(large_image.height, 3000);
}
#[test]
fn test_page_and_index_numbering() {
let image1 = ExtractedImage {
page_number: 0, image_index: 0,
file_path: PathBuf::from("first.jpg"),
width: 100,
height: 100,
format: ImageFormat::Jpeg,
};
let image2 = ExtractedImage {
page_number: 99, image_index: 255, file_path: PathBuf::from("last.jpg"),
width: 100,
height: 100,
format: ImageFormat::Jpeg,
};
assert_eq!(image1.page_number, 0);
assert_eq!(image1.image_index, 0);
assert_eq!(image2.page_number, 99);
assert_eq!(image2.image_index, 255);
}
}
#[cfg(test)]
#[path = "extract_images_tests.rs"]
mod extract_images_tests;