use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
const DEFAULT_DPI: u32 = 300;
const HIGH_QUALITY_DPI: u32 = 600;
const FAST_DPI: u32 = 150;
const MIN_DPI: u32 = 72;
const MAX_DPI: u32 = 1200;
const WHITE_BACKGROUND: [u8; 3] = [255, 255, 255];
#[derive(Debug, Error)]
pub enum ExtractError {
#[error("PDF file not found: {0}")]
PdfNotFound(PathBuf),
#[error("Output directory not writable: {0}")]
OutputNotWritable(PathBuf),
#[error("Extraction failed for page {page}: {reason}")]
ExtractionFailed { page: usize, reason: String },
#[error("External tool error: {0}")]
ExternalToolError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, ExtractError>;
pub struct ExtractOptions {
pub dpi: u32,
pub format: ImageFormat,
pub colorspace: ColorSpace,
pub background: Option<[u8; 3]>,
pub parallel: usize,
#[allow(clippy::type_complexity)]
pub progress_callback: Option<Box<dyn Fn(usize, usize) + Send + Sync>>,
}
impl std::fmt::Debug for ExtractOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExtractOptions")
.field("dpi", &self.dpi)
.field("format", &self.format)
.field("colorspace", &self.colorspace)
.field("background", &self.background)
.field("parallel", &self.parallel)
.field(
"progress_callback",
&self.progress_callback.as_ref().map(|_| "<callback>"),
)
.finish()
}
}
impl Default for ExtractOptions {
fn default() -> Self {
Self {
dpi: DEFAULT_DPI,
format: ImageFormat::Png,
colorspace: ColorSpace::Rgb,
background: Some(WHITE_BACKGROUND),
parallel: num_cpus::get(),
progress_callback: None,
}
}
}
impl ExtractOptions {
pub fn builder() -> ExtractOptionsBuilder {
ExtractOptionsBuilder::default()
}
pub fn high_quality() -> Self {
Self {
dpi: HIGH_QUALITY_DPI,
format: ImageFormat::Png,
..Default::default()
}
}
pub fn fast() -> Self {
Self {
dpi: FAST_DPI,
format: ImageFormat::Jpeg { quality: 80 },
..Default::default()
}
}
pub fn grayscale() -> Self {
Self {
colorspace: ColorSpace::Grayscale,
..Default::default()
}
}
}
#[derive(Debug, Default)]
pub struct ExtractOptionsBuilder {
options: ExtractOptions,
}
impl ExtractOptionsBuilder {
#[must_use]
pub fn dpi(mut self, dpi: u32) -> Self {
self.options.dpi = dpi.clamp(MIN_DPI, MAX_DPI);
self
}
#[must_use]
pub fn format(mut self, format: ImageFormat) -> Self {
self.options.format = format;
self
}
#[must_use]
pub fn colorspace(mut self, colorspace: ColorSpace) -> Self {
self.options.colorspace = colorspace;
self
}
#[must_use]
pub fn background(mut self, rgb: [u8; 3]) -> Self {
self.options.background = Some(rgb);
self
}
#[must_use]
pub fn no_background(mut self) -> Self {
self.options.background = None;
self
}
#[must_use]
pub fn parallel(mut self, workers: usize) -> Self {
self.options.parallel = workers.max(1);
self
}
#[must_use]
pub fn progress_callback(mut self, callback: Box<dyn Fn(usize, usize) + Send + Sync>) -> Self {
self.options.progress_callback = Some(callback);
self
}
#[must_use]
pub fn build(self) -> ExtractOptions {
self.options
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum ImageFormat {
#[default]
Png,
Jpeg {
quality: u8,
},
Bmp,
Tiff,
}
impl ImageFormat {
pub fn extension(&self) -> &str {
match self {
ImageFormat::Png => "png",
ImageFormat::Jpeg { .. } => "jpg",
ImageFormat::Bmp => "bmp",
ImageFormat::Tiff => "tiff",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorSpace {
#[default]
Rgb,
Grayscale,
Cmyk,
}
#[derive(Debug)]
pub struct ExtractedPage {
pub page_index: usize,
pub path: PathBuf,
pub width: u32,
pub height: u32,
pub format: ImageFormat,
}
pub trait ImageExtractor {
fn extract_all(
pdf_path: &Path,
output_dir: &Path,
options: &ExtractOptions,
) -> Result<Vec<ExtractedPage>>;
fn extract_page(
pdf_path: &Path,
page_index: usize,
output_path: &Path,
options: &ExtractOptions,
) -> Result<ExtractedPage>;
}
pub struct MagickExtractor;
impl MagickExtractor {
pub fn extract_page(
pdf_path: &Path,
page_index: usize,
output_path: &Path,
options: &ExtractOptions,
) -> Result<ExtractedPage> {
if !pdf_path.exists() {
return Err(ExtractError::PdfNotFound(pdf_path.to_path_buf()));
}
if let Some(parent) = output_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
let test_file = parent.join(".write_test");
if std::fs::write(&test_file, b"test").is_err() {
return Err(ExtractError::OutputNotWritable(parent.to_path_buf()));
}
let _ = std::fs::remove_file(test_file);
}
let mut cmd = Command::new("magick");
cmd.arg("-density").arg(options.dpi.to_string());
if let Some(bg) = options.background {
cmd.arg("-background")
.arg(format!("rgb({},{},{})", bg[0], bg[1], bg[2]));
cmd.arg("-alpha").arg("remove");
cmd.arg("-alpha").arg("off");
}
match options.colorspace {
ColorSpace::Grayscale => {
cmd.arg("-colorspace").arg("gray");
}
ColorSpace::Cmyk => {
cmd.arg("-colorspace").arg("CMYK");
}
ColorSpace::Rgb => {
cmd.arg("-colorspace").arg("sRGB");
}
}
cmd.arg(format!("{}[{}]", pdf_path.display(), page_index));
if let ImageFormat::Jpeg { quality } = options.format {
cmd.arg("-quality").arg(quality.to_string());
}
cmd.arg(output_path);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ExtractError::ExternalToolError(stderr.to_string()));
}
let img = image::open(output_path).map_err(|e| ExtractError::ExtractionFailed {
page: page_index,
reason: e.to_string(),
})?;
Ok(ExtractedPage {
page_index,
path: output_path.to_path_buf(),
width: img.width(),
height: img.height(),
format: options.format,
})
}
pub fn extract_all(
pdf_path: &Path,
output_dir: &Path,
options: &ExtractOptions,
) -> Result<Vec<ExtractedPage>> {
if !pdf_path.exists() {
return Err(ExtractError::PdfNotFound(pdf_path.to_path_buf()));
}
if !output_dir.exists() {
std::fs::create_dir_all(output_dir)?;
}
let test_file = output_dir.join(".write_test");
if std::fs::write(&test_file, b"test").is_err() {
return Err(ExtractError::OutputNotWritable(output_dir.to_path_buf()));
}
let _ = std::fs::remove_file(test_file);
let page_count = Self::get_page_count(pdf_path)?;
let extension = options.format.extension();
let mut results = Vec::with_capacity(page_count);
for i in 0..page_count {
let output_path = output_dir.join(format!("page_{:05}.{}", i, extension));
let result = Self::extract_page(pdf_path, i, &output_path, options)?;
results.push(result);
if let Some(ref callback) = options.progress_callback {
callback(i + 1, page_count);
}
}
Ok(results)
}
fn get_page_count(pdf_path: &Path) -> Result<usize> {
if let Ok(output) = Command::new("pdfinfo").arg(pdf_path).output() {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with("Pages:") {
if let Some(count_str) = line.split(':').nth(1) {
if let Ok(count) = count_str.trim().parse() {
return Ok(count);
}
}
}
}
}
}
let output = Command::new("magick")
.args(["identify", "-format", "%n\n"])
.arg(pdf_path)
.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next() {
if let Ok(count) = line.trim().parse() {
return Ok(count);
}
}
}
let doc = lopdf::Document::load(pdf_path).map_err(|e| ExtractError::ExtractionFailed {
page: 0,
reason: e.to_string(),
})?;
Ok(doc.get_pages().len())
}
}
pub struct LopdfExtractor;
impl LopdfExtractor {
pub fn extract_all(
pdf_path: &Path,
output_dir: &Path,
_options: &ExtractOptions,
) -> Result<Vec<ExtractedPage>> {
if !pdf_path.exists() {
return Err(ExtractError::PdfNotFound(pdf_path.to_path_buf()));
}
if !output_dir.exists() {
std::fs::create_dir_all(output_dir)?;
}
let doc = lopdf::Document::load(pdf_path).map_err(|e| ExtractError::ExtractionFailed {
page: 0,
reason: format!("Failed to load PDF: {}", e),
})?;
let mut results = Vec::new();
let mut image_count = 0;
for (obj_id, object) in doc.objects.iter() {
if let Ok(stream) = object.as_stream() {
if let Ok(subtype) = stream.dict.get(b"Subtype") {
if let Ok(subtype_name) = subtype.as_name_str() {
if subtype_name == "Image" {
if let Ok(extracted) =
Self::extract_image_stream(stream, image_count, obj_id, output_dir)
{
results.push(extracted);
image_count += 1;
}
}
}
}
}
}
if results.is_empty() {
return Err(ExtractError::ExtractionFailed {
page: 0,
reason: "No extractable images found. This PDF may require ImageMagick (install with: sudo apt install imagemagick).".to_string(),
});
}
results.sort_by_key(|r| r.page_index);
Ok(results)
}
fn extract_image_stream(
stream: &lopdf::Stream,
index: usize,
obj_id: &lopdf::ObjectId,
output_dir: &Path,
) -> Result<ExtractedPage> {
let width = stream
.dict
.get(b"Width")
.ok()
.and_then(|w| w.as_i64().ok())
.unwrap_or(0) as u32;
let height = stream
.dict
.get(b"Height")
.ok()
.and_then(|h| h.as_i64().ok())
.unwrap_or(0) as u32;
let filter = stream
.dict
.get(b"Filter")
.ok()
.and_then(|f| f.as_name_str().ok())
.unwrap_or("");
if filter == "DCTDecode" {
let output_path = output_dir.join(format!(
"page_{:04}_obj{}_{}.jpg",
index, obj_id.0, obj_id.1
));
std::fs::write(&output_path, &stream.content)?;
return Ok(ExtractedPage {
page_index: index,
path: output_path,
width,
height,
format: ImageFormat::Jpeg { quality: 95 },
});
}
if let Ok(decoded) = stream.decompressed_content() {
if width > 0 && height > 0 {
let channels = stream
.dict
.get(b"ColorSpace")
.ok()
.and_then(|cs| cs.as_name_str().ok())
.map(|name| match name {
"DeviceGray" | "CalGray" => 1,
"DeviceRGB" | "CalRGB" => 3,
"DeviceCMYK" => 4,
_ => 3,
})
.unwrap_or(3);
let expected_size = (width as usize) * (height as usize) * channels;
if decoded.len() >= expected_size {
let output_path = output_dir.join(format!(
"page_{:04}_obj{}_{}.png",
index, obj_id.0, obj_id.1
));
let img_opt = match channels {
1 => image::GrayImage::from_raw(
width,
height,
decoded[..expected_size].to_vec(),
)
.map(image::DynamicImage::ImageLuma8),
3 => image::RgbImage::from_raw(
width,
height,
decoded[..expected_size].to_vec(),
)
.map(image::DynamicImage::ImageRgb8),
4 => {
let rgb: Vec<u8> = decoded[..expected_size]
.chunks_exact(4)
.flat_map(|cmyk| {
let (c, m, y, k) = (
cmyk[0] as f32 / 255.0,
cmyk[1] as f32 / 255.0,
cmyk[2] as f32 / 255.0,
cmyk[3] as f32 / 255.0,
);
[
((1.0 - c) * (1.0 - k) * 255.0) as u8,
((1.0 - m) * (1.0 - k) * 255.0) as u8,
((1.0 - y) * (1.0 - k) * 255.0) as u8,
]
})
.collect();
image::RgbImage::from_raw(width, height, rgb)
.map(image::DynamicImage::ImageRgb8)
}
_ => None,
};
if let Some(img) = img_opt {
img.save(&output_path)
.map_err(|e| ExtractError::ExtractionFailed {
page: index,
reason: format!("Failed to save: {}", e),
})?;
return Ok(ExtractedPage {
page_index: index,
path: output_path,
width,
height,
format: ImageFormat::Png,
});
}
}
}
}
Err(ExtractError::ExtractionFailed {
page: index,
reason: format!("Unsupported image filter: {}", filter),
})
}
pub fn magick_available() -> bool {
which::which("magick").is_ok() || which::which("convert").is_ok()
}
pub fn pdftoppm_available() -> bool {
which::which("pdftoppm").is_ok()
}
pub fn extract_auto(
pdf_path: &Path,
output_dir: &Path,
options: &ExtractOptions,
) -> Result<Vec<ExtractedPage>> {
if Self::magick_available() {
return MagickExtractor::extract_all(pdf_path, output_dir, options);
}
if Self::pdftoppm_available() {
return PopplerExtractor::extract_all(pdf_path, output_dir, options);
}
Self::extract_all(pdf_path, output_dir, options)
}
}
pub struct PopplerExtractor;
impl PopplerExtractor {
pub fn extract_page(
pdf_path: &Path,
page_index: usize,
output_path: &Path,
options: &ExtractOptions,
) -> Result<ExtractedPage> {
if !pdf_path.exists() {
return Err(ExtractError::PdfNotFound(pdf_path.to_path_buf()));
}
if let Some(parent) = output_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
let page_num = page_index + 1;
let output_stem = output_path.with_extension("");
let output_stem_str = output_stem.to_string_lossy();
let mut cmd = Command::new("pdftoppm");
cmd.arg("-r").arg(options.dpi.to_string()); cmd.arg("-f").arg(page_num.to_string()); cmd.arg("-l").arg(page_num.to_string()); cmd.arg("-singlefile");
match options.format {
ImageFormat::Png | ImageFormat::Bmp => {
cmd.arg("-png");
}
ImageFormat::Jpeg { quality } => {
cmd.arg("-jpeg");
cmd.arg("-jpegopt")
.arg(format!("quality={}", quality));
}
ImageFormat::Tiff => {
cmd.arg("-tiff");
}
}
match options.colorspace {
ColorSpace::Grayscale => {
cmd.arg("-gray");
}
ColorSpace::Rgb | ColorSpace::Cmyk => {
}
}
cmd.arg(pdf_path);
cmd.arg(&*output_stem_str);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ExtractError::ExternalToolError(format!(
"pdftoppm failed: {}",
stderr
)));
}
let actual_output = match options.format {
ImageFormat::Png | ImageFormat::Bmp => output_stem.with_extension("png"),
ImageFormat::Jpeg { .. } => output_stem.with_extension("jpg"),
ImageFormat::Tiff => output_stem.with_extension("tif"),
};
if actual_output != output_path {
std::fs::rename(&actual_output, output_path)?;
}
let img = image::open(output_path).map_err(|e| ExtractError::ExtractionFailed {
page: page_index,
reason: e.to_string(),
})?;
Ok(ExtractedPage {
page_index,
path: output_path.to_path_buf(),
width: img.width(),
height: img.height(),
format: options.format,
})
}
pub fn extract_all(
pdf_path: &Path,
output_dir: &Path,
options: &ExtractOptions,
) -> Result<Vec<ExtractedPage>> {
if !pdf_path.exists() {
return Err(ExtractError::PdfNotFound(pdf_path.to_path_buf()));
}
if !output_dir.exists() {
std::fs::create_dir_all(output_dir)?;
}
let page_count = Self::get_page_count(pdf_path)?;
let extension = options.format.extension();
let mut results = Vec::with_capacity(page_count);
for i in 0..page_count {
let output_path = output_dir.join(format!("page_{:05}.{}", i, extension));
let result = Self::extract_page(pdf_path, i, &output_path, options)?;
results.push(result);
if let Some(ref callback) = options.progress_callback {
callback(i + 1, page_count);
}
}
Ok(results)
}
fn get_page_count(pdf_path: &Path) -> Result<usize> {
let output = Command::new("pdfinfo").arg(pdf_path).output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with("Pages:") {
if let Some(count_str) = line.split_whitespace().nth(1) {
if let Ok(count) = count_str.parse::<usize>() {
return Ok(count);
}
}
}
}
}
let doc = lopdf::Document::load(pdf_path).map_err(|e| ExtractError::ExtractionFailed {
page: 0,
reason: format!("Failed to load PDF: {}", e),
})?;
Ok(doc.get_pages().len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_nonexistent_pdf_error() {
let temp_dir = tempdir().unwrap();
let result = MagickExtractor::extract_all(
Path::new("/nonexistent/file.pdf"),
temp_dir.path(),
&ExtractOptions::default(),
);
assert!(matches!(result, Err(ExtractError::PdfNotFound(_))));
}
#[test]
fn test_default_options() {
let opts = ExtractOptions::default();
assert_eq!(opts.dpi, 300);
assert!(matches!(opts.format, ImageFormat::Png));
assert!(matches!(opts.colorspace, ColorSpace::Rgb));
assert_eq!(opts.background, Some([255, 255, 255]));
assert!(opts.parallel > 0);
}
#[test]
fn test_image_format_extension() {
assert_eq!(ImageFormat::Png.extension(), "png");
assert_eq!(ImageFormat::Jpeg { quality: 90 }.extension(), "jpg");
assert_eq!(ImageFormat::Bmp.extension(), "bmp");
assert_eq!(ImageFormat::Tiff.extension(), "tiff");
}
#[test]
fn test_builder_pattern() {
let options = ExtractOptions::builder()
.dpi(600)
.format(ImageFormat::Jpeg { quality: 95 })
.colorspace(ColorSpace::Grayscale)
.background([0, 0, 0])
.parallel(4)
.build();
assert_eq!(options.dpi, 600);
assert!(matches!(options.format, ImageFormat::Jpeg { quality: 95 }));
assert!(matches!(options.colorspace, ColorSpace::Grayscale));
assert_eq!(options.background, Some([0, 0, 0]));
assert_eq!(options.parallel, 4);
}
#[test]
fn test_builder_dpi_clamping() {
let options = ExtractOptions::builder().dpi(50).build();
assert_eq!(options.dpi, 72);
let options = ExtractOptions::builder().dpi(2000).build();
assert_eq!(options.dpi, 1200);
let options = ExtractOptions::builder().dpi(300).build();
assert_eq!(options.dpi, 300);
}
#[test]
fn test_builder_parallel_minimum() {
let options = ExtractOptions::builder().parallel(0).build();
assert_eq!(options.parallel, 1);
}
#[test]
fn test_builder_no_background() {
let options = ExtractOptions::builder().no_background().build();
assert!(options.background.is_none());
}
#[test]
fn test_high_quality_preset() {
let options = ExtractOptions::high_quality();
assert_eq!(options.dpi, 600);
assert!(matches!(options.format, ImageFormat::Png));
}
#[test]
fn test_fast_preset() {
let options = ExtractOptions::fast();
assert_eq!(options.dpi, 150);
assert!(matches!(options.format, ImageFormat::Jpeg { quality: 80 }));
}
#[test]
fn test_grayscale_preset() {
let options = ExtractOptions::grayscale();
assert!(matches!(options.colorspace, ColorSpace::Grayscale));
}
#[test]
#[ignore = "requires external tool"]
fn test_extract_single_page() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("page_0.png");
let result = MagickExtractor::extract_page(
Path::new("tests/fixtures/sample.pdf"),
0,
&output,
&ExtractOptions::default(),
)
.unwrap();
assert!(output.exists());
assert_eq!(result.page_index, 0);
assert!(result.width > 0);
assert!(result.height > 0);
}
#[test]
#[ignore = "requires external tool"]
fn test_extract_all_pages() {
let temp_dir = tempdir().unwrap();
let results = MagickExtractor::extract_all(
Path::new("tests/fixtures/10pages.pdf"),
temp_dir.path(),
&ExtractOptions::default(),
)
.unwrap();
assert_eq!(results.len(), 10);
for (i, result) in results.iter().enumerate() {
assert_eq!(result.page_index, i);
assert!(result.path.exists());
}
}
#[test]
#[ignore = "requires external tool"]
fn test_dpi_setting() {
let temp_dir = tempdir().unwrap();
let output_72 = temp_dir.path().join("72dpi.png");
let result_72 = MagickExtractor::extract_page(
Path::new("tests/fixtures/a4.pdf"),
0,
&output_72,
&ExtractOptions {
dpi: 72,
..Default::default()
},
)
.unwrap();
let output_300 = temp_dir.path().join("300dpi.png");
let result_300 = MagickExtractor::extract_page(
Path::new("tests/fixtures/a4.pdf"),
0,
&output_300,
&ExtractOptions {
dpi: 300,
..Default::default()
},
)
.unwrap();
assert!(result_300.width > result_72.width * 3);
assert!(result_300.height > result_72.height * 3);
}
#[test]
#[ignore = "requires external tool"]
fn test_jpeg_output() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("page_0.jpg");
MagickExtractor::extract_page(
Path::new("tests/fixtures/sample.pdf"),
0,
&output,
&ExtractOptions {
format: ImageFormat::Jpeg { quality: 85 },
..Default::default()
},
)
.unwrap();
assert!(output.exists());
let bytes = std::fs::read(&output).unwrap();
assert_eq!(&bytes[0..2], &[0xFF, 0xD8]);
}
#[test]
#[ignore = "requires external tool"]
fn test_grayscale_extraction() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("gray.png");
MagickExtractor::extract_page(
Path::new("tests/fixtures/color.pdf"),
0,
&output,
&ExtractOptions {
colorspace: ColorSpace::Grayscale,
..Default::default()
},
)
.unwrap();
let img = image::open(&output).unwrap();
let rgb = img.to_rgb8();
for pixel in rgb.pixels() {
assert_eq!(pixel[0], pixel[1]);
assert_eq!(pixel[1], pixel[2]);
}
}
#[test]
fn test_extracted_page_construction() {
let page = ExtractedPage {
page_index: 5,
path: PathBuf::from("/test/page_5.png"),
width: 2480,
height: 3508,
format: ImageFormat::Png,
};
assert_eq!(page.page_index, 5);
assert_eq!(page.path, PathBuf::from("/test/page_5.png"));
assert_eq!(page.width, 2480);
assert_eq!(page.height, 3508);
assert!(matches!(page.format, ImageFormat::Png));
}
#[test]
fn test_all_image_formats() {
let formats = [
ImageFormat::Png,
ImageFormat::Jpeg { quality: 90 },
ImageFormat::Bmp,
ImageFormat::Tiff,
];
let expected_ext = ["png", "jpg", "bmp", "tiff"];
for (format, ext) in formats.iter().zip(expected_ext.iter()) {
assert_eq!(format.extension(), *ext);
}
}
#[test]
fn test_all_colorspaces() {
let colorspaces = vec![ColorSpace::Rgb, ColorSpace::Grayscale, ColorSpace::Cmyk];
for cs in colorspaces {
let options = ExtractOptions::builder().colorspace(cs).build();
match (cs, options.colorspace) {
(ColorSpace::Rgb, ColorSpace::Rgb) => {}
(ColorSpace::Grayscale, ColorSpace::Grayscale) => {}
(ColorSpace::Cmyk, ColorSpace::Cmyk) => {}
_ => panic!("Colorspace mismatch"),
}
}
}
#[test]
fn test_error_types() {
let _err1 = ExtractError::PdfNotFound(PathBuf::from("/test/path"));
let _err2 = ExtractError::OutputNotWritable(PathBuf::from("/readonly/dir"));
let _err3 = ExtractError::ExternalToolError("ImageMagick not found".to_string());
let _err4 = ExtractError::ExtractionFailed {
page: 3,
reason: "Test error".to_string(),
};
let _err5: ExtractError = std::io::Error::new(std::io::ErrorKind::NotFound, "test").into();
}
#[test]
fn test_output_not_writable_error_message() {
let err = ExtractError::OutputNotWritable(PathBuf::from("/readonly/output"));
let msg = err.to_string();
assert!(msg.contains("/readonly/output"));
assert!(msg.contains("not writable"));
}
#[test]
fn test_extraction_failed_error_message() {
let err = ExtractError::ExtractionFailed {
page: 5,
reason: "ImageMagick crashed".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("5"));
assert!(msg.contains("ImageMagick"));
}
#[test]
fn test_format_builder() {
let options_low = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: 50 })
.build();
let options_high = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: 95 })
.build();
match (options_low.format, options_high.format) {
(ImageFormat::Jpeg { quality: q1 }, ImageFormat::Jpeg { quality: q2 }) => {
assert_eq!(q1, 50);
assert_eq!(q2, 95);
}
_ => panic!("Expected JPEG format"),
}
}
#[test]
fn test_background_color_setting() {
let options = ExtractOptions::builder()
.background([255, 255, 255])
.build();
assert_eq!(options.background, Some([255, 255, 255]));
let options = ExtractOptions::builder().background([0, 0, 0]).build();
assert_eq!(options.background, Some([0, 0, 0]));
let options = ExtractOptions::builder().no_background().build();
assert!(options.background.is_none());
}
#[test]
fn test_parallel_extraction_config() {
let options_single = ExtractOptions::builder().parallel(1).build();
assert_eq!(options_single.parallel, 1);
let options_multi = ExtractOptions::builder().parallel(4).build();
assert_eq!(options_multi.parallel, 4);
let options_max = ExtractOptions::builder().parallel(16).build();
assert_eq!(options_max.parallel, 16);
let options_zero = ExtractOptions::builder().parallel(0).build();
assert_eq!(options_zero.parallel, 1);
}
#[test]
fn test_progress_callback_structure() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let progress_count = Arc::new(AtomicUsize::new(0));
let progress_clone = progress_count.clone();
let options = ExtractOptions::builder()
.progress_callback(Box::new(move |current, total| {
progress_clone.fetch_add(1, Ordering::SeqCst);
assert!(current <= total);
}))
.build();
assert!(options.progress_callback.is_some());
if let Some(callback) = &options.progress_callback {
callback(1, 10);
callback(5, 10);
callback(10, 10);
}
assert_eq!(progress_count.load(Ordering::SeqCst), 3);
}
#[test]
fn test_output_not_writable_error_display() {
let err = ExtractError::OutputNotWritable(std::path::PathBuf::from("/root/protected"));
let display = format!("{}", err);
assert!(display.contains("/root/protected"));
assert!(display.contains("not writable") || display.contains("writable"));
}
#[test]
fn test_extracted_page_display() {
let page = ExtractedPage {
page_index: 0,
path: std::path::PathBuf::from("/tmp/page_001.png"),
width: 2480,
height: 3508,
format: ImageFormat::Png,
};
assert_eq!(page.page_index, 0);
assert_eq!(page.width, 2480);
assert_eq!(page.height, 3508);
assert!(page.path.to_string_lossy().contains("page_001"));
}
#[test]
fn test_colorspace_all_variants() {
let colorspaces = [ColorSpace::Rgb, ColorSpace::Grayscale, ColorSpace::Cmyk];
for cs in &colorspaces {
let options = ExtractOptions::builder().colorspace(*cs).build();
assert_eq!(options.colorspace, *cs);
}
}
#[test]
fn test_image_format_all_variants() {
let formats = [
ImageFormat::Png,
ImageFormat::Jpeg { quality: 85 },
ImageFormat::Bmp,
ImageFormat::Tiff,
];
for fmt in &formats {
let options = ExtractOptions::builder().format(*fmt).build();
match (&options.format, fmt) {
(ImageFormat::Png, ImageFormat::Png) => {}
(ImageFormat::Tiff, ImageFormat::Tiff) => {}
(ImageFormat::Bmp, ImageFormat::Bmp) => {}
(ImageFormat::Jpeg { quality: q1 }, ImageFormat::Jpeg { quality: q2 }) => {
assert_eq!(q1, q2);
}
_ => panic!("Format mismatch"),
}
}
}
#[test]
fn test_dpi_boundary_values() {
let options_below = ExtractOptions::builder().dpi(50).build();
assert_eq!(options_below.dpi, 72);
let options_min = ExtractOptions::builder().dpi(72).build();
assert_eq!(options_min.dpi, 72);
let options_normal = ExtractOptions::builder().dpi(300).build();
assert_eq!(options_normal.dpi, 300);
let options_max = ExtractOptions::builder().dpi(1200).build();
assert_eq!(options_max.dpi, 1200);
let options_above = ExtractOptions::builder().dpi(2400).build();
assert_eq!(options_above.dpi, 1200);
}
#[test]
fn test_jpeg_quality_edge_cases() {
let opts_min = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: 0 })
.build();
if let ImageFormat::Jpeg { quality } = opts_min.format {
assert_eq!(quality, 0);
} else {
panic!("Expected JPEG format");
}
let opts_max = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: 100 })
.build();
if let ImageFormat::Jpeg { quality } = opts_max.format {
assert_eq!(quality, 100);
} else {
panic!("Expected JPEG format");
}
for q in [1, 50, 75, 85, 99] {
let opts = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: q })
.build();
if let ImageFormat::Jpeg { quality } = opts.format {
assert_eq!(quality, q);
}
}
}
#[test]
fn test_builder_method_chaining() {
let options = ExtractOptions::builder()
.dpi(400)
.format(ImageFormat::Tiff)
.colorspace(ColorSpace::Cmyk)
.background([128, 128, 128])
.parallel(8)
.build();
assert_eq!(options.dpi, 400);
assert!(matches!(options.format, ImageFormat::Tiff));
assert_eq!(options.colorspace, ColorSpace::Cmyk);
assert_eq!(options.background, Some([128, 128, 128]));
assert_eq!(options.parallel, 8);
}
#[test]
fn test_extracted_page_various_sizes() {
let a4_page = ExtractedPage {
page_index: 0,
path: PathBuf::from("/tmp/a4.png"),
width: 2480,
height: 3508,
format: ImageFormat::Png,
};
assert_eq!(a4_page.width, 2480);
assert_eq!(a4_page.height, 3508);
let letter_page = ExtractedPage {
page_index: 1,
path: PathBuf::from("/tmp/letter.png"),
width: 2550,
height: 3300,
format: ImageFormat::Png,
};
assert_eq!(letter_page.width, 2550);
assert_eq!(letter_page.height, 3300);
let thumb = ExtractedPage {
page_index: 99,
path: PathBuf::from("/tmp/thumb.jpg"),
width: 150,
height: 150,
format: ImageFormat::Jpeg { quality: 70 },
};
assert_eq!(thumb.width, thumb.height);
}
#[test]
fn test_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let extract_err: ExtractError = io_err.into();
let msg = extract_err.to_string();
assert!(msg.contains("file not found") || msg.contains("IO error"));
}
#[test]
fn test_preset_high_quality_details() {
let hq = ExtractOptions::high_quality();
assert_eq!(hq.dpi, 600);
assert!(matches!(hq.format, ImageFormat::Png));
assert_eq!(hq.colorspace, ColorSpace::Rgb);
assert!(hq.background.is_some());
}
#[test]
fn test_preset_fast_details() {
let fast = ExtractOptions::fast();
assert_eq!(fast.dpi, 150);
if let ImageFormat::Jpeg { quality } = fast.format {
assert_eq!(quality, 80);
} else {
panic!("Fast preset should use JPEG");
}
}
#[test]
fn test_preset_grayscale_details() {
let gray = ExtractOptions::grayscale();
assert_eq!(gray.colorspace, ColorSpace::Grayscale);
assert_eq!(gray.dpi, 300);
}
#[test]
fn test_background_color_extremes() {
let black = ExtractOptions::builder().background([0, 0, 0]).build();
assert_eq!(black.background, Some([0, 0, 0]));
let white = ExtractOptions::builder()
.background([255, 255, 255])
.build();
assert_eq!(white.background, Some([255, 255, 255]));
let gray = ExtractOptions::builder()
.background([128, 128, 128])
.build();
assert_eq!(gray.background, Some([128, 128, 128]));
let red = ExtractOptions::builder().background([255, 0, 0]).build();
assert_eq!(red.background, Some([255, 0, 0]));
let green = ExtractOptions::builder().background([0, 255, 0]).build();
assert_eq!(green.background, Some([0, 255, 0]));
let blue = ExtractOptions::builder().background([0, 0, 255]).build();
assert_eq!(blue.background, Some([0, 0, 255]));
}
#[test]
fn test_extract_options_debug_impl() {
let options = ExtractOptions::builder()
.dpi(300)
.format(ImageFormat::Png)
.build();
let debug_str = format!("{:?}", options);
assert!(debug_str.contains("ExtractOptions"));
assert!(debug_str.contains("dpi"));
assert!(debug_str.contains("300"));
}
#[test]
fn test_extract_options_with_callback_debug() {
let options = ExtractOptions::builder()
.progress_callback(Box::new(|_, _| {}))
.build();
let debug_str = format!("{:?}", options);
assert!(debug_str.contains("<callback>"));
}
#[test]
fn test_image_format_default() {
let format: ImageFormat = Default::default();
assert!(matches!(format, ImageFormat::Png));
}
#[test]
fn test_colorspace_default() {
let cs: ColorSpace = Default::default();
assert_eq!(cs, ColorSpace::Rgb);
}
#[test]
fn test_parallel_workers_various_values() {
for workers in [1, 2, 4, 8, 16, 32, 64] {
let options = ExtractOptions::builder().parallel(workers).build();
assert_eq!(options.parallel, workers);
}
}
#[test]
fn test_extracted_page_with_all_formats() {
let formats = [
(ImageFormat::Png, "png"),
(ImageFormat::Jpeg { quality: 85 }, "jpg"),
(ImageFormat::Bmp, "bmp"),
(ImageFormat::Tiff, "tiff"),
];
for (idx, (format, ext)) in formats.iter().enumerate() {
let page = ExtractedPage {
page_index: idx,
path: PathBuf::from(format!("/tmp/page.{}", ext)),
width: 1000,
height: 1500,
format: *format,
};
assert_eq!(page.page_index, idx);
assert!(page.path.to_string_lossy().ends_with(ext));
}
}
#[test]
fn test_error_display_all_variants() {
let errors = [
ExtractError::PdfNotFound(PathBuf::from("/test.pdf")),
ExtractError::OutputNotWritable(PathBuf::from("/output")),
ExtractError::ExtractionFailed {
page: 1,
reason: "test reason".to_string(),
},
ExtractError::ExternalToolError("tool error".to_string()),
];
for err in &errors {
let display = format!("{}", err);
assert!(!display.is_empty());
}
}
#[test]
fn test_options_builder_default_state() {
let builder = ExtractOptionsBuilder::default();
let options = builder.build();
assert_eq!(options.dpi, 300);
assert!(matches!(options.format, ImageFormat::Png));
assert_eq!(options.colorspace, ColorSpace::Rgb);
}
#[test]
fn test_colorspace_partial_eq() {
assert_eq!(ColorSpace::Rgb, ColorSpace::Rgb);
assert_eq!(ColorSpace::Grayscale, ColorSpace::Grayscale);
assert_eq!(ColorSpace::Cmyk, ColorSpace::Cmyk);
assert_ne!(ColorSpace::Rgb, ColorSpace::Grayscale);
assert_ne!(ColorSpace::Rgb, ColorSpace::Cmyk);
assert_ne!(ColorSpace::Grayscale, ColorSpace::Cmyk);
}
#[test]
fn test_image_format_debug_impl() {
let png = ImageFormat::Png;
let debug_str = format!("{:?}", png);
assert!(debug_str.contains("Png"));
let jpeg = ImageFormat::Jpeg { quality: 85 };
let debug_str = format!("{:?}", jpeg);
assert!(debug_str.contains("Jpeg"));
assert!(debug_str.contains("85"));
let tiff = ImageFormat::Tiff;
let debug_str = format!("{:?}", tiff);
assert!(debug_str.contains("Tiff"));
}
#[test]
fn test_image_format_clone() {
let original = ImageFormat::Jpeg { quality: 92 };
let cloned = original;
if let ImageFormat::Jpeg { quality } = cloned {
assert_eq!(quality, 92);
} else {
panic!("Clone should preserve JPEG format");
}
let original_png = ImageFormat::Png;
let cloned_png = original_png;
assert!(matches!(cloned_png, ImageFormat::Png));
}
#[test]
fn test_image_format_copy() {
let original = ImageFormat::Bmp;
let copied = original; let _still_valid = original; assert!(matches!(copied, ImageFormat::Bmp));
}
#[test]
fn test_colorspace_debug_impl() {
let rgb = ColorSpace::Rgb;
let debug_str = format!("{:?}", rgb);
assert!(debug_str.contains("Rgb"));
let gray = ColorSpace::Grayscale;
let debug_str = format!("{:?}", gray);
assert!(debug_str.contains("Grayscale"));
let cmyk = ColorSpace::Cmyk;
let debug_str = format!("{:?}", cmyk);
assert!(debug_str.contains("Cmyk"));
}
#[test]
fn test_colorspace_clone() {
let original = ColorSpace::Cmyk;
let cloned = original;
assert_eq!(cloned, ColorSpace::Cmyk);
}
#[test]
fn test_colorspace_copy() {
let original = ColorSpace::Grayscale;
let copied = original; let _still_valid = original; assert_eq!(copied, ColorSpace::Grayscale);
}
#[test]
fn test_extracted_page_debug_impl() {
let page = ExtractedPage {
page_index: 3,
path: PathBuf::from("/tmp/test.png"),
width: 1920,
height: 1080,
format: ImageFormat::Png,
};
let debug_str = format!("{:?}", page);
assert!(debug_str.contains("ExtractedPage"));
assert!(debug_str.contains("3"));
assert!(debug_str.contains("1920"));
assert!(debug_str.contains("1080"));
}
#[test]
fn test_error_debug_impl() {
let err = ExtractError::PdfNotFound(PathBuf::from("/test.pdf"));
let debug_str = format!("{:?}", err);
assert!(debug_str.contains("PdfNotFound"));
let err2 = ExtractError::ExtractionFailed {
page: 5,
reason: "test reason".to_string(),
};
let debug_str2 = format!("{:?}", err2);
assert!(debug_str2.contains("ExtractionFailed"));
}
#[test]
fn test_extract_options_builder_debug_impl() {
let builder = ExtractOptionsBuilder::default();
let debug_str = format!("{:?}", builder);
assert!(debug_str.contains("ExtractOptionsBuilder"));
}
#[test]
fn test_error_path_extraction() {
let path = PathBuf::from("/some/pdf/file.pdf");
let err = ExtractError::PdfNotFound(path.clone());
if let ExtractError::PdfNotFound(p) = err {
assert_eq!(p, path);
} else {
panic!("Wrong error variant");
}
}
#[test]
fn test_error_page_extraction() {
let err = ExtractError::ExtractionFailed {
page: 42,
reason: "Out of memory".to_string(),
};
if let ExtractError::ExtractionFailed { page, reason } = err {
assert_eq!(page, 42);
assert!(reason.contains("memory"));
} else {
panic!("Wrong error variant");
}
}
#[test]
fn test_page_index_sequential() {
let pages: Vec<ExtractedPage> = (0..100)
.map(|i| ExtractedPage {
page_index: i,
path: PathBuf::from(format!("/tmp/page_{:05}.png", i)),
width: 1000,
height: 1500,
format: ImageFormat::Png,
})
.collect();
for (i, page) in pages.iter().enumerate() {
assert_eq!(page.page_index, i);
}
}
#[test]
fn test_large_page_dimensions() {
let large_page = ExtractedPage {
page_index: 0,
path: PathBuf::from("/tmp/a0.png"),
width: 19842,
height: 28067,
format: ImageFormat::Png,
};
assert!(large_page.width > 10000);
assert!(large_page.height > 20000);
}
#[test]
fn test_small_page_dimensions() {
let tiny_page = ExtractedPage {
page_index: 0,
path: PathBuf::from("/tmp/tiny.png"),
width: 16,
height: 16,
format: ImageFormat::Png,
};
assert!(tiny_page.width <= 100);
assert!(tiny_page.height <= 100);
}
#[test]
fn test_preset_consistency() {
let high = ExtractOptions::high_quality();
let fast = ExtractOptions::fast();
let gray = ExtractOptions::grayscale();
assert!(high.dpi > fast.dpi);
assert_eq!(gray.colorspace, ColorSpace::Grayscale);
assert!(high.background.is_some() || high.background.is_none()); }
#[test]
fn test_output_path_types() {
let abs_page = ExtractedPage {
page_index: 0,
path: PathBuf::from("/absolute/path/page.png"),
width: 100,
height: 100,
format: ImageFormat::Png,
};
assert!(abs_page.path.is_absolute());
let rel_page = ExtractedPage {
page_index: 0,
path: PathBuf::from("relative/path/page.png"),
width: 100,
height: 100,
format: ImageFormat::Png,
};
assert!(rel_page.path.is_relative());
}
#[test]
fn test_jpeg_quality_boundary() {
let opts_1 = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: 1 })
.build();
if let ImageFormat::Jpeg { quality } = opts_1.format {
assert_eq!(quality, 1);
}
for q in (0..=100).step_by(10) {
let opts = ExtractOptions::builder()
.format(ImageFormat::Jpeg { quality: q })
.build();
if let ImageFormat::Jpeg { quality } = opts.format {
assert_eq!(quality, q);
}
}
}
#[test]
fn test_magick_extractor_marker() {
let _ = std::any::type_name::<MagickExtractor>();
}
#[test]
fn test_error_io_details_preserved() {
let io_err = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"access denied to file",
);
let extract_err: ExtractError = io_err.into();
let msg = extract_err.to_string().to_lowercase();
assert!(msg.contains("io") || msg.contains("error") || msg.contains("access"));
}
#[test]
fn test_all_dpi_presets() {
let dpi_values = [72, 96, 150, 200, 300, 400, 600, 1200];
for dpi in dpi_values {
let opts = ExtractOptions::builder().dpi(dpi).build();
assert_eq!(opts.dpi, dpi);
}
}
#[test]
fn test_background_none_vs_some() {
let with_bg = ExtractOptions::builder()
.background([255, 255, 255])
.build();
assert!(with_bg.background.is_some());
let without_bg = ExtractOptions::builder().no_background().build();
assert!(without_bg.background.is_none());
}
#[test]
fn test_image_extract_types_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ExtractOptions>();
assert_send_sync::<ExtractedPage>();
assert_send_sync::<ImageFormat>();
assert_send_sync::<ColorSpace>();
}
#[test]
fn test_concurrent_options_building() {
use std::thread;
let handles: Vec<_> = (0..8)
.map(|i| {
thread::spawn(move || {
ExtractOptions::builder()
.dpi(150 + (i as u32 * 50))
.colorspace(if i % 2 == 0 {
ColorSpace::Rgb
} else {
ColorSpace::Grayscale
})
.build()
})
})
.collect();
let results: Vec<_> = handles
.into_iter()
.map(|h: std::thread::JoinHandle<ExtractOptions>| h.join().unwrap())
.collect();
assert_eq!(results.len(), 8);
for (i, opt) in results.iter().enumerate() {
assert_eq!(opt.dpi, 150 + (i as u32 * 50));
}
}
#[test]
fn test_parallel_extracted_page_creation() {
use rayon::prelude::*;
let pages: Vec<_> = (0..100)
.into_par_iter()
.map(|i| ExtractedPage {
page_index: i,
path: PathBuf::from(format!("page_{:04}.png", i)),
width: 1000 + i as u32,
height: 1500 + i as u32,
format: ImageFormat::Png,
})
.collect();
assert_eq!(pages.len(), 100);
for (i, page) in pages.iter().enumerate() {
assert_eq!(page.page_index, i);
assert_eq!(page.width, 1000 + i as u32);
}
}
#[test]
fn test_extracted_page_thread_transfer() {
use std::thread;
let page = ExtractedPage {
page_index: 42,
path: PathBuf::from("/tmp/test_page.png"),
width: 2480,
height: 3508,
format: ImageFormat::Jpeg { quality: 95 },
};
let handle = thread::spawn(move || {
assert_eq!(page.page_index, 42);
assert_eq!(page.width, 2480);
page.path.to_string_lossy().to_string()
});
let result = handle.join().unwrap();
assert!(result.contains("test_page"));
}
#[test]
fn test_options_shared_across_threads() {
use std::sync::Arc;
use std::thread;
let options = Arc::new(
ExtractOptions::builder()
.dpi(600)
.format(ImageFormat::Png)
.colorspace(ColorSpace::Rgb)
.build(),
);
let handles: Vec<_> = (0..4)
.map(|_| {
let opts = Arc::clone(&options);
thread::spawn(move || {
assert_eq!(opts.dpi, 600);
opts.dpi
})
})
.collect();
for handle in handles {
let result: u32 = handle.join().unwrap();
assert_eq!(result, 600);
}
}
#[test]
fn test_image_format_thread_safe() {
use std::thread;
let formats = vec![
ImageFormat::Png,
ImageFormat::Jpeg { quality: 90 },
ImageFormat::Tiff,
ImageFormat::Bmp,
];
let handles: Vec<_> = formats
.into_iter()
.map(|format| {
thread::spawn(move || {
let ext = format.extension();
ext.to_string()
})
})
.collect();
let extensions: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(extensions.len(), 4);
assert!(extensions.contains(&"png".to_string()));
assert!(extensions.contains(&"jpg".to_string()));
}
#[test]
fn test_dpi_boundary_minimum() {
let opts = ExtractOptions::builder().dpi(1).build();
assert_eq!(opts.dpi, MIN_DPI);
}
#[test]
fn test_dpi_boundary_maximum() {
let opts = ExtractOptions::builder().dpi(2400).build();
assert_eq!(opts.dpi, MAX_DPI);
}
#[test]
fn test_page_index_zero() {
let page = ExtractedPage {
page_index: 0,
path: PathBuf::from("first.png"),
width: 100,
height: 100,
format: ImageFormat::Png,
};
assert_eq!(page.page_index, 0);
}
#[test]
fn test_page_index_large() {
let page = ExtractedPage {
page_index: 10000,
path: PathBuf::from("page_10000.png"),
width: 100,
height: 100,
format: ImageFormat::Png,
};
assert_eq!(page.page_index, 10000);
}
#[test]
fn test_page_dimensions_zero() {
let page = ExtractedPage {
page_index: 0,
path: PathBuf::from("empty.png"),
width: 0,
height: 0,
format: ImageFormat::Png,
};
assert_eq!(page.width, 0);
assert_eq!(page.height, 0);
}
#[test]
fn test_page_dimensions_large() {
let page = ExtractedPage {
page_index: 0,
path: PathBuf::from("huge.png"),
width: 32768,
height: 32768,
format: ImageFormat::Png,
};
assert_eq!(page.width, 32768);
assert_eq!(page.height, 32768);
}
#[test]
fn test_background_color_black() {
let opts = ExtractOptions::builder().background([0, 0, 0]).build();
assert_eq!(opts.background, Some([0, 0, 0]));
}
#[test]
fn test_background_color_white() {
let opts = ExtractOptions::builder()
.background([255, 255, 255])
.build();
assert_eq!(opts.background, Some([255, 255, 255]));
}
#[test]
fn test_all_color_spaces() {
let spaces = [ColorSpace::Rgb, ColorSpace::Grayscale, ColorSpace::Cmyk];
for space in spaces {
let opts = ExtractOptions::builder().colorspace(space).build();
assert_eq!(opts.colorspace, space);
}
}
}