use crate::error::{PdfError, Result};
use crate::objects::{Dictionary, Object};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct DeviceNColorSpace {
pub colorant_names: Vec<String>,
pub alternate_space: AlternateColorSpace,
pub tint_transform: TintTransformFunction,
pub attributes: Option<DeviceNAttributes>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AlternateColorSpace {
DeviceRGB,
DeviceCMYK,
DeviceGray,
CIEBased(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum TintTransformFunction {
Linear(LinearTransform),
Function(Vec<u8>),
Sampled(SampledFunction),
}
#[derive(Debug, Clone, PartialEq)]
pub struct LinearTransform {
pub matrix: Vec<Vec<f64>>,
pub black_generation: Option<Vec<f64>>,
pub undercolor_removal: Option<Vec<f64>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SampledFunction {
pub domain: Vec<(f64, f64)>,
pub range: Vec<(f64, f64)>,
pub size: Vec<usize>,
pub samples: Vec<u8>,
pub bits_per_sample: u8,
pub order: u8,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeviceNAttributes {
pub colorants: HashMap<String, ColorantDefinition>,
pub process: Option<String>,
pub mix: Option<String>,
pub dot_gain: HashMap<String, Vec<f64>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColorantDefinition {
pub colorant_type: ColorantType,
pub cmyk_equivalent: Option<[f64; 4]>,
pub rgb_approximation: Option<[f64; 3]>,
pub lab_color: Option<[f64; 3]>,
pub density: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ColorantType {
Process,
Spot,
Special,
}
impl DeviceNColorSpace {
pub fn new(
colorant_names: Vec<String>,
alternate_space: AlternateColorSpace,
tint_transform: TintTransformFunction,
) -> Self {
Self {
colorant_names,
alternate_space,
tint_transform,
attributes: None,
}
}
pub fn cmyk_plus_spots(spot_names: Vec<String>) -> Self {
let mut colorants = vec![
"Cyan".to_string(),
"Magenta".to_string(),
"Yellow".to_string(),
"Black".to_string(),
];
colorants.extend(spot_names);
let n_colorants = colorants.len();
let mut matrix = vec![vec![0.0; 4]; n_colorants];
for (i, row) in matrix.iter_mut().enumerate().take(4) {
row[i] = 1.0;
}
for row in matrix.iter_mut().skip(4).take(n_colorants - 4) {
row[3] = 1.0; }
Self::new(
colorants,
AlternateColorSpace::DeviceCMYK,
TintTransformFunction::Linear(LinearTransform {
matrix,
black_generation: None,
undercolor_removal: None,
}),
)
}
pub fn with_attributes(mut self, attributes: DeviceNAttributes) -> Self {
self.attributes = Some(attributes);
self
}
pub fn convert_to_alternate(&self, devicen_values: &[f64]) -> Result<Vec<f64>> {
if devicen_values.len() != self.colorant_names.len() {
return Err(PdfError::InvalidStructure(
"DeviceN values count must match colorant names count".to_string(),
));
}
match &self.tint_transform {
TintTransformFunction::Linear(transform) => {
self.apply_linear_transform(devicen_values, transform)
}
TintTransformFunction::Function(_) => {
self.linear_approximation(devicen_values)
}
TintTransformFunction::Sampled(sampled) => {
self.apply_sampled_function(devicen_values, sampled)
}
}
}
fn apply_linear_transform(
&self,
input: &[f64],
transform: &LinearTransform,
) -> Result<Vec<f64>> {
let n_output = match self.alternate_space {
AlternateColorSpace::DeviceRGB => 3,
AlternateColorSpace::DeviceCMYK => 4,
AlternateColorSpace::DeviceGray => 1,
AlternateColorSpace::CIEBased(_) => 3, };
if transform.matrix.len() != input.len() {
return Err(PdfError::InvalidStructure(
"Transform matrix size mismatch".to_string(),
));
}
let mut output = vec![0.0; n_output];
for (i, input_val) in input.iter().enumerate() {
if transform.matrix[i].len() != n_output {
return Err(PdfError::InvalidStructure(
"Transform matrix column size mismatch".to_string(),
));
}
for (j, transform_val) in transform.matrix[i].iter().enumerate() {
output[j] += input_val * transform_val;
}
}
for val in &mut output {
*val = val.clamp(0.0, 1.0);
}
Ok(output)
}
fn linear_approximation(&self, input: &[f64]) -> Result<Vec<f64>> {
match self.alternate_space {
AlternateColorSpace::DeviceRGB => {
let gray = input.iter().sum::<f64>() / input.len() as f64;
Ok(vec![1.0 - gray, 1.0 - gray, 1.0 - gray])
}
AlternateColorSpace::DeviceCMYK => {
let mut cmyk = vec![0.0; 4];
for (i, val) in input.iter().enumerate() {
cmyk[i % 4] += val / (input.len() / 4 + 1) as f64;
}
Ok(cmyk)
}
AlternateColorSpace::DeviceGray => {
let gray = input.iter().sum::<f64>() / input.len() as f64;
Ok(vec![gray])
}
AlternateColorSpace::CIEBased(_) => {
Ok(vec![50.0, 0.0, 0.0])
}
}
}
fn apply_sampled_function(&self, input: &[f64], sampled: &SampledFunction) -> Result<Vec<f64>> {
if input.len() != sampled.domain.len() {
return Err(PdfError::InvalidStructure(
"Input dimension mismatch for sampled function".to_string(),
));
}
let mut coords = Vec::new();
for (i, &val) in input.iter().enumerate() {
let (min, max) = sampled.domain[i];
let normalized = (val - min) / (max - min);
let coord = normalized * (sampled.size[i] - 1) as f64;
coords.push(coord.max(0.0).min((sampled.size[i] - 1) as f64));
}
let mut sample_index = 0;
let mut stride = 1;
for i in (0..coords.len()).rev() {
sample_index += (coords[i] as usize) * stride;
stride *= sampled.size[i];
}
let output_components = sampled.range.len();
let bytes_per_sample = (sampled.bits_per_sample as f64 / 8.0).ceil() as usize;
let start_byte = sample_index * output_components * bytes_per_sample;
let mut output = Vec::new();
for i in 0..output_components {
let byte_offset = start_byte + i * bytes_per_sample;
if byte_offset + bytes_per_sample <= sampled.samples.len() {
let sample_value = self.extract_sample_value(
&sampled.samples[byte_offset..byte_offset + bytes_per_sample],
sampled.bits_per_sample,
);
let (min, max) = sampled.range[i];
let normalized = sample_value / ((1 << sampled.bits_per_sample) - 1) as f64;
output.push(min + normalized * (max - min));
}
}
Ok(output)
}
fn extract_sample_value(&self, bytes: &[u8], bits_per_sample: u8) -> f64 {
match bits_per_sample {
8 => bytes[0] as f64,
16 => ((bytes[0] as u16) << 8 | bytes[1] as u16) as f64,
32 => {
let value = ((bytes[0] as u32) << 24)
| ((bytes[1] as u32) << 16)
| ((bytes[2] as u32) << 8)
| bytes[3] as u32;
value as f64
}
_ => bytes[0] as f64, }
}
pub fn colorant_count(&self) -> usize {
self.colorant_names.len()
}
pub fn colorant_name(&self, index: usize) -> Option<&str> {
self.colorant_names.get(index).map(|s| s.as_str())
}
pub fn has_process_colors(&self) -> bool {
self.colorant_names.iter().any(|name| {
matches!(
name.as_str(),
"Cyan" | "Magenta" | "Yellow" | "Black" | "C" | "M" | "Y" | "K"
)
})
}
pub fn spot_color_names(&self) -> Vec<&str> {
self.colorant_names
.iter()
.filter(|name| {
!matches!(
name.as_str(),
"Cyan" | "Magenta" | "Yellow" | "Black" | "C" | "M" | "Y" | "K"
)
})
.map(|s| s.as_str())
.collect()
}
pub fn to_pdf_object(&self) -> Object {
let mut array = Vec::new();
array.push(Object::Name("DeviceN".to_string()));
let mut names_array = Vec::new();
for name in &self.colorant_names {
names_array.push(Object::Name(name.clone()));
}
array.push(Object::Array(names_array));
let alternate_obj = match &self.alternate_space {
AlternateColorSpace::DeviceRGB => Object::Name("DeviceRGB".to_string()),
AlternateColorSpace::DeviceCMYK => Object::Name("DeviceCMYK".to_string()),
AlternateColorSpace::DeviceGray => Object::Name("DeviceGray".to_string()),
AlternateColorSpace::CIEBased(name) => Object::Name(name.clone()),
};
array.push(alternate_obj);
match &self.tint_transform {
TintTransformFunction::Function(data) => {
let mut func_dict = Dictionary::new();
func_dict.set("FunctionType", Object::Integer(4)); func_dict.set("Domain", self.create_domain_array());
func_dict.set("Range", self.create_range_array());
array.push(Object::Stream(func_dict, data.clone()));
}
_ => {
let mut func_dict = Dictionary::new();
func_dict.set("FunctionType", Object::Integer(2)); func_dict.set("Domain", self.create_domain_array());
func_dict.set("Range", self.create_range_array());
func_dict.set("N", Object::Real(1.0));
array.push(Object::Dictionary(func_dict));
}
}
if let Some(attributes) = &self.attributes {
let mut attr_dict = Dictionary::new();
if let Some(process) = &attributes.process {
attr_dict.set("Process", Object::Name(process.clone()));
}
if !attributes.colorants.is_empty() {
let mut colorants_dict = Dictionary::new();
for (name, def) in &attributes.colorants {
let mut colorant_dict = Dictionary::new();
match def.colorant_type {
ColorantType::Process => {
colorant_dict.set("Type", Object::Name("Process".to_string()))
}
ColorantType::Spot => {
colorant_dict.set("Type", Object::Name("Spot".to_string()))
}
ColorantType::Special => {
colorant_dict.set("Type", Object::Name("Special".to_string()))
}
}
if let Some(cmyk) = def.cmyk_equivalent {
let cmyk_array: Vec<Object> =
cmyk.iter().map(|&v| Object::Real(v)).collect();
colorant_dict.set("CMYK", Object::Array(cmyk_array));
}
colorants_dict.set(name, Object::Dictionary(colorant_dict));
}
attr_dict.set("Colorants", Object::Dictionary(colorants_dict));
}
array.push(Object::Dictionary(attr_dict));
}
Object::Array(array)
}
fn create_domain_array(&self) -> Object {
let mut domain = Vec::new();
for _ in 0..self.colorant_names.len() {
domain.push(Object::Real(0.0));
domain.push(Object::Real(1.0));
}
Object::Array(domain)
}
fn create_range_array(&self) -> Object {
let mut range = Vec::new();
let components = match self.alternate_space {
AlternateColorSpace::DeviceRGB => 3,
AlternateColorSpace::DeviceCMYK => 4,
AlternateColorSpace::DeviceGray => 1,
AlternateColorSpace::CIEBased(_) => 3,
};
for _ in 0..components {
range.push(Object::Real(0.0));
range.push(Object::Real(1.0));
}
Object::Array(range)
}
}
impl ColorantDefinition {
pub fn process(cmyk_equivalent: [f64; 4]) -> Self {
Self {
colorant_type: ColorantType::Process,
cmyk_equivalent: Some(cmyk_equivalent),
rgb_approximation: Some([
1.0 - cmyk_equivalent[0].min(1.0),
1.0 - cmyk_equivalent[1].min(1.0),
1.0 - cmyk_equivalent[2].min(1.0),
]),
lab_color: None,
density: None,
}
}
pub fn spot(_name: &str, cmyk_equivalent: [f64; 4]) -> Self {
Self {
colorant_type: ColorantType::Spot,
cmyk_equivalent: Some(cmyk_equivalent),
rgb_approximation: Some([
1.0 - cmyk_equivalent[0].min(1.0),
1.0 - cmyk_equivalent[1].min(1.0),
1.0 - cmyk_equivalent[2].min(1.0),
]),
lab_color: None,
density: None,
}
}
pub fn special_effect(rgb_approximation: [f64; 3]) -> Self {
Self {
colorant_type: ColorantType::Special,
cmyk_equivalent: None,
rgb_approximation: Some(rgb_approximation),
lab_color: None,
density: Some(0.5), }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_devicen_new() {
let colorants = vec!["Cyan".to_string(), "Magenta".to_string()];
let transform = TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]],
black_generation: None,
undercolor_removal: None,
});
let space =
DeviceNColorSpace::new(colorants.clone(), AlternateColorSpace::DeviceRGB, transform);
assert_eq!(space.colorant_names, colorants);
assert_eq!(space.alternate_space, AlternateColorSpace::DeviceRGB);
assert!(space.attributes.is_none());
}
#[test]
fn test_cmyk_plus_spots() {
let spot_names = vec!["PANTONE 185 C".to_string(), "Gold".to_string()];
let space = DeviceNColorSpace::cmyk_plus_spots(spot_names);
assert_eq!(space.colorant_count(), 6);
assert_eq!(space.colorant_name(0), Some("Cyan"));
assert_eq!(space.colorant_name(1), Some("Magenta"));
assert_eq!(space.colorant_name(2), Some("Yellow"));
assert_eq!(space.colorant_name(3), Some("Black"));
assert_eq!(space.colorant_name(4), Some("PANTONE 185 C"));
assert_eq!(space.colorant_name(5), Some("Gold"));
assert_eq!(space.colorant_name(6), None);
}
#[test]
fn test_has_process_colors() {
let with_cmyk = DeviceNColorSpace::cmyk_plus_spots(vec![]);
assert!(with_cmyk.has_process_colors());
let spot_only = DeviceNColorSpace::new(
vec!["PANTONE Red".to_string()],
AlternateColorSpace::DeviceCMYK,
TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![0.0, 1.0, 0.0, 0.0]],
black_generation: None,
undercolor_removal: None,
}),
);
assert!(!spot_only.has_process_colors());
}
#[test]
fn test_spot_color_names() {
let space = DeviceNColorSpace::cmyk_plus_spots(vec![
"PANTONE 185 C".to_string(),
"Gold".to_string(),
]);
let spots = space.spot_color_names();
assert_eq!(spots.len(), 2);
assert!(spots.contains(&"PANTONE 185 C"));
assert!(spots.contains(&"Gold"));
}
#[test]
fn test_colorant_count() {
let space = DeviceNColorSpace::new(
vec!["A".to_string(), "B".to_string(), "C".to_string()],
AlternateColorSpace::DeviceGray,
TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![1.0], vec![1.0], vec![1.0]],
black_generation: None,
undercolor_removal: None,
}),
);
assert_eq!(space.colorant_count(), 3);
}
#[test]
fn test_with_attributes() {
let mut colorants = HashMap::new();
colorants.insert(
"Spot1".to_string(),
ColorantDefinition::spot("Spot1", [0.0, 1.0, 0.0, 0.0]),
);
let attributes = DeviceNAttributes {
colorants,
process: Some("CMYK".to_string()),
mix: None,
dot_gain: HashMap::new(),
};
let space = DeviceNColorSpace::new(
vec!["Cyan".to_string()],
AlternateColorSpace::DeviceCMYK,
TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![1.0, 0.0, 0.0, 0.0]],
black_generation: None,
undercolor_removal: None,
}),
)
.with_attributes(attributes);
assert!(space.attributes.is_some());
let attrs = space.attributes.unwrap();
assert_eq!(attrs.process, Some("CMYK".to_string()));
assert!(attrs.colorants.contains_key("Spot1"));
}
#[test]
fn test_convert_to_alternate_rgb() {
let transform = TintTransformFunction::Linear(LinearTransform {
matrix: vec![
vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0], ],
black_generation: None,
undercolor_removal: None,
});
let space = DeviceNColorSpace::new(
vec!["Cyan".to_string(), "Magenta".to_string()],
AlternateColorSpace::DeviceRGB,
transform,
);
let result = space.convert_to_alternate(&[0.5, 0.3]).unwrap();
assert_eq!(result.len(), 3);
assert!((result[0] - 0.5).abs() < 0.001);
assert!((result[1] - 0.3).abs() < 0.001);
assert!((result[2] - 0.0).abs() < 0.001);
}
#[test]
fn test_convert_to_alternate_cmyk() {
let space = DeviceNColorSpace::cmyk_plus_spots(vec![]);
let result = space.convert_to_alternate(&[0.5, 0.3, 0.2, 0.1]).unwrap();
assert_eq!(result.len(), 4);
assert!((result[0] - 0.5).abs() < 0.001);
assert!((result[1] - 0.3).abs() < 0.001);
assert!((result[2] - 0.2).abs() < 0.001);
assert!((result[3] - 0.1).abs() < 0.001);
}
#[test]
fn test_convert_to_alternate_wrong_count() {
let space = DeviceNColorSpace::cmyk_plus_spots(vec![]);
let result = space.convert_to_alternate(&[0.5, 0.3]);
assert!(result.is_err());
}
#[test]
fn test_convert_clamping() {
let transform = TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![2.0, 0.0, 0.0]],
black_generation: None,
undercolor_removal: None,
});
let space = DeviceNColorSpace::new(
vec!["Intense".to_string()],
AlternateColorSpace::DeviceRGB,
transform,
);
let result = space.convert_to_alternate(&[0.8]).unwrap();
assert_eq!(result[0], 1.0); }
#[test]
fn test_alternate_color_space_variants() {
assert_eq!(
AlternateColorSpace::DeviceRGB,
AlternateColorSpace::DeviceRGB
);
assert_eq!(
AlternateColorSpace::DeviceCMYK,
AlternateColorSpace::DeviceCMYK
);
assert_eq!(
AlternateColorSpace::DeviceGray,
AlternateColorSpace::DeviceGray
);
let cie = AlternateColorSpace::CIEBased("sRGB".to_string());
assert_eq!(cie, AlternateColorSpace::CIEBased("sRGB".to_string()));
}
#[test]
fn test_colorant_type_variants() {
assert_eq!(ColorantType::Process, ColorantType::Process);
assert_eq!(ColorantType::Spot, ColorantType::Spot);
assert_eq!(ColorantType::Special, ColorantType::Special);
}
#[test]
fn test_colorant_definition_process() {
let cmyk = [1.0, 0.0, 0.0, 0.0]; let def = ColorantDefinition::process(cmyk);
assert_eq!(def.colorant_type, ColorantType::Process);
assert_eq!(def.cmyk_equivalent, Some(cmyk));
assert!(def.rgb_approximation.is_some());
let rgb = def.rgb_approximation.unwrap();
assert!((rgb[0] - 0.0).abs() < 0.001); assert!((rgb[1] - 1.0).abs() < 0.001); assert!((rgb[2] - 1.0).abs() < 0.001); }
#[test]
fn test_colorant_definition_spot() {
let cmyk = [0.0, 1.0, 1.0, 0.0]; let def = ColorantDefinition::spot("PANTONE Red", cmyk);
assert_eq!(def.colorant_type, ColorantType::Spot);
assert_eq!(def.cmyk_equivalent, Some(cmyk));
assert!(def.rgb_approximation.is_some());
}
#[test]
fn test_colorant_definition_special_effect() {
let rgb = [0.8, 0.8, 0.4]; let def = ColorantDefinition::special_effect(rgb);
assert_eq!(def.colorant_type, ColorantType::Special);
assert_eq!(def.cmyk_equivalent, None);
assert_eq!(def.rgb_approximation, Some(rgb));
assert_eq!(def.density, Some(0.5));
}
#[test]
fn test_linear_transform_struct() {
let transform = LinearTransform {
matrix: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
black_generation: Some(vec![0.1, 0.2]),
undercolor_removal: Some(vec![0.05]),
};
assert_eq!(transform.matrix.len(), 2);
assert_eq!(transform.black_generation, Some(vec![0.1, 0.2]));
assert_eq!(transform.undercolor_removal, Some(vec![0.05]));
}
#[test]
fn test_sampled_function_struct() {
let sampled = SampledFunction {
domain: vec![(0.0, 1.0), (0.0, 1.0)],
range: vec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
size: vec![4, 4],
samples: vec![0; 48],
bits_per_sample: 8,
order: 1,
};
assert_eq!(sampled.domain.len(), 2);
assert_eq!(sampled.range.len(), 3);
assert_eq!(sampled.bits_per_sample, 8);
assert_eq!(sampled.order, 1);
}
#[test]
fn test_extract_sample_value_8bit() {
let space = DeviceNColorSpace::new(
vec!["Test".to_string()],
AlternateColorSpace::DeviceGray,
TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![1.0]],
black_generation: None,
undercolor_removal: None,
}),
);
let bytes = [128u8];
let value = space.extract_sample_value(&bytes, 8);
assert_eq!(value, 128.0);
}
#[test]
fn test_extract_sample_value_16bit() {
let space = DeviceNColorSpace::new(
vec!["Test".to_string()],
AlternateColorSpace::DeviceGray,
TintTransformFunction::Linear(LinearTransform {
matrix: vec![vec![1.0]],
black_generation: None,
undercolor_removal: None,
}),
);
let bytes = [0x01, 0x00]; let value = space.extract_sample_value(&bytes, 16);
assert_eq!(value, 256.0);
}
#[test]
fn test_to_pdf_object() {
let space = DeviceNColorSpace::cmyk_plus_spots(vec!["Gold".to_string()]);
let obj = space.to_pdf_object();
if let Object::Array(arr) = obj {
assert!(arr.len() >= 4); if let Object::Name(name) = &arr[0] {
assert_eq!(name, "DeviceN");
} else {
panic!("First element should be Name");
}
} else {
panic!("Should return Array object");
}
}
#[test]
fn test_devicen_attributes() {
let mut colorants = HashMap::new();
colorants.insert(
"Cyan".to_string(),
ColorantDefinition::process([1.0, 0.0, 0.0, 0.0]),
);
let mut dot_gain = HashMap::new();
dot_gain.insert("Cyan".to_string(), vec![0.0, 0.1, 0.2]);
let attrs = DeviceNAttributes {
colorants,
process: Some("DeviceCMYK".to_string()),
mix: Some("DeviceRGB".to_string()),
dot_gain,
};
assert!(attrs.colorants.contains_key("Cyan"));
assert_eq!(attrs.process, Some("DeviceCMYK".to_string()));
assert_eq!(attrs.mix, Some("DeviceRGB".to_string()));
assert!(attrs.dot_gain.contains_key("Cyan"));
}
#[test]
fn test_linear_approximation_rgb() {
let space = DeviceNColorSpace::new(
vec!["Test".to_string()],
AlternateColorSpace::DeviceRGB,
TintTransformFunction::Function(vec![]), );
let result = space.convert_to_alternate(&[0.5]).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_linear_approximation_gray() {
let space = DeviceNColorSpace::new(
vec!["Test".to_string()],
AlternateColorSpace::DeviceGray,
TintTransformFunction::Function(vec![]),
);
let result = space.convert_to_alternate(&[0.5]).unwrap();
assert_eq!(result.len(), 1);
assert!((result[0] - 0.5).abs() < 0.001);
}
#[test]
fn test_linear_approximation_cie() {
let space = DeviceNColorSpace::new(
vec!["Test".to_string()],
AlternateColorSpace::CIEBased("Lab".to_string()),
TintTransformFunction::Function(vec![]),
);
let result = space.convert_to_alternate(&[0.5]).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0], 50.0);
assert_eq!(result[1], 0.0);
assert_eq!(result[2], 0.0);
}
}