use crate::error::{Error, Result};
use crate::extractors::ccitt_bilevel;
use crate::geometry::Rect;
use crate::object::ObjectRef;
use std::cmp::min;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct PdfImage {
width: u32,
height: u32,
color_space: ColorSpace,
bits_per_component: u8,
#[serde(skip_serializing_if = "ImageData::is_empty")]
data: ImageData,
bbox: Option<Rect>,
rotation_degrees: i32,
matrix: [f32; 6],
#[serde(skip)]
ccitt_params: Option<crate::decoders::CcittParams>,
#[serde(skip)]
icc_profile: Option<std::sync::Arc<crate::color::IccProfile>>,
rendering_intent: crate::color::RenderingIntent,
#[serde(skip)]
samples_are_raw: bool,
#[serde(skip)]
decode_folded_in: bool,
#[serde(skip)]
raw_indexed_samples: Option<Vec<u8>>,
#[serde(skip)]
color_key_samples: Option<Vec<u8>>,
#[serde(skip)]
soft_mask_in_data: Option<JpxSoftMaskInData>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct JpxSoftMaskInData {
samples: Vec<u8>,
premultiplied: bool,
}
impl JpxSoftMaskInData {
pub(crate) fn samples(&self) -> &[u8] {
&self.samples
}
pub(crate) fn is_premultiplied(&self) -> bool {
self.premultiplied
}
}
impl PdfImage {
pub fn new(width: u32, height: u32, color_space: ColorSpace, bits_per_component: u8, data: ImageData) -> Self {
Self {
width,
height,
color_space,
bits_per_component,
data,
bbox: None,
rotation_degrees: 0,
matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
ccitt_params: None,
icc_profile: None,
rendering_intent: crate::color::RenderingIntent::default(),
samples_are_raw: true,
decode_folded_in: false,
raw_indexed_samples: None,
color_key_samples: None,
soft_mask_in_data: None,
}
}
pub fn samples_are_raw(&self) -> bool {
self.samples_are_raw
}
pub(crate) fn set_samples_are_raw(&mut self, raw: bool) {
self.samples_are_raw = raw;
}
pub fn decode_folded_in(&self) -> bool {
self.decode_folded_in
}
pub(crate) fn set_decode_folded_in(&mut self, folded: bool) {
self.decode_folded_in = folded;
}
pub(crate) fn raw_indexed_samples(&self) -> Option<&[u8]> {
self.raw_indexed_samples.as_deref()
}
pub(crate) fn set_raw_indexed_samples(&mut self, samples: Vec<u8>) {
self.raw_indexed_samples = Some(samples);
}
pub(crate) fn color_key_samples(&self) -> Option<&[u8]> {
self.color_key_samples.as_deref()
}
pub(crate) fn soft_mask_in_data(&self) -> Option<&JpxSoftMaskInData> {
self.soft_mask_in_data.as_ref()
}
pub fn with_spatial(
width: u32,
height: u32,
color_space: ColorSpace,
bits_per_component: u8,
data: ImageData,
bbox: Rect,
rotation: i32,
matrix: [f32; 6],
) -> Self {
Self {
width,
height,
color_space,
bits_per_component,
data,
bbox: Some(bbox),
rotation_degrees: rotation,
matrix,
ccitt_params: None,
icc_profile: None,
rendering_intent: crate::color::RenderingIntent::default(),
samples_are_raw: true,
decode_folded_in: false,
raw_indexed_samples: None,
color_key_samples: None,
soft_mask_in_data: None,
}
}
pub fn with_bbox(
width: u32,
height: u32,
color_space: ColorSpace,
bits_per_component: u8,
data: ImageData,
bbox: Rect,
) -> Self {
Self::with_spatial(
width,
height,
color_space,
bits_per_component,
data,
bbox,
0,
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
)
}
pub fn with_ccitt_params(
width: u32,
height: u32,
color_space: ColorSpace,
bits_per_component: u8,
data: ImageData,
ccitt_params: crate::decoders::CcittParams,
) -> Self {
Self {
width,
height,
color_space,
bits_per_component,
data,
bbox: None,
rotation_degrees: 0,
matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
ccitt_params: Some(ccitt_params),
icc_profile: None,
rendering_intent: crate::color::RenderingIntent::default(),
samples_are_raw: true,
decode_folded_in: false,
raw_indexed_samples: None,
color_key_samples: None,
soft_mask_in_data: None,
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn color_space(&self) -> &ColorSpace {
&self.color_space
}
pub fn bits_per_component(&self) -> u8 {
self.bits_per_component
}
pub fn data(&self) -> &ImageData {
&self.data
}
pub fn bbox(&self) -> Option<&Rect> {
self.bbox.as_ref()
}
pub fn set_bbox(&mut self, bbox: Rect) {
self.bbox = Some(bbox);
}
pub fn rotation_degrees(&self) -> i32 {
self.rotation_degrees
}
pub fn set_rotation_degrees(&mut self, rotation: i32) {
self.rotation_degrees = rotation;
}
pub fn matrix(&self) -> [f32; 6] {
self.matrix
}
pub fn set_matrix(&mut self, matrix: [f32; 6]) {
self.matrix = matrix;
}
pub fn set_ccitt_params(&mut self, params: crate::decoders::CcittParams) {
self.ccitt_params = Some(params);
}
pub fn ccitt_params(&self) -> Option<&crate::decoders::CcittParams> {
self.ccitt_params.as_ref()
}
pub fn icc_profile(&self) -> Option<&std::sync::Arc<crate::color::IccProfile>> {
self.icc_profile.as_ref()
}
pub fn set_icc_profile(&mut self, profile: std::sync::Arc<crate::color::IccProfile>) {
self.icc_profile = Some(profile);
}
pub fn rendering_intent(&self) -> crate::color::RenderingIntent {
self.rendering_intent
}
pub fn set_rendering_intent(&mut self, intent: crate::color::RenderingIntent) {
self.rendering_intent = intent;
}
fn build_icc_transform(&self) -> Option<crate::color::Transform> {
self.icc_profile
.as_ref()
.map(|p| crate::color::Transform::new_srgb_target(p.clone(), self.rendering_intent))
}
pub fn save_as_png(&self, path: impl AsRef<Path>) -> Result<()> {
match &self.data {
ImageData::Jpeg(jpeg_data) => {
if self.color_space.components() == 4 {
let transform = self.build_icc_transform();
let rgb = decode_cmyk_jpeg_to_rgb_with_profile(jpeg_data, transform.as_ref())?;
let buf = image::ImageBuffer::<image::Rgb<u8>, _>::from_raw(self.width, self.height, rgb)
.ok_or_else(|| Error::Image("Invalid CMYK image dimensions".to_string()))?;
buf.save_with_format(path, image::ImageFormat::Png)
.map_err(|e| Error::Image(format!("Failed to save PNG: {}", e)))
} else {
save_jpeg_as_png(jpeg_data, path)
}
}
ImageData::Raw { pixels, format } => {
let transform = self.build_icc_transform();
save_raw_as_png(pixels, self.width, self.height, *format, transform.as_ref(), path)
}
}
}
pub fn save_as_jpeg(&self, path: impl AsRef<Path>) -> Result<()> {
match &self.data {
ImageData::Jpeg(jpeg_data) => {
if self.color_space.components() == 4 {
let transform = self.build_icc_transform();
let rgb = decode_cmyk_jpeg_to_rgb_with_profile(jpeg_data, transform.as_ref())?;
let buf = image::ImageBuffer::<image::Rgb<u8>, _>::from_raw(self.width, self.height, rgb)
.ok_or_else(|| Error::Image("Invalid CMYK image dimensions".to_string()))?;
buf.save_with_format(path, image::ImageFormat::Jpeg)
.map_err(|e| Error::Image(format!("Failed to save JPEG: {}", e)))
} else {
std::fs::write(path, jpeg_data).map_err(Error::from)
}
}
ImageData::Raw { pixels, format } => {
let transform = self.build_icc_transform();
save_raw_as_jpeg(pixels, self.width, self.height, *format, transform.as_ref(), path)
}
}
}
pub fn to_png_bytes(&self) -> Result<Vec<u8>> {
use image::ImageEncoder;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use std::io::Cursor;
let mut buffer = Cursor::new(Vec::new());
let encoder = PngEncoder::new_with_quality(&mut buffer, CompressionType::Default, FilterType::Adaptive);
match &self.data {
ImageData::Raw { pixels, format } => {
let expected_gray = (self.width * self.height) as usize;
let expected_rgb = expected_gray * 3;
if *format == PixelFormat::Grayscale
&& matches!(self.color_space, ColorSpace::DeviceGray | ColorSpace::CalGray)
&& pixels.len() == expected_gray
{
encoder
.write_image(pixels, self.width, self.height, image::ColorType::L8.into())
.map_err(|e| Error::Encode(format!("Failed to encode PNG: {}", e)))?;
} else if *format == PixelFormat::RGB && pixels.len() == expected_rgb {
encoder
.write_image(pixels, self.width, self.height, image::ColorType::Rgb8.into())
.map_err(|e| Error::Encode(format!("Failed to encode PNG: {}", e)))?;
} else {
let dynamic_image = self.to_dynamic_image()?;
let rgb = dynamic_image.to_rgb8();
if rgb.as_raw().len() != expected_rgb {
return Err(Error::Encode(format!(
"image buffer length {} does not match {}x{} RGB image ({} expected)",
rgb.as_raw().len(),
self.width,
self.height,
expected_rgb
)));
}
encoder
.write_image(rgb.as_raw(), self.width, self.height, image::ColorType::Rgb8.into())
.map_err(|e| Error::Encode(format!("Failed to encode PNG: {}", e)))?;
}
}
ImageData::Jpeg(_) => {
let dynamic_image = self.to_dynamic_image()?;
let rgb = dynamic_image.to_rgb8();
encoder
.write_image(rgb.as_raw(), self.width, self.height, image::ColorType::Rgb8.into())
.map_err(|e| Error::Encode(format!("Failed to encode PNG: {}", e)))?;
}
}
Ok(buffer.into_inner())
}
pub fn to_base64_data_uri(&self) -> Result<String> {
use base64::{Engine, engine::general_purpose::STANDARD};
match &self.data {
ImageData::Jpeg(jpeg_data) => {
let base64_str = STANDARD.encode(jpeg_data);
Ok(format!("data:image/jpeg;base64,{}", base64_str))
}
ImageData::Raw { .. } => {
let png_bytes = self.to_png_bytes()?;
let base64_str = STANDARD.encode(&png_bytes);
Ok(format!("data:image/png;base64,{}", base64_str))
}
}
}
pub fn to_dynamic_image(&self) -> Result<image::DynamicImage> {
match &self.data {
ImageData::Jpeg(jpeg_data) => self.jpeg_to_dynamic_image(jpeg_data),
ImageData::Raw { pixels, format } => self.raw_to_dynamic_image(pixels, format),
}
}
fn jpeg_to_dynamic_image(&self, jpeg_data: &[u8]) -> Result<image::DynamicImage> {
if self.color_space.components() == 4 {
let transform = self.build_icc_transform();
let rgb = decode_cmyk_jpeg_to_rgb_with_profile(jpeg_data, transform.as_ref())?;
return image::ImageBuffer::<image::Rgb<u8>, Vec<u8>>::from_raw(self.width, self.height, rgb)
.ok_or_else(|| Error::Decode("Invalid CMYK image dimensions".to_string()))
.map(image::DynamicImage::ImageRgb8);
}
tracing::debug!(
"Decoding JPEG data ({} bytes), starts with: {:02X?}",
jpeg_data.len(),
&jpeg_data[..min(jpeg_data.len(), 16)]
);
image::load_from_memory(jpeg_data).map_err(|e| Error::Decode(format!("Failed to decode JPEG: {}", e)))
}
fn raw_to_dynamic_image(&self, pixels: &[u8], format: &PixelFormat) -> Result<image::DynamicImage> {
let is_1bpc_gray = self.bits_per_component == 1 && matches!(self.color_space, ColorSpace::DeviceGray);
if is_1bpc_gray && self.ccitt_params.is_some() {
return self.ccitt_to_dynamic_image(pixels);
}
if is_1bpc_gray {
return self.unpack_1bpc_gray_to_dynamic_image(pixels);
}
match (format, self.color_space) {
(PixelFormat::RGB, ColorSpace::DeviceRGB) => {
image::ImageBuffer::<image::Rgb<u8>, Vec<u8>>::from_raw(self.width, self.height, pixels.to_vec())
.ok_or_else(|| Error::Decode("Invalid image dimensions".to_string()))
.map(image::DynamicImage::ImageRgb8)
}
(PixelFormat::Grayscale, ColorSpace::DeviceGray) => {
image::ImageBuffer::<image::Luma<u8>, Vec<u8>>::from_raw(self.width, self.height, pixels.to_vec())
.ok_or_else(|| Error::Decode("Invalid image dimensions".to_string()))
.map(image::DynamicImage::ImageLuma8)
}
_ => {
let rgb_pixels = match format {
PixelFormat::Grayscale => pixels.iter().flat_map(|&g| vec![g, g, g]).collect(),
PixelFormat::CMYK => cmyk_to_rgb_with_transform(pixels, self.build_icc_transform().as_ref()),
PixelFormat::RGB => pixels.to_vec(),
};
image::ImageBuffer::<image::Rgb<u8>, Vec<u8>>::from_raw(self.width, self.height, rgb_pixels)
.ok_or_else(|| Error::Decode("Invalid image dimensions".to_string()))
.map(image::DynamicImage::ImageRgb8)
}
}
}
fn ccitt_to_dynamic_image(&self, pixels: &[u8]) -> Result<image::DynamicImage> {
let Some(params) = self.ccitt_params.clone() else {
unreachable!("guarded by ccitt_params.is_some() above")
};
let decompressed = ccitt_bilevel::decompress_ccitt(pixels, ¶ms)?;
let grayscale = ccitt_bilevel::bilevel_to_grayscale(&decompressed, self.width, self.height);
image::ImageBuffer::<image::Luma<u8>, Vec<u8>>::from_raw(self.width, self.height, grayscale)
.ok_or_else(|| Error::Decode("Invalid image dimensions".to_string()))
.map(image::DynamicImage::ImageLuma8)
}
fn unpack_1bpc_gray_to_dynamic_image(&self, pixels: &[u8]) -> Result<image::DynamicImage> {
const MAX_GRAYSCALE_OUTPUT_BYTES: usize = 256 * 1024 * 1024;
let output_bytes = (self.width as usize).checked_mul(self.height as usize).ok_or_else(|| {
Error::Decode(format!(
"1-bit DeviceGray image output size overflow: {} x {} exceeds usize",
self.width, self.height
))
})?;
if output_bytes > MAX_GRAYSCALE_OUTPUT_BYTES {
return Err(Error::Decode(format!(
"1-bit DeviceGray image decode would produce {output_bytes} bytes, exceeds guard \
limit of {MAX_GRAYSCALE_OUTPUT_BYTES} bytes (width={}, height={})",
self.width, self.height
)));
}
let row_bytes = (self.width as usize).div_ceil(8);
let mut grayscale = Vec::with_capacity(output_bytes);
for row in 0..self.height as usize {
let row_start = row * row_bytes;
for col in 0..self.width as usize {
let byte_idx = row_start + col / 8;
let bit = pixels.get(byte_idx).map(|b| (b >> (7 - (col % 8))) & 1).unwrap_or(1);
grayscale.push(if bit == 0 { 0x00 } else { 0xFF });
}
}
image::ImageBuffer::<image::Luma<u8>, Vec<u8>>::from_raw(self.width, self.height, grayscale)
.ok_or_else(|| Error::Decode("Invalid image dimensions".to_string()))
.map(image::DynamicImage::ImageLuma8)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(untagged)]
pub enum ImageData {
Jpeg(Vec<u8>),
Raw {
pixels: Vec<u8>,
format: PixelFormat,
},
}
impl ImageData {
pub fn is_empty(&self) -> bool {
match self {
ImageData::Jpeg(data) => data.is_empty(),
ImageData::Raw { pixels, .. } => pixels.is_empty(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum ColorSpace {
DeviceRGB,
DeviceGray,
DeviceCMYK,
Indexed,
CalGray,
CalRGB,
Lab,
ICCBased(usize),
Separation,
DeviceN,
Pattern,
}
impl ColorSpace {
pub fn components(&self) -> usize {
match self {
ColorSpace::DeviceGray => 1,
ColorSpace::DeviceRGB => 3,
ColorSpace::DeviceCMYK => 4,
ColorSpace::Indexed => 1,
ColorSpace::CalGray => 1,
ColorSpace::CalRGB => 3,
ColorSpace::Lab => 3,
ColorSpace::ICCBased(n) => *n,
ColorSpace::Separation => 1,
ColorSpace::DeviceN => 4,
ColorSpace::Pattern => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum PixelFormat {
RGB,
Grayscale,
CMYK,
}
impl PixelFormat {
pub fn bytes_per_pixel(&self) -> usize {
match self {
PixelFormat::Grayscale => 1,
PixelFormat::RGB => 3,
PixelFormat::CMYK => 4,
}
}
}
fn color_space_to_pixel_format(color_space: &ColorSpace) -> PixelFormat {
match color_space {
ColorSpace::DeviceGray => PixelFormat::Grayscale,
ColorSpace::DeviceRGB => PixelFormat::RGB,
ColorSpace::DeviceCMYK => PixelFormat::CMYK,
ColorSpace::Indexed => PixelFormat::RGB,
ColorSpace::CalGray => PixelFormat::Grayscale,
ColorSpace::CalRGB => PixelFormat::RGB,
ColorSpace::Lab => PixelFormat::RGB,
ColorSpace::ICCBased(n) => match n {
1 => PixelFormat::Grayscale,
3 => PixelFormat::RGB,
4 => PixelFormat::CMYK,
_ => PixelFormat::RGB,
},
ColorSpace::Separation => PixelFormat::Grayscale,
ColorSpace::DeviceN => PixelFormat::CMYK,
ColorSpace::Pattern => PixelFormat::RGB,
}
}
pub fn parse_color_space(obj: &crate::object::Object) -> Result<ColorSpace> {
use crate::object::Object;
match obj {
Object::Name(name) => match name.as_str() {
"DeviceRGB" => Ok(ColorSpace::DeviceRGB),
"DeviceGray" => Ok(ColorSpace::DeviceGray),
"DeviceCMYK" => Ok(ColorSpace::DeviceCMYK),
"Pattern" => Ok(ColorSpace::Pattern),
other => Err(Error::Image(format!("Unsupported color space: {}", other))),
},
Object::Array(arr) if !arr.is_empty() => {
if let Some(name) = arr[0].as_name() {
match name {
"Indexed" => Ok(ColorSpace::Indexed),
"CalGray" => Ok(ColorSpace::CalGray),
"CalRGB" => Ok(ColorSpace::CalRGB),
"Lab" => Ok(ColorSpace::Lab),
"ICCBased" => Ok(ColorSpace::ICCBased(iccbased_num_components(arr))),
"Separation" => Ok(ColorSpace::Separation),
"DeviceN" => Ok(ColorSpace::DeviceN),
"Pattern" => Ok(ColorSpace::Pattern),
other => Err(Error::Image(format!("Unsupported array color space: {}", other))),
}
} else {
Err(Error::Image("Color space array must start with a name".to_string()))
}
}
_ => Err(Error::Image(format!("Invalid color space object: {:?}", obj))),
}
}
fn iccbased_num_components(arr: &[crate::object::Object]) -> usize {
use crate::object::Object;
let Some(stream_dict) = arr.get(1).and_then(|obj| obj.as_dict()) else {
return 3;
};
stream_dict
.get("N")
.and_then(|obj| match obj {
Object::Integer(n) => Some(*n as usize),
_ => None,
})
.unwrap_or(3)
}
fn decode_array_inverts_1bpc(decode: Option<&crate::object::Object>) -> bool {
let arr = match decode.and_then(|o| o.as_array()) {
Some(a) if a.len() == 2 => a,
_ => return false,
};
let as_num = |o: &crate::object::Object| o.as_integer().map(|i| i as f64).or_else(|| o.as_real());
matches!((as_num(&arr[0]), as_num(&arr[1])), (Some(lo), Some(hi)) if lo > hi)
}
fn devicen_ink_count(cs_obj: &crate::object::Object) -> Option<usize> {
let arr = cs_obj.as_array()?;
if !matches!(arr.first(), Some(crate::object::Object::Name(n)) if n == "DeviceN") {
return None;
}
match arr.get(1)? {
crate::object::Object::Array(names) if !names.is_empty() => Some(names.len()),
_ => None,
}
}
fn decode_ranges(decode: Option<&crate::object::Object>, ncomp: usize) -> Option<Vec<(f32, f32)>> {
let arr = decode.and_then(|o| o.as_array())?;
if arr.len() != ncomp * 2 {
return None;
}
let as_num = |o: &crate::object::Object| o.as_integer().map(|i| i as f64).or_else(|| o.as_real());
let mut out = Vec::with_capacity(ncomp);
for pair in arr.chunks_exact(2) {
out.push((as_num(&pair[0])? as f32, as_num(&pair[1])? as f32));
}
Some(out)
}
fn samples_to_decoded_bytes(
data: &[u8],
width: u32,
height: u32,
ncomp: usize,
bpc: u8,
ranges: Option<&[(f32, f32)]>,
) -> Option<Vec<u8>> {
if !matches!(bpc, 1 | 2 | 4 | 8) || ncomp == 0 {
return None;
}
let bpc = usize::from(bpc);
let (samples_per_row, row_bytes, total) = unpacked_sample_geometry(width, height, ncomp, bpc)?;
let lut = build_decode_lut(ncomp, bpc, ranges);
if bpc == 8 {
return Some(
data.iter()
.enumerate()
.map(|(i, &b)| lut[i % ncomp][b as usize])
.collect(),
);
}
let mask = (1u32 << bpc) - 1;
let mut out = Vec::with_capacity(total);
for row in 0..height as usize {
let row_start = row * row_bytes;
for s in 0..samples_per_row {
let bit_offset = s * bpc;
let byte = data.get(row_start + bit_offset / 8).copied().unwrap_or(0);
let shift = 8 - bpc - (bit_offset % 8);
let raw = ((byte >> shift) as u32) & mask;
out.push(lut[s % ncomp][raw as usize]);
}
}
Some(out)
}
fn unpacked_sample_geometry(width: u32, height: u32, ncomp: usize, bpc: usize) -> Option<(usize, usize, usize)> {
let samples_per_row = (width as usize).checked_mul(ncomp)?;
let total = samples_per_row.checked_mul(height as usize)?;
let row_bytes = samples_per_row.checked_mul(bpc)?.div_ceil(8);
const MAX_UNPACKED_OUTPUT_BYTES: usize = 256 * 1024 * 1024;
if total > MAX_UNPACKED_OUTPUT_BYTES {
tracing::warn!(
"Refusing to unpack a {total}-byte image buffer (cap {MAX_UNPACKED_OUTPUT_BYTES}); \
samples stay packed and any /Decode is left unapplied"
);
return None;
}
Some((samples_per_row, row_bytes, total))
}
fn build_decode_lut(ncomp: usize, bpc: usize, ranges: Option<&[(f32, f32)]>) -> Vec<[u8; 256]> {
let levels = 1usize << bpc;
let max = (levels - 1) as f32;
(0..ncomp)
.map(|comp| {
let (lo, hi) = ranges.map_or((0.0, 1.0), |r| r[comp]);
let mut table = [0u8; 256];
for (raw, slot) in table.iter_mut().enumerate().take(levels) {
let v = lo + (raw as f32) * (hi - lo) / max;
*slot = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
table
})
.collect()
}
fn expected_image_filter_output_size(
dict: &std::collections::HashMap<String, crate::object::Object>,
width: u32,
height: u32,
components: usize,
bits_per_component: u8,
) -> Result<Option<usize>> {
if components == 0 || bits_per_component == 0 {
return Ok(None);
}
let row_bits = (width as usize)
.checked_mul(components)
.and_then(|samples| samples.checked_mul(bits_per_component as usize))
.ok_or_else(|| Error::Decode("image raster dimensions overflow".to_string()))?;
let row_bytes = row_bits.div_ceil(8);
let raster_bytes = row_bytes
.checked_mul(height as usize)
.ok_or_else(|| Error::Decode("image raster byte count overflow".to_string()))?;
let Some(params) = crate::object::extract_decode_params(dict.get("DecodeParms")) else {
return Ok(Some(raster_bytes));
};
if params.predictor == 1 {
return Ok(Some(raster_bytes));
}
if params.checked_pixel_bytes_per_row()? != row_bytes {
return Ok(None);
}
let encoded_bytes = params
.checked_bytes_per_row()?
.checked_mul(height as usize)
.ok_or_else(|| Error::Decode("predictor-framed image byte count overflow".to_string()))?;
Ok(Some(encoded_bytes))
}
fn decode_dimension_bounded_image_stream(
doc: Option<&crate::document::PdfDocument>,
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
expected_filter_output_size: Option<usize>,
) -> Result<Vec<u8>> {
match (doc, obj_ref, expected_filter_output_size) {
(Some(document), Some(reference), Some(expected_size)) => {
document.decode_image_stream_with_encryption(xobject, reference, expected_size)
}
(Some(document), Some(reference), None) => document.decode_stream_with_encryption(xobject, reference),
(_, _, Some(expected_size)) => xobject.decode_image_stream_data(expected_size),
(_, _, None) => xobject.decode_stream_data(),
}
}
#[cfg(test)]
thread_local! {
static DECODE_CALL_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn decode_call_count() -> usize {
DECODE_CALL_COUNT.with(std::cell::Cell::get)
}
fn resolve_image_color_space_object(
doc: Option<&crate::document::PdfDocument>,
color_space_obj: &crate::object::Object,
color_space_map: Option<&std::collections::HashMap<String, crate::object::Object>>,
) -> Result<crate::object::Object> {
use crate::object::Object;
let resolved_color_space = if let Some(d) = doc {
let res = if let Some(obj_ref) = color_space_obj.as_reference() {
d.load_object(obj_ref)?
} else {
color_space_obj.clone()
};
if let Object::Name(ref name) = res {
if let Some(map) = color_space_map {
map.get(name).cloned().unwrap_or(res)
} else {
res
}
} else {
res
}
} else {
color_space_obj.clone()
};
let resolved_color_space = if let (Some(doc_mut), Object::Array(arr)) = (doc, &resolved_color_space) {
if arr.len() > 1 {
if let Some(second_ref) = arr[1].as_reference() {
match doc_mut.load_object(second_ref) {
Ok(resolved_second) => {
let mut new_arr = arr.clone();
new_arr[1] = resolved_second;
Object::Array(new_arr)
}
_ => resolved_color_space,
}
} else {
resolved_color_space
}
} else {
resolved_color_space
}
} else {
resolved_color_space
};
Ok(resolved_color_space)
}
struct ImageDimensions {
width: u32,
height: u32,
bits_per_component: u8,
is_image_mask: bool,
}
fn resolve_image_dimensions(
doc: Option<&crate::document::PdfDocument>,
dict: &std::collections::HashMap<String, crate::object::Object>,
) -> Result<ImageDimensions> {
use crate::object::Object;
let resolve_int = |obj: &Object| -> Option<i64> {
if let (Some(d), Some(r)) = (doc, obj.as_reference()) {
d.load_object(r).ok().and_then(|o| o.as_integer())
} else {
obj.as_integer()
}
};
let width = dict
.get("Width")
.and_then(resolve_int)
.ok_or_else(|| Error::Image("Image missing /Width".to_string()))? as u32;
let height = dict
.get("Height")
.and_then(resolve_int)
.ok_or_else(|| Error::Image("Image missing /Height".to_string()))? as u32;
let is_image_mask = dict
.get("ImageMask")
.is_some_and(|value| matches!(value, Object::Boolean(true)));
let default_bits_per_component = if is_image_mask { 1 } else { 8 };
let bits_per_component = dict
.get("BitsPerComponent")
.and_then(resolve_int)
.unwrap_or(default_bits_per_component) as u8;
Ok(ImageDimensions {
width,
height,
bits_per_component,
is_image_mask,
})
}
struct ImageFilterInfo {
is_jbig2: bool,
is_jpx: bool,
is_jpeg_only: bool,
is_jpeg_chain: bool,
is_ccitt: bool,
ccitt_params: Option<crate::decoders::CcittParams>,
}
fn resolve_image_filter_info(
dict: &std::collections::HashMap<String, crate::object::Object>,
width: u32,
height: u32,
) -> ImageFilterInfo {
use crate::object::Object;
let filter_names = if let Some(filter_obj) = dict.get("Filter") {
match filter_obj {
Object::Name(name) => vec![name.clone()],
Object::Array(filters) => filters.iter().filter_map(|f| f.as_name().map(String::from)).collect(),
_ => vec![],
}
} else {
vec![]
};
let has_dct = filter_names.iter().any(|name| name == "DCTDecode");
let is_jpeg_only = has_dct && filter_names.len() == 1;
let is_jpeg_chain = has_dct && filter_names.len() > 1;
let is_jbig2 = filter_names.iter().any(|n| n.eq_ignore_ascii_case("JBIG2Decode"));
let is_jpx = filter_names.iter().any(|n| n.eq_ignore_ascii_case("JPXDecode"));
let is_ccitt = filter_names.iter().any(|n| n.eq_ignore_ascii_case("CCITTFaxDecode"));
let ccitt_params = if is_ccitt {
let ccitt_index = filter_names
.iter()
.position(|name| name.eq_ignore_ascii_case("CCITTFaxDecode"))
.unwrap_or(0);
let mut params =
crate::object::extract_ccitt_params_with_width_at_index(dict.get("DecodeParms"), ccitt_index, Some(width))
.unwrap_or(crate::decoders::CcittParams {
columns: width,
rows: Some(height),
..crate::decoders::CcittParams::default()
});
if params.rows.is_none() {
params.rows = Some(height);
}
if decode_array_inverts_1bpc(dict.get("Decode")) {
params.black_is_1 = !params.black_is_1;
}
Some(params)
} else {
None
};
ImageFilterInfo {
is_jbig2,
is_jpx,
is_jpeg_only,
is_jpeg_chain,
is_ccitt,
ccitt_params,
}
}
fn resolve_indexed_and_icc_profile(
doc: Option<&crate::document::PdfDocument>,
color_space: ColorSpace,
resolved_color_space: &crate::object::Object,
) -> Result<(
Option<IndexedResolution>,
Option<std::sync::Arc<crate::color::IccProfile>>,
)> {
let indexed_resolution: Option<IndexedResolution> = if color_space == ColorSpace::Indexed {
let resolved = resolve_indexed_palette(doc, resolved_color_space)?;
if resolved.is_none() {
return Err(Error::Image(
"Unable to resolve Indexed color space palette".to_string(),
));
}
resolved
} else {
None
};
let direct_icc_profile = if matches!(color_space, ColorSpace::ICCBased(_)) {
resolve_icc_profile_from_obj(doc, resolved_color_space)
} else if color_space == ColorSpace::DeviceCMYK {
doc.and_then(|d| d.output_intent_cmyk_profile())
} else {
None
};
Ok((indexed_resolution, direct_icc_profile))
}
struct ImageXObjectMetadata {
width: u32,
height: u32,
bits_per_component: u8,
color_space: ColorSpace,
resolved_color_space: crate::object::Object,
indexed_resolution: Option<IndexedResolution>,
direct_icc_profile: Option<std::sync::Arc<crate::color::IccProfile>>,
rendering_intent: crate::color::RenderingIntent,
is_jbig2: bool,
is_jpx: bool,
jpx_color_space_is_placeholder: bool,
is_jpeg_only: bool,
is_jpeg_chain: bool,
is_ccitt: bool,
ccitt_params: Option<crate::decoders::CcittParams>,
}
fn resolve_image_xobject_metadata(
doc: Option<&crate::document::PdfDocument>,
dict: &std::collections::HashMap<String, crate::object::Object>,
color_space_map: Option<&std::collections::HashMap<String, crate::object::Object>>,
) -> Result<ImageXObjectMetadata> {
use crate::object::Object;
let ImageDimensions {
width,
height,
bits_per_component,
is_image_mask,
} = resolve_image_dimensions(doc, dict)?;
let ImageFilterInfo {
is_jbig2,
is_jpx,
is_jpeg_only,
is_jpeg_chain,
is_ccitt,
ccitt_params,
} = resolve_image_filter_info(dict, width, height);
let default_mask_color_space = Object::Name("DeviceGray".to_string());
let default_jpx_color_space = Object::Name("DeviceRGB".to_string());
let color_space_missing_ok_for_jpx = is_jpx && !is_image_mask;
let color_space_obj = match dict.get("ColorSpace") {
Some(color_space) => color_space,
None if is_image_mask => &default_mask_color_space,
None if color_space_missing_ok_for_jpx => &default_jpx_color_space,
None => return Err(Error::Image("Image missing /ColorSpace".to_string())),
};
let resolved_color_space = resolve_image_color_space_object(doc, color_space_obj, color_space_map)?;
let color_space = parse_color_space(&resolved_color_space)?;
let (indexed_resolution, direct_icc_profile) =
resolve_indexed_and_icc_profile(doc, color_space, &resolved_color_space)?;
let rendering_intent = dict
.get("Intent")
.and_then(|obj| obj.as_name())
.map(crate::color::RenderingIntent::from_pdf_name)
.unwrap_or_default();
Ok(ImageXObjectMetadata {
width,
height,
bits_per_component,
color_space,
resolved_color_space,
indexed_resolution,
direct_icc_profile,
rendering_intent,
is_jbig2,
is_jpx,
jpx_color_space_is_placeholder: dict.get("ColorSpace").is_none() && color_space_missing_ok_for_jpx,
is_jpeg_only,
is_jpeg_chain,
is_ccitt,
ccitt_params,
})
}
pub fn extract_image_from_xobject(
doc: Option<&crate::document::PdfDocument>,
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
color_space_map: Option<&std::collections::HashMap<String, crate::object::Object>>,
) -> Result<PdfImage> {
#[cfg(test)]
DECODE_CALL_COUNT.with(|count| count.set(count.get() + 1));
let dict = xobject
.as_dict()
.ok_or_else(|| Error::Image("XObject is not a stream".to_string()))?;
let subtype = dict
.get("Subtype")
.and_then(|obj| obj.as_name())
.ok_or_else(|| Error::Image("XObject missing /Subtype".to_string()))?;
if subtype != "Image" {
return Err(Error::Image(format!("XObject subtype is not Image: {}", subtype)));
}
let ImageXObjectMetadata {
mut width,
mut height,
bits_per_component,
mut color_space,
resolved_color_space,
indexed_resolution,
mut direct_icc_profile,
rendering_intent,
is_jbig2,
is_jpx,
jpx_color_space_is_placeholder,
is_jpeg_only,
is_jpeg_chain,
is_ccitt,
ccitt_params,
} = resolve_image_xobject_metadata(doc, dict, color_space_map)?;
let mut stored_bpc = bits_per_component;
let mut samples_are_raw = true;
let mut decode_folded_in = false;
let mut raw_indexed_samples: Option<Vec<u8>> = None;
let has_color_key_mask = matches!(dict.get("Mask"), Some(crate::object::Object::Array(_)));
let mut color_key_samples = None;
let soft_mask_in_data = (is_jpx && dict.get("SMask").is_none())
.then(|| {
dict.get("SMaskInData").and_then(|obj| match (doc, obj.as_reference()) {
(Some(d), Some(r)) => d.load_object(r).ok().and_then(|o| o.as_integer()),
_ => obj.as_integer(),
})
})
.flatten()
.filter(|value| matches!(value, 1 | 2));
let mut jpx_opacity = None;
let data = if is_jbig2 {
decode_jbig2_image(xobject, obj_ref, dict, doc, width, height)?
} else if is_jpx {
let JpxDecoded {
data: jpx_data,
width: jpx_width,
height: jpx_height,
palette: codestream_palette,
opacity,
} = decode_jpx_image(
xobject,
obj_ref,
doc,
&color_space,
jpx_color_space_is_placeholder,
(width, height),
indexed_resolution.as_ref(),
)?;
width = jpx_width;
height = jpx_height;
jpx_opacity = opacity;
let jpx_data = match (codestream_palette, jpx_data) {
(Some(palette), ImageData::Raw { pixels: indices, .. }) => {
samples_are_raw = false;
stored_bpc = 8;
ImageData::Raw {
pixels: expand_indexed_to_base_samples(
&indices,
&palette.palette,
palette.components,
width,
height,
8,
)?,
format: palette.base_fmt,
}
}
(_, data) => data,
};
if let Some(ir) = indexed_resolution.as_ref()
&& let ImageData::Raw { pixels: indices, .. } = &jpx_data
{
samples_are_raw = false;
stored_bpc = 8;
if has_color_key_mask {
raw_indexed_samples = Some(indices.clone());
}
ImageData::Raw {
pixels: expand_indexed_image(indices, ir, width, height, 8, rendering_intent)?,
format: PixelFormat::RGB,
}
} else {
if jpx_color_space_is_placeholder && let ImageData::Raw { format, .. } = &jpx_data {
color_space = match format {
PixelFormat::Grayscale => ColorSpace::DeviceGray,
PixelFormat::RGB => ColorSpace::DeviceRGB,
PixelFormat::CMYK => ColorSpace::DeviceCMYK,
};
if color_space == ColorSpace::DeviceCMYK {
direct_icc_profile = doc.and_then(|d| d.output_intent_cmyk_profile());
}
}
jpx_data
}
} else if is_jpeg_only || is_jpeg_chain {
let decoded = if let (Some(d), Some(ref_id)) = (doc.as_ref(), obj_ref) {
d.decode_stream_with_encryption(xobject, ref_id)?
} else {
xobject.decode_stream_data()?
};
ImageData::Jpeg(decoded)
} else if let Some(params) = ccitt_params.as_ref() {
let compressed = if let (Some(d), Some(ref_id)) = (doc.as_ref(), obj_ref) {
d.decode_stream_with_encryption(xobject, ref_id)?
} else {
xobject.decode_stream_data()?
};
let packed = ccitt_bilevel::decompress_ccitt(&compressed, params)?;
let pixels = ccitt_bilevel::bilevel_to_grayscale(&packed, width, height);
let expected_len = (width as usize)
.checked_mul(height as usize)
.ok_or_else(|| Error::Decode(format!("CCITT image dimensions overflow: {width} x {height}")))?;
if pixels.len() != expected_len {
return Err(Error::Decode(format!(
"CCITT image decoded to {} grayscale samples, expected {expected_len} for {width} x {height}",
pixels.len()
)));
}
stored_bpc = 8;
samples_are_raw = false;
decode_folded_in = decode_array_inverts_1bpc(dict.get("Decode"));
ImageData::Raw {
pixels,
format: PixelFormat::Grayscale,
}
} else {
let expected_filter_output_size =
expected_image_filter_output_size(dict, width, height, color_space.components(), bits_per_component)?;
let decoded_data = decode_dimension_bounded_image_stream(doc, xobject, obj_ref, expected_filter_output_size)?;
if let Some(ir) = indexed_resolution.as_ref() {
let expanded =
expand_indexed_image(&decoded_data, ir, width, height, bits_per_component, rendering_intent)?;
samples_are_raw = false;
stored_bpc = 8;
raw_indexed_samples = Some(unpack_indexed_indices(
&decoded_data,
width,
height,
bits_per_component,
)?);
ImageData::Raw {
pixels: expanded,
format: PixelFormat::RGB,
}
} else {
let pixel_format = color_space_to_pixel_format(&color_space);
let reduced: Vec<u8> = if bits_per_component == 16 {
decoded_data
.as_chunks::<2>()
.0
.iter()
.map(|sample| reduce_16_to_8(sample[0], sample[1]))
.collect()
} else {
decoded_data
};
let bpc_after_reduce = if bits_per_component == 16 {
8
} else {
bits_per_component
};
let ncomp = devicen_ink_count(&resolved_color_space).unwrap_or_else(|| color_space.components());
let representable = ncomp == pixel_format.bytes_per_pixel();
let keep_raw = color_space == ColorSpace::Lab || !representable;
let ranges =
decode_ranges(dict.get("Decode"), ncomp).filter(|r| r.iter().any(|&(lo, hi)| (lo, hi) != (0.0, 1.0)));
let unpacked = if keep_raw || (bpc_after_reduce == 8 && ranges.is_none()) {
None
} else {
samples_to_decoded_bytes(&reduced, width, height, ncomp, bpc_after_reduce, ranges.as_deref())
};
let pixels = match unpacked {
Some(samples) => {
stored_bpc = 8;
samples_are_raw = false;
decode_folded_in = ranges.is_some();
if has_color_key_mask && matches!(bits_per_component, 1 | 2 | 4 | 8) {
color_key_samples =
Some(unpack_raw_samples(&reduced, width, height, ncomp, bits_per_component));
}
samples
}
None => reduced,
};
ImageData::Raw {
pixels,
format: pixel_format,
}
}
};
let effective_bpc = if is_jbig2 || is_ccitt || bits_per_component == 16 {
8
} else {
stored_bpc
};
if i64::from(effective_bpc) != i64::from(bits_per_component) {
samples_are_raw = false;
}
let mut image = PdfImage::new(width, height, color_space, effective_bpc, data);
image.set_samples_are_raw(samples_are_raw);
image.set_decode_folded_in(decode_folded_in);
if let Some(indices) = raw_indexed_samples {
image.set_raw_indexed_samples(indices);
}
image.color_key_samples = color_key_samples;
image.soft_mask_in_data = jpx_opacity
.zip(soft_mask_in_data)
.map(|(samples, value)| JpxSoftMaskInData {
samples,
premultiplied: value == 2,
});
if let Some(p) = direct_icc_profile {
image.set_icc_profile(p);
} else if let Some(ir) = indexed_resolution.as_ref()
&& let Some(p) = ir.base_profile.clone()
{
image.set_icc_profile(p);
}
image.set_rendering_intent(rendering_intent);
if let Some(ccitt_params) = ccitt_params {
image.set_ccitt_params(ccitt_params);
}
Ok(image)
}
pub(crate) fn resolve_icc_profile_from_obj(
doc: Option<&crate::document::PdfDocument>,
cs_obj: &crate::object::Object,
) -> Option<std::sync::Arc<crate::color::IccProfile>> {
use crate::object::Object;
let Object::Array(arr) = cs_obj else {
return None;
};
if arr.len() < 2 || arr[0].as_name() != Some("ICCBased") {
return None;
}
let profile_obj = match (&arr[1], doc) {
(Object::Stream { .. }, _) => arr[1].clone(),
(Object::Reference(r), Some(d)) => match d.load_object(*r) {
Ok(obj) => obj,
Err(_) => return None,
},
_ => return None,
};
let Object::Stream { dict, .. } = &profile_obj else {
return None;
};
let n = dict
.get("N")
.and_then(|obj| obj.as_integer())
.filter(|n| matches!(*n, 1 | 3 | 4))? as u8;
let bytes = profile_obj.decode_stream_data().ok()?;
let profile = crate::color::IccProfile::parse(bytes, n)?;
Some(std::sync::Arc::new(profile))
}
pub(crate) struct IndexedResolution {
pub base_fmt: PixelFormat,
pub components: usize,
pub palette: Vec<u8>,
pub base_profile: Option<std::sync::Arc<crate::color::IccProfile>>,
}
impl IndexedResolution {
fn highest_index(&self) -> u8 {
let entries = self.palette.len() / self.components;
u8::try_from(entries.saturating_sub(1)).unwrap_or(u8::MAX)
}
}
fn resolve_indexed_palette(
doc: Option<&crate::document::PdfDocument>,
cs_obj: &crate::object::Object,
) -> Result<Option<IndexedResolution>> {
use crate::object::Object;
let Object::Array(arr) = cs_obj else {
return Ok(None);
};
if arr.len() < 4 {
return Ok(None);
}
let base = resolve_indexed_base(doc, arr)?;
let hival = resolve_indexed_hival(doc, arr)?;
let n = devicen_ink_count(&base.base_obj).unwrap_or_else(|| base.base_fmt.bytes_per_pixel());
let Some(palette_bytes) = resolve_indexed_palette_bytes(doc, arr, hival, n)? else {
return Ok(None);
};
if matches!(base.base_cs, ColorSpace::Lab) {
let white = extract_lab_whitepoint(&base.base_obj);
let rgb_palette = lab_palette_to_rgb(&palette_bytes, white);
return Ok(Some(IndexedResolution {
base_fmt: PixelFormat::RGB,
components: 3,
palette: rgb_palette,
base_profile: None,
}));
}
Ok(Some(IndexedResolution {
base_fmt: base.base_fmt,
components: n,
palette: palette_bytes,
base_profile: base.base_profile,
}))
}
struct IndexedBase {
base_obj: crate::object::Object,
base_cs: ColorSpace,
base_fmt: PixelFormat,
base_profile: Option<std::sync::Arc<crate::color::IccProfile>>,
}
fn resolve_indexed_base(
doc: Option<&crate::document::PdfDocument>,
arr: &[crate::object::Object],
) -> Result<IndexedBase> {
use crate::object::Object;
let base_obj = if let Some(d) = doc {
let outer = if let Some(r) = arr[1].as_reference() {
d.load_object(r)?
} else {
arr[1].clone()
};
if let Object::Array(mut inner) = outer {
for item in inner.iter_mut() {
if let Some(r) = item.as_reference()
&& let Ok(resolved) = d.load_object(r)
{
*item = resolved;
}
}
Object::Array(inner)
} else {
outer
}
} else {
arr[1].clone()
};
let base_cs = parse_color_space(&base_obj)?;
let base_fmt = color_space_to_pixel_format(&base_cs);
let base_profile = if matches!(base_cs, ColorSpace::ICCBased(_)) {
resolve_icc_profile_from_obj(doc, &base_obj)
} else {
None
};
Ok(IndexedBase {
base_obj,
base_cs,
base_fmt,
base_profile,
})
}
fn resolve_indexed_hival(
doc: Option<&crate::document::PdfDocument>,
arr: &[crate::object::Object],
) -> Result<Option<usize>> {
let hival_obj = if let Some(d) = doc {
if let Some(r) = arr[2].as_reference() {
d.load_object(r)?
} else {
arr[2].clone()
}
} else {
arr[2].clone()
};
Ok(hival_obj
.as_integer()
.and_then(|i| if (0..=255).contains(&i) { Some(i as usize) } else { None }))
}
fn resolve_indexed_palette_bytes(
doc: Option<&crate::document::PdfDocument>,
arr: &[crate::object::Object],
hival: Option<usize>,
n: usize,
) -> Result<Option<Vec<u8>>> {
use crate::object::Object;
let lookup_obj = if let Some(d) = doc {
if let Some(r) = arr[3].as_reference() {
d.load_object(r)?
} else {
arr[3].clone()
}
} else {
arr[3].clone()
};
let mut palette_bytes = match &lookup_obj {
Object::String(s) => s.clone(),
Object::Stream { .. } => lookup_obj.decode_stream_data()?,
_ => return Ok(None),
};
if palette_bytes.is_empty() {
return Ok(None);
}
if let Some(h) = hival {
let expected = (h + 1).saturating_mul(n);
if expected > 0 && palette_bytes.len() > expected {
palette_bytes.truncate(expected);
}
}
Ok(Some(palette_bytes))
}
#[inline]
fn reduce_16_to_8(hi: u8, lo: u8) -> u8 {
let v = u16::from_be_bytes([hi, lo]) as u32;
((v * 255 + 32_767) / 65_535) as u8
}
#[cfg(test)]
fn expand_indexed_to_rgb(
raw: &[u8],
palette: &[u8],
base_fmt: PixelFormat,
width: u32,
height: u32,
bpc: u8,
) -> Result<Vec<u8>> {
expand_indexed_to_rgb_with_transform(IndexedExpandParams {
raw,
palette,
base_fmt,
components: base_fmt.bytes_per_pixel(),
width,
height,
bpc,
transform: None,
})
}
struct IndexedExpandParams<'a> {
raw: &'a [u8],
palette: &'a [u8],
base_fmt: PixelFormat,
components: usize,
width: u32,
height: u32,
bpc: u8,
transform: Option<&'a crate::color::Transform>,
}
fn expand_indexed_image(
raw: &[u8],
ir: &IndexedResolution,
width: u32,
height: u32,
bpc: u8,
rendering_intent: crate::color::RenderingIntent,
) -> Result<Vec<u8>> {
let transform = ir
.base_profile
.clone()
.map(|p| crate::color::Transform::new_srgb_target(p, rendering_intent));
expand_indexed_to_rgb_with_transform(IndexedExpandParams {
raw,
palette: &ir.palette,
base_fmt: ir.base_fmt,
components: ir.components,
width,
height,
bpc,
transform: transform.as_ref(),
})
}
fn expand_indexed_to_rgb_with_transform(params: IndexedExpandParams<'_>) -> Result<Vec<u8>> {
let bpc = params.bpc;
if !matches!(bpc, 1 | 2 | 4 | 8) {
return Err(Error::Image(format!(
"Indexed image has invalid /BitsPerComponent {bpc} \
(PDF spec requires 1, 2, 4, or 8)"
)));
}
let w = params.width as usize;
let h = params.height as usize;
let n = params.components;
let (bytes_per_row, output_bytes) = indexed_expand_geometry(w, h, bpc, 3)?;
validate_indexed_input_len(params.raw, bytes_per_row, h)?;
let mut out = Vec::with_capacity(output_bytes);
decode_indexed_pixels(¶ms, w, h, bytes_per_row, n, &mut out);
Ok(out)
}
fn indexed_expand_geometry(w: usize, h: usize, bpc: u8, n: usize) -> Result<(usize, usize)> {
const MAX_INDEXED_OUTPUT_BYTES: usize = 256 * 1024 * 1024;
let bytes_per_row = w.checked_mul(bpc as usize).map(|v| v.div_ceil(8)).ok_or_else(|| {
Error::Image(format!(
"Indexed image row width overflow: {w} × {bpc} bpc exceeds usize"
))
})?;
let output_bytes = w.checked_mul(h).and_then(|v| v.checked_mul(n)).ok_or_else(|| {
Error::Image(format!(
"Indexed image output size overflow: {w} × {h} × {n} exceeds usize"
))
})?;
if output_bytes > MAX_INDEXED_OUTPUT_BYTES {
return Err(Error::Image(format!(
"Indexed image decode would produce {output_bytes} bytes, \
exceeds guard limit of {MAX_INDEXED_OUTPUT_BYTES} bytes \
(width={w}, height={h})"
)));
}
Ok((bytes_per_row, output_bytes))
}
fn validate_indexed_input_len(raw: &[u8], bytes_per_row: usize, h: usize) -> Result<()> {
let required_bytes = bytes_per_row.checked_mul(h).ok_or_else(|| {
Error::Image(format!(
"Indexed image required-input size overflow: {bytes_per_row} × {h} exceeds usize"
))
})?;
if raw.len() < required_bytes {
return Err(Error::Image(format!(
"Indexed image index stream truncated: {} bytes available, \
{} required ({} bytes/row × {} rows)",
raw.len(),
required_bytes,
bytes_per_row,
h
)));
}
Ok(())
}
fn unpack_raw_samples(raw: &[u8], width: u32, height: u32, ncomp: usize, bpc: u8) -> Vec<u8> {
let samples_per_row = width as usize * ncomp;
let bytes_per_row = (samples_per_row * usize::from(bpc)).div_ceil(8);
let mut out = Vec::with_capacity(samples_per_row * height as usize);
for y in 0..height as usize {
let row = raw.get(y * bytes_per_row..).unwrap_or(&[]);
let row = &row[..bytes_per_row.min(row.len())];
out.extend((0..samples_per_row).map(|i| read_indexed_pixel(row, i, bpc) as u8));
}
out
}
fn read_indexed_pixel(row: &[u8], x: usize, bpc: u8) -> usize {
match bpc {
8 => row.get(x).copied().unwrap_or(0) as usize,
4 => {
let byte_idx = x / 2;
let b = row.get(byte_idx).copied().unwrap_or(0);
if x.is_multiple_of(2) {
(b >> 4) as usize
} else {
(b & 0x0F) as usize
}
}
2 => {
let byte_idx = x / 4;
let b = row.get(byte_idx).copied().unwrap_or(0);
let shift = 6 - (x % 4) * 2;
((b >> shift) & 0x03) as usize
}
1 => {
let byte_idx = x / 8;
let b = row.get(byte_idx).copied().unwrap_or(0);
let shift = 7 - (x % 8);
((b >> shift) & 0x01) as usize
}
_ => unreachable!("bpc validated to {{1,2,4,8}} before read_indexed_pixel"),
}
}
fn decode_indexed_pixels(
params: &IndexedExpandParams<'_>,
w: usize,
h: usize,
bytes_per_row: usize,
n: usize,
out: &mut Vec<u8>,
) {
for y in 0..h {
let row_start = y * bytes_per_row;
let row_end = (row_start + bytes_per_row).min(params.raw.len());
let row: &[u8] = if row_start < params.raw.len() {
¶ms.raw[row_start..row_end]
} else {
&[]
};
for x in 0..w {
let idx = read_indexed_pixel(row, x, params.bpc);
let off = idx * n;
if off + n > params.palette.len() {
out.extend_from_slice(&[0, 0, 0]);
continue;
}
match params.base_fmt {
PixelFormat::RGB => out.extend_from_slice(¶ms.palette[off..off + 3]),
PixelFormat::Grayscale => {
let g = params.palette[off];
out.push(g);
out.push(g);
out.push(g);
}
PixelFormat::CMYK => {
let ink = |i: usize| if i < n { params.palette[off + i] } else { 0 };
let (c, m, y_c, k) = (ink(0), ink(1), ink(2), ink(3));
let [r, g, b] = if let Some(t) = params.transform {
t.convert_cmyk_pixel(c, m, y_c, k)
} else {
cmyk_pixel_to_rgb(c, m, y_c, k)
};
out.push(r);
out.push(g);
out.push(b);
}
}
}
}
}
fn unpack_indexed_indices(raw: &[u8], width: u32, height: u32, bpc: u8) -> Result<Vec<u8>> {
if !matches!(bpc, 1 | 2 | 4 | 8) {
return Err(Error::Image(format!(
"Indexed image has invalid /BitsPerComponent {bpc} \
(PDF spec requires 1, 2, 4, or 8)"
)));
}
let w = width as usize;
let h = height as usize;
let (bytes_per_row, output_bytes) = indexed_expand_geometry(w, h, bpc, 1)?;
validate_indexed_input_len(raw, bytes_per_row, h)?;
let mut out = Vec::with_capacity(output_bytes);
for y in 0..h {
let row_start = y * bytes_per_row;
let row_end = (row_start + bytes_per_row).min(raw.len());
let row: &[u8] = if row_start < raw.len() {
&raw[row_start..row_end]
} else {
&[]
};
for x in 0..w {
out.push(read_indexed_pixel(row, x, bpc) as u8);
}
}
Ok(out)
}
pub(crate) fn expand_indexed_to_base_samples(
raw: &[u8],
palette: &[u8],
components: usize,
width: u32,
height: u32,
bpc: u8,
) -> Result<Vec<u8>> {
if !matches!(bpc, 1 | 2 | 4 | 8) {
return Err(Error::Image(format!(
"Indexed image has invalid /BitsPerComponent {bpc} \
(PDF spec requires 1, 2, 4, or 8)"
)));
}
let w = width as usize;
let h = height as usize;
let n = components;
let (bytes_per_row, output_bytes) = indexed_expand_geometry(w, h, bpc, n)?;
validate_indexed_input_len(raw, bytes_per_row, h)?;
let mut out = Vec::with_capacity(output_bytes);
for y in 0..h {
let row_start = y * bytes_per_row;
let row_end = (row_start + bytes_per_row).min(raw.len());
let row: &[u8] = if row_start < raw.len() {
&raw[row_start..row_end]
} else {
&[]
};
for x in 0..w {
let idx = read_indexed_pixel(row, x, bpc);
let off = idx * n;
if off + n > palette.len() {
out.resize(out.len() + n, 0);
continue;
}
out.extend_from_slice(&palette[off..off + n]);
}
}
Ok(out)
}
pub(crate) struct IndexedBaseSamples {
pub samples: Vec<u8>,
pub components: usize,
pub width: u32,
pub height: u32,
}
pub(crate) fn decode_indexed_image_in_base_space(
doc: Option<&crate::document::PdfDocument>,
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
color_space_map: Option<&std::collections::HashMap<String, crate::object::Object>>,
) -> Result<Option<IndexedBaseSamples>> {
let dict = xobject
.as_dict()
.ok_or_else(|| Error::Image("XObject is not a stream".to_string()))?;
let ImageXObjectMetadata {
width,
height,
bits_per_component,
color_space,
indexed_resolution,
is_jbig2,
is_jpx,
jpx_color_space_is_placeholder,
is_jpeg_only,
is_jpeg_chain,
is_ccitt,
..
} = resolve_image_xobject_metadata(doc, dict, color_space_map)?;
let Some(ir) = indexed_resolution else {
return Ok(None);
};
if is_jpx {
let JpxDecoded {
data, width, height, ..
} = decode_jpx_image(
xobject,
obj_ref,
doc,
&color_space,
jpx_color_space_is_placeholder,
(width, height),
Some(&ir),
)?;
let ImageData::Raw { pixels: indices, .. } = data else {
return Ok(None);
};
let samples = expand_indexed_to_base_samples(&indices, &ir.palette, ir.components, width, height, 8)?;
return Ok(Some(IndexedBaseSamples {
samples,
components: ir.components,
width,
height,
}));
}
if is_jbig2 || is_jpeg_only || is_jpeg_chain || is_ccitt {
return Ok(None);
}
let expected_size = expected_image_filter_output_size(
dict,
width,
height,
ColorSpace::Indexed.components(),
bits_per_component,
)?;
let decoded_data = decode_dimension_bounded_image_stream(doc, xobject, obj_ref, expected_size)?;
let samples = expand_indexed_to_base_samples(
&decoded_data,
&ir.palette,
ir.components,
width,
height,
bits_per_component,
)?;
Ok(Some(IndexedBaseSamples {
samples,
components: ir.components,
width,
height,
}))
}
pub(crate) fn cmyk_pixel_to_rgb(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
let (r, g, b) = crate::color::cmyk_to_rgb(c as f32 / 255.0, m as f32 / 255.0, y as f32 / 255.0, k as f32 / 255.0);
[
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
]
}
fn extract_lab_whitepoint(cs_obj: &crate::object::Object) -> [f64; 3] {
const D65: [f64; 3] = [0.9505, 1.0, 1.0890];
let arr = match cs_obj {
crate::object::Object::Array(a) => a,
_ => return D65,
};
if arr.len() < 2 {
return D65;
}
let dict = match &arr[1] {
crate::object::Object::Dictionary(d) => d,
_ => return D65,
};
let wp = match dict.get("WhitePoint") {
Some(crate::object::Object::Array(a)) if a.len() >= 3 => a,
_ => return D65,
};
let f = |obj: &crate::object::Object| -> Option<f64> {
match obj {
crate::object::Object::Real(v) => Some(*v),
crate::object::Object::Integer(v) => Some(*v as f64),
_ => None,
}
};
match (f(&wp[0]), f(&wp[1]), f(&wp[2])) {
(Some(x), Some(y), Some(z)) => [x, y, z],
_ => D65,
}
}
pub(crate) fn lab_palette_to_rgb(palette: &[u8], white: [f64; 3]) -> Vec<u8> {
let mut rgb = Vec::with_capacity(palette.len());
for chunk in palette.chunks(3) {
if chunk.len() < 3 {
rgb.extend_from_slice(&[0, 0, 0]);
continue;
}
let [r, g, b] = lab_pixel_to_rgb(chunk[0], chunk[1], chunk[2], white);
rgb.push(r);
rgb.push(g);
rgb.push(b);
}
rgb
}
fn lab_pixel_to_rgb(l_byte: u8, a_byte: u8, b_byte: u8, white: [f64; 3]) -> [u8; 3] {
let l_star = l_byte as f64 / 255.0 * 100.0;
let a_star = a_byte as f64 - 128.0;
let b_star = b_byte as f64 - 128.0;
let fy = (l_star + 16.0) / 116.0;
let fx = a_star / 500.0 + fy;
let fz = fy - b_star / 200.0;
let [xw, yw, zw] = white;
let x = xw * f_inv(fx);
let y = yw * f_inv(fy);
let z = zw * f_inv(fz);
let r_lin = 3.2406254773 * x - 1.5372079722 * y - 0.4986285987 * z;
let g_lin = -0.9689307147 * x + 1.8757560609 * y + 0.0415175580 * z;
let b_lin = 0.0557101204 * x - 0.2040210506 * y + 1.0569959423 * z;
[srgb_gamma(r_lin), srgb_gamma(g_lin), srgb_gamma(b_lin)]
}
fn f_inv(t: f64) -> f64 {
const DELTA: f64 = 6.0 / 29.0;
if t > DELTA {
t * t * t
} else {
3.0 * DELTA * DELTA * (t - 4.0 / 29.0)
}
}
fn srgb_gamma(lin: f64) -> u8 {
let v = if lin <= 0.0031308 {
12.92 * lin
} else {
1.055 * lin.powf(1.0 / 2.4) - 0.055
};
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
pub fn cmyk_to_rgb(cmyk: &[u8]) -> Vec<u8> {
cmyk_to_rgb_with_transform(cmyk, None)
}
pub fn cmyk_to_rgb_with_transform(cmyk: &[u8], transform: Option<&crate::color::Transform>) -> Vec<u8> {
if let Some(t) = transform {
return t.convert_cmyk_buffer(cmyk);
}
let mut rgb = Vec::with_capacity((cmyk.len() / 4) * 3);
for chunk in cmyk.as_chunks::<4>().0 {
let [r, g, b] = cmyk_pixel_to_rgb(chunk[0], chunk[1], chunk[2], chunk[3]);
rgb.push(r);
rgb.push(g);
rgb.push(b);
}
rgb
}
pub fn decode_cmyk_jpeg_to_rgb(jpeg_data: &[u8]) -> Result<Vec<u8>> {
decode_cmyk_jpeg_to_rgb_with_profile(jpeg_data, None)
}
pub(crate) fn decode_cmyk_jpeg_to_raw_cmyk(jpeg_data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(jpeg_data));
let cmyk = decoder
.decode()
.map_err(|e| Error::Decode(format!("Failed to decode CMYK JPEG: {}", e)))?;
let info = decoder
.info()
.ok_or_else(|| Error::Decode("JPEG info unavailable".to_string()))?;
let pixel_count = (info.width as usize) * (info.height as usize);
let expected = pixel_count * 4;
if cmyk.len() < expected {
return Err(Error::Decode(format!(
"CMYK JPEG decoded {} bytes, expected {}",
cmyk.len(),
expected
)));
}
let mut raw = cmyk;
raw.truncate(expected);
if matches!(scan_app14_color_transform(jpeg_data), Some(0) | Some(2)) {
for b in raw.iter_mut() {
*b = 255 - *b;
}
}
Ok(raw)
}
pub fn decode_cmyk_jpeg_to_rgb_with_profile(
jpeg_data: &[u8],
transform: Option<&crate::color::Transform>,
) -> Result<Vec<u8>> {
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(jpeg_data));
let cmyk = decoder
.decode()
.map_err(|e| Error::Decode(format!("Failed to decode CMYK JPEG: {}", e)))?;
let info = decoder
.info()
.ok_or_else(|| Error::Decode("JPEG info unavailable".to_string()))?;
let pixel_count = (info.width as usize) * (info.height as usize);
let expected = pixel_count * 4;
if cmyk.len() < expected {
return Err(Error::Decode(format!(
"CMYK JPEG decoded {} bytes, expected {}",
cmyk.len(),
expected
)));
}
let straight_cmyk: Vec<u8> = if matches!(scan_app14_color_transform(jpeg_data), Some(0) | Some(2)) {
cmyk[..expected].iter().map(|b| 255 - *b).collect()
} else {
cmyk[..expected].to_vec()
};
if let Some(t) = transform {
return Ok(t.convert_cmyk_buffer(&straight_cmyk));
}
let mut rgb = Vec::with_capacity(pixel_count * 3);
for chunk in straight_cmyk.as_chunks::<4>().0 {
let [r, g, b] = cmyk_pixel_to_rgb(chunk[0], chunk[1], chunk[2], chunk[3]);
rgb.push(r);
rgb.push(g);
rgb.push(b);
}
Ok(rgb)
}
fn scan_app14_color_transform(jpeg_data: &[u8]) -> Option<u8> {
let mut i = 0;
while i + 1 < jpeg_data.len() {
if jpeg_data[i] != 0xFF {
i += 1;
continue;
}
let marker = jpeg_data[i + 1];
i += 2;
if marker == 0x00 || marker == 0xFF {
continue;
}
if matches!(marker, 0xD0..=0xD9) || marker == 0x01 {
continue;
}
if i + 1 >= jpeg_data.len() {
break;
}
let seg_len = u16::from_be_bytes([jpeg_data[i], jpeg_data[i + 1]]) as usize;
if seg_len < 2 || i + seg_len > jpeg_data.len() {
break;
}
if marker == 0xEE && seg_len >= 14 {
let payload = &jpeg_data[i + 2..i + seg_len];
if payload.len() >= 12 && payload.starts_with(b"Adobe") {
return Some(payload[11]);
}
}
if marker == 0xDA {
break;
}
i += seg_len;
}
None
}
fn save_jpeg_as_png(jpeg_data: &[u8], path: impl AsRef<Path>) -> Result<()> {
use image::ImageFormat;
let img = image::load_from_memory_with_format(jpeg_data, ImageFormat::Jpeg)
.map_err(|e| Error::Image(format!("Failed to decode JPEG: {}", e)))?;
img.save_with_format(path, ImageFormat::Png)
.map_err(|e| Error::Image(format!("Failed to save PNG: {}", e)))
}
fn icc_matches_format(
transform: Option<&crate::color::Transform>,
format: PixelFormat,
) -> Option<&crate::color::Transform> {
let t = transform?;
let needed = match format {
PixelFormat::RGB => 3,
PixelFormat::Grayscale => 1,
PixelFormat::CMYK => 4,
};
if t.source_n_components() == needed {
Some(t)
} else {
None
}
}
fn save_raw_as_png(
pixels: &[u8],
width: u32,
height: u32,
format: PixelFormat,
transform: Option<&crate::color::Transform>,
path: impl AsRef<Path>,
) -> Result<()> {
use image::{ImageBuffer, ImageFormat, Luma, Rgb};
match format {
PixelFormat::RGB => {
let rgb = match icc_matches_format(transform, format) {
Some(t) => t.convert_rgb_buffer(pixels),
None => pixels.to_vec(),
};
let img = ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
.ok_or_else(|| Error::Image("Invalid RGB image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Png)
.map_err(|e| Error::Image(format!("Failed to save PNG: {}", e)))
}
PixelFormat::Grayscale => {
if let Some(t) = icc_matches_format(transform, format) {
let rgb = t.convert_gray_buffer(pixels);
let img = ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
.ok_or_else(|| Error::Image("Invalid grayscale image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Png)
.map_err(|e| Error::Image(format!("Failed to save PNG: {}", e)))
} else {
let img = ImageBuffer::<Luma<u8>, _>::from_raw(width, height, pixels.to_vec())
.ok_or_else(|| Error::Image("Invalid grayscale image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Png)
.map_err(|e| Error::Image(format!("Failed to save PNG: {}", e)))
}
}
PixelFormat::CMYK => {
let rgb = cmyk_to_rgb_with_transform(pixels, icc_matches_format(transform, format));
let img = ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
.ok_or_else(|| Error::Image("Invalid CMYK image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Png)
.map_err(|e| Error::Image(format!("Failed to save PNG: {}", e)))
}
}
}
fn save_raw_as_jpeg(
pixels: &[u8],
width: u32,
height: u32,
format: PixelFormat,
transform: Option<&crate::color::Transform>,
path: impl AsRef<Path>,
) -> Result<()> {
use image::{ImageBuffer, ImageFormat, Luma, Rgb};
match format {
PixelFormat::RGB => {
let rgb = match icc_matches_format(transform, format) {
Some(t) => t.convert_rgb_buffer(pixels),
None => pixels.to_vec(),
};
let img = ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
.ok_or_else(|| Error::Image("Invalid RGB image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Jpeg)
.map_err(|e| Error::Image(format!("Failed to save JPEG: {}", e)))
}
PixelFormat::Grayscale => {
if let Some(t) = icc_matches_format(transform, format) {
let rgb = t.convert_gray_buffer(pixels);
let img = ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
.ok_or_else(|| Error::Image("Invalid grayscale image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Jpeg)
.map_err(|e| Error::Image(format!("Failed to save JPEG: {}", e)))
} else {
let img = ImageBuffer::<Luma<u8>, _>::from_raw(width, height, pixels.to_vec())
.ok_or_else(|| Error::Image("Invalid grayscale image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Jpeg)
.map_err(|e| Error::Image(format!("Failed to save JPEG: {}", e)))
}
}
PixelFormat::CMYK => {
let rgb = cmyk_to_rgb_with_transform(pixels, icc_matches_format(transform, format));
let img = ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
.ok_or_else(|| Error::Image("Invalid CMYK image dimensions".to_string()))?;
img.save_with_format(path, ImageFormat::Jpeg)
.map_err(|e| Error::Image(format!("Failed to save JPEG: {}", e)))
}
}
}
fn checked_jbig2_pixel_capacity(width: u32, height: u32) -> Result<usize> {
const MAX_JBIG2_PIXEL_BYTES: usize = 256 * 1024 * 1024;
let pixel_count = (width as usize).checked_mul(height as usize).ok_or_else(|| {
Error::Image(format!(
"JBIG2 image pixel-count overflow: {width} × {height} exceeds usize"
))
})?;
if pixel_count > MAX_JBIG2_PIXEL_BYTES {
return Err(Error::Image(format!(
"JBIG2 image would decode to {pixel_count} pixel bytes, exceeds guard limit of \
{MAX_JBIG2_PIXEL_BYTES} bytes (width={width}, height={height})"
)));
}
Ok(pixel_count)
}
fn decode_jbig2_image(
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
dict: &std::collections::HashMap<String, crate::object::Object>,
doc: Option<&crate::document::PdfDocument>,
width: u32,
height: u32,
) -> Result<ImageData> {
let pixel_count = checked_jbig2_pixel_capacity(width, height)?;
let jbig2_bytes: Vec<u8> = if let (Some(d), Some(ref_id)) = (doc.as_ref(), obj_ref) {
d.decode_stream_with_encryption(xobject, ref_id)?
} else {
xobject.decode_stream_data()?
};
let globals: Option<Vec<u8>> = (|| -> Option<Vec<u8>> {
let dp = dict.get("DecodeParms")?.as_dict()?;
let globals_ref = dp.get("JBIG2Globals")?.as_reference()?;
let d = doc.as_ref()?;
let globals_obj = d.load_object(globals_ref).ok()?;
d.decode_stream_with_encryption(&globals_obj, globals_ref).ok()
})();
let image = hayro_jbig2::Image::new_embedded(&jbig2_bytes, globals.as_deref())
.map_err(|e| Error::Image(format!("JBIG2 decode error: {e}")))?;
struct PixelCollector {
pixels: Vec<u8>,
row_buf: Vec<u8>,
}
impl hayro_jbig2::Decoder for PixelCollector {
fn push_pixel(&mut self, black: bool) {
self.row_buf.push(if black { 0 } else { 255 });
}
fn push_pixel_chunk(&mut self, black: bool, chunk_count: u32) {
let v = if black { 0u8 } else { 255u8 };
let n = chunk_count as usize * 8;
self.row_buf.extend(std::iter::repeat_n(v, n));
}
fn next_line(&mut self) {
self.pixels.append(&mut self.row_buf);
}
}
let mut collector = PixelCollector {
pixels: Vec::with_capacity(pixel_count),
row_buf: Vec::with_capacity(width as usize),
};
image
.decode(&mut collector)
.map_err(|e| Error::Image(format!("JBIG2 pixel decode error: {e}")))?;
Ok(ImageData::Raw {
pixels: collector.pixels,
format: PixelFormat::Grayscale,
})
}
#[cfg(test)]
mod jbig2_pixel_capacity_tests {
use super::checked_jbig2_pixel_capacity;
#[test]
fn should_accept_an_ordinary_scanned_page_size() {
let capacity = checked_jbig2_pixel_capacity(2550, 3300).expect("an ordinary page size must not be rejected");
assert_eq!(capacity, 2550 * 3300);
}
#[test]
fn should_reject_dimensions_whose_pixel_count_exceeds_the_guard_cap() {
let result = checked_jbig2_pixel_capacity(20_000, 20_000);
assert!(result.is_err(), "oversized JBIG2 image must be rejected, not decoded");
}
#[test]
fn should_reject_dimensions_whose_product_overflows_usize() {
let result = checked_jbig2_pixel_capacity(u32::MAX, u32::MAX);
assert!(
result.is_err(),
"overflowing width * height must be rejected, not panic"
);
}
}
pub(crate) fn decode_jbig2_image_mask_bits(
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
dict: &std::collections::HashMap<String, crate::object::Object>,
doc: Option<&crate::document::PdfDocument>,
width: u32,
height: u32,
) -> Result<Vec<u8>> {
let decoded = decode_jbig2_image(xobject, obj_ref, dict, doc, width, height)?;
let ImageData::Raw { pixels, .. } = decoded else {
return Err(Error::Image(
"JBIG2 ImageMask decode did not yield raw pixels".to_string(),
));
};
pack_image_mask_rows(&pixels, width, height)
}
fn pack_image_mask_rows(pixels: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
const MAX_MASK_OUTPUT_BYTES: usize = 256 * 1024 * 1024;
let row_bytes = (width as usize).div_ceil(8);
let packed_len = row_bytes.checked_mul(height as usize).ok_or_else(|| {
Error::Image(format!(
"ImageMask row-bytes overflow: {row_bytes} × {height} exceeds usize"
))
})?;
if packed_len > MAX_MASK_OUTPUT_BYTES {
return Err(Error::Image(format!(
"ImageMask decode would produce {packed_len} bytes, exceeds guard limit of \
{MAX_MASK_OUTPUT_BYTES} bytes (width={width}, height={height})"
)));
}
let mut packed = vec![0u8; packed_len];
for y in 0..height as usize {
for x in 0..width as usize {
let is_background = pixels.get(y * width as usize + x).is_none_or(|&pixel| pixel >= 128);
if is_background {
packed[y * row_bytes + (x / 8)] |= 0x80 >> (x % 8);
}
}
}
Ok(packed)
}
fn decode_jpx_image(
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
doc: Option<&crate::document::PdfDocument>,
color_space: &ColorSpace,
color_space_is_placeholder: bool,
declared_size: (u32, u32),
indexed: Option<&IndexedResolution>,
) -> Result<JpxDecoded> {
let codestream: Vec<u8> = if let (Some(d), Some(ref_id)) = (doc.as_ref(), obj_ref) {
d.decode_stream_with_encryption(xobject, ref_id)?
} else {
xobject.decode_stream_data()?
};
let codestream_palette = if indexed.is_some() {
None
} else {
codestream_palette_resolution(&codestream, color_space, color_space_is_placeholder)?
};
if let Some(highest_index) = indexed
.or(codestream_palette.as_ref())
.map(IndexedResolution::highest_index)
{
let indices = crate::decoders::jpx::decode_jpx_indices(&codestream, highest_index)?;
warn_on_jpx_size_mismatch(declared_size, (indices.width, indices.height));
return Ok(JpxDecoded {
data: ImageData::Raw {
pixels: indices.samples,
format: PixelFormat::Grayscale,
},
width: indices.width,
height: indices.height,
palette: codestream_palette,
opacity: indices.opacity,
});
}
let declared_components = if color_space_is_placeholder {
None
} else {
u8::try_from(color_space.components()).ok().filter(|&n| n > 0)
};
let img = crate::decoders::jpx::decode_jpx(&codestream, declared_components)?;
warn_on_jpx_size_mismatch(declared_size, (img.width, img.height));
let format = match img.num_components {
1 => PixelFormat::Grayscale,
3 => PixelFormat::RGB,
4 => PixelFormat::CMYK,
n => {
return Err(Error::UnsupportedFilter(format!(
"JPXDecode: unsupported JPEG 2000 component count {n}"
)));
}
};
Ok(JpxDecoded {
data: ImageData::Raw {
pixels: img.samples,
format,
},
width: img.width,
height: img.height,
palette: None,
opacity: img.opacity,
})
}
fn warn_on_jpx_size_mismatch(declared: (u32, u32), codestream: (u32, u32)) {
if declared == codestream {
return;
}
let ((declared_width, declared_height), (width, height)) = (declared, codestream);
let msg = format!(
"SPEC NOTE: /JPXDecode image dictionary declares {declared_width}x{declared_height}, but \
its codestream's SIZ marker segment declares {width}x{height}. ISO 32000-1:2008 Section 7.4.9 \
treats /Width and /Height as informative for JPEG 2000, so the codestream's size is used."
);
tracing::warn!("{}", msg);
crate::extractors::warnings::push_global_warning(crate::extractors::warnings::Warning {
category: crate::extractors::warnings::WarningCategory::JpxSizeMismatch,
page: None,
message: msg,
spec_section: Some("7.4.9"),
});
}
struct JpxDecoded {
data: ImageData,
width: u32,
height: u32,
palette: Option<IndexedResolution>,
opacity: Option<Vec<u8>>,
}
fn codestream_palette_resolution(
codestream: &[u8],
color_space: &ColorSpace,
color_space_is_placeholder: bool,
) -> Result<Option<IndexedResolution>> {
let Some(palette) = crate::decoders::jpx::codestream_palette(codestream) else {
return Ok(None);
};
let base_fmt = match palette.columns {
1 => PixelFormat::Grayscale,
3 => PixelFormat::RGB,
4 => PixelFormat::CMYK,
_ => return Ok(None),
};
let component_count_fits = color_space.components() == usize::from(palette.columns);
if !color_space_is_placeholder && !component_count_fits {
return Err(Error::UnsupportedFilter(format!(
"JPXDecode: codestream palette has {} components, but /ColorSpace has {}",
palette.columns,
color_space.components()
)));
}
let declared_is_device = matches!(
color_space,
ColorSpace::DeviceGray | ColorSpace::DeviceRGB | ColorSpace::DeviceCMYK
);
if !color_space_is_placeholder && !declared_is_device {
return Ok(None);
}
Ok(Some(IndexedResolution {
base_fmt,
components: usize::from(palette.columns),
palette: palette.entries,
base_profile: None,
}))
}
pub fn expand_inline_image_dict(
mut dict: std::collections::HashMap<String, crate::object::Object>,
) -> std::collections::HashMap<String, crate::object::Object> {
use std::collections::HashMap;
const KEY_ABBREVS: [(&str, &str); 9] = [
("W", "Width"),
("H", "Height"),
("CS", "ColorSpace"),
("BPC", "BitsPerComponent"),
("F", "Filter"),
("DP", "DecodeParms"),
("IM", "ImageMask"),
("I", "Interpolate"),
("D", "Decode"),
];
let mut expanded = HashMap::new();
for (abbrev, full) in KEY_ABBREVS {
let value = match dict.remove(abbrev) {
Some(v) => {
dict.remove(full);
Some(v)
}
None => dict.remove(full),
};
if let Some(v) = value {
expanded.insert(full.to_string(), v);
}
}
for (key, value) in dict {
expanded.insert(key, value);
}
for (key, map) in [
("ColorSpace", colorspace_abbrev as fn(&str) -> Option<&'static str>),
("Filter", filter_abbrev as fn(&str) -> Option<&'static str>),
] {
if let Some(v) = expanded.remove(key) {
expanded.insert(key.to_string(), expand_inline_abbrev(v, map));
}
}
expanded
.entry("Subtype".to_string())
.or_insert_with(|| crate::object::Object::Name("Image".to_string()));
expanded
}
fn colorspace_abbrev(name: &str) -> Option<&'static str> {
match name {
"G" => Some("DeviceGray"),
"RGB" => Some("DeviceRGB"),
"CMYK" => Some("DeviceCMYK"),
"I" => Some("Indexed"),
_ => None,
}
}
fn filter_abbrev(name: &str) -> Option<&'static str> {
match name {
"AHx" => Some("ASCIIHexDecode"),
"A85" => Some("ASCII85Decode"),
"LZW" => Some("LZWDecode"),
"Fl" => Some("FlateDecode"),
"RL" => Some("RunLengthDecode"),
"CCF" => Some("CCITTFaxDecode"),
"DCT" => Some("DCTDecode"),
_ => None,
}
}
fn expand_inline_abbrev(value: crate::object::Object, map: fn(&str) -> Option<&'static str>) -> crate::object::Object {
use crate::object::Object;
match value {
Object::Name(n) => match map(&n) {
Some(full) => Object::Name(full.to_string()),
None => Object::Name(n),
},
Object::Array(items) => Object::Array(items.into_iter().map(|item| expand_inline_abbrev(item, map)).collect()),
other => other,
}
}
#[cfg(test)]
mod inline_image_dict_tests {
use super::*;
use crate::object::Object;
use std::collections::HashMap;
fn dict(pairs: &[(&str, Object)]) -> HashMap<String, Object> {
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn supplies_the_implied_image_subtype() {
let out = expand_inline_image_dict(dict(&[("W", Object::Integer(26)), ("H", Object::Integer(1))]));
assert_eq!(out.get("Subtype"), Some(&Object::Name("Image".to_string())));
assert_eq!(out.get("Width"), Some(&Object::Integer(26)));
assert_eq!(out.get("Height"), Some(&Object::Integer(1)));
}
#[test]
fn expands_abbreviated_colour_space_and_filter_values() {
let out = expand_inline_image_dict(dict(&[
("CS", Object::Name("RGB".to_string())),
("F", Object::Name("Fl".to_string())),
]));
assert_eq!(out.get("ColorSpace"), Some(&Object::Name("DeviceRGB".to_string())));
assert_eq!(out.get("Filter"), Some(&Object::Name("FlateDecode".to_string())));
}
#[test]
fn expands_every_table_92_abbreviation() {
for (abbr, full) in [
("G", "DeviceGray"),
("RGB", "DeviceRGB"),
("CMYK", "DeviceCMYK"),
("I", "Indexed"),
] {
let out = expand_inline_image_dict(dict(&[("CS", Object::Name(abbr.to_string()))]));
assert_eq!(
out.get("ColorSpace"),
Some(&Object::Name(full.to_string())),
"colour space /{abbr}"
);
}
for (abbr, full) in [
("AHx", "ASCIIHexDecode"),
("A85", "ASCII85Decode"),
("LZW", "LZWDecode"),
("Fl", "FlateDecode"),
("RL", "RunLengthDecode"),
("CCF", "CCITTFaxDecode"),
("DCT", "DCTDecode"),
] {
let out = expand_inline_image_dict(dict(&[("F", Object::Name(abbr.to_string()))]));
assert_eq!(
out.get("Filter"),
Some(&Object::Name(full.to_string())),
"filter /{abbr}"
);
}
}
#[test]
fn expands_abbreviations_inside_arrays() {
let out = expand_inline_image_dict(dict(&[
(
"F",
Object::Array(vec![Object::Name("A85".to_string()), Object::Name("Fl".to_string())]),
),
(
"CS",
Object::Array(vec![
Object::Name("I".to_string()),
Object::Name("RGB".to_string()),
Object::Integer(255),
]),
),
]));
assert_eq!(
out.get("Filter"),
Some(&Object::Array(vec![
Object::Name("ASCII85Decode".to_string()),
Object::Name("FlateDecode".to_string()),
]))
);
assert_eq!(
out.get("ColorSpace"),
Some(&Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(255),
]))
);
}
#[test]
fn leaves_unabbreviated_and_named_spaces_alone() {
let out = expand_inline_image_dict(dict(&[("CS", Object::Name("CS0".to_string()))]));
assert_eq!(out.get("ColorSpace"), Some(&Object::Name("CS0".to_string())));
let out = expand_inline_image_dict(dict(&[("CS", Object::Name("DeviceGray".to_string()))]));
assert_eq!(out.get("ColorSpace"), Some(&Object::Name("DeviceGray".to_string())));
}
#[test]
fn abbreviated_key_beats_its_full_twin() {
let out = expand_inline_image_dict(dict(&[
("F", Object::Name("AHx".to_string())),
("Filter", Object::Name("A85".to_string())),
("W", Object::Integer(20)),
("Width", Object::Integer(999)),
("DP", Object::Null),
("DecodeParms", Object::Integer(15)),
]));
assert_eq!(out.get("Filter"), Some(&Object::Name("ASCIIHexDecode".to_string())));
assert_eq!(out.get("Width"), Some(&Object::Integer(20)));
assert_eq!(out.get("DecodeParms"), Some(&Object::Null));
}
}
#[cfg(test)]
mod indexed_tests {
use super::*;
#[test]
fn expand_indexed_rgb_8bpc() {
let palette = vec![0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255];
let raw = vec![0, 1, 2, 3];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 2, 2, 8).unwrap();
assert_eq!(out, vec![0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]);
}
#[test]
fn expand_indexed_gray_base_to_rgb() {
let palette = vec![10, 128, 255];
let raw = vec![0, 1, 2];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::Grayscale, 3, 1, 8).unwrap();
assert_eq!(out, vec![10, 10, 10, 128, 128, 128, 255, 255, 255]);
}
#[test]
fn expand_indexed_out_of_range_index() {
let palette = vec![10, 20, 30, 40, 50, 60];
let raw = vec![0, 5];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 2, 1, 8).unwrap();
assert_eq!(out, vec![10, 20, 30, 0, 0, 0]);
}
#[test]
fn highest_index_is_the_last_entry_the_palette_holds() {
use crate::object::Object;
let highest = |hival: i64, lookup: Vec<u8>| {
let cs = Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(hival),
Object::String(lookup),
]);
resolve_indexed_palette(None, &cs).unwrap().unwrap().highest_index()
};
assert_eq!(highest(15, vec![7; 16 * 3]), 15, "a full lookup ends at hival");
assert_eq!(
highest(255, vec![7; 256 * 3]),
255,
"a full 256-entry lookup ends at 255"
);
assert_eq!(
highest(255, vec![7; 4 * 3 + 2]),
3,
"a short lookup ends at its last whole entry"
);
assert_eq!(highest(255, vec![7; 2]), 0, "a lookup with no whole entry clamps to 0");
}
#[test]
fn resolve_indexed_palette_truncates_to_hival() {
use crate::object::Object;
let cs = Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(1),
Object::String(vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]),
]);
let ir = resolve_indexed_palette(None, &cs).unwrap().unwrap();
assert_eq!(ir.base_fmt, PixelFormat::RGB);
assert_eq!(ir.palette, vec![10, 20, 30, 40, 50, 60]);
assert!(ir.base_profile.is_none(), "DeviceRGB base has no ICC profile");
let (fmt, palette) = (ir.base_fmt, ir.palette);
let raw = vec![0, 1, 2];
let out = expand_indexed_to_rgb(&raw, &palette, fmt, 3, 1, 8).unwrap();
assert_eq!(out, vec![10, 20, 30, 40, 50, 60, 0, 0, 0]);
}
#[test]
fn a_devicen_palette_is_read_one_byte_per_colorant() {
use crate::object::Object;
let cs = Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Array(vec![
Object::Name("DeviceN".to_string()),
Object::Array(vec![
Object::Name("Spot-A".to_string()),
Object::Name("Spot-B".to_string()),
]),
Object::Name("DeviceCMYK".to_string()),
Object::Null,
]),
Object::Integer(1),
Object::String(vec![0xFF, 0x00, 0x00, 0xFF]),
]);
let ir = resolve_indexed_palette(None, &cs).unwrap().unwrap();
assert_eq!(ir.components, 2, "one byte per colorant");
assert_eq!(ir.highest_index(), 1, "two entries");
let rgb = expand_indexed_image(&[0, 1], &ir, 2, 1, 8, crate::color::RenderingIntent::default()).unwrap();
assert_eq!(
rgb[..3],
cmyk_pixel_to_rgb(0xFF, 0, 0, 0),
"entry 0 is the first colorant"
);
assert_eq!(
rgb[3..],
cmyk_pixel_to_rgb(0, 0xFF, 0, 0),
"entry 1 is the second colorant"
);
let base = expand_indexed_to_base_samples(&[0, 1], &ir.palette, ir.components, 2, 1, 8).unwrap();
assert_eq!(
base,
vec![0xFF, 0x00, 0x00, 0xFF],
"the plates see each entry's own two bytes"
);
}
#[test]
fn a_lab_palette_is_stepped_as_rgb_after_conversion() {
use crate::object::Object;
let (black, white) = ([0u8, 128, 128], [255u8, 128, 128]);
let cs = Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Array(vec![
Object::Name("Lab".to_string()),
Object::Dictionary(std::collections::HashMap::new()),
]),
Object::Integer(1),
Object::String([black, white].concat()),
]);
let ir = resolve_indexed_palette(None, &cs).unwrap().unwrap();
assert_eq!(ir.highest_index(), 1, "two entries");
let d65 = [0.9505, 1.0, 1.0890];
let rgb = expand_indexed_image(&[0, 1], &ir, 2, 1, 8, crate::color::RenderingIntent::default()).unwrap();
assert_eq!(rgb[..3], lab_palette_to_rgb(&black, d65)[..], "entry 0 is Lab black");
assert_eq!(rgb[3..], lab_palette_to_rgb(&white, d65)[..], "entry 1 is Lab white");
assert_ne!(rgb[..3], rgb[3..], "the two entries differ");
}
#[test]
fn expand_indexed_cmyk_base_matches_cmyk_to_rgb() {
let palette = vec![64, 128, 192, 32];
let raw = vec![0];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::CMYK, 1, 1, 8).unwrap();
let expected = cmyk_pixel_to_rgb(64, 128, 192, 32);
assert_eq!(out, expected.to_vec());
}
#[test]
fn cmyk_pixel_uses_process_inks_not_additive() {
assert_eq!(cmyk_pixel_to_rgb(0, 0, 0, 255), [0x23, 0x1F, 0x20]);
assert_eq!(cmyk_pixel_to_rgb(255, 0, 0, 0), [0x00, 0xAD, 0xEF]);
assert_eq!(cmyk_pixel_to_rgb(0, 0, 0, 0), [255, 255, 255]);
}
#[test]
fn expand_indexed_1bpc_with_row_padding() {
let palette = vec![10, 20, 30, 200, 210, 220];
let raw = vec![0x50, 0xC8];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 5, 2, 1).unwrap();
assert_eq!(
out,
vec![
10, 20, 30, 200, 210, 220, 10, 20, 30, 200, 210, 220, 10, 20, 30, 200, 210, 220, 200, 210, 220, 10, 20,
30, 10, 20, 30, 200, 210, 220,
]
);
}
#[test]
fn expand_indexed_2bpc_with_row_padding() {
let palette = vec![0, 0, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
let raw = vec![0x18];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 3, 1, 2).unwrap();
assert_eq!(out, vec![0, 0, 0, 10, 20, 30, 40, 50, 60]);
}
#[test]
fn expand_indexed_4bpc_packs_two_per_byte() {
let palette = vec![0, 0, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
let raw = vec![0x01, 0x23];
let out = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 4, 1, 4).unwrap();
assert_eq!(out, vec![0, 0, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90]);
}
#[test]
fn expand_indexed_rejects_overflow_dimensions() {
let palette = vec![0, 0, 0, 255, 0, 0];
let raw = vec![0, 1];
let huge = u32::MAX / 2;
let result = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, huge, huge, 8);
assert!(result.is_err(), "overflow dimensions must be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("overflow") || err.contains("exceeds"),
"expected overflow/limit error, got: {err}"
);
}
#[test]
fn expand_indexed_rejects_truncated_stream() {
let palette = vec![10, 20, 30, 40, 50, 60];
let raw = vec![0; 10];
let result = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 10, 10, 8);
assert!(result.is_err(), "truncated stream must be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("truncated"), "expected truncated error, got: {err}");
}
#[test]
fn expand_indexed_rejects_output_over_cap() {
let palette = vec![0, 0, 0];
let raw: Vec<u8> = Vec::new();
let result = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 12_000, 12_000, 8);
assert!(result.is_err(), "oversized output must be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("guard limit") || err.contains("exceeds"),
"expected output-size guard error, got: {err}"
);
}
#[test]
fn expand_indexed_rejects_bpc_zero() {
let palette = vec![0, 0, 0, 255, 0, 0];
let raw = vec![0xFF];
let result = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 1, 1, 0);
assert!(result.is_err(), "bpc=0 must be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("BitsPerComponent") || err.contains("bpc"),
"expected bpc error, got: {err}"
);
}
#[test]
fn expand_indexed_rejects_unsupported_bpc() {
let palette = vec![0, 0, 0, 255, 0, 0];
let raw = vec![0xFF];
for bpc in [3u8, 5, 6, 7, 9, 12, 16] {
let result = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 1, 1, bpc);
assert!(result.is_err(), "bpc={bpc} must be rejected");
}
}
#[test]
fn expand_indexed_accepts_all_spec_bpc_values() {
let palette = vec![0, 0, 0, 255, 0, 0, 10, 20, 30, 40, 50, 60];
let raw = vec![0xFF];
for bpc in [1u8, 2, 4, 8] {
let result = expand_indexed_to_rgb(&raw, &palette, PixelFormat::RGB, 1, 1, bpc);
assert!(result.is_ok(), "bpc={bpc} must be accepted, got {result:?}");
}
}
#[test]
fn resolve_indexed_palette_array_lookup_returns_none() {
use crate::object::Object;
let cs = Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(1),
Object::Array(vec![
Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(0)]),
Object::Array(vec![Object::Integer(255), Object::Integer(255), Object::Integer(255)]),
]),
]);
assert!(resolve_indexed_palette(None, &cs).unwrap().is_none());
}
#[test]
fn extract_image_errors_when_indexed_lookup_is_array() {
use crate::object::Object;
use std::collections::HashMap;
let mut dict = HashMap::new();
dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
dict.insert("Width".to_string(), Object::Integer(2));
dict.insert("Height".to_string(), Object::Integer(1));
dict.insert("BitsPerComponent".to_string(), Object::Integer(8));
dict.insert(
"ColorSpace".to_string(),
Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(1),
Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(0)]),
]),
);
let xobject = Object::Stream {
dict,
data: bytes::Bytes::from_static(&[0, 1]),
};
let err =
extract_image_from_xobject(None, &xobject, None, None).expect_err("Indexed with Array lookup must error");
let msg = format!("{err:?}");
assert!(
msg.contains("Unable to resolve Indexed color space palette"),
"error message should identify palette-resolution failure, got: {msg}"
);
assert!(
!msg.contains("Invalid RGB image dimensions"),
"must not fall through to misleading RGB-dimension error, got: {msg}"
);
}
#[test]
fn lab_pixel_mid_gray() {
let d65: [f64; 3] = [0.9505, 1.0, 1.0890];
let [r, g, b] = super::lab_pixel_to_rgb(128, 128, 128, d65);
for (label, v, expected) in [("R", r, 119), ("G", g, 119), ("B", b, 119)] {
let diff = (v as i32 - expected).abs();
assert!(
diff <= 3,
"Lab(50,0,0) {label}: expected ~{expected}, got {v} (Δ={diff})"
);
}
}
#[test]
fn lab_pixel_white() {
let d65: [f64; 3] = [0.9505, 1.0, 1.0890];
let [r, g, b] = super::lab_pixel_to_rgb(255, 128, 128, d65);
for (label, v) in [("R", r), ("G", g), ("B", b)] {
assert!(v >= 250, "Lab(100,0,0) {label}: expected ~255, got {v}");
}
}
#[test]
fn lab_pixel_black() {
let d65: [f64; 3] = [0.9505, 1.0, 1.0890];
let [r, g, b] = super::lab_pixel_to_rgb(0, 128, 128, d65);
for (label, v) in [("R", r), ("G", g), ("B", b)] {
assert!(v <= 5, "Lab(0,0,0) {label}: expected ~0, got {v}");
}
}
#[test]
fn lab_pixel_red_tint() {
let d65: [f64; 3] = [0.9505, 1.0, 1.0890];
let [r, g, b] = super::lab_pixel_to_rgb(128, 208, 128, d65);
assert!(r > g + 50, "Lab(50,80,0) should have R >> G: R={r}, G={g}");
assert!(r > b, "Lab(50,80,0) should have R > B: R={r}, B={b}");
}
#[test]
fn lab_palette_round_trip() {
let d65: [f64; 3] = [0.9505, 1.0, 1.0890];
let palette: Vec<u8> = vec![0, 128, 128, 128, 128, 128, 255, 128, 128];
let rgb = super::lab_palette_to_rgb(&palette, d65);
assert_eq!(rgb.len(), 9, "3 Lab entries → 9 RGB bytes");
assert!(rgb[0] <= 5 && rgb[1] <= 5 && rgb[2] <= 5);
assert!(rgb[6] >= 250 && rgb[7] >= 250 && rgb[8] >= 250);
}
#[test]
fn extract_lab_whitepoint_d65() {
use crate::object::Object;
let cs = Object::Array(vec![
Object::Name("Lab".to_string()),
Object::Dictionary({
let mut d = std::collections::HashMap::new();
d.insert(
"WhitePoint".to_string(),
Object::Array(vec![Object::Real(0.9505), Object::Real(1.0), Object::Real(1.0890)]),
);
d
}),
]);
let wp = super::extract_lab_whitepoint(&cs);
assert!((wp[0] - 0.9505).abs() < 1e-6);
assert!((wp[1] - 1.0).abs() < 1e-6);
assert!((wp[2] - 1.0890).abs() < 1e-6);
}
#[test]
fn extract_lab_whitepoint_missing_falls_back_to_d65() {
use crate::object::Object;
let cs = Object::Name("Lab".to_string());
let wp = super::extract_lab_whitepoint(&cs);
assert!((wp[0] - 0.9505).abs() < 1e-6);
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum PdfFilter {
DCTDecode,
JPXDecode,
FlateDecode,
LZWDecode,
CCITTFaxDecode,
JBIG2Decode,
ASCIIHexDecode,
ASCII85Decode,
RunLengthDecode,
Crypt,
Other(String),
}
impl PdfFilter {
pub fn from_name(name: &str) -> Self {
match name {
"DCTDecode" | "DCT" => PdfFilter::DCTDecode,
"JPXDecode" => PdfFilter::JPXDecode,
"FlateDecode" | "Fl" => PdfFilter::FlateDecode,
"LZWDecode" | "LZW" => PdfFilter::LZWDecode,
"CCITTFaxDecode" | "CCF" => PdfFilter::CCITTFaxDecode,
"JBIG2Decode" => PdfFilter::JBIG2Decode,
"ASCIIHexDecode" | "AHx" => PdfFilter::ASCIIHexDecode,
"ASCII85Decode" | "A85" => PdfFilter::ASCII85Decode,
"RunLengthDecode" | "RL" => PdfFilter::RunLengthDecode,
"Crypt" => PdfFilter::Crypt,
other => PdfFilter::Other(other.to_string()),
}
}
}
pub(crate) fn parse_filter_chain(dict: &std::collections::HashMap<String, crate::object::Object>) -> Vec<PdfFilter> {
use crate::object::Object;
match dict.get("Filter") {
Some(Object::Name(n)) => vec![PdfFilter::from_name(n)],
Some(Object::Array(arr)) => arr
.iter()
.filter_map(|o| o.as_name())
.map(PdfFilter::from_name)
.collect(),
_ => vec![],
}
}
#[derive(Clone)]
enum PdfImageSource {
XObject(ObjectRef),
Inline {
stream_object: crate::object::Object,
compressed_bytes: bytes::Bytes,
},
}
#[derive(Clone)]
#[non_exhaustive]
pub struct PdfImageHandle<'doc> {
pub width: u32,
pub height: u32,
pub color_space: ColorSpace,
pub bits_per_component: u8,
pub byte_size_compressed: u64,
pub filter_chain: Vec<PdfFilter>,
pub is_inline: bool,
pub paint_order: usize,
pub bbox: crate::geometry::Rect,
pub rotation_degrees: f32,
ctm: crate::content::Matrix,
doc: &'doc crate::document::PdfDocument,
source: PdfImageSource,
color_space_resources: std::collections::HashMap<String, crate::object::Object>,
indexed_base: Option<ColorSpace>,
}
impl<'doc> PdfImageHandle<'doc> {
pub fn decode(&self) -> Result<PdfImage> {
use crate::extractors::extract_image_from_xobject;
let xobject_for_extract;
let (obj, obj_ref) = match &self.source {
PdfImageSource::XObject(obj_ref) => {
xobject_for_extract = self.doc.load_object(*obj_ref)?;
(&xobject_for_extract, Some(*obj_ref))
}
PdfImageSource::Inline { stream_object, .. } => (stream_object, None),
};
let cs_map = if self.color_space_resources.is_empty() {
None
} else {
Some(&self.color_space_resources)
};
let mut image = extract_image_from_xobject(Some(self.doc), obj, obj_ref, cs_map)?;
image.set_bbox(self.bbox);
image.set_matrix([self.ctm.a, self.ctm.b, self.ctm.c, self.ctm.d, self.ctm.e, self.ctm.f]);
image.set_rotation_degrees(self.rotation_degrees as i32);
Ok(image)
}
pub fn raw_compressed_bytes(&self) -> Result<Vec<u8>> {
match &self.source {
PdfImageSource::XObject(obj_ref) => {
let obj = self.doc.load_object(*obj_ref)?;
match obj {
crate::object::Object::Stream { data, .. } => Ok(data.to_vec()),
_ => Err(crate::error::Error::Image("XObject is not a stream".to_string())),
}
}
PdfImageSource::Inline { compressed_bytes, .. } => Ok(compressed_bytes.to_vec()),
}
}
pub fn indexed_base(&self) -> Option<ColorSpace> {
self.indexed_base
}
}
impl std::fmt::Debug for PdfImageHandle<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PdfImageHandle")
.field("width", &self.width)
.field("height", &self.height)
.field("color_space", &self.color_space)
.field("bits_per_component", &self.bits_per_component)
.field("byte_size_compressed", &self.byte_size_compressed)
.field("filter_chain", &self.filter_chain)
.field("is_inline", &self.is_inline)
.field("paint_order", &self.paint_order)
.field("bbox", &self.bbox)
.field("rotation_degrees", &self.rotation_degrees)
.field("color_space_resources", &self.color_space_resources)
.field("indexed_base", &self.indexed_base)
.finish_non_exhaustive()
}
}
fn matrix_to_rotation(m: crate::content::Matrix) -> f32 {
let angle_rad = m.b.atan2(m.a);
let angle_deg = angle_rad.to_degrees();
let normalized = angle_deg % 360.0;
if normalized < 0.0 {
normalized + 360.0
} else {
normalized
}
}
fn transform_bbox_with_ctm(rect: &crate::geometry::Rect, ctm: crate::content::Matrix) -> crate::geometry::Rect {
let x0 = rect.x;
let y0 = rect.y;
let x1 = rect.x + rect.width;
let y1 = rect.y + rect.height;
let tx0 = ctm.a * x0 + ctm.c * y0 + ctm.e;
let ty0 = ctm.b * x0 + ctm.d * y0 + ctm.f;
let tx1 = ctm.a * x1 + ctm.c * y0 + ctm.e;
let ty1 = ctm.b * x1 + ctm.d * y0 + ctm.f;
let tx2 = ctm.a * x0 + ctm.c * y1 + ctm.e;
let ty2 = ctm.b * x0 + ctm.d * y1 + ctm.f;
let tx3 = ctm.a * x1 + ctm.c * y1 + ctm.e;
let ty3 = ctm.b * x1 + ctm.d * y1 + ctm.f;
let min_x = tx0.min(tx1).min(tx2).min(tx3);
let max_x = tx0.max(tx1).max(tx2).max(tx3);
let min_y = ty0.min(ty1).min(ty2).min(ty3);
let max_y = ty0.max(ty1).max(ty2).max(ty3);
crate::geometry::Rect {
x: min_x,
y: min_y,
width: max_x - min_x,
height: max_y - min_y,
}
}
fn resolve_color_space_for_handle(
entry: &crate::object::Object,
color_space_resources: &std::collections::HashMap<String, crate::object::Object>,
doc: Option<&crate::document::PdfDocument>,
) -> (ColorSpace, Option<ColorSpace>) {
use crate::object::Object;
let after_name = match entry {
Object::Name(name) if !matches!(name.as_str(), "DeviceGray" | "DeviceRGB" | "DeviceCMYK" | "Pattern") => {
color_space_resources
.get(name)
.cloned()
.unwrap_or_else(|| entry.clone())
}
_ => entry.clone(),
};
let resolved = match (after_name.as_reference(), doc) {
(Some(r), Some(d)) => d.load_object(r).unwrap_or(after_name),
_ => after_name,
};
if let Object::Array(arr) = &resolved
&& arr.first().and_then(|o| o.as_name()) == Some("Indexed")
&& arr.len() >= 2
{
let base_obj = resolve_indexed_base_obj_for_handle(doc, arr);
let base = parse_color_space(&base_obj).ok();
return (ColorSpace::Indexed, base);
}
match parse_color_space(&resolved) {
Ok(cs) => (cs, None),
Err(_) => (ColorSpace::DeviceRGB, None),
}
}
fn resolve_indexed_base_obj_for_handle(
doc: Option<&crate::document::PdfDocument>,
arr: &[crate::object::Object],
) -> crate::object::Object {
use crate::object::Object;
let Some(d) = doc else {
return arr[1].clone();
};
let outer = if let Some(r) = arr[1].as_reference() {
d.load_object(r).unwrap_or_else(|_| arr[1].clone())
} else {
arr[1].clone()
};
let Object::Array(mut inner) = outer else {
return outer;
};
for item in inner.iter_mut() {
if let Some(r) = item.as_reference()
&& let Ok(resolved) = d.load_object(r)
{
*item = resolved;
}
}
Object::Array(inner)
}
pub(crate) fn image_handle_from_xobject<'doc>(
doc: &'doc crate::document::PdfDocument,
obj_ref: ObjectRef,
xobject_dict: &std::collections::HashMap<String, crate::object::Object>,
ctm: crate::content::Matrix,
paint_order: usize,
color_space_resources: &std::collections::HashMap<String, crate::object::Object>,
) -> Option<PdfImageHandle<'doc>> {
let resolve_int = |o: &crate::object::Object| -> Option<i64> {
match o.as_reference() {
Some(r) => doc.load_object(r).ok().and_then(|v| v.as_integer()),
None => o.as_integer(),
}
};
let w = xobject_dict
.get("Width")
.and_then(resolve_int)
.filter(|&n| n > 0)
.map(|n| n as u32)?;
let h = xobject_dict
.get("Height")
.and_then(resolve_int)
.filter(|&n| n > 0)
.map(|n| n as u32)?;
let is_image_mask = xobject_dict
.get("ImageMask")
.is_some_and(|value| matches!(value, crate::object::Object::Boolean(true)));
let bpc = xobject_dict
.get("BitsPerComponent")
.and_then(resolve_int)
.unwrap_or(if is_image_mask { 1 } else { 8 }) as u8;
let byte_size = xobject_dict
.get("Length")
.and_then(resolve_int)
.filter(|&n| n >= 0)
.map(|n| n as u64)
.unwrap_or(0);
let filter_chain = parse_filter_chain(xobject_dict);
let missing_color_space_default = if is_image_mask {
crate::object::Object::Name("DeviceGray".to_string())
} else {
crate::object::Object::Name("DeviceRGB".to_string())
};
let (color_space, indexed_base) = match xobject_dict.get("ColorSpace") {
Some(entry) => resolve_color_space_for_handle(entry, color_space_resources, Some(doc)),
None => resolve_color_space_for_handle(&missing_color_space_default, color_space_resources, Some(doc)),
};
let unit_rect = crate::geometry::Rect::new(0.0, 0.0, 1.0, 1.0);
let bbox = transform_bbox_with_ctm(&unit_rect, ctm);
let rotation_degrees = matrix_to_rotation(ctm);
Some(PdfImageHandle {
width: w,
height: h,
color_space,
bits_per_component: bpc,
byte_size_compressed: byte_size,
filter_chain,
is_inline: false,
paint_order,
bbox,
rotation_degrees,
ctm,
doc,
source: PdfImageSource::XObject(obj_ref),
color_space_resources: color_space_resources.clone(),
indexed_base,
})
}
fn inline_image_dimensions(
expanded: &std::collections::HashMap<String, crate::object::Object>,
) -> Option<(u32, u32, u8)> {
let w = expanded
.get("Width")
.and_then(|o| o.as_integer())
.filter(|&n| n > 0)
.map(|n| n as u32)?;
let h = expanded
.get("Height")
.and_then(|o| o.as_integer())
.filter(|&n| n > 0)
.map(|n| n as u32)?;
let bpc = expanded
.get("BitsPerComponent")
.and_then(|o| o.as_integer())
.unwrap_or(8) as u8;
Some((w, h, bpc))
}
pub(crate) fn image_handle_from_inline<'doc>(
doc: &'doc crate::document::PdfDocument,
dict: &std::collections::HashMap<String, crate::object::Object>,
data: Vec<u8>,
ctm: crate::content::Matrix,
paint_order: usize,
color_space_resources: &std::collections::HashMap<String, crate::object::Object>,
) -> Option<PdfImageHandle<'doc>> {
use crate::object::Object;
let expanded = crate::extractors::expand_inline_image_dict(dict.clone());
let (w, h, bpc) = inline_image_dimensions(&expanded)?;
let byte_size = data.len() as u64;
let filter_chain = parse_filter_chain(&expanded);
let is_image_mask = expanded
.get("ImageMask")
.is_some_and(|value| matches!(value, Object::Boolean(true)));
let missing_color_space_default = if is_image_mask {
Object::Name("DeviceGray".to_string())
} else {
Object::Name("DeviceRGB".to_string())
};
let (color_space, indexed_base) = match expanded.get("ColorSpace") {
Some(entry) => resolve_color_space_for_handle(entry, color_space_resources, Some(doc)),
None => resolve_color_space_for_handle(&missing_color_space_default, color_space_resources, Some(doc)),
};
let unit_rect = crate::geometry::Rect::new(0.0, 0.0, 1.0, 1.0);
let bbox = transform_bbox_with_ctm(&unit_rect, ctm);
let rotation_degrees = matrix_to_rotation(ctm);
let mut stream_dict = expanded;
stream_dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
let compressed_bytes = bytes::Bytes::from(data);
let stream_object = Object::Stream {
dict: stream_dict,
data: compressed_bytes.clone(),
};
Some(PdfImageHandle {
width: w,
height: h,
color_space,
bits_per_component: bpc,
byte_size_compressed: byte_size,
filter_chain,
is_inline: true,
paint_order,
bbox,
rotation_degrees,
ctm,
doc,
source: PdfImageSource::Inline {
stream_object,
compressed_bytes,
},
color_space_resources: color_space_resources.clone(),
indexed_base,
})
}
#[cfg(test)]
mod handle_color_space_tests {
use super::*;
use crate::object::{Object, ObjectRef};
use std::collections::HashMap;
fn empty_map() -> HashMap<String, Object> {
HashMap::new()
}
fn direct_indexed_rgb() -> Object {
Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(1),
Object::String(vec![0, 0, 0, 255, 255, 255]),
])
}
#[test]
fn helper_direct_indexed_reports_indexed_with_rgb_base() {
let entry = direct_indexed_rgb();
let (cs, base) = resolve_color_space_for_handle(&entry, &empty_map(), None);
assert_eq!(cs, ColorSpace::Indexed);
assert_eq!(base, Some(ColorSpace::DeviceRGB));
}
#[test]
fn helper_resource_name_resolves_to_device_gray() {
let mut map = empty_map();
map.insert("CS0".to_string(), Object::Name("DeviceGray".to_string()));
let entry = Object::Name("CS0".to_string());
let (cs, base) = resolve_color_space_for_handle(&entry, &map, None);
assert_eq!(cs, ColorSpace::DeviceGray);
assert_eq!(base, None);
}
#[test]
fn helper_resource_name_resolves_to_indexed() {
let mut map = empty_map();
map.insert("CS0".to_string(), direct_indexed_rgb());
let entry = Object::Name("CS0".to_string());
let (cs, base) = resolve_color_space_for_handle(&entry, &map, None);
assert_eq!(cs, ColorSpace::Indexed);
assert_eq!(base, Some(ColorSpace::DeviceRGB));
}
#[test]
fn helper_standard_device_name_not_resource_resolved() {
let mut map = empty_map();
map.insert("DeviceRGB".to_string(), Object::Name("DeviceGray".to_string()));
let entry = Object::Name("DeviceRGB".to_string());
let (cs, base) = resolve_color_space_for_handle(&entry, &map, None);
assert_eq!(cs, ColorSpace::DeviceRGB);
assert_eq!(base, None);
}
fn pdf_with_cs_object(cs_body: &str) -> Vec<u8> {
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets: Vec<(u32, usize)> = Vec::new();
offsets.push((1, pdf.len()));
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.push((2, pdf.len()));
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
offsets.push((3, pdf.len()));
pdf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1 1] >>\nendobj\n");
offsets.push((99, pdf.len()));
pdf.extend_from_slice(format!("99 0 obj\n{}\nendobj\n", cs_body).as_bytes());
let xref_off = pdf.len();
let max_id = 99u32;
pdf.extend_from_slice(format!("xref\n0 {}\n", max_id + 1).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for id in 1..=max_id {
if let Some((_, off)) = offsets.iter().find(|(oid, _)| *oid == id) {
pdf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
} else {
pdf.extend_from_slice(b"0000000000 65535 f \n");
}
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
max_id + 1,
xref_off
)
.as_bytes(),
);
pdf
}
#[test]
fn helper_indirect_indexed_array_de_indexed() {
let doc =
crate::document::PdfDocument::from_bytes(pdf_with_cs_object("[/Indexed /DeviceRGB 1 <000000FFFFFF>]"))
.expect("synthetic pdf parses");
let entry = Object::Reference(ObjectRef::new(99, 0));
let (cs, base) = resolve_color_space_for_handle(&entry, &empty_map(), Some(&doc));
assert_eq!(cs, ColorSpace::Indexed);
assert_eq!(base, Some(ColorSpace::DeviceRGB));
}
#[test]
fn helper_indirect_plain_device_gray() {
let doc = crate::document::PdfDocument::from_bytes(pdf_with_cs_object("/DeviceGray")).unwrap();
let entry = Object::Reference(ObjectRef::new(99, 0));
let (cs, base) = resolve_color_space_for_handle(&entry, &empty_map(), Some(&doc));
assert_eq!(cs, ColorSpace::DeviceGray);
assert_eq!(base, None);
}
#[test]
fn inline_indexed_consistent_with_xobject() {
let entry = direct_indexed_rgb();
let (xobj_cs, xobj_base) = resolve_color_space_for_handle(&entry, &empty_map(), None);
let (inline_cs, inline_base) = resolve_color_space_for_handle(&entry, &empty_map(), None);
assert_eq!(xobj_cs, inline_cs);
assert_eq!(xobj_base, inline_base);
assert_eq!(inline_cs, ColorSpace::Indexed);
assert_eq!(inline_base, Some(ColorSpace::DeviceRGB));
}
fn pdf_with_image_xobject(cs_name: &str, sample: &[u8]) -> Vec<u8> {
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets: Vec<(u32, usize)> = Vec::new();
offsets.push((1, pdf.len()));
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.push((2, pdf.len()));
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
offsets.push((3, pdf.len()));
pdf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1 1] >>\nendobj\n");
offsets.push((99, pdf.len()));
let header = format!(
"99 0 obj\n<< /Type /XObject /Subtype /Image /Width 1 /Height 1 \
/BitsPerComponent 8 /ColorSpace {} /Length {} >>\nstream\n",
cs_name,
sample.len()
);
pdf.extend_from_slice(header.as_bytes());
pdf.extend_from_slice(sample);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
let xref_off = pdf.len();
let max_id = 99u32;
pdf.extend_from_slice(format!("xref\n0 {}\n", max_id + 1).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for id in 1..=max_id {
if let Some((_, off)) = offsets.iter().find(|(oid, _)| *oid == id) {
pdf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
} else {
pdf.extend_from_slice(b"0000000000 65535 f \n");
}
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
max_id + 1,
xref_off
)
.as_bytes(),
);
pdf
}
#[test]
fn decode_resolves_resource_name_cs() {
let doc = crate::document::PdfDocument::from_bytes(pdf_with_image_xobject("/CS0", &[128])).unwrap();
let obj_ref = ObjectRef::new(99, 0);
let xobj = doc.load_object(obj_ref).unwrap();
let dict = xobj.as_dict().expect("image xobject dict").clone();
let mut map = empty_map();
map.insert("CS0".to_string(), Object::Name("DeviceGray".to_string()));
let handle = image_handle_from_xobject(&doc, obj_ref, &dict, crate::content::Matrix::identity(), 0, &map)
.expect("handle builds");
assert_eq!(handle.color_space, ColorSpace::DeviceGray);
assert!(handle.indexed_base().is_none());
let image = handle.decode().expect("decode resolves resource-name CS");
assert_eq!(*image.color_space(), ColorSpace::DeviceGray);
}
#[test]
fn xobject_direct_indexed_handle_reports_indexed_with_base() {
let doc = crate::document::PdfDocument::from_bytes(pdf_with_image_xobject(
"[/Indexed /DeviceRGB 1 <000000FFFFFF>]",
&[0],
))
.unwrap();
let obj_ref = ObjectRef::new(99, 0);
let dict = doc.load_object(obj_ref).unwrap().as_dict().unwrap().clone();
let handle = image_handle_from_xobject(
&doc,
obj_ref,
&dict,
crate::content::Matrix::identity(),
0,
&empty_map(),
)
.unwrap();
assert_eq!(handle.color_space, ColorSpace::Indexed);
assert_eq!(handle.indexed_base(), Some(ColorSpace::DeviceRGB));
}
}
#[cfg(test)]
mod png_bytes_panic_safety_tests {
use super::*;
#[test]
fn to_png_bytes_rejects_oversized_rgb_buffer_without_panicking() {
let (w, h) = (4u32, 2u32);
let pixels = vec![0u8; (w * h * 3 * 2) as usize];
let img = PdfImage::new(
w,
h,
ColorSpace::DeviceRGB,
8,
ImageData::Raw {
pixels,
format: PixelFormat::RGB,
},
);
let result = img.to_png_bytes();
assert!(
result.is_err(),
"oversized RGB buffer must yield a recoverable Err, not a panic"
);
}
#[test]
fn png_export_adaptive_filter_shrinks_gradient() {
let (w, h) = (256u32, 256u32);
let mut pixels = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let v = ((x + y) % 256) as u8;
pixels.extend_from_slice(&[v, v.wrapping_add(64), v.wrapping_add(128)]);
}
}
let raw_len = pixels.len();
let img = PdfImage::new(
w,
h,
ColorSpace::DeviceRGB,
8,
ImageData::Raw {
pixels: pixels.clone(),
format: PixelFormat::RGB,
},
);
let png = img.to_png_bytes().expect("gradient must encode");
let decoded = image::load_from_memory(&png).expect("valid PNG").to_rgb8();
assert_eq!(
decoded.as_raw().as_slice(),
pixels.as_slice(),
"PNG must round-trip the samples byte-for-byte"
);
assert!(
png.len() * 8 <= raw_len,
"adaptive filtering must compress the ramp at least 8x: {} bytes from {} raw",
png.len(),
raw_len
);
}
#[test]
fn to_png_bytes_encodes_correctly_sized_rgb() {
let (w, h) = (4u32, 2u32);
let pixels = vec![128u8; (w * h * 3) as usize];
let img = PdfImage::new(
w,
h,
ColorSpace::DeviceRGB,
8,
ImageData::Raw {
pixels,
format: PixelFormat::RGB,
},
);
let png = img.to_png_bytes().expect("correctly sized RGB must encode");
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']), "valid PNG signature");
}
#[test]
fn reduce_16_to_8_rounds_to_nearest() {
assert_eq!(reduce_16_to_8(0x00, 0x00), 0, "black stays black");
assert_eq!(reduce_16_to_8(0xFF, 0xFF), 255, "white stays white");
assert_eq!(reduce_16_to_8(0x00, 0x80), 0);
assert_eq!(reduce_16_to_8(0x01, 0x00), 1);
assert_eq!(reduce_16_to_8(0x80, 0x80), 128);
assert_eq!(reduce_16_to_8(0xFF, 0x7F), 255);
for hi in 0u8..=255 {
let out = reduce_16_to_8(hi, hi);
assert!(
out >= hi.saturating_sub(1) && out <= hi.saturating_add(1),
"reduction stays within 1 LSB of the high byte for hi={hi}"
);
}
}
}
#[cfg(test)]
mod ccitt_decode_array_polarity_tests {
use super::*;
use crate::object::Object;
use std::collections::HashMap;
fn pack_bits(bits: &str) -> Vec<u8> {
let mut bytes = Vec::new();
let mut acc = 0u8;
let mut n = 0u8;
for ch in bits.chars() {
acc = (acc << 1) | (ch == '1') as u8;
n += 1;
if n == 8 {
bytes.push(acc);
acc = 0;
n = 0;
}
}
if n > 0 {
bytes.push(acc << (8 - n));
}
bytes
}
fn black_majority_g4_stream() -> Vec<u8> {
let row_all_black = format!("001{}{}", "00110101", "000101");
let row_notch = format!("001{}{}001{}{}", "00110101", "10", "0111", "10");
pack_bits(&format!("{row_all_black}{row_all_black}{row_notch}"))
}
fn ccitt_xobject(decode: Option<[i64; 2]>) -> Object {
let mut decode_parms = HashMap::new();
decode_parms.insert("K".to_string(), Object::Integer(-1));
decode_parms.insert("Columns".to_string(), Object::Integer(8));
decode_parms.insert("Rows".to_string(), Object::Integer(3));
let mut dict = HashMap::new();
dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
dict.insert("Width".to_string(), Object::Integer(8));
dict.insert("Height".to_string(), Object::Integer(3));
dict.insert("BitsPerComponent".to_string(), Object::Integer(1));
dict.insert("ColorSpace".to_string(), Object::Name("DeviceGray".to_string()));
dict.insert("Filter".to_string(), Object::Name("CCITTFaxDecode".to_string()));
dict.insert("DecodeParms".to_string(), Object::Dictionary(decode_parms));
if let Some([lo, hi]) = decode {
dict.insert(
"Decode".to_string(),
Object::Array(vec![Object::Integer(lo), Object::Integer(hi)]),
);
}
Object::Stream {
dict,
data: bytes::Bytes::from(black_majority_g4_stream()),
}
}
fn white_black_counts(xobject: &Object) -> (u32, u32) {
let img = extract_image_from_xobject(None, xobject, None, None).expect("decode hand-built CCITT test image");
let luma = img
.to_dynamic_image()
.expect("decode CCITT test image pixels")
.into_luma8();
let white = luma.iter().filter(|&&v| v == 0xFF).count() as u32;
let black = luma.iter().filter(|&&v| v == 0x00).count() as u32;
(white, black)
}
#[test]
fn default_decode_keeps_raw_black_majority_polarity() {
let (white, black) = white_black_counts(&ccitt_xobject(None));
assert_eq!(white + black, 24);
assert!(
black > white,
"expected black-majority (raw, no /Decode override), got white={white} black={black}"
);
}
#[test]
fn decode_1_0_inverts_to_white_majority() {
let (white, black) = white_black_counts(&ccitt_xobject(Some([1, 0])));
assert_eq!(white + black, 24);
assert!(
white > black,
"expected white-majority under /Decode [1 0], got white={white} black={black}"
);
assert_eq!(black, 2, "exactly the 2-pixel notch should render black (the 'mark')");
}
#[test]
fn decode_0_1_is_a_no_op() {
let (white, black) = white_black_counts(&ccitt_xobject(Some([0, 1])));
assert_eq!((white, black), white_black_counts(&ccitt_xobject(None)));
}
#[test]
fn extracted_ccitt_data_contains_decoded_grayscale_samples() {
let image = extract_image_from_xobject(None, &ccitt_xobject(None), None, None)
.expect("decode hand-built CCITT test image");
assert_eq!(image.bits_per_component(), 8);
assert!(image.ccitt_params().is_some(), "CCITT codec metadata must be preserved");
match image.data() {
ImageData::Raw { pixels, format } => {
assert_eq!(*format, PixelFormat::Grayscale);
assert_eq!(
pixels.len(),
24,
"8 x 3 image must expose one byte per grayscale sample"
);
assert_eq!(pixels.iter().filter(|&&sample| sample == 0x00).count(), 22);
assert_eq!(pixels.iter().filter(|&&sample| sample == 0xFF).count(), 2);
}
other => panic!("expected decoded raw grayscale samples, got {other:?}"),
}
}
}
#[cfg(test)]
mod non_ccitt_1bpc_devicegray_tests {
use super::*;
use crate::object::Object;
use std::collections::HashMap;
fn black_majority_rows() -> [u8; 3] {
[0x00, 0x00, 0x18]
}
fn devicegray_1bpc_xobject(flate: bool, decode: Option<[i64; 2]>) -> Object {
let raw: Vec<u8> = black_majority_rows().to_vec();
let (filter, data) = if flate {
use flate2::Compression;
use flate2::write::ZlibEncoder;
use std::io::Write;
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&raw).expect("flate-compress test bitmap");
(Some("FlateDecode"), encoder.finish().expect("finish flate stream"))
} else {
(None, raw)
};
let mut dict = HashMap::new();
dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
dict.insert("Width".to_string(), Object::Integer(8));
dict.insert("Height".to_string(), Object::Integer(3));
dict.insert("BitsPerComponent".to_string(), Object::Integer(1));
dict.insert("ColorSpace".to_string(), Object::Name("DeviceGray".to_string()));
if let Some(f) = filter {
dict.insert("Filter".to_string(), Object::Name(f.to_string()));
}
if let Some([lo, hi]) = decode {
dict.insert(
"Decode".to_string(),
Object::Array(vec![Object::Integer(lo), Object::Integer(hi)]),
);
}
Object::Stream {
dict,
data: bytes::Bytes::from(data),
}
}
fn white_black_counts(xobject: &Object) -> (u32, u32) {
let img =
extract_image_from_xobject(None, xobject, None, None).expect("decode hand-built non-CCITT 1bpc test image");
let luma = img
.to_dynamic_image()
.expect("decode non-CCITT 1bpc test image pixels")
.into_luma8();
let white = luma.iter().filter(|&&v| v == 0xFF).count() as u32;
let black = luma.iter().filter(|&&v| v == 0x00).count() as u32;
(white, black)
}
#[test]
fn uncompressed_1bpc_devicegray_decodes_without_ccitt() {
let (white, black) = white_black_counts(&devicegray_1bpc_xobject(false, None));
assert_eq!(white + black, 24);
assert_eq!(black, 22, "22 of 24 pixels are black per the hand-built bitmap");
assert_eq!(white, 2, "the 2-pixel notch at row 2 cols 3-4 is white");
}
#[test]
fn flate_decoded_1bpc_devicegray_decodes_without_ccitt() {
let (white, black) = white_black_counts(&devicegray_1bpc_xobject(true, None));
assert_eq!(white + black, 24);
assert_eq!(black, 22);
assert_eq!(white, 2);
}
#[test]
fn decode_1_0_inverts_polarity_on_non_ccitt_path() {
let (white, black) = white_black_counts(&devicegray_1bpc_xobject(true, Some([1, 0])));
assert_eq!(white + black, 24);
assert_eq!(white, 22, "polarity inverted: majority is now white");
assert_eq!(black, 2, "the notch is now the black pixels");
}
#[test]
fn huge_declared_dimensions_are_rejected_without_allocating() {
let img = PdfImage::new(
u32::MAX,
u32::MAX,
ColorSpace::DeviceGray,
1,
ImageData::Raw {
pixels: Vec::new(),
format: PixelFormat::Grayscale,
},
);
let result = img.to_dynamic_image();
assert!(
result.is_err(),
"huge declared /Width x /Height must be rejected, not allocated"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("overflow") || err.contains("exceeds"),
"expected an overflow/limit error, got: {err}"
);
}
}
#[cfg(test)]
mod image_mask_packing_tests {
use super::pack_image_mask_rows;
const INK: u8 = 0;
const BACKGROUND: u8 = 255;
#[test]
fn should_clear_the_bit_for_ink_and_set_it_for_background() {
let pixels = [
INK, BACKGROUND, INK, BACKGROUND, BACKGROUND, BACKGROUND, BACKGROUND, BACKGROUND,
];
let packed = pack_image_mask_rows(&pixels, 8, 1).expect("decode within the guard cap must not error");
assert_eq!(packed, vec![0b0101_1111]);
}
#[test]
fn should_start_each_row_on_its_own_byte_boundary() {
let pixels = [[INK; 9], [BACKGROUND; 9]].concat();
let packed = pack_image_mask_rows(&pixels, 9, 2).expect("decode within the guard cap must not error");
assert_eq!(packed.len(), 4, "2 rows x 2 bytes");
assert_eq!(packed[0], 0b0000_0000, "row 0 px 0-7: ink");
assert_eq!(packed[1], 0b0000_0000, "row 0 px 8 ink + 7 untouched pad bits");
assert_eq!(packed[2], 0b1111_1111, "row 1 px 0-7: background");
assert_eq!(packed[3], 0b1000_0000, "row 1 px 8 background + 7 pad bits");
}
#[test]
fn should_treat_a_short_decode_as_background_rather_than_ink() {
let pixels = [INK, INK];
let packed = pack_image_mask_rows(&pixels, 8, 1).expect("decode within the guard cap must not error");
assert_eq!(packed, vec![0b0011_1111], "only the 2 decoded pixels are ink");
}
#[test]
fn should_treat_mid_gray_at_the_threshold_as_background() {
let pixels = [127, 128, 127, 128, 128, 128, 128, 128];
let packed = pack_image_mask_rows(&pixels, 8, 1).expect("decode within the guard cap must not error");
assert_eq!(packed, vec![0b0101_1111]);
}
#[test]
fn should_produce_a_fully_set_buffer_when_every_pixel_is_background() {
let packed =
pack_image_mask_rows(&[BACKGROUND; 16], 16, 1).expect("decode within the guard cap must not error");
assert_eq!(packed, vec![0xFF, 0xFF]);
}
#[test]
fn should_produce_a_fully_cleared_buffer_when_every_pixel_is_ink() {
let packed = pack_image_mask_rows(&[INK; 16], 16, 1).expect("decode within the guard cap must not error");
assert_eq!(packed, vec![0x00, 0x00]);
}
#[test]
fn should_reject_dimensions_whose_packed_size_exceeds_the_guard_cap() {
let result = pack_image_mask_rows(&[], 100_000, 100_000);
assert!(result.is_err(), "oversized ImageMask must be rejected, not decoded");
}
#[test]
fn should_reject_dimensions_whose_row_byte_product_overflows_usize() {
let result = pack_image_mask_rows(&[], u32::MAX, u32::MAX);
assert!(
result.is_err(),
"overflowing row_bytes * height must be rejected, not panic"
);
}
}
#[cfg(test)]
mod palette_expansion_tests {
use super::*;
fn jpx_xobject(color_space: Option<crate::object::Object>) -> crate::object::Object {
use crate::object::Object;
const PALETTE_CMYK_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_palette_cmyk.jp2");
let mut dict = std::collections::HashMap::new();
dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
dict.insert("Width".to_string(), Object::Integer(120));
dict.insert("Height".to_string(), Object::Integer(40));
dict.insert("BitsPerComponent".to_string(), Object::Integer(4));
dict.insert("Filter".to_string(), Object::Name("JPXDecode".to_string()));
if let Some(cs) = color_space {
dict.insert("ColorSpace".to_string(), cs);
}
Object::Stream {
dict,
data: bytes::Bytes::from_static(PALETTE_CMYK_JP2),
}
}
#[test]
fn a_codestream_palette_without_indexed_keeps_the_palette_device_space() {
use crate::object::Object;
const PALETTE_CMYK_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_palette_cmyk.jp2");
let palette = crate::decoders::jpx::codestream_palette(PALETTE_CMYK_JP2).expect("the fixture's pclr box");
let indexed = Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceCMYK".to_string()),
Object::Integer(255),
Object::String(palette.entries.clone()),
]);
let oracle = decode_indexed_image_in_base_space(None, &jpx_xobject(Some(indexed)), None, None)
.expect("the /Indexed image")
.expect("an /Indexed image has base-space samples");
assert_eq!(oracle.components, 4, "the oracle is a CMYK lookup");
for declared in [None, Some(Object::Name("DeviceCMYK".to_string()))] {
let label = format!("{declared:?}");
let image = extract_image_from_xobject(None, &jpx_xobject(declared), None, None)
.unwrap_or_else(|e| panic!("{label}: the codestream palette must resolve: {e:?}"));
let ImageData::Raw { pixels, format } = image.data() else {
panic!("{label}: raw pixels expected");
};
assert_eq!(*format, PixelFormat::CMYK, "{label}");
assert_eq!(
*pixels, oracle.samples,
"{label}: the samples must match the /Indexed lookup"
);
assert_eq!(*image.color_space(), ColorSpace::DeviceCMYK, "{label}");
assert_eq!(image.bits_per_component(), 8, "{label}");
}
let err = extract_image_from_xobject(
None,
&jpx_xobject(Some(Object::Name("DeviceRGB".to_string()))),
None,
None,
)
.expect_err("a four-column palette does not fit /DeviceRGB");
assert!(
format!("{err:?}").contains("codestream palette has 4 components, but /ColorSpace has 3"),
"unexpected failure: {err:?}"
);
}
#[test]
fn an_expanded_indexed_image_reports_8_bits_per_component() {
use crate::object::Object;
let mut dict = std::collections::HashMap::new();
dict.insert("Subtype".to_string(), Object::Name("Image".to_string()));
dict.insert("Width".to_string(), Object::Integer(2));
dict.insert("Height".to_string(), Object::Integer(1));
dict.insert("BitsPerComponent".to_string(), Object::Integer(4));
dict.insert(
"ColorSpace".to_string(),
Object::Array(vec![
Object::Name("Indexed".to_string()),
Object::Name("DeviceRGB".to_string()),
Object::Integer(1),
Object::String(vec![255, 0, 0, 0, 0, 255]),
]),
);
let xobject = Object::Stream {
dict,
data: bytes::Bytes::from_static(&[0x01]),
};
let image = extract_image_from_xobject(None, &xobject, None, None).expect("a 4-bit indexed image");
let ImageData::Raw { pixels, format } = image.data() else {
panic!("raw pixels expected");
};
assert_eq!(
(*format, pixels.as_slice()),
(PixelFormat::RGB, &[255, 0, 0, 0, 0, 255][..])
);
assert_eq!(image.bits_per_component(), 8);
}
}