use kurbo::Affine;
use pdfrum_render::{ImageQuality, Pixmap, pixmap};
#[derive(Debug, Clone, Copy)]
pub struct Sampler<'a> {
image: &'a Pixmap,
inverse: Affine,
quality: ImageQuality,
}
impl<'a> Sampler<'a> {
#[must_use]
pub fn new(image: &'a Pixmap, transform: Affine, quality: ImageQuality) -> Option<Self> {
if image.width() == 0 || image.height() == 0 {
return None;
}
let det = transform.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
return None;
}
let inverse = transform.inverse();
inverse
.as_coeffs()
.iter()
.all(|c| c.is_finite())
.then_some(Self {
image,
inverse,
quality,
})
}
#[must_use]
pub fn sample(&self, x: u32, y: u32) -> Option<[u8; 4]> {
let p = self.inverse * kurbo::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5);
if !p.x.is_finite() || !p.y.is_finite() {
return None;
}
match self.quality {
ImageQuality::Nearest => self.nearest(p),
ImageQuality::Bilinear => self.bilinear(p),
}
}
fn nearest(&self, at: kurbo::Point) -> Option<[u8; 4]> {
let (width, height) = (self.image.width(), self.image.height());
let texel_x = floor_to_i64(at.x)?;
let texel_y = floor_to_i64(at.y)?;
if texel_x < 0 || texel_y < 0 || texel_x >= i64::from(width) || texel_y >= i64::from(height)
{
return None;
}
self.image
.pixel(u32::try_from(texel_x).ok()?, u32::try_from(texel_y).ok()?)
}
fn bilinear(&self, at: kurbo::Point) -> Option<[u8; 4]> {
let (width, height) = (self.image.width(), self.image.height());
let shifted_x = at.x - 0.5;
let shifted_y = at.y - 0.5;
let left = floor_to_i64(shifted_x)?;
let top_row = floor_to_i64(shifted_y)?;
if left < -1 || top_row < -1 || left >= i64::from(width) || top_row >= i64::from(height) {
return None;
}
#[expect(
clippy::cast_possible_truncation,
reason = "a fraction of a texel is 0.0..1.0; scaling it by 256 and \
truncating is the fixed-point weight"
)]
let weight_x = ((shifted_x - shifted_x.floor()) * 256.0) as i32;
#[expect(clippy::cast_possible_truncation, reason = "same bound as weight_x")]
let weight_y = ((shifted_y - shifted_y.floor()) * 256.0) as i32;
let texel = |x: i64, y: i64| -> [u8; 4] {
let clamped_x = x.clamp(0, i64::from(width) - 1);
let clamped_y = y.clamp(0, i64::from(height) - 1);
match (u32::try_from(clamped_x), u32::try_from(clamped_y)) {
(Ok(tx), Ok(ty)) => self.image.pixel(tx, ty).unwrap_or([0; 4]),
_ => [0; 4],
}
};
let top_left = texel(left, top_row);
let top_right = texel(left + 1, top_row);
let bottom_left = texel(left, top_row + 1);
let bottom_right = texel(left + 1, top_row + 1);
let mut out = [0u8; 4];
for channel in 0..4 {
let (Some(&tl), Some(&tr), Some(&bl), Some(&br)) = (
top_left.get(channel),
top_right.get(channel),
bottom_left.get(channel),
bottom_right.get(channel),
) else {
continue;
};
let top = i32::from(tl) * (256 - weight_x) + i32::from(tr) * weight_x;
let bottom = i32::from(bl) * (256 - weight_x) + i32::from(br) * weight_x;
let value = (top * (256 - weight_y) + bottom * weight_y) >> 16;
if let Some(slot) = out.get_mut(channel) {
#[expect(
clippy::cast_sign_loss,
reason = "the clamp's lower bound is 0, so the value is non-negative"
)]
let byte = value.clamp(0, 255) as u8;
*slot = byte;
}
}
let alpha = out.get(3).copied().unwrap_or(0);
for slot in out.iter_mut().take(3) {
*slot = (*slot).min(alpha);
}
Some(out)
}
}
fn floor_to_i64(v: f64) -> Option<i64> {
let f = v.floor();
(f.is_finite() && f.abs() < 1e15).then(|| {
#[expect(
clippy::cast_possible_truncation,
reason = "the guard bounds the value to +/-1e15, inside i64"
)]
let out = f as i64;
out
})
}
#[must_use]
pub fn scale_alpha(px: [u8; 4], alpha: u8) -> [u8; 4] {
if alpha == 255 {
return px;
}
[
pixmap::mul255(px[0], alpha),
pixmap::mul255(px[1], alpha),
pixmap::mul255(px[2], alpha),
pixmap::mul255(px[3], alpha),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn checker(w: u32, h: u32) -> Pixmap {
let mut p = Pixmap::new(w, h);
for y in 0..h {
for x in 0..w {
let v = if (x + y) % 2 == 0 { 255 } else { 0 };
p.set_pixel(x, y, [v, v, v, 255]);
}
}
p
}
#[test]
fn an_identity_transform_samples_texel_for_pixel() {
let img = checker(4, 4);
let s = Sampler::new(&img, Affine::IDENTITY, ImageQuality::Nearest).expect("invertible");
for y in 0..4 {
for x in 0..4 {
assert_eq!(s.sample(x, y), img.pixel(x, y), "at ({x}, {y})");
}
}
}
#[test]
fn outside_the_image_there_is_no_sample() {
let img = checker(2, 2);
let s = Sampler::new(&img, Affine::IDENTITY, ImageQuality::Nearest).expect("invertible");
assert!(s.sample(2, 0).is_none());
assert!(s.sample(0, 2).is_none());
}
#[test]
fn a_singular_transform_has_no_sampler() {
let img = checker(2, 2);
assert!(Sampler::new(&img, Affine::scale(0.0), ImageQuality::Nearest).is_none());
}
#[test]
fn an_empty_image_has_no_sampler() {
let img = Pixmap::new(0, 0);
assert!(Sampler::new(&img, Affine::IDENTITY, ImageQuality::Nearest).is_none());
}
#[test]
fn bilinear_between_two_texels_is_their_average() {
let mut img = Pixmap::new(2, 1);
img.set_pixel(0, 0, [0, 0, 0, 255]);
img.set_pixel(1, 0, [255, 255, 255, 255]);
let s = Sampler::new(
&img,
Affine::scale_non_uniform(2.0, 1.0),
ImageQuality::Bilinear,
)
.expect("invertible");
let px = s.sample(1, 0).expect("inside");
assert!(
(60..=70).contains(&px[0]),
"a quarter of the way is about 64, got {}",
px[0]
);
}
#[test]
fn bilinear_clamps_the_border_rather_than_wrapping() {
let mut img = Pixmap::new(2, 1);
img.set_pixel(0, 0, [255, 0, 0, 255]);
img.set_pixel(1, 0, [0, 0, 255, 255]);
let s = Sampler::new(&img, Affine::IDENTITY, ImageQuality::Bilinear).expect("invertible");
let px = s.sample(0, 0).expect("inside");
assert_eq!(px, [255, 0, 0, 255], "the left edge stays its own colour");
}
#[test]
fn bilinear_keeps_colours_within_alpha() {
let mut img = Pixmap::new(2, 1);
img.set_pixel(0, 0, [255, 255, 255, 255]);
img.set_pixel(1, 0, [0, 0, 0, 0]);
let s = Sampler::new(
&img,
Affine::scale_non_uniform(4.0, 1.0),
ImageQuality::Bilinear,
)
.expect("invertible");
for x in 0..8 {
if let Some(px) = s.sample(x, 0) {
let alpha = px.get(3).copied().unwrap_or(0);
for c in 0..3 {
let channel = px.get(c).copied().unwrap_or(0);
assert!(channel <= alpha, "channel {c} above alpha at x={x}: {px:?}");
}
}
}
}
#[test]
fn scaling_alpha_truncates_like_the_oracle() {
assert_eq!(scale_alpha([255, 255, 255, 255], 128), [128, 128, 128, 128]);
assert_eq!(scale_alpha([10, 20, 30, 40], 255), [10, 20, 30, 40]);
}
}