use std::collections::HashSet;
use crate::error::PdfError;
use crate::objects::{Dict, Object, ObjectId};
use crate::reader::document::DocumentReader;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ColorSpace {
DeviceRGB,
DeviceCMYK,
DeviceGray,
Indexed,
Other(String),
}
impl ColorSpace {
fn from_object(obj: &Object) -> Self {
match obj {
Object::Name(n) => match n.as_str() {
"DeviceRGB" | "RGB" => ColorSpace::DeviceRGB,
"DeviceCMYK" | "CMYK" => ColorSpace::DeviceCMYK,
"DeviceGray" | "G" => ColorSpace::DeviceGray,
other => ColorSpace::Other(other.to_owned()),
},
Object::Array(items) => match items.first() {
Some(Object::Name(n)) if n == "Indexed" => ColorSpace::Indexed,
Some(Object::Name(n)) => ColorSpace::Other(n.clone()),
_ => ColorSpace::Other(String::new()),
},
_ => ColorSpace::Other(String::new()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PdfImageXObject {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub color_space: ColorSpace,
pub bits_per_component: u8,
}
impl<'a> DocumentReader<'a> {
pub fn image_xobjects(&mut self) -> Result<Vec<(ObjectId, PdfImageXObject)>, PdfError> {
image_xobjects(self)
}
}
pub fn image_xobjects(
reader: &mut DocumentReader<'_>,
) -> Result<Vec<(ObjectId, PdfImageXObject)>, PdfError> {
let root_id = reader.xref().root()?;
let catalog_obj = reader.resolve(root_id)?;
let Object::Dict(catalog) = catalog_obj else {
return Err(PdfError::other(format!(
"PDF image extraction: /Root must be a dictionary (got {catalog_obj:?})"
)));
};
let pages_ref = catalog
.entries()
.iter()
.find(|(k, _)| k == "Pages")
.map(|(_, v)| v.clone())
.ok_or_else(|| PdfError::other("PDF image extraction: catalog missing /Pages"))?;
let Object::Reference(pages_root_id) = pages_ref else {
return Err(PdfError::other(format!(
"PDF image extraction: catalog /Pages must be a reference (got {pages_ref:?})"
)));
};
let mut leaves = Vec::new();
walk_pages(reader, pages_root_id, &mut leaves)?;
let mut out = Vec::new();
let mut seen: HashSet<ObjectId> = HashSet::new();
for leaf in leaves {
collect_page_xobjects(reader, leaf, &mut out, &mut seen)?;
}
Ok(out)
}
fn walk_pages(
reader: &mut DocumentReader<'_>,
node_id: ObjectId,
out: &mut Vec<ObjectId>,
) -> Result<(), PdfError> {
let node = reader.resolve(node_id)?;
let Object::Dict(d) = node else {
return Err(PdfError::other(format!(
"PDF image extraction: /Pages node {node_id:?} is not a dict"
)));
};
let kind = d
.entries()
.iter()
.find(|(k, _)| k == "Type")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.as_str()),
_ => None,
});
match kind {
Some("Page") => {
out.push(node_id);
Ok(())
}
Some("Pages") => {
let kids = d
.entries()
.iter()
.find(|(k, _)| k == "Kids")
.map(|(_, v)| v.clone())
.ok_or_else(|| {
PdfError::other(format!(
"PDF image extraction: /Pages node {node_id:?} missing /Kids"
))
})?;
let Object::Array(items) = kids else {
return Err(PdfError::other(format!(
"PDF image extraction: /Kids must be an array on {node_id:?}"
)));
};
for item in items {
if let Object::Reference(id) = item {
walk_pages(reader, id, out)?;
}
}
Ok(())
}
_ => Ok(()),
}
}
fn collect_page_xobjects(
reader: &mut DocumentReader<'_>,
page_id: ObjectId,
out: &mut Vec<(ObjectId, PdfImageXObject)>,
seen: &mut HashSet<ObjectId>,
) -> Result<(), PdfError> {
let page_obj = reader.resolve(page_id)?;
let Object::Dict(page_dict) = page_obj else {
return Ok(());
};
let resources = page_dict
.entries()
.iter()
.find(|(k, _)| k == "Resources")
.map(|(_, v)| v.clone());
let resources = match resources {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(()),
};
let Object::Dict(rdict) = resources else {
return Ok(());
};
let xobject_obj = rdict
.entries()
.iter()
.find(|(k, _)| k == "XObject")
.map(|(_, v)| v.clone());
let Some(xobject_obj) = xobject_obj else {
return Ok(());
};
let xobject_obj = match xobject_obj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
let Object::Dict(xobject_dict) = xobject_obj else {
return Ok(());
};
let entries: Vec<(String, ObjectId)> = xobject_dict
.entries()
.iter()
.filter_map(|(name, val)| match val {
Object::Reference(id) => Some((name.clone(), *id)),
_ => None,
})
.collect();
for (_name, id) in entries {
if !seen.insert(id) {
continue;
}
let resolved = reader.resolve(id)?;
if let Some(jpeg) = try_extract_jpeg(reader, &resolved)? {
out.push((id, jpeg));
}
}
Ok(())
}
fn try_extract_jpeg(
reader: &mut DocumentReader<'_>,
obj: &Object,
) -> Result<Option<PdfImageXObject>, PdfError> {
let Object::Stream(s) = obj else {
return Ok(None);
};
let subtype = s.dict.entries().iter().find(|(k, _)| k == "Subtype");
if !matches!(subtype, Some((_, Object::Name(n))) if n == "Image") {
return Ok(None);
}
let filter = s
.dict
.entries()
.iter()
.find(|(k, _)| k == "Filter")
.map(|(_, v)| v);
let chain: Vec<String> = match filter {
Some(Object::Name(n)) => vec![n.clone()],
Some(Object::Array(items)) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
let Object::Name(n) = item else {
return Ok(None);
};
out.push(n.clone());
}
out
}
_ => return Ok(None),
};
let Some(last) = chain.last() else {
return Ok(None);
};
if last != "DCTDecode" {
return Ok(None);
}
let mut payload = s.data.clone();
for filter_name in &chain[..chain.len() - 1] {
payload = match filter_name.as_str() {
"ASCII85Decode" | "A85" => crate::reader::filters::ascii85_decode(&payload)?,
"ASCIIHexDecode" | "AHx" => crate::reader::filters::ascii_hex_decode(&payload)?,
"FlateDecode" | "Fl" => crate::reader::filters::flate_decompress(&payload)?,
"RunLengthDecode" | "RL" => crate::reader::filters::run_length_decode(&payload)?,
"LZWDecode" | "LZW" => crate::reader::filters::lzw_decode(&payload)?,
_ => return Ok(None),
};
}
let width = lookup_int(&s.dict, "Width")
.ok_or_else(|| PdfError::other("PDF image extraction: Image XObject missing /Width"))?;
let height = lookup_int(&s.dict, "Height")
.ok_or_else(|| PdfError::other("PDF image extraction: Image XObject missing /Height"))?;
if width < 0 || height < 0 {
return Err(PdfError::other(format!(
"PDF image extraction: negative /Width or /Height ({width}, {height})"
)));
}
let cs_obj = s
.dict
.entries()
.iter()
.find(|(k, _)| k == "ColorSpace")
.map(|(_, v)| v.clone());
let cs_obj = match cs_obj {
Some(Object::Reference(id)) => Some(reader.resolve(id)?),
other => other,
};
let color_space = match cs_obj {
Some(o) => ColorSpace::from_object(&o),
None => ColorSpace::DeviceRGB,
};
let bpc = lookup_int(&s.dict, "BitsPerComponent").unwrap_or(8);
if !(1..=16).contains(&bpc) {
return Err(PdfError::other(format!(
"PDF image extraction: implausible /BitsPerComponent {bpc}"
)));
}
Ok(Some(PdfImageXObject {
data: payload,
width: width as u32,
height: height as u32,
color_space,
bits_per_component: bpc as u8,
}))
}
fn lookup_int(d: &Dict, key: &str) -> Option<i64> {
d.entries()
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| match v {
Object::Integer(n) => Some(*n),
Object::Real(f) => Some(*f as i64),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_space_from_name_recognises_devicergb() {
assert_eq!(
ColorSpace::from_object(&Object::Name("DeviceRGB".into())),
ColorSpace::DeviceRGB
);
assert_eq!(
ColorSpace::from_object(&Object::Name("DeviceCMYK".into())),
ColorSpace::DeviceCMYK
);
assert_eq!(
ColorSpace::from_object(&Object::Name("DeviceGray".into())),
ColorSpace::DeviceGray
);
}
#[test]
fn color_space_from_indexed_array() {
let cs = Object::Array(vec![
Object::Name("Indexed".into()),
Object::Name("DeviceRGB".into()),
Object::Integer(255),
Object::HexString(b"".to_vec()),
]);
assert_eq!(ColorSpace::from_object(&cs), ColorSpace::Indexed);
}
}