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,
}
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(),
}
}
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(),
}
}
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(),
}
}
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::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageEncoder;
use std::io::Cursor;
let mut buffer = Cursor::new(Vec::new());
let encoder =
PngEncoder::new_with_quality(&mut buffer, CompressionType::Fast, FilterType::NoFilter);
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)
.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)
.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();
encoder
.write_image(rgb.as_raw(), self.width, self.height, image::ColorType::Rgb8)
.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)
.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::general_purpose::STANDARD, Engine};
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) => {
log::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)))
},
ImageData::Raw { pixels, format } => {
if self.bits_per_component == 1
&& matches!(self.color_space, ColorSpace::DeviceGray)
{
let params =
self.ccitt_params
.clone()
.unwrap_or_else(|| crate::decoders::CcittParams {
columns: self.width,
rows: Some(self.height),
..Default::default()
});
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)
} else {
match (format, self.color_space) {
(PixelFormat::RGB, ColorSpace::DeviceRGB) => {
image::ImageBuffer::<image::Rgb<u8>, Vec<u8>>::from_raw(
self.width,
self.height,
pixels.clone(),
)
.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.clone(),
)
.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.clone(),
};
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)
},
}
}
},
}
}
}
#[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" => {
let num_components = if arr.len() > 1 {
if let Some(stream_dict) = arr[1].as_dict() {
stream_dict
.get("N")
.and_then(|obj| match obj {
Object::Integer(n) => Some(*n as usize),
_ => None,
})
.unwrap_or(3)
} else {
3
}
} else {
3
};
Ok(ColorSpace::ICCBased(num_components))
},
"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))),
}
}
pub fn extract_image_from_xobject(
mut doc: Option<&mut crate::document::PdfDocument>,
xobject: &crate::object::Object,
obj_ref: Option<ObjectRef>,
color_space_map: Option<&std::collections::HashMap<String, crate::object::Object>>,
) -> Result<PdfImage> {
use crate::object::Object;
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 width = dict
.get("Width")
.and_then(|obj| obj.as_integer())
.ok_or_else(|| Error::Image("Image missing /Width".to_string()))? as u32;
let height = dict
.get("Height")
.and_then(|obj| obj.as_integer())
.ok_or_else(|| Error::Image("Image missing /Height".to_string()))? as u32;
let bits_per_component = dict
.get("BitsPerComponent")
.and_then(|obj| obj.as_integer())
.unwrap_or(8) as u8;
let color_space_obj = dict
.get("ColorSpace")
.ok_or_else(|| Error::Image("Image missing /ColorSpace".to_string()))?;
let resolved_color_space = if let Some(ref mut 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.as_deref_mut(), &resolved_color_space) {
if arr.len() > 1 {
if let Some(second_ref) = arr[1].as_reference() {
if let Ok(resolved_second) = doc_mut.load_object(second_ref) {
let mut new_arr = arr.clone();
new_arr[1] = resolved_second;
Object::Array(new_arr)
} else {
resolved_color_space
}
} else {
resolved_color_space
}
} else {
resolved_color_space
}
} else {
resolved_color_space
};
let color_space = parse_color_space(&resolved_color_space)?;
let indexed_resolution: Option<IndexedResolution> = if color_space == ColorSpace::Indexed {
let resolved = resolve_indexed_palette(doc.as_deref_mut(), &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.as_deref_mut(), &resolved_color_space)
} else if color_space == ColorSpace::DeviceCMYK {
doc.as_deref_mut()
.and_then(|d| d.output_intent_cmyk_profile())
} else {
None
};
let rendering_intent = dict
.get("Intent")
.and_then(|obj| obj.as_name())
.map(crate::color::RenderingIntent::from_pdf_name)
.unwrap_or_default();
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 mut ccitt_params_override: Option<crate::decoders::CcittParams> = None;
if (filter_names.contains(&"JBIG2Decode".to_string())
|| filter_names.contains(&"Jbig2Decode".to_string()))
&& bits_per_component == 1
{
let mut ccitt_params =
crate::object::extract_ccitt_params_with_width(dict.get("DecodeParms"), Some(width));
if let Some(ref mut params) = ccitt_params {
if params.rows.is_none() {
params.rows = Some(height);
}
ccitt_params_override = ccitt_params;
}
}
let data = if is_jpeg_only || is_jpeg_chain {
let decoded = if let (Some(d), Some(ref_id)) = (doc.as_mut(), obj_ref) {
d.decode_stream_with_encryption(xobject, ref_id)?
} else {
xobject.decode_stream_data()?
};
ImageData::Jpeg(decoded)
} else if ccitt_params_override.is_some() {
match xobject {
Object::Stream { data, .. } => ImageData::Raw {
pixels: data.to_vec(),
format: PixelFormat::Grayscale,
},
_ => return Err(Error::Image("XObject is not a stream".to_string())),
}
} else {
let decoded_data = if let (Some(d), Some(ref_id)) = (doc.as_mut(), obj_ref) {
d.decode_stream_with_encryption(xobject, ref_id)?
} else {
xobject.decode_stream_data()?
};
if let Some(ir) = indexed_resolution.as_ref() {
let transform = ir
.base_profile
.clone()
.map(|p| crate::color::Transform::new_srgb_target(p, rendering_intent));
let expanded = expand_indexed_to_rgb_with_transform(
&decoded_data,
&ir.palette,
ir.base_fmt,
width,
height,
bits_per_component,
transform.as_ref(),
)?;
ImageData::Raw {
pixels: expanded,
format: PixelFormat::RGB,
}
} else {
let pixel_format = color_space_to_pixel_format(&color_space);
ImageData::Raw {
pixels: decoded_data,
format: pixel_format,
}
}
};
let mut image = PdfImage::new(width, height, color_space, bits_per_component, data);
if let Some(p) = direct_icc_profile {
image.set_icc_profile(p);
} else if let Some(ir) = indexed_resolution.as_ref() {
if 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_override {
image.set_ccitt_params(ccitt_params);
} else if bits_per_component == 1 && image.color_space == ColorSpace::DeviceGray {
if let Some(mut ccitt_params) =
crate::object::extract_ccitt_params_with_width(dict.get("DecodeParms"), Some(width))
{
if ccitt_params.rows.is_none() {
ccitt_params.rows = Some(height);
}
image.set_ccitt_params(ccitt_params);
}
}
Ok(image)
}
pub(crate) fn resolve_icc_profile_from_obj(
doc: Option<&mut 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 palette: Vec<u8>,
pub base_profile: Option<std::sync::Arc<crate::color::IccProfile>>,
}
fn resolve_indexed_palette(
mut doc: Option<&mut 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_obj = if let Some(ref mut 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() {
if 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 n = base_fmt.bytes_per_pixel();
let base_profile = if matches!(base_cs, ColorSpace::ICCBased(_)) {
resolve_icc_profile_from_obj(doc.as_deref_mut(), &base_obj)
} else {
None
};
let hival_obj = if let Some(ref mut d) = doc {
if let Some(r) = arr[2].as_reference() {
d.load_object(r)?
} else {
arr[2].clone()
}
} else {
arr[2].clone()
};
let hival: Option<usize> = hival_obj.as_integer().and_then(|i| {
if (0..=255).contains(&i) {
Some(i as usize)
} else {
None
}
});
let lookup_obj = if let Some(ref mut 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);
}
}
if matches!(base_cs, ColorSpace::Lab) {
let white = extract_lab_whitepoint(&base_obj);
let rgb_palette = lab_palette_to_rgb(&palette_bytes, white);
return Ok(Some(IndexedResolution {
base_fmt: PixelFormat::RGB,
palette: rgb_palette,
base_profile: None,
}));
}
Ok(Some(IndexedResolution {
base_fmt,
palette: palette_bytes,
base_profile,
}))
}
#[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(raw, palette, base_fmt, width, height, bpc, None)
}
fn expand_indexed_to_rgb_with_transform(
raw: &[u8],
palette: &[u8],
base_fmt: PixelFormat,
width: u32,
height: u32,
bpc: u8,
transform: Option<&crate::color::Transform>,
) -> Result<Vec<u8>> {
const MAX_INDEXED_OUTPUT_BYTES: usize = 256 * 1024 * 1024;
let w = width as usize;
let h = height as usize;
let n = base_fmt.bytes_per_pixel();
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 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(3))
.ok_or_else(|| {
Error::Image(format!("Indexed image output size overflow: {w} × {h} × 3 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})"
)));
}
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
)));
}
let mut out = Vec::with_capacity(output_bytes);
let read_index = |row: &[u8], x: usize| -> 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_index"),
}
};
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_index(row, x);
let off = idx * n;
if off + n > palette.len() {
out.extend_from_slice(&[0, 0, 0]);
continue;
}
match base_fmt {
PixelFormat::RGB => out.extend_from_slice(&palette[off..off + 3]),
PixelFormat::Grayscale => {
let g = palette[off];
out.push(g);
out.push(g);
out.push(g);
},
PixelFormat::CMYK => {
let c = palette[off];
let m = palette[off + 1];
let y_c = palette[off + 2];
let k = palette[off + 3];
let [r, g, b] = if let Some(t) = 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);
},
}
}
}
Ok(out)
}
pub(crate) fn cmyk_pixel_to_rgb(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
let c = c as f32 / 255.0;
let m = m as f32 / 255.0;
let y = y as f32 / 255.0;
let k = k as f32 / 255.0;
let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8;
let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8;
let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8;
[r, g, b]
}
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.chunks_exact(4) {
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 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 adobe_inverted = scan_adobe_inverted(jpeg_data);
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 adobe_inverted {
let mut buf = Vec::with_capacity(pixel_count * 4);
for chunk in cmyk.chunks_exact(4).take(pixel_count) {
buf.extend_from_slice(&[
255 - chunk[0],
255 - chunk[1],
255 - chunk[2],
255 - chunk[3],
]);
}
buf
} else {
cmyk[..pixel_count * 4].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.chunks_exact(4) {
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_adobe_inverted(jpeg_data: &[u8]) -> bool {
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") {
let transform = payload[11];
return transform == 0 || transform == 2;
}
}
if marker == 0xDA {
break;
}
i += seg_len;
}
false
}
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)))
},
}
}
pub fn expand_inline_image_dict(
dict: std::collections::HashMap<String, crate::object::Object>,
) -> std::collections::HashMap<String, crate::object::Object> {
use std::collections::HashMap;
let mut expanded = HashMap::new();
for (key, value) in dict {
let expanded_key = match key.as_str() {
"W" => "Width",
"H" => "Height",
"CS" => "ColorSpace",
"BPC" => "BitsPerComponent",
"F" => "Filter",
"DP" => "DecodeParms",
"IM" => "ImageMask",
"I" => "Interpolate",
"D" => "Decode",
"EF" => "EFontFile",
"Intent" => "Intent",
_ => &key,
};
expanded.insert(expanded_key.to_string(), value);
}
expanded
}
#[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 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 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 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);
}
}