use crate::error::Error;
use crate::names;
use pdfrum_common::{DiagKind, Diagnostics, Severity};
use pdfrum_filters::Filter;
use pdfrum_object::{Array, Dict, Object, Resolve, Resolved};
pub const MAX_DIMENSION: i64 = 131_071;
const ALLOWED_BPC: [i64; 5] = [1, 2, 4, 8, 16];
#[derive(Debug, Clone, PartialEq)]
pub struct ImageDict {
pub width: u32,
pub height: u32,
pub bpc: u32,
pub components: u32,
pub image_mask: bool,
pub default_decode: bool,
pub decode: Option<Array>,
pub last_filter: Option<Filter>,
pub last_filter_name: Option<pdfrum_object::Name>,
pub params: Dict,
}
impl ImageDict {
pub fn load<R: Resolve>(dict: &Dict, r: &R, diags: &mut Diagnostics) -> Result<Self, Error> {
let width = dict.int(names::WIDTH, r).unwrap_or(0);
let height = dict.int(names::HEIGHT, r).unwrap_or(0);
if !is_valid_dimension(width) || !is_valid_dimension(height) {
diags.record(Severity::Suspicious, DiagKind::ImageBadDimensions, None);
return Err(Error::ImageBadDict {
what: "width or height",
});
}
let (last_filter, last_filter_name, params) = last_filter(dict, r);
let bpc_orig = dict.int(names::BITS_PER_COMPONENT, r).unwrap_or(0);
if !(0..=16).contains(&bpc_orig) {
diags.record(Severity::Suspicious, DiagKind::ImageBadBitDepth, None);
return Err(Error::ImageBadDict {
what: "bits per component",
});
}
let declared_mask = dict.bool(names::IMAGE_MASK).unwrap_or(false);
let has_colorspace = dict.raw(names::COLOR_SPACE).is_some();
let decode = dict.array(names::DECODE, r);
if declared_mask || !has_colorspace {
if !declared_mask && last_filter == Some(Filter::Jpx) {
return Ok(Self {
width: to_u32(width),
height: to_u32(height),
bpc: 0,
components: 0,
image_mask: false,
default_decode: default_decode(decode.as_ref()),
decode,
last_filter,
last_filter_name,
params,
});
}
return Ok(Self {
width: to_u32(width),
height: to_u32(height),
bpc: 1,
components: 1,
image_mask: true,
default_decode: default_decode(decode.as_ref()),
decode,
last_filter,
last_filter_name,
params,
});
}
if last_filter == Some(Filter::Jpx) {
return Ok(Self {
width: to_u32(width),
height: to_u32(height),
bpc: to_u32(bpc_orig),
components: 0,
image_mask: false,
default_decode: default_decode(decode.as_ref()),
decode,
last_filter,
last_filter_name,
params,
});
}
let (bpc, forced_components) = match last_filter {
Some(Filter::CcittFax | Filter::Jbig2) => (1, Some(1)),
Some(Filter::Dct) => (8, None),
_ => (bpc_orig, None),
};
if !ALLOWED_BPC.contains(&bpc) {
diags.record(Severity::Suspicious, DiagKind::ImageBadBitDepth, None);
return Err(Error::ImageBadDict {
what: "bits per component",
});
}
Ok(Self {
width: to_u32(width),
height: to_u32(height),
bpc: to_u32(bpc),
components: forced_components.unwrap_or(0),
image_mask: false,
default_decode: default_decode(decode.as_ref()),
decode,
last_filter,
last_filter_name,
params,
})
}
#[must_use]
pub fn pitch(&self) -> Option<usize> {
let bits = u64::from(self.bpc)
.checked_mul(u64::from(self.components))?
.checked_mul(u64::from(self.width))?;
usize::try_from(bits.checked_add(7)? / 8).ok()
}
#[must_use]
pub fn total_bytes(&self) -> Option<usize> {
let total = u64::try_from(self.pitch()?)
.ok()?
.checked_mul(u64::from(self.height))?;
if total > u64::from(u32::MAX) {
return None;
}
usize::try_from(total).ok()
}
}
#[must_use]
pub fn is_valid_dimension(v: i64) -> bool {
v > 0 && v <= MAX_DIMENSION
}
#[must_use]
pub fn is_allowed_bits_per_component(v: i64) -> bool {
ALLOWED_BPC.contains(&v)
}
fn to_u32(v: i64) -> u32 {
u32::try_from(v).unwrap_or(0)
}
fn default_decode(decode: Option<&Array>) -> bool {
match decode {
None => true,
Some(a) => a.int_at(0) == Some(0),
}
}
fn last_filter<R: Resolve>(
dict: &Dict,
r: &R,
) -> (Option<Filter>, Option<pdfrum_object::Name>, Dict) {
let params_obj = dict.get(names::DECODE_PARMS, r);
let params_obj = params_obj.as_ref().and_then(Resolved::as_direct);
let filter = dict.get(names::FILTER, r);
match filter.as_ref().and_then(Resolved::as_direct) {
Some(Object::Name(n)) => (
Filter::from_name(n),
Some(n.clone()),
match params_obj {
Some(Object::Dict(d)) => d.clone(),
_ => Dict::new(),
},
),
Some(Object::Array(a)) => {
let last = a.len().checked_sub(1);
let name = last
.and_then(|i| a.get(i, r))
.as_ref()
.and_then(Resolved::as_direct)
.and_then(Object::as_name)
.cloned();
let params = last
.and_then(|i| {
params_obj
.and_then(Object::as_array)
.and_then(|p| p.dict_at(i, r))
})
.unwrap_or_default();
(name.as_ref().and_then(Filter::from_name), name, params)
}
_ => (None, None, Dict::new()),
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{ImageDict, MAX_DIMENSION, is_allowed_bits_per_component, is_valid_dimension};
use pdfrum_common::Diagnostics;
use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
fn image(pairs: Vec<(Name, Object)>) -> Result<ImageDict, crate::Error> {
let mut diags = Diagnostics::default();
ImageDict::load(&Dict::from_pairs(pairs), &NoResolve, &mut diags)
}
fn base(bpc: i64) -> Vec<(Name, Object)> {
vec![
(Name::from("Width"), Object::Int(4)),
(Name::from("Height"), Object::Int(4)),
(Name::from("BitsPerComponent"), Object::Int(bpc)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
]
}
#[test]
fn dimensions_are_bounded_at_both_ends() {
assert!(is_valid_dimension(1));
assert!(is_valid_dimension(MAX_DIMENSION));
assert!(!is_valid_dimension(0));
assert!(!is_valid_dimension(-1));
assert!(!is_valid_dimension(MAX_DIMENSION + 1));
let mut pairs = base(8);
pairs[0] = (Name::from("Width"), Object::Int(0));
assert!(image(pairs).is_err());
}
#[test]
fn odd_bit_depths_are_rejected_not_repaired_to_eight() {
for bpc in [3i64, 5, 6, 7, 9, 15] {
let got = image(base(bpc));
assert!(got.is_err(), "bpc {bpc} should be rejected");
}
for bpc in [1i64, 2, 4, 8, 16] {
assert!(is_allowed_bits_per_component(bpc));
assert!(image(base(bpc)).is_ok(), "bpc {bpc} should load");
}
}
#[test]
fn a_bit_depth_outside_zero_to_sixteen_fails_the_gate() {
assert!(image(base(17)).is_err());
assert!(image(base(-1)).is_err());
assert!(image(base(0)).is_err());
}
#[test]
fn ccitt_and_jbig2_force_one_bit_one_component() {
for filter in ["CCITTFaxDecode", "JBIG2Decode"] {
let mut pairs = base(8);
pairs.push((Name::from("Filter"), Object::Name(Name::from(filter))));
let got = image(pairs).expect("should load");
assert_eq!(got.bpc, 1, "{filter}");
assert_eq!(got.components, 1, "{filter}");
}
}
#[test]
fn dct_forces_eight_bits() {
let mut pairs = base(4);
pairs.push((Name::from("Filter"), Object::Name(Name::from("DCTDecode"))));
assert_eq!(image(pairs).expect("should load").bpc, 8);
}
#[test]
fn run_length_is_deliberately_not_coerced() {
let mut pairs = base(3);
pairs.push((
Name::from("Filter"),
Object::Name(Name::from("RunLengthDecode")),
));
assert!(image(pairs).is_err());
}
#[test]
fn a_filter_array_may_name_its_last_filter_by_reference() {
struct FilterStore;
impl pdfrum_object::Resolve for FilterStore {
fn fetch(
&self,
r: pdfrum_object::ObjRef,
) -> Result<std::sync::Arc<Object>, pdfrum_object::Error> {
match r.num {
6 => Ok(std::sync::Arc::new(Object::Name(Name::from(
"ASCIIHexDecode",
)))),
7 => Ok(std::sync::Arc::new(Object::Name(Name::from("JPXDecode")))),
_ => Err(pdfrum_object::Error::UnresolvedRef(r)),
}
}
}
let dict = Dict::from_pairs(vec![
(Name::from("Width"), Object::Int(612)),
(Name::from("Height"), Object::Int(792)),
(
Name::from("Filter"),
Object::Array(Array::of([
Object::Ref(pdfrum_object::ObjRef::new(6, 0)),
Object::Name(Name::from("LZWDecode")),
Object::Ref(pdfrum_object::ObjRef::new(7, 0)),
])),
),
]);
let mut diags = Diagnostics::default();
let got = ImageDict::load(&dict, &FilterStore, &mut diags).expect("should load");
assert_eq!(got.last_filter, Some(pdfrum_filters::Filter::Jpx));
assert!(
!got.image_mask,
"a JPX image with no /ColorSpace is not a stencil: the codestream \
carries the space"
);
assert_eq!(got.bpc, 0, "the JPX no-colour-space path leaves it at zero");
}
#[test]
fn jpx_skips_the_bit_depth_check_entirely() {
let mut pairs = base(0);
pairs.push((Name::from("Filter"), Object::Name(Name::from("JPXDecode"))));
let got = image(pairs).expect("a JPX image with bpc 0 should load");
assert_eq!(got.bpc, 0);
assert!(!got.image_mask);
}
#[test]
fn jpx_skips_the_check_but_keeps_the_declared_bit_depth() {
for depth in [1i64, 2, 4, 8, 16] {
let mut pairs = base(depth);
pairs.push((Name::from("Filter"), Object::Name(Name::from("JPXDecode"))));
let got = image(pairs).expect("a JPX image should load at any depth");
assert_eq!(got.bpc, u32::try_from(depth).expect("small"), "bpc {depth}");
}
let mut pairs = base(7);
pairs.push((Name::from("Filter"), Object::Name(Name::from("JPXDecode"))));
assert_eq!(image(pairs).expect("should load").bpc, 7);
}
#[test]
fn a_jpx_image_with_no_colorspace_keeps_a_zero_depth() {
let pairs = vec![
(Name::from("Width"), Object::Int(4)),
(Name::from("Height"), Object::Int(4)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(Name::from("Filter"), Object::Name(Name::from("JPXDecode"))),
];
let got = image(pairs).expect("should load");
assert_eq!(got.bpc, 0);
assert!(!got.image_mask);
}
#[test]
fn an_image_mask_ignores_its_colorspace_and_bit_depth() {
let mut pairs = base(8);
pairs.push((Name::from("ImageMask"), Object::Bool(true)));
let got = image(pairs).expect("should load");
assert!(got.image_mask);
assert_eq!(got.bpc, 1);
assert_eq!(got.components, 1);
}
#[test]
fn a_missing_colorspace_makes_a_mask_unless_the_filter_is_jpx() {
let pairs = vec![
(Name::from("Width"), Object::Int(4)),
(Name::from("Height"), Object::Int(4)),
(Name::from("BitsPerComponent"), Object::Int(8)),
];
assert!(image(pairs).expect("should load").image_mask);
let pairs = vec![
(Name::from("Width"), Object::Int(4)),
(Name::from("Height"), Object::Int(4)),
(Name::from("Filter"), Object::Name(Name::from("JPXDecode"))),
];
assert!(!image(pairs).expect("should load").image_mask);
}
#[test]
fn default_decode_tracks_whether_the_array_states_the_identity() {
let mut pairs = base(1);
pairs.push((Name::from("ImageMask"), Object::Bool(true)));
assert!(image(pairs.clone()).expect("should load").default_decode);
let mut with_identity = pairs.clone();
with_identity.push((
Name::from("Decode"),
Object::Array(Array::of([Object::Int(0), Object::Int(1)])),
));
assert!(image(with_identity).expect("should load").default_decode);
let mut inverted = pairs;
inverted.push((
Name::from("Decode"),
Object::Array(Array::of([Object::Int(1), Object::Int(0)])),
));
assert!(!image(inverted).expect("should load").default_decode);
}
#[test]
fn the_last_filter_of_a_chain_is_the_one_that_counts() {
let mut pairs = base(4);
pairs.push((
Name::from("Filter"),
Object::Array(Array::of([
Object::Name(Name::from("ASCII85Decode")),
Object::Name(Name::from("DCTDecode")),
])),
));
assert_eq!(image(pairs).expect("should load").bpc, 8);
}
#[test]
fn the_whole_image_size_cap_is_just_under_four_gibibytes() {
let d = ImageDict {
width: 131_071,
height: 131_071,
bpc: 8,
components: 4,
image_mask: false,
default_decode: true,
decode: None,
last_filter: None,
last_filter_name: None,
params: Dict::new(),
};
assert!(d.total_bytes().is_none());
let small = ImageDict {
width: 100,
height: 100,
..d
};
assert_eq!(small.total_bytes(), Some(40_000));
assert_eq!(small.pitch(), Some(400));
}
}