#[cfg(test)]
use pdfrum_page::Rgb;
use crate::color::Argb;
pub const STEPS: usize = 256;
const STEPS_I32: i32 = {
const { assert!(STEPS <= i32::MAX as usize) };
#[expect(
clippy::cast_possible_wrap,
clippy::cast_possible_truncation,
reason = "the const assertion above proves the value fits"
)]
let v = STEPS as i32;
v
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColorSteps {
entries: Box<[Argb; STEPS]>,
}
impl ColorSteps {
#[must_use]
pub fn sample(
shading: &pdfrum_page::Shading,
t_min: f32,
t_max: f32,
alpha: u8,
) -> Option<Self> {
if shading.functions.is_empty() && shading.space.n_components() == 0 {
return None;
}
let diff = t_max - t_min;
let mut entries = Box::new([Argb::TRANSPARENT; STEPS]);
for (i, slot) in entries.iter_mut().enumerate() {
#[expect(clippy::cast_precision_loss, reason = "i < 256 is exact in f32")]
let input = diff * (i as f32) / (STEPS as f32) + t_min;
let rgb = shading.color_at(input);
let [r, g, b] = rgb.to_bytes();
*slot = Argb { a: alpha, r, g, b };
}
Some(Self { entries })
}
#[cfg(test)]
#[must_use]
#[expect(
clippy::large_types_passed_by_value,
reason = "a test helper's caller has the array by value; a reference would only move the copy"
)]
pub fn from_colors(colors: [Rgb; STEPS], alpha: u8) -> Self {
let mut entries = Box::new([Argb::TRANSPARENT; STEPS]);
for (slot, rgb) in entries.iter_mut().zip(colors.iter()) {
let [r, g, b] = rgb.to_bytes();
*slot = Argb { a: alpha, r, g, b };
}
Self { entries }
}
#[must_use]
pub fn lookup(&self, s: f32, extend_start: bool, extend_end: bool) -> Option<Argb> {
if s.is_nan() {
return extend_start
.then(|| self.entries.first().copied())
.flatten();
}
#[expect(
clippy::cast_possible_truncation,
reason = "the clamp below bounds the index; the truncation is the ported behaviour"
)]
let index = (s * 255.0) as i32;
let index = if index < 0 {
if !extend_start {
return None;
}
0
} else if index >= STEPS_I32 {
if !extend_end {
return None;
}
STEPS - 1
} else {
#[expect(
clippy::cast_sign_loss,
reason = "the `index < 0` branch above has already returned, so \
the value here is 0..STEPS"
)]
let idx = index as usize;
idx
};
self.entries.get(index).copied()
}
#[must_use]
pub fn entry(&self, index: usize) -> Option<Argb> {
self.entries.get(index).copied()
}
}
#[must_use]
#[expect(
clippy::float_cmp,
reason = "the exact `c_min == c_max` is upstream's divide-by-zero guard, \
and it must be exact: an epsilon would collapse a narrow but \
real component range to a constant 0.0 where PDFium still \
interpolates across it"
)]
pub fn component_to_shading_index(c: f32, c_min: f32, c_max: f32) -> f32 {
if c_min == c_max {
0.0
} else {
((c - c_min) / (c_max - c_min)) * 255.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp() -> ColorSteps {
let mut colors = [Rgb::BLACK; STEPS];
for (i, c) in colors.iter_mut().enumerate() {
#[expect(clippy::cast_precision_loss, reason = "i < 256 is exact")]
let v = i as f32 / 255.0;
*c = Rgb { r: v, g: v, b: v };
}
ColorSteps::from_colors(colors, 255)
}
#[test]
fn lut_divisor_is_256_so_t_max_is_never_sampled() {
let last_input: f64 = 255.0 / 256.0;
assert!(last_input < 1.0);
assert!((last_input - 0.996_093_75).abs() < 1e-6);
}
#[test]
fn the_last_entry_is_sampled_at_255_over_256_not_at_t_max() {
use kurbo::Point;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
use pdfrum_page::{Axial, ColorSpace, FunctionCache, Geometry, Shading};
let dict = Dict::from_pairs([
(Name::from("FunctionType"), Object::Int(2)),
(
Name::from("Domain"),
Object::Array(Array::of([Object::Int(0), Object::Int(1)])),
),
(Name::from("N"), Object::Int(1)),
]);
let gray = FunctionCache::new()
.load(
&Object::Dict(dict),
&NoResolve,
&Limits::default(),
&mut Diagnostics::default(),
)
.expect("a type 2 function");
let shading = Shading {
geometry: Geometry::Axial(Axial {
start: Point::new(0.0, 0.0),
end: Point::new(1.0, 0.0),
t_min: 0.0,
t_max: 1.0,
extend_start: false,
extend_end: false,
}),
space: std::sync::Arc::new(ColorSpace::DeviceGray),
functions: Box::new([gray]),
background: None,
bbox: None,
};
let ramp = ColorSteps::sample(&shading, 0.0, 1.0, 255).expect("samples");
assert_eq!(ramp.entry(0).map(|c| c.r), Some(0));
assert_eq!(ramp.entry(STEPS - 1).map(|c| c.r), Some(254));
}
#[test]
fn axial_index_truncates() {
let r = ramp();
assert_eq!(r.lookup(0.999, true, true).map(|c| c.r), Some(254));
assert_eq!(r.lookup(1.0, true, true).map(|c| c.r), Some(255));
assert_eq!(r.lookup(0.0, true, true).map(|c| c.r), Some(0));
}
#[test]
fn extend_skips_leave_the_pixel_untouched() {
let r = ramp();
assert_eq!(r.lookup(-0.5, false, true), None);
assert_eq!(r.lookup(1.5, true, false), None);
assert_eq!(r.lookup(-0.5, true, true).map(|c| c.r), Some(0));
assert_eq!(r.lookup(1.5, true, true).map(|c| c.r), Some(255));
}
#[test]
fn nan_takes_the_start_extend_arm() {
let r = ramp();
assert_eq!(r.lookup(f32::NAN, false, true), None);
assert_eq!(r.lookup(f32::NAN, true, false).map(|c| c.r), Some(0));
}
#[test]
fn component_index_scales_by_255_not_256() {
assert!((component_to_shading_index(1.0, 0.0, 1.0) - 255.0).abs() < 1e-6);
assert!((component_to_shading_index(0.5, 0.0, 1.0) - 127.5).abs() < 1e-6);
#[expect(
clippy::float_cmp,
reason = "the guard branch returns the literal 0.0, so exact \
equality is what pins that it is not a computed near-zero"
)]
{
assert_eq!(component_to_shading_index(7.0, 3.0, 3.0), 0.0);
}
}
}