use crate::color::ColorSpace;
use crate::function::FunctionCache;
use crate::names;
use crate::transfer::CHANNEL_SAMPLES;
use kurbo::Affine;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Dict, Object, Resolve, Stream};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Transparency {
pub group: bool,
pub isolated: bool,
pub knockout: bool,
}
impl Transparency {
#[must_use]
pub fn from_group(group: Option<&Dict>, r: &impl Resolve) -> Self {
let Some(group) = group else {
return Self::default();
};
if group.byte_string(names::S, r).as_deref() != Some(b"Transparency") {
return Self::default();
}
Self {
group: true,
isolated: truthy(group, names::I, r),
knockout: truthy(group, names::K, r),
}
}
#[must_use]
pub fn for_page(group: Option<&Dict>, r: &impl Resolve) -> Self {
Self {
isolated: true,
..Self::from_group(group, r)
}
}
}
fn truthy(dict: &Dict, key: &pdfrum_object::Name, r: &impl Resolve) -> bool {
match dict.get(key, r).as_deref() {
Some(Object::Bool(b)) => *b,
Some(Object::Int(i)) => *i != 0,
Some(Object::Real(v)) => *v != 0.0,
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SoftMaskKind {
#[default]
Luminosity,
Alpha,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SoftMask {
pub group: Stream,
pub kind: SoftMaskKind,
pub backdrop: crate::color::Rgb,
pub transfer: Option<Box<[u8; CHANNEL_SAMPLES]>>,
pub matrix: Affine,
pub objects: Vec<crate::page::PageObject>,
}
impl SoftMask {
#[must_use]
pub fn load<R: Resolve>(
value: &Object,
matrix: Affine,
r: &R,
functions: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Self> {
let resolved = value.resolve(r).ok()?;
let dict = resolved.as_dict()?;
let group = dict.stream(names::G, r)?;
let kind = if dict.byte_string(names::S, r).as_deref() == Some(b"Alpha") {
SoftMaskKind::Alpha
} else {
SoftMaskKind::Luminosity
};
let backdrop = match kind {
SoftMaskKind::Luminosity => backdrop_color(dict, &group, r, functions, limits, diags),
SoftMaskKind::Alpha => crate::color::Rgb::BLACK,
};
let transfer = dict
.raw(names::TR)
.filter(|obj| {
matches!(
obj.resolve(r).as_deref(),
Ok(Object::Dict(_) | Object::Stream(_))
)
})
.and_then(|obj| functions.load(obj, r, limits, diags))
.map(|func| {
let mut lut = Box::new([0u8; CHANNEL_SAMPLES]);
let mut results = vec![0.0f32; func.output_count().max(1)];
for (i, slot) in lut.iter_mut().enumerate() {
#[expect(
clippy::cast_precision_loss,
reason = "an index below 256 is exact in f32"
)]
let input = (i as f32) / 255.0;
let identity = u8::try_from(i).unwrap_or(u8::MAX);
if func.eval_into(&[input], &mut results) == 0 {
*slot = identity;
continue;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the product to 0..=255"
)]
let byte = (results.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0)
.round() as u8;
*slot = byte;
}
lut
});
Some(Self {
group,
kind,
backdrop,
transfer,
matrix,
objects: Vec::new(),
})
}
#[must_use]
pub fn apply_transfer(&self, value: u8) -> u8 {
self.transfer
.as_ref()
.and_then(|lut| lut.get(usize::from(value)))
.copied()
.unwrap_or(value)
}
}
fn backdrop_color<R: Resolve>(
smask: &Dict,
group_stream: &Stream,
r: &R,
functions: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> crate::color::Rgb {
let default = crate::color::Rgb::BLACK;
let Some(bc) = smask.array(names::BC, r) else {
return default;
};
let Some(cs_obj) = group_stream
.dict
.dict(names::GROUP, r)
.and_then(|g| g.raw(names::CS).cloned())
else {
return default;
};
let Some(space) = crate::color::load_colorspace(&cs_obj, None, r, functions, limits, diags)
else {
return default;
};
if matches!(space, ColorSpace::Lab(_)) || space.is_special() {
return default;
}
if matches!(space, ColorSpace::IccBased(_)) && !space.is_normal() {
return default;
}
let count = bc.len().min(8);
let width = space.n_components().max(8);
let mut comps = vec![0.0f32; width];
for i in 0..count {
if let Some(slot) = comps.get_mut(i) {
*slot = bc.number_at_or_zero(i);
}
}
space.to_rgb(&comps)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{SoftMask, SoftMaskKind, Transparency};
use crate::function::FunctionCache;
use kurbo::Affine;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, ObjRef, Object, Resolve, Stream};
use std::sync::Arc;
const GROUP_REF: ObjRef = ObjRef::new(1, 0);
struct GroupStore;
impl Resolve for GroupStore {
fn fetch(&self, r: ObjRef) -> Result<Arc<Object>, pdfrum_object::Error> {
if r.num == GROUP_REF.num {
return Ok(Arc::new(Object::Stream(Box::new(Stream::new(
Dict::new(),
ByteSpan::empty(),
)))));
}
Err(pdfrum_object::Error::UnresolvedRef(r))
}
}
fn group_ref() -> Object {
Object::Ref(GROUP_REF)
}
fn load_mask(pairs: Vec<(Name, Object)>) -> Option<SoftMask> {
let mut funcs = FunctionCache::new();
let mut diags = Diagnostics::default();
SoftMask::load(
&Object::Dict(Dict::from_pairs(pairs)),
Affine::IDENTITY,
&GroupStore,
&mut funcs,
&Limits::default(),
&mut diags,
)
}
#[test]
fn a_group_needs_s_to_be_exactly_transparency() {
let good = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("Transparency")))]);
assert!(Transparency::from_group(Some(&good), &NoResolve).group);
let wrong = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("Transp")))]);
assert!(!Transparency::from_group(Some(&wrong), &NoResolve).group);
assert!(!Transparency::from_group(None, &NoResolve).group);
}
#[test]
fn isolation_and_knockout_are_true_for_any_non_zero_value() {
let make = |key: &str, value: Object| {
Dict::from_pairs([
(Name::from("S"), Object::Name(Name::from("Transparency"))),
(Name::from(key), value),
])
};
assert!(
Transparency::from_group(Some(&make("I", Object::Bool(true))), &NoResolve).isolated
);
assert!(Transparency::from_group(Some(&make("I", Object::Int(1))), &NoResolve).isolated);
assert!(!Transparency::from_group(Some(&make("I", Object::Int(0))), &NoResolve).isolated);
assert!(
Transparency::from_group(Some(&make("K", Object::Bool(true))), &NoResolve).knockout
);
}
#[test]
fn a_page_is_isolated_whatever_its_group_says() {
let not_isolated = Dict::from_pairs([
(Name::from("S"), Object::Name(Name::from("Transparency"))),
(Name::from("I"), Object::Bool(false)),
]);
assert!(Transparency::for_page(Some(¬_isolated), &NoResolve).isolated);
assert!(Transparency::for_page(None, &NoResolve).isolated);
}
#[test]
fn a_soft_mask_defaults_to_luminosity() {
let mask = load_mask(vec![(Name::from("G"), group_ref())]).expect("should load");
assert_eq!(mask.kind, SoftMaskKind::Luminosity);
let mask = load_mask(vec![
(Name::from("G"), group_ref()),
(Name::from("S"), Object::Name(Name::from("Luminosity"))),
])
.expect("should load");
assert_eq!(mask.kind, SoftMaskKind::Luminosity);
let mask = load_mask(vec![
(Name::from("G"), group_ref()),
(Name::from("S"), Object::Name(Name::from("Nonsense"))),
])
.expect("should load");
assert_eq!(mask.kind, SoftMaskKind::Luminosity);
}
#[test]
fn only_the_exact_string_alpha_selects_alpha() {
let mask = load_mask(vec![
(Name::from("G"), group_ref()),
(Name::from("S"), Object::Name(Name::from("Alpha"))),
])
.expect("should load");
assert_eq!(mask.kind, SoftMaskKind::Alpha);
}
#[test]
fn a_mask_without_a_stream_g_is_dropped() {
assert!(load_mask(vec![(Name::from("G"), Object::Int(7))]).is_none());
assert!(load_mask(vec![]).is_none());
}
#[test]
fn smask_none_yields_no_mask() {
let mut funcs = FunctionCache::new();
let mut diags = Diagnostics::default();
let got = SoftMask::load(
&Object::Name(Name::from("None")),
Affine::IDENTITY,
&NoResolve,
&mut funcs,
&Limits::default(),
&mut diags,
);
assert!(got.is_none());
}
#[test]
fn the_default_backdrop_is_opaque_black() {
let mask = load_mask(vec![(Name::from("G"), group_ref())]).expect("should load");
assert_eq!(mask.backdrop, crate::color::Rgb::BLACK);
}
#[test]
fn a_tr_name_is_ignored_rather_than_installed() {
let mask = load_mask(vec![
(Name::from("G"), group_ref()),
(Name::from("TR"), Object::Name(Name::from("Identity"))),
])
.expect("should load");
assert!(mask.transfer.is_none());
assert_eq!(mask.apply_transfer(128), 128);
}
}