use crate::compat::pixmap::{self, Colorspace as CompatColorspace};
use crate::document::PdfDocument;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ImageId(u32);
impl ImageId {
pub fn as_u32(self) -> u32 {
self.0
}
}
impl From<u32> for ImageId {
fn from(id: u32) -> Self {
Self(id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ImageColorSpace {
Gray,
Rgb,
Cmyk,
}
impl ImageColorSpace {
pub fn name(self) -> &'static str {
match self {
Self::Gray => "DeviceGray",
Self::Rgb => "DeviceRGB",
Self::Cmyk => "DeviceCMYK",
}
}
pub fn components(self) -> u8 {
match self {
Self::Gray => 1,
Self::Rgb => 3,
Self::Cmyk => 4,
}
}
}
fn color_space_from_comps(comps: i32) -> ImageColorSpace {
match comps {
1 => ImageColorSpace::Gray,
4 => ImageColorSpace::Cmyk,
_ => ImageColorSpace::Rgb,
}
}
impl From<CompatColorspace> for ImageColorSpace {
fn from(colorspace: CompatColorspace) -> Self {
match colorspace {
CompatColorspace::Gray => Self::Gray,
CompatColorspace::Rgb => Self::Rgb,
CompatColorspace::Cmyk => Self::Cmyk,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ImageInfo {
pub id: ImageId,
pub width: u32,
pub height: u32,
pub bits_per_component: u8,
pub components: u8,
pub color_space: ImageColorSpace,
pub color_space_name: String,
pub filter: String,
pub name: String,
pub has_mask: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ExtractedImage {
pub width: u32,
pub height: u32,
pub bits_per_component: u8,
pub components: u8,
pub color_space: ImageColorSpace,
pub color_space_name: String,
pub encoding: String,
pub data: Vec<u8>,
pub masked: bool,
pub xres: u32,
pub yres: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DecodedImage {
pub width: u32,
pub height: u32,
pub components: u8,
pub color_space: ImageColorSpace,
pub pixels: Vec<u8>,
}
fn clamp_u32(value: i32) -> u32 {
u32::try_from(value).unwrap_or(0)
}
fn clamp_u8(value: i32) -> u8 {
u8::try_from(value).unwrap_or(0)
}
impl PdfDocument {
pub fn page_images(&self, page: usize) -> Result<Vec<ImageInfo>> {
let total = self.engine().page_count();
if page == 0 || page > total {
return Err(Error::PageOutOfRange {
requested: page,
page_count: total,
});
}
let id = pixmap::page_object_id(self.lopdf(), page - 1)?;
let mut out = Vec::new();
for row in pixmap::page_image_facts_in(self.lopdf(), id)? {
let image = row.image;
out.push(ImageInfo {
id: ImageId(clamp_u32(image.xref)),
width: clamp_u32(image.width),
height: clamp_u32(image.height),
bits_per_component: clamp_u8(image.bits),
components: clamp_u8(row.comps),
color_space: color_space_from_comps(row.comps),
color_space_name: image.colorspace_name,
filter: image.filter,
name: image.name,
has_mask: image.mask != 0,
});
}
Ok(out)
}
pub fn extract_image_data(&self, id: ImageId) -> Result<ExtractedImage> {
let xref = i32::try_from(id.as_u32()).map_err(|_| Error::InvalidPdf {
byte_offset: None,
reason: format!("image id {} is not an object number", id.as_u32()),
})?;
let data = pixmap::extract_image_in(self.lopdf(), xref)?;
Ok(ExtractedImage {
width: clamp_u32(data.width),
height: clamp_u32(data.height),
bits_per_component: clamp_u8(data.bits),
components: clamp_u8(data.colorspace),
color_space: color_space_from_comps(data.colorspace),
color_space_name: data.colorspace_name,
encoding: data.ext,
data: data.data,
masked: data.masked,
xres: clamp_u32(data.xres),
yres: clamp_u32(data.yres),
})
}
pub fn decode_image_pixels(&self, id: ImageId) -> Result<DecodedImage> {
let xref = i32::try_from(id.as_u32()).map_err(|_| Error::InvalidPdf {
byte_offset: None,
reason: format!("image id {} is not an object number", id.as_u32()),
})?;
let decoded = pixmap::pixmap_from_image_in(self.lopdf(), xref)?;
let color_space = ImageColorSpace::from(decoded.colorspace());
Ok(DecodedImage {
width: clamp_u32(decoded.width()),
height: clamp_u32(decoded.height()),
components: color_space.components(),
color_space,
pixels: decoded.samples().to_vec(),
})
}
}
#[cfg(test)]
mod tests {
use lopdf::{dictionary, Document, Object, Stream};
use super::*;
const GRAY_SAMPLES: [u8; 4] = [0, 128, 192, 255];
const RGB_SAMPLES: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
fn fixture() -> (PdfDocument, ImageId, ImageId) {
let mut doc = Document::with_version("1.7");
let gray_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => "Image",
"Width" => 2,
"Height" => 2,
"ColorSpace" => "DeviceGray",
"BitsPerComponent" => 8,
},
GRAY_SAMPLES.to_vec(),
)));
let rgb_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => "Image",
"Width" => 2,
"Height" => 2,
"ColorSpace" => "DeviceRGB",
"BitsPerComponent" => 8,
},
RGB_SAMPLES.to_vec(),
)));
let resources_id = doc.add_object(dictionary! {
"XObject" => dictionary! { "G0" => gray_id, "R0" => rgb_id },
});
let content_id = doc.add_object(Stream::new(dictionary! {}, b"q\nQ\n".to_vec()));
let pages_id = doc.new_object_id();
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => content_id,
"Resources" => resources_id,
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1,
}),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let gray = ImageId(gray_id.0);
let rgb = ImageId(rgb_id.0);
let doc = PdfDocument::from_lopdf(doc).expect("fixture opens");
(doc, gray, rgb)
}
#[test]
fn page_images_lists_both_fixture_images() {
let (doc, gray_id, rgb_id) = fixture();
let images = doc.page_images(1).expect("list");
assert_eq!(images.len(), 2);
let gray = images.iter().find(|i| i.name == "G0").expect("grey row");
assert_eq!(gray.id, gray_id);
assert_eq!((gray.width, gray.height), (2, 2));
assert_eq!(gray.bits_per_component, 8);
assert_eq!(gray.components, 1);
assert_eq!(gray.color_space, ImageColorSpace::Gray);
assert!(!gray.has_mask);
let rgb = images.iter().find(|i| i.name == "R0").expect("rgb row");
assert_eq!(rgb.id, rgb_id);
assert_eq!((rgb.width, rgb.height), (2, 2));
assert_eq!(rgb.components, 3);
assert_eq!(rgb.color_space, ImageColorSpace::Rgb);
}
#[test]
fn page_images_rejects_out_of_range_pages() {
let (doc, _, _) = fixture();
for bad in [0, 2] {
match doc.page_images(bad) {
Err(Error::PageOutOfRange {
requested,
page_count,
}) => {
assert_eq!(requested, bad);
assert_eq!(page_count, 1);
}
other => panic!("page {bad} must be out of range, got {other:?}"),
}
}
}
#[test]
fn extract_image_data_returns_png_for_raw_gray() {
let (doc, gray_id, _) = fixture();
let image = doc.extract_image_data(gray_id).expect("extract");
assert_eq!((image.width, image.height), (2, 2));
assert_eq!(image.bits_per_component, 8);
assert_eq!(image.components, 1);
assert_eq!(image.color_space, ImageColorSpace::Gray);
assert_eq!(image.encoding, "png");
assert!(!image.masked);
assert!(image.data.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[test]
fn extract_image_data_rejects_unknown_id() {
let (doc, _, _) = fixture();
assert!(doc.extract_image_data(ImageId::from(9999)).is_err());
}
#[test]
fn decode_image_pixels_returns_raw_samples() {
let (doc, gray_id, rgb_id) = fixture();
let gray = doc.decode_image_pixels(gray_id).expect("decode grey");
assert_eq!((gray.width, gray.height), (2, 2));
assert_eq!(gray.components, 1);
assert_eq!(gray.color_space, ImageColorSpace::Gray);
assert_eq!(gray.pixels, GRAY_SAMPLES);
let rgb = doc.decode_image_pixels(rgb_id).expect("decode rgb");
assert_eq!(rgb.components, 3);
assert_eq!(rgb.color_space, ImageColorSpace::Rgb);
assert_eq!(rgb.pixels, RGB_SAMPLES);
}
#[test]
fn decode_image_pixels_rejects_unknown_id() {
let (doc, _, _) = fixture();
assert!(doc.decode_image_pixels(ImageId::from(9999)).is_err());
}
}