mod bitimage;
mod cache;
mod dct;
mod decode_array;
mod dict;
#[cfg(feature = "jbig2")]
mod jbig2;
#[cfg(feature = "jpeg2000")]
mod jpx;
mod mask;
mod packed;
mod rows;
mod scanline;
pub use bitimage::BitImage;
pub use cache::{ImageCache, MAX_BYTES, RequestedSize};
pub(crate) use dct::decode_dct;
pub(crate) use decode_array::DecodeMap;
pub(crate) use dict::ImageDict;
#[cfg(feature = "jbig2")]
pub use jbig2::decode_jbig2;
#[cfg(feature = "jpeg2000")]
pub(crate) use jpx::SpaceOverride;
#[cfg(feature = "jpeg2000")]
pub use jpx::{JpxImage, decode_jpx};
pub use mask::ImageMask;
pub(crate) use mask::{ColorKey, matte_color};
pub use packed::{Depth, Packed, Unpacked};
pub use rows::{Converted, Palette, Rgb8, Rgba8, Row, Rows, Source};
use crate::color::{ColorSpace, Rgb};
use crate::error::Error;
use crate::function::FunctionCache;
use crate::names;
use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
#[cfg(feature = "ccitt")]
use pdfrum_filters::{CcittParams, decode_ccitt};
use pdfrum_filters::{Filter, decode_chain};
use pdfrum_object::{Dict, Object, Resolve, Stream};
pub const MAX_IMAGE_PIXELS: u64 = 1 << 30;
#[must_use]
pub fn image_area_is_workable(width: u32, height: u32) -> bool {
u64::from(width).saturating_mul(u64::from(height)) <= MAX_IMAGE_PIXELS
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Pixels {
Stencil(BitImage),
Gray8(Box<[u8]>),
Rgb8(Box<[u8]>),
Cmyk8(Box<[u8]>),
Indexed {
indices: Box<[u8]>,
palette: Box<[Rgb]>,
},
}
impl Pixels {
#[must_use]
pub fn components(&self) -> usize {
match self {
Self::Stencil(_) | Self::Gray8(_) | Self::Indexed { .. } => 1,
Self::Rgb8(_) => 3,
Self::Cmyk8(_) => 4,
}
}
#[must_use]
pub fn byte_size(&self) -> usize {
match self {
Self::Stencil(b) => b.bits.len(),
Self::Gray8(d) | Self::Rgb8(d) | Self::Cmyk8(d) => d.len(),
Self::Indexed { indices, palette } => {
indices.len() + palette.len() * std::mem::size_of::<Rgb>()
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Samples {
Packed(Packed),
Whole(Pixels),
}
impl Samples {
#[must_use]
pub fn components(&self) -> usize {
match self {
Self::Packed(p) => p.components(),
Self::Whole(p) => p.components(),
}
}
#[must_use]
pub fn byte_size(&self) -> usize {
match self {
Self::Packed(p) => p.byte_size(),
Self::Whole(p) => p.byte_size(),
}
}
#[must_use]
pub const fn is_stencil(&self) -> bool {
matches!(self, Self::Whole(Pixels::Stencil(_)))
}
#[must_use]
pub fn palette(&self) -> Option<&[Rgb]> {
match self {
Self::Whole(Pixels::Indexed { palette, .. }) => Some(palette),
_ => None,
}
}
#[must_use]
pub fn to_pixels(&self) -> Pixels {
match self {
Self::Whole(p) => p.clone(),
Self::Packed(p) => {
let data = Unpacked::new(p).collect_all();
match p.components() {
1 => Pixels::Gray8(data),
4 => Pixels::Cmyk8(data),
_ => Pixels::Rgb8(data),
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageData {
pub width: u32,
pub height: u32,
pub samples: Samples,
pub mask: Option<ImageMask>,
pub matte: Option<Rgb>,
pub interpolate: bool,
}
impl ImageData {
#[must_use]
pub fn byte_size(&self) -> usize {
self.samples.byte_size()
+ match &self.mask {
Some(ImageMask::Alpha { alpha, .. }) => alpha.len(),
_ => 0,
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "the image ladder genuinely needs the stream, both resource \
dictionaries, the requested size, the resolver, the function \
cache, limits and diagnostics"
)]
#[expect(
clippy::too_many_lines,
reason = "the load ladder reads as one sequence; splitting it would hide \
the order the rungs run in"
)]
pub fn decode_image<R: Resolve>(
stream: &Stream,
form_resources: Option<&Dict>,
page_resources: Option<&Dict>,
size: RequestedSize,
r: &R,
functions: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<ImageData, Error> {
let info = ImageDict::load(&stream.dict, r, diags)?;
if info.image_mask {
return decode_stencil(stream, &info, r, limits, diags);
}
let space = resolve_space(
&stream.dict,
form_resources,
page_resources,
r,
functions,
limits,
diags,
);
let components = info
.components
.max(u32::try_from(space.as_ref().map_or(0, ColorSpace::n_components)).unwrap_or(0));
let info = ImageDict { components, ..info };
let decoded = decode_chain(stream, info.total_bytes().unwrap_or(0), r, limits, diags);
#[cfg(not(feature = "jpeg2000"))]
let _ = size;
let (width, height, samples, jpx_alpha) = match info.last_filter {
#[cfg(feature = "jpeg2000")]
Some(Filter::Jpx) => {
let smask_in_data = stream.dict.int(names::SMASK_IN_DATA, r).unwrap_or(0);
let image = decode_jpx(&decoded.data, space.as_ref(), smask_in_data, size, limits)
.inspect_err(|_| {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
})?;
if image.space_override != SpaceOverride::Keep {
diags.record(Severity::Recovered, DiagKind::JpxColorSpaceOverride, None);
}
let pixels = match (&space, image.components) {
(Some(cs @ ColorSpace::Indexed(indexed)), 1) => {
let indices: Box<[u8]> = if info.bpc >= 8 {
image.data.iter().copied().collect()
} else {
let scale = 8u32.saturating_sub(info.bpc);
image
.data
.iter()
.map(|&v| u8::try_from(u32::from(v) >> scale).unwrap_or(0))
.collect()
};
let palette = (0..=indexed.max_index)
.map(|i| cs.to_rgb(&[f32::from(i)]))
.collect();
Pixels::Indexed { indices, palette }
}
(_, 1) => Pixels::Gray8(image.data.into()),
(_, 4) => Pixels::Cmyk8(image.data.into()),
_ => Pixels::Rgb8(image.data.into()),
};
(
image.width,
image.height,
Samples::Whole(pixels),
image.alpha,
)
}
#[cfg(not(feature = "jpeg2000"))]
Some(Filter::Jpx) => {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
return Err(Error::ImageUndecodable {
what: concat!("this build has no ", "Jpx", " decoder (feature `jpeg2000`)"),
});
}
#[cfg(feature = "jbig2")]
Some(Filter::Jbig2) => {
let globals = info
.params
.stream(names::JBIG2_GLOBALS, r)
.map(|s| decode_chain(&s, 0, r, limits, diags).data);
let bits = decode_jbig2(
globals.as_deref(),
&decoded.data,
info.width,
info.height,
limits,
)
.inspect_err(|_| {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
})?;
if info.image_mask {
(
info.width,
info.height,
Samples::Whole(Pixels::Stencil(bits)),
None,
)
} else {
let mut samples = bits.bits;
for byte in &mut samples {
*byte = !*byte;
}
let samples = unpack(&info, space.as_ref(), &samples, diags)?;
(info.width, info.height, samples, None)
}
}
#[cfg(not(feature = "jbig2"))]
Some(Filter::Jbig2) => {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
return Err(Error::ImageUndecodable {
what: concat!("this build has no ", "Jbig2", " decoder (feature `jbig2`)"),
});
}
Some(Filter::Dct) => {
let image = decode_dct(&decoded.data, (info.width, info.height)).inspect_err(|_| {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
})?;
if image.width != info.width || image.height != info.height {
diags.record(
Severity::Recovered,
DiagKind::ImageDimensionsFromCodec,
None,
);
}
if !dct::component_mismatch_allowed(space.as_ref(), image.components) {
return Err(Error::ImageUndecodable {
what: "JPEG component count disagrees with the colour space",
});
}
let mut data = image.data;
apply_codec_decode(&mut data, space.as_ref(), image.components, &info);
let pixels = match image.components {
1 => Pixels::Gray8(data.into()),
4 => Pixels::Cmyk8(data.into()),
_ => Pixels::Rgb8(data.into()),
};
(image.width, image.height, Samples::Whole(pixels), None)
}
Some(Filter::CcittFax) => {
let samples = ccitt_samples(&info, &decoded.data, r, limits, diags)?;
let samples = unpack(&info, space.as_ref(), &samples, diags)?;
(info.width, info.height, samples, None)
}
_ => {
if decoded.image.is_some() && info.last_filter.is_none() {
return Err(Error::ImageUndecodable {
what: "an unrecognised filter left no decoder",
});
}
let samples = unpack(&info, space.as_ref(), &decoded.data, diags)?;
(info.width, info.height, samples, None)
}
};
let mask = load_mask(
&stream.dict,
&info,
space.as_ref(),
jpx_alpha,
r,
functions,
limits,
diags,
);
let mask = match mask {
Some(ImageMask::ColorKey(key)) => {
resolve_color_key(&key, &info, &decoded.data, width, height)
}
other => other,
};
let matte = matte_color(
stream.dict.array(names::MATTE, r).as_ref(),
space.as_ref(),
usize::try_from(info.components).unwrap_or(0),
);
Ok(ImageData {
width,
height,
samples,
mask,
matte,
interpolate: stream.dict.bool(names::INTERPOLATE).unwrap_or(false),
})
}
fn resolve_color_key(
key: &ColorKey,
info: &ImageDict,
data: &[u8],
width: u32,
height: u32,
) -> Option<ImageMask> {
let components = usize::try_from(info.components).unwrap_or(0);
if components == 0 || info.bpc == 0 || key.ranges.is_empty() {
return None;
}
let pitch = info.pitch()?;
let pixels_per_row = usize::try_from(width).ok()?;
let rows = usize::try_from(height).ok()?;
let mut alpha = vec![255u8; pixels_per_row.checked_mul(rows)?];
let mut samples = vec![0u32; components];
let mut any = false;
for y in 0..rows {
let (line, availability) = scanline::scanline(data, u32::try_from(y).unwrap_or(0), pitch);
if availability == scanline::Availability::Absent {
continue;
}
for x in 0..pixels_per_row {
for (c, slot) in samples.iter_mut().enumerate() {
let bit_pos = (x * components + c) * info.bpc as usize;
*slot = scanline::get_bits(&line, bit_pos, info.bpc);
}
if key.is_transparent(&samples)
&& let Some(a) = alpha.get_mut(y * pixels_per_row + x)
{
*a = 0;
any = true;
}
}
}
any.then(|| ImageMask::Alpha {
width,
height,
alpha: alpha.into(),
stencil: false,
})
}
fn reads_the_stream_directly(info: &ImageDict) -> bool {
!matches!(
info.last_filter,
Some(Filter::Jbig2 | Filter::Jpx | Filter::Dct | Filter::CcittFax)
)
}
fn decode_stencil<R: Resolve>(
stream: &Stream,
info: &ImageDict,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<ImageData, Error> {
let total = info.total_bytes().ok_or(Error::ImageTooLarge)?;
let decoded = decode_chain(stream, total, r, limits, diags);
let row_bytes = info.pitch().ok_or(Error::ImageTooLarge)?;
if info.last_filter == Some(Filter::Jbig2) {
return stencil_from_jbig2(stream, info, &decoded.data, r, limits, diags);
}
let ccitt = if info.last_filter == Some(Filter::CcittFax) {
Some(ccitt_samples(info, &decoded.data, r, limits, diags)?)
} else {
None
};
let samples = ccitt.as_deref().unwrap_or(&decoded.data);
let mut bits = vec![0u8; total];
let mut padded = false;
let raw = reads_the_stream_directly(info);
for y in 0..info.height {
let (mut line, availability) = scanline::scanline(samples, y, row_bytes);
padded |= availability != scanline::Availability::Whole;
if info.default_decode && !(raw && availability == scanline::Availability::Absent) {
scanline::invert_line(&mut line);
}
let start = usize::try_from(y).unwrap_or(0).saturating_mul(row_bytes);
if let Some(dest) = bits.get_mut(start..start + row_bytes) {
dest.copy_from_slice(&line);
}
}
if padded {
diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
}
Ok(ImageData {
width: info.width,
height: info.height,
samples: Samples::Whole(Pixels::Stencil(BitImage {
width: info.width,
height: info.height,
row_bytes,
bits,
})),
mask: None,
matte: None,
interpolate: stream.dict.bool(names::INTERPOLATE).unwrap_or(false),
})
}
#[cfg(feature = "ccitt")]
fn ccitt_samples<R: Resolve>(
info: &ImageDict,
data: &[u8],
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<Vec<u8>, Error> {
let params = CcittParams::from_dict(&info.params, r);
let image =
decode_ccitt(data, params, info.width, info.height, limits, diags).map_err(|_| {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
Error::ImageUndecodable {
what: "CCITT fax data would not decode",
}
})?;
let pitch = info.pitch().ok_or(Error::ImageTooLarge)?;
let total = info.total_bytes().ok_or(Error::ImageTooLarge)?;
if total > limits.max_decoded_stream_len {
return Err(Error::ImageTooLarge);
}
let mut out = vec![0xffu8; total];
for y in 0..info.height {
let src = usize::try_from(y)
.ok()
.and_then(|y| y.checked_mul(image.row_bytes));
let dest = usize::try_from(y).ok().and_then(|y| y.checked_mul(pitch));
let (Some(src), Some(dest)) = (src, dest) else {
continue;
};
let copy = pitch.min(image.row_bytes);
let (Some(from), Some(to)) = (
image.bits.get(src..src.saturating_add(copy)),
out.get_mut(dest..dest.saturating_add(copy)),
) else {
continue;
};
to.copy_from_slice(from);
}
Ok(out)
}
#[cfg(not(feature = "ccitt"))]
fn ccitt_samples<R: Resolve>(
info: &ImageDict,
data: &[u8],
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<Vec<u8>, Error> {
let _ = (info, data, r, limits);
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
Err(Error::ImageUndecodable {
what: "this build has no CCITT fax decoder (feature `ccitt`)",
})
}
#[cfg(feature = "jbig2")]
fn stencil_from_jbig2<R: Resolve>(
stream: &Stream,
info: &ImageDict,
data: &[u8],
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<ImageData, Error> {
let globals = info
.params
.stream(names::JBIG2_GLOBALS, r)
.map(|s| decode_chain(&s, 0, r, limits, diags).data);
let mut image = decode_jbig2(globals.as_deref(), data, info.width, info.height, limits)
.inspect_err(|_| {
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
})?;
if !info.default_decode {
for byte in &mut image.bits {
*byte = !*byte;
}
}
Ok(ImageData {
width: info.width,
height: info.height,
samples: Samples::Whole(Pixels::Stencil(image)),
mask: None,
matte: None,
interpolate: stream.dict.bool(names::INTERPOLATE).unwrap_or(false),
})
}
#[cfg(not(feature = "jbig2"))]
fn stencil_from_jbig2<R: Resolve>(
stream: &Stream,
info: &ImageDict,
data: &[u8],
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Result<ImageData, Error> {
let _ = (stream, info, data, r, limits);
diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
Err(Error::ImageUndecodable {
what: "this build has no JBIG2 decoder (feature `jbig2`)",
})
}
fn resolve_space<R: Resolve>(
dict: &Dict,
form_resources: Option<&Dict>,
page_resources: Option<&Dict>,
r: &R,
functions: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<ColorSpace> {
let cs_obj = dict.raw(names::COLOR_SPACE)?;
form_resources
.and_then(|res| {
crate::color::load_colorspace(cs_obj, Some(res), r, functions, limits, diags)
})
.or_else(|| {
crate::color::load_colorspace(cs_obj, page_resources, r, functions, limits, diags)
})
}
struct SampleLayout {
pitch: usize,
pixels_per_row: usize,
rows: usize,
total_pixels: usize,
max_raw: u32,
}
fn scan_indices(
info: &ImageDict,
data: &[u8],
remap: Option<&DecodeMap>,
layout: &SampleLayout,
diags: &mut Diagnostics,
) -> Box<[u8]> {
let mut indices = vec![0u8; layout.total_pixels];
let mut padded = false;
for y in 0..layout.rows {
let (line, availability) =
scanline::scanline(data, u32::try_from(y).unwrap_or(0), layout.pitch);
padded |= availability != scanline::Availability::Whole;
if availability == scanline::Availability::Absent {
continue;
}
for x in 0..layout.pixels_per_row {
let raw = scanline::get_bits(&line, x * info.bpc as usize, info.bpc);
let index = match remap {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the value to a palette index"
)]
Some(decode) => decode.apply(0, f64_to_f32(raw)).clamp(0.0, 255.0) as u8,
None => u8::try_from(raw.min(255)).unwrap_or(u8::MAX),
};
if let Some(slot) = indices.get_mut(y * layout.pixels_per_row + x) {
*slot = index;
}
}
}
if padded {
diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
}
indices.into()
}
fn tint_palette(
info: &ImageDict,
space: &ColorSpace,
data: &[u8],
decode: &DecodeMap,
layout: &SampleLayout,
diags: &mut Diagnostics,
) -> Pixels {
let indices = scan_indices(info, data, None, layout, diags);
let entries = usize::try_from(layout.max_raw).unwrap_or(255).min(255) + 1;
let palette = (0..entries)
.map(|i| {
#[expect(
clippy::cast_precision_loss,
reason = "an index of at most 255 is exact in f32"
)]
let value = decode.apply(0, i as f32);
space.to_rgb(&[value])
})
.collect();
Pixels::Indexed { indices, palette }
}
fn tint_per_pixel(
space: &ColorSpace,
samples: &[u8],
total_pixels: usize,
) -> Result<Pixels, Error> {
let mut bgr = vec![0u8; total_pixels.checked_mul(3).ok_or(Error::ImageTooLarge)?];
space.translate_image_line(&mut bgr, samples, total_pixels, false);
for px in bgr.as_chunks_mut::<3>().0 {
px.swap(0, 2);
}
Ok(Pixels::Rgb8(bgr.into()))
}
fn unpack(
info: &ImageDict,
space: Option<&ColorSpace>,
data: &[u8],
diags: &mut Diagnostics,
) -> Result<Samples, Error> {
let space = space.ok_or(Error::ImageNoColorSpace)?;
let components = usize::try_from(info.components).unwrap_or(0);
if components == 0 || info.bpc == 0 {
return Err(Error::ImageUndecodable {
what: "zero components or bit depth",
});
}
let pitch = info.pitch().ok_or(Error::ImageTooLarge)?;
let pixels_per_row = usize::try_from(info.width).unwrap_or(0);
let rows = usize::try_from(info.height).unwrap_or(0);
let total_pixels = pixels_per_row
.checked_mul(rows)
.ok_or(Error::ImageTooLarge)?;
let decode = DecodeMap::new(Some(space), components, info.bpc, info.decode.as_ref());
let max_raw = if info.bpc >= 32 {
u32::MAX
} else {
(1u32 << info.bpc) - 1
};
let layout = SampleLayout {
pitch,
pixels_per_row,
rows,
total_pixels,
max_raw,
};
if let ColorSpace::Indexed(indexed) = space {
let indices = scan_indices(info, data, Some(&decode), &layout, diags);
let palette = (0..=indexed.max_index)
.map(|i| space.to_rgb(&[f32::from(i)]))
.collect();
return Ok(Samples::Whole(Pixels::Indexed { indices, palette }));
}
if components == 1 && space.needs_image_conversion() {
return Ok(Samples::Whole(tint_palette(
info, space, data, &decode, &layout, diags,
)));
}
if space.needs_image_conversion() {
let out = widen_whole(info, data, &decode, &layout, diags)?;
return Ok(Samples::Whole(tint_per_pixel(space, &out, total_pixels)?));
}
let depth = Depth::new(info.bpc).ok_or(Error::ImageUndecodable {
what: "a bit depth that is not 1, 2, 4, 8 or 16",
})?;
let packed = Packed::with_map(
data.into(),
depth,
components,
pitch,
info.width,
info.height,
&decode,
);
if packed.truncated() {
diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
}
Ok(Samples::Packed(packed))
}
fn widen_whole(
info: &ImageDict,
data: &[u8],
decode: &DecodeMap,
layout: &SampleLayout,
diags: &mut Diagnostics,
) -> Result<Vec<u8>, Error> {
let components = usize::try_from(info.components).unwrap_or(0);
let mut out = vec![
0u8;
layout
.total_pixels
.checked_mul(components)
.ok_or(Error::ImageTooLarge)?
];
let mut padded = false;
for y in 0..layout.rows {
let (line, availability) =
scanline::scanline(data, u32::try_from(y).unwrap_or(0), layout.pitch);
padded |= availability != scanline::Availability::Whole;
if availability == scanline::Availability::Absent {
continue;
}
for x in 0..layout.pixels_per_row {
for c in 0..components {
let bit_pos = (x * components + c) * info.bpc as usize;
let raw = scanline::get_bits(&line, bit_pos, info.bpc);
let value = decode.apply(c, f64_to_f32(raw));
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the product to 0..=255"
)]
let byte = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
if let Some(slot) = out.get_mut((y * layout.pixels_per_row + x) * components + c) {
*slot = byte;
}
}
}
}
if padded {
diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
}
Ok(out)
}
fn apply_codec_decode(
data: &mut [u8],
space: Option<&ColorSpace>,
components: u8,
info: &ImageDict,
) {
let components = usize::from(components);
if components == 0 || info.default_decode {
return;
}
let Some(space) = space else { return };
if matches!(space, ColorSpace::Indexed(_)) {
return;
}
let decode = DecodeMap::new(Some(space), components, 8, info.decode.as_ref());
if decode.default {
return;
}
let table = decode_table(&decode, components);
for chunk in data.chunks_mut(components) {
for (component, sample) in chunk.iter_mut().enumerate() {
if let Some(row) = table.get(component)
&& let Some(mapped) = row.get(usize::from(*sample))
{
*sample = *mapped;
}
}
}
}
fn decode_table(decode: &DecodeMap, components: usize) -> Vec<[u8; 256]> {
(0..components)
.map(|component| {
let mut row = [0u8; 256];
for (raw, slot) in row.iter_mut().enumerate() {
#[expect(
clippy::cast_precision_loss,
reason = "a table index below 256 is exact in f32"
)]
let value = decode.apply(component, raw as f32);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the product to 0..=255"
)]
let byte = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
*slot = byte;
}
row
})
.collect()
}
#[expect(
clippy::cast_precision_loss,
reason = "raw samples cap at sixteen bits, exact in f32"
)]
fn f64_to_f32(raw: u32) -> f32 {
raw as f32
}
#[expect(
clippy::too_many_arguments,
reason = "loading a mask recursively needs the same context the base image did"
)]
fn load_mask<R: Resolve>(
dict: &Dict,
info: &ImageDict,
space: Option<&ColorSpace>,
jpx_alpha: Option<Vec<u8>>,
r: &R,
functions: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<ImageMask> {
if let Some(alpha) = jpx_alpha {
return Some(ImageMask::Alpha {
width: info.width,
height: info.height,
alpha: alpha.into(),
stencil: false,
});
}
if let Some(smask) = dict.stream(names::SMASK, r) {
return load_mask_image(&smask, false, r, functions, limits, diags);
}
match dict.get(names::MASK, r).as_deref() {
Some(Object::Stream(mask_stream)) => {
load_mask_image(mask_stream, true, r, functions, limits, diags)
}
Some(Object::Array(array)) => {
let components = usize::try_from(info.components).unwrap_or(0);
if !ColorKey::is_complete(array, components) {
diags.record(Severity::Suspicious, DiagKind::ColorKeyArrayShort, None);
}
let max_raw = if info.bpc >= 32 {
u32::MAX
} else {
(1u32 << info.bpc.max(1)) - 1
};
let _ = space;
Some(ImageMask::ColorKey(ColorKey::from_array(
array, components, max_raw,
)))
}
_ => None,
}
}
fn load_mask_image<R: Resolve>(
stream: &Stream,
stencil: bool,
r: &R,
functions: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<ImageMask> {
let decoded = decode_image(
stream,
None,
None,
RequestedSize::Full,
r,
functions,
limits,
diags,
);
let Ok(image) = decoded else {
diags.record(Severity::Recovered, DiagKind::MaskDropped, None);
return None;
};
let Some(alpha) = mask_plane(&image.samples, image.width, image.height) else {
diags.record(Severity::Recovered, DiagKind::MaskDropped, None);
return None;
};
Some(ImageMask::Alpha {
width: image.width,
height: image.height,
alpha,
stencil,
})
}
fn mask_plane(samples: &Samples, width: u32, height: u32) -> Option<Box<[u8]>> {
if !image_area_is_workable(width, height) {
return None;
}
let len = usize::try_from(width)
.ok()?
.checked_mul(usize::try_from(height).ok()?)?;
let mut alpha = Vec::new();
alpha.try_reserve_exact(len).ok()?;
if let Samples::Whole(Pixels::Gray8(data)) = samples {
alpha.extend(data.iter().take(len).copied());
} else {
let palette = match samples {
Samples::Whole(Pixels::Indexed { palette, .. }) => Some(rows::Palette::new(palette)),
_ => None,
};
let mut converted =
rows::Converted::new(rows::Source::new(samples, width, height), palette);
while let Some(row) = rows::Rows::next(&mut converted) {
alpha.extend(row.pixels().iter().map(|px| px.0[0]));
}
}
alpha.resize(len, 0);
Some(alpha.into())
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{ImageData, Pixels, RequestedSize, Samples, decode_image};
use crate::color::Rgb;
use crate::function::FunctionCache;
use crate::image::BitImage;
use pdfrum_common::{DiagKind, Diagnostics, Limits};
use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Stream};
fn stream(pairs: Vec<(Name, Object)>, data: &[u8]) -> Stream {
Stream::new(Dict::from_pairs(pairs), ByteSpan::from(data.to_vec()))
}
fn decode(s: &Stream) -> Result<ImageData, crate::Error> {
let mut funcs = FunctionCache::new();
let mut diags = Diagnostics::default();
decode_image(
s,
None,
None,
RequestedSize::Full,
&NoResolve,
&mut funcs,
&Limits::default(),
&mut diags,
)
}
fn converted_row(samples: &Samples, width: u32, y: u32) -> Vec<[u8; 3]> {
let palette = samples.palette().map(super::rows::Palette::new);
let mut converted =
super::rows::Converted::new(super::rows::Source::at_row(samples, width, y), palette);
super::rows::Rows::next(&mut converted)
.map(|row| {
row.pixels()
.iter()
.map(|px| [px.0[0], px.0[1], px.0[2]])
.collect()
})
.unwrap_or_default()
}
fn sample_at(samples: &Samples, x: u32, y: u32, width: u32) -> [u8; 3] {
converted_row(samples, width, y)
.get(x as usize)
.copied()
.unwrap_or([0, 0, 0])
}
#[test]
fn a_colour_key_becomes_an_alpha_plane_on_the_raw_samples() {
let mut mask = Array::default();
mask.push(Object::Int(0));
mask.push(Object::Int(0));
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(2)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
(Name::from("Mask"), Object::Array(mask)),
],
&[0, 200, 0, 255],
);
let image = decode(&s).expect("should decode");
let Some(crate::image::ImageMask::Alpha { alpha, .. }) = image.mask else {
panic!("expected a resolved alpha plane, got {:?}", image.mask);
};
assert_eq!(&*alpha, &[0u8, 255, 0, 255]);
}
#[test]
fn a_colour_key_that_matches_nothing_leaves_the_image_opaque() {
let mut mask = Array::default();
mask.push(Object::Int(7));
mask.push(Object::Int(9));
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
(Name::from("Mask"), Object::Array(mask)),
],
&[0, 200],
);
assert!(decode(&s).expect("should decode").mask.is_none());
}
#[test]
fn a_row_past_the_end_of_the_stream_skips_the_decode_entirely() {
let mut decode_array = Array::default();
decode_array.push(Object::Real(1.0));
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(2)),
(Name::from("BitsPerComponent"), Object::Int(4)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceRGB")),
),
(Name::from("Decode"), Object::Array(decode_array)),
],
&[0xFF, 0xFF, 0xFF],
);
let image = decode(&s).expect("should decode");
assert_eq!(
image.samples.to_pixels(),
Pixels::Rgb8(Box::from(&[0u8; 12][..])),
"both rows are black; the absent one never reaches `/Decode`"
);
}
#[test]
fn an_eight_bit_grayscale_image_round_trips() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(2)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
],
&[0, 85, 170, 255],
);
let image = decode(&s).expect("should decode");
assert_eq!((image.width, image.height), (2, 2));
assert_eq!(
image.samples.to_pixels(),
Pixels::Gray8(Box::from(&[0u8, 85, 170, 255][..]))
);
assert!(image.mask.is_none());
}
const JBIG2_ALL_BLACK: [u8; 94] = [
0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x13, 0x00, 0x00, 0x0d, 0xea,
0x00, 0x00, 0x03, 0x53, 0x00, 0x00, 0x17, 0x11, 0x00, 0x00, 0x17, 0x11, 0x51, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x26, 0x00, 0x01, 0x00, 0x00, 0x00, 0x35, 0x00, 0x00, 0x0d, 0xea,
0x00, 0x00, 0x03, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03,
0xff, 0xfd, 0xff, 0x02, 0xfe, 0xfe, 0xfe, 0xff, 0x7f, 0x86, 0x53, 0x0f, 0xb6, 0xc9, 0x22,
0xcf, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f,
0xff, 0x7f, 0xff, 0xac,
];
#[test]
fn a_jbig2_image_with_a_colour_space_is_a_picture_and_not_a_stencil() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(400)),
(Name::from("Height"), Object::Int(400)),
(Name::from("BitsPerComponent"), Object::Int(1)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
(
Name::from("Filter"),
Object::Name(Name::from("JBIG2Decode")),
),
],
&JBIG2_ALL_BLACK,
);
let image = decode(&s).expect("should decode");
assert_eq!((image.width, image.height), (400, 400));
let pixels = image.samples.to_pixels();
let Pixels::Gray8(gray) = &pixels else {
panic!("expected grey samples, got {pixels:?}");
};
assert_eq!(gray.len(), 400 * 400);
assert!(
gray.iter().all(|&v| v == 0),
"every sample is black: JBIG2's set bit inverts to sample 0"
);
}
const JBIG2_RIGHT_HALF_BLACK: [u8; 69] = [
0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x13, 0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x26, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x36,
0xcd, 0xb3, 0x6c, 0xdb, 0x36, 0xcd, 0xb3, 0x6c, 0xdb,
];
fn jbig2_stencil(data: &[u8], decode: Option<[i64; 2]>) -> Stream {
let mut pairs = vec![
(Name::from("Width"), Object::Int(8)),
(Name::from("Height"), Object::Int(8)),
(Name::from("ImageMask"), Object::Bool(true)),
(
Name::from("Filter"),
Object::Name(Name::from("JBIG2Decode")),
),
];
if let Some([lo, hi]) = decode {
pairs.push((
Name::from("Decode"),
Object::Array(Array::of([Object::Int(lo), Object::Int(hi)])),
));
}
stream(pairs, data)
}
#[test]
fn a_jbig2_stencil_takes_its_bits_from_the_codestream() {
let image = decode(&jbig2_stencil(&JBIG2_RIGHT_HALF_BLACK, None)).expect("should decode");
let Samples::Whole(Pixels::Stencil(BitImage {
bits, row_bytes, ..
})) = &image.samples
else {
panic!("expected a stencil, got {:?}", image.samples);
};
assert_eq!(*row_bytes, 1);
assert_eq!(
&bits[..],
&[0b0000_1111u8; 8][..],
"the codestream's black half is where the stencil inks"
);
}
#[test]
fn a_jbig2_stencil_with_decode_one_zero_flips_every_bit() {
let image =
decode(&jbig2_stencil(&JBIG2_RIGHT_HALF_BLACK, Some([1, 0]))).expect("should decode");
let Samples::Whole(Pixels::Stencil(BitImage { bits, .. })) = &image.samples else {
panic!("expected a stencil, got {:?}", image.samples);
};
assert_eq!(&bits[..], &[0b1111_0000u8; 8][..]);
}
#[test]
fn a_jbig2_stencil_whose_codestream_will_not_decode_is_refused() {
let mut funcs = FunctionCache::new();
let mut diags = Diagnostics::default();
let got = decode_image(
&jbig2_stencil(b"0", None),
None,
None,
RequestedSize::Full,
&NoResolve,
&mut funcs,
&Limits::default(),
&mut diags,
);
assert!(
got.is_err(),
"an undecodable codestream is fatal, got {got:?}"
);
assert!(
diags.contains(&DiagKind::ImageDecodeFailed),
"the refusal is recorded, not silent: {:?}",
diags.entries()
);
}
#[test]
fn a_stencil_mask_with_the_default_decode_is_inverted() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(8)),
(Name::from("Height"), Object::Int(1)),
(Name::from("ImageMask"), Object::Bool(true)),
],
&[0b1010_1010],
);
let image = decode(&s).expect("should decode");
let Samples::Whole(Pixels::Stencil(BitImage { bits, .. })) = &image.samples else {
panic!("expected a stencil, got {:?}", image.samples);
};
assert_eq!(bits.first(), Some(&0b0101_0101));
}
#[test]
fn a_stencil_mask_with_decode_one_zero_is_copied_verbatim() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(8)),
(Name::from("Height"), Object::Int(1)),
(Name::from("ImageMask"), Object::Bool(true)),
(
Name::from("Decode"),
Object::Array(Array::of([Object::Int(1), Object::Int(0)])),
),
],
&[0b1010_1010],
);
let image = decode(&s).expect("should decode");
let Samples::Whole(Pixels::Stencil(BitImage { bits, .. })) = &image.samples else {
panic!("expected a stencil");
};
assert_eq!(bits.first(), Some(&0b1010_1010));
}
#[test]
fn a_truncated_stream_is_zero_padded_rather_than_rejected() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(2)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
],
&[10, 20],
);
let image = decode(&s).expect("should still decode");
assert_eq!(
image.samples.to_pixels(),
Pixels::Gray8(Box::from(&[10u8, 20, 0, 0][..]))
);
}
#[test]
fn an_indexed_image_keeps_its_indices_and_a_palette() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(4)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(2)),
(
Name::from("ColorSpace"),
Object::Array(Array::of([
Object::Name(Name::from("Indexed")),
Object::Name(Name::from("DeviceGray")),
Object::Int(3),
Object::Str(pdfrum_object::PdfString::literal([0u8, 85, 170, 255])),
])),
),
],
&[0b00_01_10_11],
);
let image = decode(&s).expect("should decode");
let Samples::Whole(Pixels::Indexed { indices, palette }) = &image.samples else {
panic!("expected indexed pixels, got {:?}", image.samples);
};
assert_eq!(&**indices, &[0, 1, 2, 3]);
assert_eq!(palette.len(), 4);
assert!(palette[0].r.abs() < 1e-6);
assert!((palette[3].r - 1.0).abs() < 1e-6);
}
#[test]
fn a_bad_bit_depth_is_an_error_rather_than_a_repair() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(2)),
(Name::from("BitsPerComponent"), Object::Int(3)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
],
&[0; 16],
);
assert!(decode(&s).is_err());
}
#[test]
fn a_colour_key_mask_is_read_from_a_mask_array() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
),
(
Name::from("Mask"),
Object::Array(Array::of([Object::Int(0), Object::Int(10)])),
),
],
&[5, 200],
);
let image = decode(&s).expect("should decode");
let Some(super::ImageMask::Alpha { alpha, .. }) = &image.mask else {
panic!("expected a resolved alpha plane, got {:?}", image.mask);
};
assert_eq!(&**alpha, &[0u8, 255]);
let key =
super::ColorKey::from_array(&Array::of([Object::Int(0), Object::Int(10)]), 1, 255);
assert!(key.is_transparent(&[5]));
assert!(!key.is_transparent(&[200]));
}
#[test]
fn pixel_lookup_is_bounds_checked() {
let pixels = Samples::Whole(Pixels::Rgb8(Box::from(&[255u8, 0, 0, 0, 255, 0][..])));
assert_eq!(sample_at(&pixels, 0, 0, 2), [255, 0, 0]);
assert_eq!(sample_at(&pixels, 99, 99, 2), [0, 0, 0]);
assert_eq!(pixels.components(), 3);
}
fn codec_dict(space: &str, decode: Option<Vec<f32>>) -> super::ImageDict {
let mut pairs = vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(Name::from("ColorSpace"), Object::Name(Name::from(space))),
(Name::from("Filter"), Object::Name(Name::from("DCTDecode"))),
];
if let Some(values) = decode {
pairs.push((
Name::from("Decode"),
Object::Array(values.into_iter().map(Object::Real).collect()),
));
}
let mut diags = Diagnostics::default();
super::ImageDict::load(&Dict::from_pairs(pairs), &NoResolve, &mut diags)
.expect("the fixture dictionary should load")
}
#[test]
fn a_decode_array_reaches_a_codecs_output_too() {
let info = codec_dict(
"DeviceCMYK",
Some(vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0]),
);
assert!(!info.default_decode);
let space = crate::color::ColorSpace::DeviceCmyk;
let mut data = vec![255u8, 0, 0, 253, 0, 255, 255, 2];
super::apply_codec_decode(&mut data, Some(&space), 4, &info);
assert_eq!(data, vec![0u8, 255, 255, 2, 255, 0, 0, 253]);
}
#[test]
fn the_default_decode_leaves_a_codecs_output_untouched() {
let info = codec_dict("DeviceCMYK", None);
assert!(info.default_decode);
let space = crate::color::ColorSpace::DeviceCmyk;
let original = vec![255u8, 0, 0, 253, 1, 2, 3, 4];
let mut data = original.clone();
super::apply_codec_decode(&mut data, Some(&space), 4, &info);
assert_eq!(data, original);
let info = codec_dict(
"DeviceCMYK",
Some(vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]),
);
let mut data = original.clone();
super::apply_codec_decode(&mut data, Some(&space), 4, &info);
assert_eq!(data, original);
}
#[test]
fn the_row_conversion_is_exactly_the_float_path() {
for b in 0..=255u8 {
let there = f32::from(b) / 255.0;
let back = Rgb {
r: there,
g: there,
b: there,
}
.to_bytes();
assert_eq!(back, [b, b, b], "byte {b} does not survive the float trip");
}
let gray = Samples::Whole(Pixels::Gray8((0..=255u8).collect()));
for x in 0..256u32 {
let v = u8::try_from(x).expect("x < 256");
assert_eq!(sample_at(&gray, x, 0, 256), [v, v, v], "gray {x}");
}
let rgb = Samples::Whole(Pixels::Rgb8(
(0..=255u8).flat_map(|v| [v, 255 - v, v / 2]).collect(),
));
for x in 0..256u32 {
let v = u8::try_from(x).expect("x < 256");
assert_eq!(sample_at(&rgb, x, 0, 256), [v, 255 - v, v / 2], "rgb {x}");
}
let mut cmyk = Vec::new();
let step = 17u16; for c in (0..=255u16).step_by(step as usize) {
for m in (0..=255u16).step_by(step as usize) {
for y in (0..=255u16).step_by(step as usize) {
for k in (0..=255u16).step_by(step as usize) {
cmyk.extend_from_slice(&[c as u8, m as u8, y as u8, k as u8]);
}
}
}
}
let count = cmyk.len() / 4;
let raw = cmyk.clone();
let cmyk = Samples::Whole(Pixels::Cmyk8(cmyk.into()));
let row = converted_row(&cmyk, count as u32, 0);
for (x, got) in row.iter().enumerate() {
let at = x * 4;
let want = crate::color::ColorSpace::DeviceCmyk
.to_rgb(&[
f32::from(raw[at]) / 255.0,
f32::from(raw[at + 1]) / 255.0,
f32::from(raw[at + 2]) / 255.0,
f32::from(raw[at + 3]) / 255.0,
])
.to_bytes();
assert_eq!(*got, want, "cmyk lattice point {x}");
}
let palette: Box<[Rgb]> = (0..=255u8)
.map(|v| Rgb {
r: f32::from(v) / 255.0,
g: f32::from(255 - v) / 255.0,
b: 0.25,
})
.collect();
let indexed = Samples::Whole(Pixels::Indexed {
indices: (0..=255u8).collect(),
palette: palette.clone(),
});
for x in 0..256u32 {
let want = palette[x as usize].to_bytes();
assert_eq!(sample_at(&indexed, x, 0, 256), want, "indexed {x}");
}
let bits = BitImage {
width: 2,
height: 1,
row_bytes: 1,
bits: vec![0b1000_0000],
};
let stencil = Samples::Whole(Pixels::Stencil(bits));
assert_eq!(sample_at(&stencil, 0, 0, 2), [0, 0, 0], "a set bit is ink");
assert_eq!(
sample_at(&stencil, 1, 0, 2),
[255, 255, 255],
"a clear bit is paper"
);
for p in [&gray, &rgb, &cmyk, &indexed, &stencil] {
assert_eq!(
sample_at(p, 9999, 9999, 256),
[0, 0, 0],
"an out-of-range read is black"
);
}
}
#[test]
fn the_mask_planes_fast_arms_are_the_general_one() {
let general = |pixels: &Samples, w: u32, h: u32| -> Vec<u8> {
(0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.map(|(x, y)| sample_at(pixels, x, y, w)[0])
.collect()
};
for (w, h, len) in [(4_u32, 3_u32, 12_usize), (4, 3, 7), (4, 3, 20), (1, 1, 1)] {
let data: Box<[u8]> = (0..len).map(|i| (i * 31 % 256) as u8).collect();
let pixels = Samples::Whole(Pixels::Gray8(data));
let got = super::mask_plane(&pixels, w, h).expect("dimensions multiply");
let mut want = general(&pixels, w, h);
want.resize((w * h) as usize, 0);
assert_eq!(&got[..], &want[..], "gray {w}x{h}, {len} bytes");
}
let palette: Box<[Rgb]> = (0..=255u8)
.map(|v| Rgb {
r: f32::from(v) / 255.0,
g: 0.5,
b: 0.25,
})
.collect();
for (w, h, len) in [(8_u32, 4_u32, 32_usize), (8, 4, 10)] {
let pixels = Samples::Whole(Pixels::Indexed {
indices: (0..len).map(|i| (i * 7 % 256) as u8).collect(),
palette: palette.clone(),
});
let got = super::mask_plane(&pixels, w, h).expect("dimensions multiply");
let mut want = general(&pixels, w, h);
want.resize((w * h) as usize, 0);
assert_eq!(&got[..], &want[..], "indexed {w}x{h}, {len} indices");
}
let rgb = Samples::Whole(Pixels::Rgb8((0..24u8).collect()));
let got = super::mask_plane(&rgb, 4, 2).expect("dimensions multiply");
assert_eq!(&got[..], &general(&rgb, 4, 2)[..]);
}
#[test]
fn a_gigapixel_image_is_not_a_workable_area() {
assert!(
super::image_area_is_workable(20_000, 28_000),
"A0 at 600dpi"
);
assert!(!super::image_area_is_workable(65_536, 65_536), "4.3 Gpx");
assert!(!super::image_area_is_workable(131_071, 131_071), "17 Gpx");
assert!(super::image_area_is_workable(2, 2));
}
#[test]
fn a_mask_plane_too_large_to_allocate_is_dropped_rather_than_aborting() {
let pixels = Samples::Whole(Pixels::Gray8(Box::new([0u8; 4])));
assert_eq!(super::mask_plane(&pixels, 131_071, 131_071), None);
assert_eq!(super::mask_plane(&pixels, 65_536, 65_536), None);
assert!(super::mask_plane(&pixels, 2, 2).is_some());
}
#[test]
fn the_decode_table_is_exhaustively_the_per_sample_arithmetic() {
let arrays: [Vec<f32>; 4] = [
vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0],
vec![0.0, 0.5, 0.25, 1.0, 0.1, 0.9, 0.0, 1.0],
vec![-1.0, 2.0, 2.0, -1.0, -0.5, 1.5, 1.5, -0.5],
vec![0.3, 0.3, 0.0, 1.0, 1.0, 0.0, 0.7, 0.2],
];
let space = crate::color::ColorSpace::DeviceCmyk;
for values in arrays {
let info = codec_dict("DeviceCMYK", Some(values));
let decode = super::DecodeMap::new(Some(&space), 4, 8, info.decode.as_ref());
let table = super::decode_table(&decode, 4);
for (component, row) in table.iter().enumerate() {
for raw in 0..=255u8 {
let value = decode.apply(component, f32::from(raw));
let expected = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
assert_eq!(
row[usize::from(raw)],
expected,
"component {component}, raw {raw}"
);
}
}
assert_eq!(table.len(), 4, "one row per component");
}
}
#[test]
fn a_trailing_partial_pixel_maps_by_its_position_in_the_pixel() {
let info = codec_dict(
"DeviceCMYK",
Some(vec![1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0]),
);
let space = crate::color::ColorSpace::DeviceCmyk;
let mut data = vec![10u8, 20, 30, 40, 50, 60];
super::apply_codec_decode(&mut data, Some(&space), 4, &info);
assert_eq!(data, vec![245u8, 20, 225, 40, 205, 60]);
}
#[test]
fn a_codec_decode_needs_a_space_and_some_components() {
let info = codec_dict("DeviceGray", Some(vec![1.0, 0.0]));
let original = vec![10u8, 200];
let mut data = original.clone();
super::apply_codec_decode(&mut data, None, 1, &info);
assert_eq!(data, original);
let mut data = original.clone();
let gray = crate::color::ColorSpace::DeviceGray;
super::apply_codec_decode(&mut data, Some(&gray), 0, &info);
assert_eq!(data, original);
let mut data = original.clone();
super::apply_codec_decode(&mut data, Some(&gray), 1, &info);
assert_eq!(data, vec![245u8, 55]);
}
fn all_white_g4(rows: usize) -> Vec<u8> {
let mut byte = 0u8;
for i in 0..rows.min(8) {
byte |= 1 << (7 - i);
}
vec![byte]
}
fn black_then_white_g4(rows: usize) -> Vec<u8> {
let mut bits = String::from("001001101010001011");
for _ in 1..rows {
bits.push('1');
}
while bits.len() % 8 != 0 {
bits.push('0');
}
bits.as_bytes()
.chunks(8)
.filter_map(|c| {
let s = std::str::from_utf8(c).ok()?;
u8::from_str_radix(s, 2).ok()
})
.collect()
}
fn ccitt_stream(width: i64, height: i64, mask: bool, data: &[u8]) -> Stream {
let parms = Dict::from_pairs(vec![
(Name::from("K"), Object::Int(-1)),
(Name::from("Columns"), Object::Int(width)),
(Name::from("Rows"), Object::Int(height)),
]);
let mut pairs = vec![
(Name::from("Width"), Object::Int(width)),
(Name::from("Height"), Object::Int(height)),
(Name::from("BitsPerComponent"), Object::Int(1)),
(
Name::from("Filter"),
Object::Name(Name::from("CCITTFaxDecode")),
),
(Name::from("DecodeParms"), Object::Dict(parms)),
];
if mask {
pairs.push((Name::from("ImageMask"), Object::Bool(true)));
} else {
pairs.push((
Name::from("ColorSpace"),
Object::Name(Name::from("DeviceGray")),
));
}
stream(pairs, data)
}
#[test]
fn a_fax_image_reaches_the_decoder_at_all() {
let s = ccitt_stream(20, 3, false, &all_white_g4(3));
let image = decode(&s).expect("should decode");
assert_eq!((image.width, image.height), (20, 3));
for y in 0..3 {
for x in 0..20 {
assert_eq!(
sample_at(&image.samples, x, y, 20),
[255, 255, 255],
"({x},{y}) should be white"
);
}
}
}
#[test]
fn a_fax_row_is_repacked_from_four_byte_padding_to_the_images_pitch() {
let s = ccitt_stream(20, 3, false, &black_then_white_g4(3));
let image = decode(&s).expect("should decode");
let black = [0_u8, 0, 0];
let white = [255_u8, 255, 255];
for y in 0..3 {
for x in 0..20 {
let want = if y < 2 && x < 8 { black } else { white };
assert_eq!(
sample_at(&image.samples, x, y, 20),
want,
"({x},{y}) — a shear puts the black run somewhere else"
);
}
}
}
#[test]
fn a_fax_stream_that_will_not_decode_leaves_the_image_white() {
let s = ccitt_stream(20, 3, false, &[0x00, 0x00]);
let image = decode(&s).expect("damage is not a failure");
assert_eq!(sample_at(&image.samples, 0, 0, 20), [255, 255, 255]);
}
fn separation_cmyk(c1: [f32; 4]) -> Object {
let mut c0 = Array::default();
for _ in 0..4 {
c0.push(Object::Real(0.0));
}
let mut c1_arr = Array::default();
for v in c1 {
c1_arr.push(Object::Real(v));
}
let mut domain = Array::default();
domain.push(Object::Int(0));
domain.push(Object::Int(1));
let mut range = Array::default();
for _ in 0..4 {
range.push(Object::Int(0));
range.push(Object::Int(1));
}
let tint = Dict::from_pairs(vec![
(Name::from("FunctionType"), Object::Int(2)),
(Name::from("N"), Object::Real(1.0)),
(Name::from("Domain"), Object::Array(domain)),
(Name::from("Range"), Object::Array(range)),
(Name::from("C0"), Object::Array(c0)),
(Name::from("C1"), Object::Array(c1_arr)),
]);
let mut space = Array::default();
space.push(Object::Name(Name::from("Separation")));
space.push(Object::Name(Name::from("Spot")));
space.push(Object::Name(Name::from("DeviceCMYK")));
space.push(Object::Dict(tint));
Object::Array(space)
}
#[test]
fn a_separation_image_runs_its_samples_through_the_tint_transform() {
let s = stream(
vec![
(Name::from("Width"), Object::Int(1)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
separation_cmyk([1.0, 0.0, 0.600_006, 0.0]),
),
],
&[0xC6],
);
let image = decode(&s).expect("should decode");
assert_eq!(
sample_at(&image.samples, 0, 0, 1),
[0, 182, 162],
"the tint must reach the alternate space, not the page as grey"
);
let Samples::Whole(Pixels::Indexed { palette, .. }) = &image.samples else {
panic!(
"a resolved Separation image is a palette, got {:?}",
image.samples
);
};
assert_eq!(palette.len(), 256);
assert_eq!(palette[0].to_bytes(), [255, 255, 255]);
}
#[test]
fn a_separation_decode_array_is_folded_into_the_palette() {
let mut decode_arr = Array::default();
decode_arr.push(Object::Int(1));
decode_arr.push(Object::Int(0));
let s = stream(
vec![
(Name::from("Width"), Object::Int(1)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
separation_cmyk([1.0, 0.0, 0.600_006, 0.0]),
),
(Name::from("Decode"), Object::Array(decode_arr)),
],
&[0xC6],
);
let image = decode(&s).expect("should decode");
let inverted = sample_at(&image.samples, 0, 0, 1);
let s_plain = stream(
vec![
(Name::from("Width"), Object::Int(1)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(
Name::from("ColorSpace"),
separation_cmyk([1.0, 0.0, 0.600_006, 0.0]),
),
],
&[255 - 0xC6],
);
let plain = decode(&s_plain).expect("should decode");
assert_eq!(
inverted,
sample_at(&plain.samples, 0, 0, 1),
"`/Decode [1 0]` on a tint is the complement of the sample"
);
}
#[test]
fn a_devicen_image_converts_per_pixel_rather_than_through_a_palette() {
let mut names = Array::default();
names.push(Object::Name(Name::from("SpotA")));
names.push(Object::Name(Name::from("SpotB")));
let mut domain = Array::default();
for _ in 0..2 {
domain.push(Object::Int(0));
domain.push(Object::Int(1));
}
let mut range = Array::default();
for _ in 0..4 {
range.push(Object::Int(0));
range.push(Object::Int(1));
}
let mut c0 = Array::default();
let mut c1 = Array::default();
for _ in 0..4 {
c0.push(Object::Real(0.0));
}
for v in [0.0_f32, 1.0, 1.0, 0.0] {
c1.push(Object::Real(v));
}
let tint = Dict::from_pairs(vec![
(Name::from("FunctionType"), Object::Int(2)),
(Name::from("N"), Object::Real(1.0)),
(Name::from("Domain"), Object::Array(domain)),
(Name::from("Range"), Object::Array(range)),
(Name::from("C0"), Object::Array(c0)),
(Name::from("C1"), Object::Array(c1)),
]);
let mut space = Array::default();
space.push(Object::Name(Name::from("DeviceN")));
space.push(Object::Array(names));
space.push(Object::Name(Name::from("DeviceCMYK")));
space.push(Object::Dict(tint));
let s = stream(
vec![
(Name::from("Width"), Object::Int(2)),
(Name::from("Height"), Object::Int(1)),
(Name::from("BitsPerComponent"), Object::Int(8)),
(Name::from("ColorSpace"), Object::Array(space)),
],
&[0x00, 0x00, 0xFF, 0xFF],
);
let image = decode(&s).expect("should decode");
assert!(
matches!(image.samples, Samples::Whole(Pixels::Rgb8(_))),
"a two-colorant DeviceN resolves per pixel and cannot stay packed, \
got {:?}",
image.samples
);
assert_eq!(sample_at(&image.samples, 0, 0, 2), [255, 255, 255]);
let full = sample_at(&image.samples, 1, 0, 2);
assert!(
full[0] > 200 && full[1] < 80 && full[2] < 80,
"a full tint must reach the alternate space's red, got {full:?}"
);
}
}