use pdfboss_core::{Document, Object};
const MAX_DEPTH: u32 = 8;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ColorSpace {
DeviceGray,
DeviceRGB,
DeviceCMYK,
Indexed {
base: Box<ColorSpace>,
lookup: Vec<u8>,
},
Other(usize),
}
fn comp(comps: &[f32], i: usize) -> f32 {
let v = comps.get(i).copied().unwrap_or(0.0);
if v.is_finite() {
v.clamp(0.0, 1.0)
} else {
0.0
}
}
impl ColorSpace {
pub(crate) fn components(&self) -> usize {
match self {
ColorSpace::DeviceGray => 1,
ColorSpace::DeviceRGB => 3,
ColorSpace::DeviceCMYK => 4,
ColorSpace::Indexed { .. } => 1,
ColorSpace::Other(n) => *n,
}
}
pub(crate) fn to_rgb(&self, comps: &[f32]) -> [f32; 3] {
match self {
ColorSpace::DeviceGray => {
let g = comp(comps, 0);
[g, g, g]
}
ColorSpace::DeviceRGB => [comp(comps, 0), comp(comps, 1), comp(comps, 2)],
ColorSpace::DeviceCMYK => {
let k = comp(comps, 3);
[
1.0 - (comp(comps, 0) + k).min(1.0),
1.0 - (comp(comps, 1) + k).min(1.0),
1.0 - (comp(comps, 2) + k).min(1.0),
]
}
ColorSpace::Indexed { base, lookup } => {
let n = base.components().max(1);
let count = lookup.len() / n;
if count == 0 {
return [0.0, 0.0, 0.0];
}
let raw = comps.first().copied().unwrap_or(0.0);
let idx = if raw.is_finite() {
(raw.round().max(0.0) as usize).min(count - 1)
} else {
0
};
let mut base_comps = [0.0f32; 8];
let n = n.min(8);
for (i, bc) in base_comps.iter_mut().take(n).enumerate() {
*bc = lookup[idx * base.components() + i] as f32 / 255.0;
}
base.to_rgb(&base_comps[..n])
}
ColorSpace::Other(n) => {
let tint = (0..*n).map(|i| comp(comps, i)).fold(0.0f32, f32::max);
let g = 1.0 - tint;
[g, g, g]
}
}
}
pub(crate) fn parse(doc: &Document, obj: &Object) -> ColorSpace {
Self::parse_at(doc, obj, 0)
}
fn parse_at(doc: &Document, obj: &Object, depth: u32) -> ColorSpace {
if depth > MAX_DEPTH {
return ColorSpace::DeviceGray;
}
let obj = doc.resolve(obj).unwrap_or(Object::Null);
match &obj {
Object::Name(n) => Self::from_name(&n.0),
Object::Array(items) if !items.is_empty() => {
let family = match doc.resolve(&items[0]) {
Ok(Object::Name(n)) => n.0,
_ => return ColorSpace::DeviceGray,
};
match family.as_str() {
"ICCBased" => Self::parse_icc(doc, items.get(1), depth),
"Indexed" | "I" => Self::parse_indexed(doc, items, depth),
"Separation" => ColorSpace::Other(1),
"DeviceN" => {
let n = match items.get(1).map(|o| doc.resolve(o)) {
Some(Ok(Object::Array(names))) => names.len().max(1),
_ => 1,
};
ColorSpace::Other(n)
}
other => Self::from_name(other),
}
}
_ => ColorSpace::DeviceGray,
}
}
fn from_name(name: &str) -> ColorSpace {
match name {
"DeviceRGB" | "RGB" | "CalRGB" => ColorSpace::DeviceRGB,
"DeviceCMYK" | "CMYK" => ColorSpace::DeviceCMYK,
"Lab" => ColorSpace::Other(3),
_ => ColorSpace::DeviceGray,
}
}
fn parse_icc(doc: &Document, stream_obj: Option<&Object>, depth: u32) -> ColorSpace {
let resolved = stream_obj.map(|o| doc.resolve(o));
if let Some(Ok(Object::Stream(s))) = resolved {
if let Some(n) = s.dict.get_int("N") {
return match n {
1 => ColorSpace::DeviceGray,
3 => ColorSpace::DeviceRGB,
4 => ColorSpace::DeviceCMYK,
n => ColorSpace::Other(n.clamp(1, 32) as usize),
};
}
if let Some(alt) = s.dict.get("Alternate") {
return Self::parse_at(doc, alt, depth + 1);
}
}
ColorSpace::DeviceRGB
}
fn parse_indexed(doc: &Document, items: &[Object], depth: u32) -> ColorSpace {
let base = match items.get(1) {
Some(b) => Self::parse_at(doc, b, depth + 1),
None => ColorSpace::DeviceGray,
};
let lookup = match items.get(3).map(|o| doc.resolve(o)) {
Some(Ok(Object::String(bytes))) => bytes,
Some(Ok(Object::Stream(s))) => doc.stream_data(&s).unwrap_or_default(),
_ => Vec::new(),
};
ColorSpace::Indexed {
base: Box::new(base),
lookup,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pdfboss_core::parser::{NoResolve, Parser};
use pdfboss_testkit::PdfBuilder;
fn test_doc() -> Document {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
b.stream(4, "/N 4", b"");
b.stream(6, "", &[10, 200, 30, 250]);
Document::load(b.build(1)).unwrap()
}
fn obj(src: &[u8]) -> Object {
Parser::new(src).parse_object(&NoResolve).unwrap()
}
#[test]
fn gray_and_rgb_to_rgb() {
assert_eq!(ColorSpace::DeviceGray.to_rgb(&[0.25]), [0.25, 0.25, 0.25]);
assert_eq!(
ColorSpace::DeviceRGB.to_rgb(&[0.1, 0.5, 0.9]),
[0.1, 0.5, 0.9]
);
assert_eq!(ColorSpace::DeviceRGB.to_rgb(&[2.0]), [1.0, 0.0, 0.0]);
assert_eq!(ColorSpace::DeviceGray.to_rgb(&[f32::NAN]), [0.0, 0.0, 0.0]);
}
#[test]
fn cmyk_to_rgb_naive() {
assert_eq!(
ColorSpace::DeviceCMYK.to_rgb(&[1.0, 0.0, 0.0, 0.0]),
[0.0, 1.0, 1.0]
);
assert_eq!(
ColorSpace::DeviceCMYK.to_rgb(&[0.0, 0.0, 0.0, 1.0]),
[0.0, 0.0, 0.0]
);
let [r, g, b] = ColorSpace::DeviceCMYK.to_rgb(&[0.5, 0.2, 0.0, 0.3]);
assert!((r - 0.2).abs() < 1e-6);
assert!((g - 0.5).abs() < 1e-6);
assert!((b - 0.7).abs() < 1e-6);
}
#[test]
fn indexed_lookup_and_out_of_range_clamp() {
let cs = ColorSpace::Indexed {
base: Box::new(ColorSpace::DeviceRGB),
lookup: vec![255, 0, 0, 0, 255, 0, 0, 0, 255],
};
assert_eq!(cs.components(), 1);
assert_eq!(cs.to_rgb(&[1.0]), [0.0, 1.0, 0.0]);
assert_eq!(cs.to_rgb(&[9.0]), [0.0, 0.0, 1.0]);
assert_eq!(cs.to_rgb(&[-3.0]), [1.0, 0.0, 0.0]);
let empty = ColorSpace::Indexed {
base: Box::new(ColorSpace::DeviceRGB),
lookup: Vec::new(),
};
assert_eq!(empty.to_rgb(&[0.0]), [0.0, 0.0, 0.0]);
}
#[test]
fn other_uses_tint_approximation() {
assert_eq!(ColorSpace::Other(1).to_rgb(&[0.25]), [0.75, 0.75, 0.75]);
let g = ColorSpace::Other(2).to_rgb(&[0.1, 0.9]);
assert!((g[0] - 0.1).abs() < 1e-5, "gray {g:?}");
assert_eq!(g[0], g[1]);
assert_eq!(g[1], g[2]);
assert_eq!(ColorSpace::Other(1).to_rgb(&[]), [1.0, 1.0, 1.0]);
}
#[test]
fn component_counts() {
assert_eq!(ColorSpace::DeviceGray.components(), 1);
assert_eq!(ColorSpace::DeviceRGB.components(), 3);
assert_eq!(ColorSpace::DeviceCMYK.components(), 4);
assert_eq!(ColorSpace::Other(5).components(), 5);
}
#[test]
fn parse_names_and_abbreviations() {
let doc = test_doc();
let p = |s: &[u8]| ColorSpace::parse(&doc, &obj(s));
assert_eq!(p(b"/DeviceGray"), ColorSpace::DeviceGray);
assert_eq!(p(b"/G"), ColorSpace::DeviceGray);
assert_eq!(p(b"/DeviceRGB"), ColorSpace::DeviceRGB);
assert_eq!(p(b"/RGB"), ColorSpace::DeviceRGB);
assert_eq!(p(b"/DeviceCMYK"), ColorSpace::DeviceCMYK);
assert_eq!(p(b"/CalRGB"), ColorSpace::DeviceRGB);
assert_eq!(p(b"/CalGray"), ColorSpace::DeviceGray);
assert_eq!(p(b"/Lab"), ColorSpace::Other(3));
assert_eq!(p(b"/NoSuchSpace"), ColorSpace::DeviceGray);
assert_eq!(p(b"42"), ColorSpace::DeviceGray);
}
#[test]
fn parse_array_families() {
let doc = test_doc();
let p = |s: &[u8]| ColorSpace::parse(&doc, &obj(s));
assert_eq!(p(b"[/ICCBased 4 0 R]"), ColorSpace::DeviceCMYK);
assert_eq!(
p(b"[/CalRGB << /WhitePoint [1 1 1] >>]"),
ColorSpace::DeviceRGB
);
assert_eq!(
p(b"[/CalGray << /WhitePoint [1 1 1] >>]"),
ColorSpace::DeviceGray
);
assert_eq!(p(b"[/Lab << /WhitePoint [1 1 1] >>]"), ColorSpace::Other(3));
assert_eq!(
p(b"[/Separation /Spot /DeviceCMYK 4 0 R]"),
ColorSpace::Other(1)
);
assert_eq!(
p(b"[/DeviceN [/A /B /C] /DeviceRGB 4 0 R]"),
ColorSpace::Other(3)
);
assert_eq!(p(b"[/DeviceRGB]"), ColorSpace::DeviceRGB);
assert_eq!(p(b"[]"), ColorSpace::DeviceGray);
assert_eq!(p(b"[/ICCBased 99 0 R]"), ColorSpace::DeviceRGB);
}
#[test]
fn parse_indexed_with_string_lookup() {
let doc = test_doc();
let cs = ColorSpace::parse(&doc, &obj(b"[/Indexed /DeviceRGB 2 <FF000000FF000000FF>]"));
match &cs {
ColorSpace::Indexed { base, lookup } => {
assert_eq!(**base, ColorSpace::DeviceRGB);
assert_eq!(lookup.len(), 9);
}
other => panic!("expected Indexed, got {other:?}"),
}
assert_eq!(cs.to_rgb(&[2.0]), [0.0, 0.0, 1.0]);
}
#[test]
fn parse_indexed_with_stream_lookup() {
let doc = test_doc();
let cs = ColorSpace::parse(&doc, &obj(b"[/Indexed /DeviceGray 3 6 0 R]"));
match &cs {
ColorSpace::Indexed { base, lookup } => {
assert_eq!(**base, ColorSpace::DeviceGray);
assert_eq!(lookup, &vec![10, 200, 30, 250]);
}
other => panic!("expected Indexed, got {other:?}"),
}
let [r, _, _] = cs.to_rgb(&[1.0]);
assert!((r - 200.0 / 255.0).abs() < 1e-5);
}
}