use crate::function::{Function, FunctionCache};
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Object, Resolve};
pub const CHANNEL_SAMPLES: usize = 256;
pub const MAX_OUTPUTS: usize = 16;
#[derive(Debug, Clone, PartialEq)]
pub struct TransferFunc {
pub samples: Box<[[u8; CHANNEL_SAMPLES]; 3]>,
pub identity: bool,
}
impl TransferFunc {
#[must_use]
pub fn load<R: Resolve>(
obj: &Object,
r: &R,
cache: &mut FunctionCache,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Self> {
let resolved = obj.resolve(r).ok()?;
if resolved.as_name().is_some() {
return None;
}
let mut samples = Box::new([[0u8; CHANNEL_SAMPLES]; 3]);
if let Some(array) = resolved.as_array() {
if array.len() < 3 {
return None;
}
for i in 0..3 {
let element = array.raw_at(i)?;
let func = cache.load(element, r, limits, diags)?;
let channel = samples.get_mut(i)?;
sample_channel(&func, channel);
}
} else {
let func = cache.load(&resolved, r, limits, diags)?;
let mut one = [0u8; CHANNEL_SAMPLES];
sample_channel(&func, &mut one);
for channel in samples.iter_mut() {
*channel = one;
}
}
let identity = samples.iter().all(|channel| {
channel
.iter()
.enumerate()
.all(|(i, v)| usize::from(*v) == i)
});
Some(Self { samples, identity })
}
#[must_use]
pub fn apply(&self, channel: usize, value: u8) -> u8 {
self.samples
.get(channel.min(2))
.and_then(|c| c.get(usize::from(value)))
.copied()
.unwrap_or(value)
}
}
fn sample_channel(func: &Function, out: &mut [u8; CHANNEL_SAMPLES]) {
if func.output_count() > MAX_OUTPUTS {
for (i, slot) in out.iter_mut().enumerate() {
*slot = u8::try_from(i).unwrap_or(u8::MAX);
}
return;
}
let mut results = vec![0.0f32; func.output_count().max(1)];
for (i, slot) in out.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;
}
let value = results.first().copied().unwrap_or(0.0);
*slot = saturate_to_byte(value * 255.0);
}
}
fn saturate_to_byte(value: f32) -> u8 {
let rounded = value.round();
if !rounded.is_finite() {
return 0;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the value to 0.0..=255.0"
)]
let saturated = rounded.clamp(0.0, 255.0) as u8;
saturated
}
#[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::{CHANNEL_SAMPLES, TransferFunc};
use crate::function::FunctionCache;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
fn nums(values: &[f32]) -> Object {
Object::Array(Array::of(values.iter().copied().map(Object::Real)))
}
fn invert() -> Object {
Object::Dict(Dict::from_pairs([
(Name::from("FunctionType"), Object::Int(2)),
(Name::from("Domain"), nums(&[0.0, 1.0])),
(Name::from("N"), Object::Int(1)),
(Name::from("C0"), nums(&[1.0])),
(Name::from("C1"), nums(&[0.0])),
]))
}
fn load(obj: &Object) -> Option<TransferFunc> {
let mut cache = FunctionCache::new();
let mut diags = Diagnostics::default();
TransferFunc::load(obj, &NoResolve, &mut cache, &Limits::default(), &mut diags)
}
#[test]
fn a_single_function_applies_to_every_channel() {
let tr = load(&invert()).expect("should load");
assert_eq!(tr.apply(0, 0), 255);
assert_eq!(tr.apply(1, 255), 0);
assert_eq!(tr.apply(2, 128), 127);
assert!(!tr.identity);
}
#[test]
fn a_name_stores_nothing() {
for name in ["Identity", "Default", "Anything"] {
assert!(
load(&Object::Name(Name::from(name))).is_none(),
"/{name} should disable the transfer function"
);
}
}
#[test]
fn the_array_form_needs_three_elements() {
let two = Object::Array(Array::of([invert(), invert()]));
assert!(load(&two).is_none());
let three = Object::Array(Array::of([invert(), invert(), invert()]));
assert!(load(&three).is_some());
}
#[test]
fn a_bad_element_makes_the_whole_function_null() {
let bad = Object::Array(Array::of([invert(), Object::Int(7), invert()]));
assert!(load(&bad).is_none());
}
fn constant(v: f32) -> Object {
Object::Dict(Dict::from_pairs([
(Name::from("FunctionType"), Object::Int(2)),
(Name::from("Domain"), nums(&[0.0, 1.0])),
(Name::from("N"), Object::Int(1)),
(Name::from("C0"), nums(&[v])),
(Name::from("C1"), nums(&[v])),
]))
}
#[test]
fn the_first_array_element_drives_red() {
let array = Object::Array(Array::of([
constant(10.0 / 255.0),
constant(100.0 / 255.0),
constant(200.0 / 255.0),
]));
let tr = load(&array).expect("should load");
assert_eq!(tr.apply(0, 0), 10, "array[0] must drive red");
assert_eq!(tr.apply(1, 0), 100, "array[1] must drive green");
assert_eq!(tr.apply(2, 0), 200, "array[2] must drive blue");
}
#[test]
fn the_array_order_survives_to_the_output_bytes() {
let identity = constant_ramp();
let array = Object::Array(Array::of([invert(), identity.clone(), identity]));
let tr = load(&array).expect("should load");
assert!(!tr.identity);
assert_eq!(tr.apply(0, 0), 255, "red inverts");
assert_eq!(tr.apply(1, 0), 0, "green is the identity");
assert_eq!(tr.apply(2, 0), 0, "blue is the identity");
}
fn constant_ramp() -> Object {
Object::Dict(Dict::from_pairs([
(Name::from("FunctionType"), Object::Int(2)),
(Name::from("Domain"), nums(&[0.0, 1.0])),
(Name::from("N"), Object::Int(1)),
(Name::from("C0"), nums(&[0.0])),
(Name::from("C1"), nums(&[1.0])),
]))
}
#[test]
fn an_identity_function_is_recognised_as_a_no_op() {
let tr = load(&constant_ramp()).expect("should load");
assert!(tr.identity);
assert_eq!(tr.samples[0].len(), CHANNEL_SAMPLES);
}
}