use crate::pixmap::Pixmap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Weight(u32);
impl Weight {
const SHIFT: u32 = 16;
const ONE: Self = Self(1 << Self::SHIFT);
const fn get(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Taps {
first: u32,
weights: Box<[Weight]>,
}
impl Taps {
const fn start(&self) -> usize {
self.first as usize
}
}
fn axis_taps(src_len: u32, dest_len: u32) -> Vec<Taps> {
if src_len == 0 || dest_len == 0 {
return Vec::new();
}
let (src, dest_n) = (u64::from(src_len), u64::from(dest_len));
let one = u64::from(Weight::ONE.get());
let mut out = Vec::with_capacity(dest_len as usize);
for d in 0..dest_n {
let first = d.saturating_mul(src) / dest_n;
let last = (d.saturating_add(1).saturating_mul(src) / dest_n).min(src - 1);
let first_u32 = u32::try_from(first.min(src - 1)).unwrap_or(0);
if first > last {
out.push(Taps {
first: first_u32,
weights: Box::new([Weight::ONE]),
});
continue;
}
let mut weights = Vec::with_capacity(usize::try_from(last - first + 1).unwrap_or(0));
let mut remaining = Weight::ONE.get();
let mut previous = 0_u64;
for s in first..last {
let numerator = s
.saturating_add(1)
.saturating_mul(dest_n)
.saturating_mul(one);
let scaled = numerator.saturating_add(src / 2) / src;
let low = d.saturating_mul(one);
let cumulative = scaled.clamp(low, low.saturating_add(one)) - low;
let step = u32::try_from(cumulative.saturating_sub(previous)).unwrap_or(0);
previous = cumulative;
let capped = step.min(remaining);
remaining -= capped;
weights.push(Weight(capped));
}
weights.push(Weight(remaining));
out.push(Taps {
first: first_u32,
weights: weights.into_boxed_slice(),
});
}
out
}
fn reduced_len(src_len: u32, dest_len: f64) -> Option<u32> {
if !dest_len.is_finite() || src_len == 0 {
return None;
}
let target = dest_len.abs().ceil();
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "bounded below by 1.0 and above by src_len, itself a u32"
)]
let target = target.clamp(1.0, f64::from(src_len)) as u32;
(target < src_len).then_some(target)
}
#[must_use]
pub fn reduce_gray_to(
src: &[u8],
src_width: u32,
src_height: u32,
dest_width: u32,
dest_height: u32,
) -> Vec<u8> {
let dest_len = (dest_width as usize).saturating_mul(dest_height as usize);
let x_taps = axis_taps(src_width, dest_width);
let y_taps = axis_taps(src_height, dest_height);
let src_w = src_width as usize;
let dest_w = dest_width as usize;
if x_taps.is_empty()
|| y_taps.is_empty()
|| src.len() != src_w.saturating_mul(src_height as usize)
{
return vec![0; dest_len];
}
let mut inter = vec![0_u8; dest_w.saturating_mul(src_height as usize)];
for y in 0..src_height as usize {
let Some(src_row) = src
.get(y.saturating_mul(src_w)..)
.and_then(|rest| rest.get(..src_w))
else {
continue;
};
let Some(inter_row) = inter
.get_mut(y.saturating_mul(dest_w)..)
.and_then(|rest| rest.get_mut(..dest_w))
else {
continue;
};
for (taps, out) in x_taps.iter().zip(inter_row.iter_mut()) {
let mut acc = 0_u32;
for (i, weight) in taps.weights.iter().enumerate() {
let weight = weight.get();
let Some(&sample) = taps.start().checked_add(i).and_then(|sx| src_row.get(sx))
else {
continue;
};
acc += weight * u32::from(sample);
}
#[expect(
clippy::cast_possible_truncation,
reason = "the weights sum to Weight::ONE and the sample is a byte, \
so the accumulator is at most 255 << 16"
)]
let byte = (acc >> 16) as u8;
*out = byte;
}
}
let mut dest = vec![0_u8; dest_len];
for (y, taps) in y_taps.iter().enumerate() {
let Some(dest_row) = dest
.get_mut(y.saturating_mul(dest_w)..)
.and_then(|rest| rest.get_mut(..dest_w))
else {
continue;
};
let rows: Vec<(u32, &[u8])> = taps
.weights
.iter()
.enumerate()
.filter_map(|(i, &weight)| {
let weight = weight.get();
let sy = taps.start().checked_add(i)?;
let at = sy.checked_mul(dest_w)?;
let row = inter.get(at..at.checked_add(dest_w)?)?;
Some((weight, row))
})
.collect();
for (x, out) in dest_row.iter_mut().enumerate() {
let mut acc = 0_u32;
for &(weight, row) in &rows {
let Some(&sample) = row.get(x) else { continue };
acc += weight * u32::from(sample);
}
#[expect(
clippy::cast_possible_truncation,
reason = "the weights sum to Weight::ONE and the sample is a byte, \
so the accumulator is at most 255 << 16"
)]
let byte = (acc >> 16) as u8;
*out = byte;
}
}
dest
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(C)]
struct Rgba16([u16; 4]);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(C)]
struct Rgba32([u32; 4]);
struct Narrowed<'a> {
src: pdfrum_page::Converted<'a>,
taps: Vec<Taps>,
buf: Vec<Rgba16>,
}
impl<'a> Narrowed<'a> {
fn new(src: pdfrum_page::Converted<'a>, src_width: u32, dest_width: u32) -> Self {
Self {
src,
taps: axis_taps(src_width, dest_width),
buf: vec![Rgba16::default(); dest_width as usize],
}
}
fn next_row(&mut self, finish: impl FnOnce(&mut [pdfrum_page::Rgba8])) -> Option<&[Rgba16]> {
let row = pdfrum_page::Converted::next_row_with(&mut self.src, finish)?;
let src = row.pixels();
for (taps, out) in self.taps.iter().zip(self.buf.iter_mut()) {
let mut acc = [0_u32; 4];
for (i, weight) in taps.weights.iter().enumerate() {
let weight = weight.get();
let Some(px) = taps.start().checked_add(i).and_then(|sx| src.get(sx)) else {
continue;
};
for (slot, &channel) in acc.iter_mut().zip(px.0.iter()) {
*slot += weight * u32::from(channel);
}
}
for (slot, a) in out.0.iter_mut().zip(acc) {
*slot = u16::try_from(a >> Weight::SHIFT).unwrap_or(u16::MAX);
}
}
Some(&self.buf)
}
}
struct Shortened<'a> {
src: Narrowed<'a>,
taps: Vec<Taps>,
acc: Vec<Rgba32>,
source_y: usize,
held: Option<(usize, Vec<Rgba16>)>,
}
impl<'a> Shortened<'a> {
fn new(src: Narrowed<'a>, dest_width: u32, src_height: u32, dest_height: u32) -> Self {
Self {
src,
taps: axis_taps(src_height, dest_height),
acc: vec![Rgba32::default(); dest_width as usize],
source_y: 0,
held: None,
}
}
fn drain_into(mut self, out: &mut Pixmap, finish: &impl Fn(&mut [pdfrum_page::Rgba8], u32)) {
let width = out.width() as usize;
for dest_y in 0..self.taps.len() {
let Some(taps) = self.taps.get(dest_y) else {
break;
};
for slot in &mut self.acc {
*slot = Rgba32::default();
}
let first = taps.start();
let last = first.saturating_add(taps.weights.len());
let add = |acc: &mut [Rgba32], y: usize, row: &[Rgba16]| {
let Some(weight) = y
.checked_sub(first)
.and_then(|o| taps.weights.get(o))
.map(|w| w.get())
else {
return;
};
for (slot, px) in acc.iter_mut().zip(row) {
for (a, &channel) in slot.0.iter_mut().zip(px.0.iter()) {
*a += weight * u32::from(channel);
}
}
};
if let Some((y, row)) = self.held.take()
&& y >= first
&& y < last
{
add(&mut self.acc, y, &row);
}
while self.source_y < last {
let y = self.source_y;
#[expect(
clippy::cast_possible_truncation,
reason = "the source height is a u32 and `source_y` counts its rows"
)]
let row_y = y as u32;
let Some(row) = self.src.next_row(|r| finish(r, row_y)) else {
break;
};
self.source_y += 1;
add(&mut self.acc, y, row);
if self.source_y == last {
self.held = Some((y, row.to_vec()));
}
}
let Some(dest_row) = out
.data_mut()
.get_mut(dest_y.saturating_mul(width).saturating_mul(4)..)
.and_then(|rest| rest.get_mut(..width.saturating_mul(4)))
else {
continue;
};
for (slot, acc) in dest_row
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(self.acc.iter())
{
for (byte, &a) in slot.iter_mut().zip(acc.0.iter()) {
#[expect(
clippy::cast_possible_truncation,
reason = "the weights sum to Weight::ONE and each channel is a \
byte, so every accumulator is at most 255 << SHIFT"
)]
let rounded = (a >> Weight::SHIFT) as u8;
*byte = rounded;
}
}
}
}
}
#[must_use]
pub fn convert_and_reduce(
image: &pdfrum_page::ImageData,
stencil_color: crate::color::Argb,
transfer: Option<&crate::transfer::TransferFunc<'_>>,
dest_width: u32,
dest_height: u32,
) -> Pixmap {
let mut out = Pixmap::new(dest_width, dest_height);
if dest_width == 0 || dest_height == 0 || image.width == 0 || image.height == 0 {
return out;
}
let finish = crate::image::RowFinish::new(image, stencil_color, transfer);
let narrowed = Narrowed::new(crate::image::converted_rows(image), image.width, dest_width);
let shortened = Shortened::new(narrowed, dest_width, image.height, dest_height);
shortened.drain_into(&mut out, &|row, y| finish.apply(row, y));
out
}
#[must_use]
pub fn reduce_to(src: &Pixmap, dest_width: u32, dest_height: u32) -> Pixmap {
let x_taps = axis_taps(src.width(), dest_width);
let y_taps = axis_taps(src.height(), dest_height);
if x_taps.is_empty() || y_taps.is_empty() {
return Pixmap::new(dest_width, dest_height);
}
let src_width = src.width() as usize;
let mut inter = Pixmap::new(dest_width, src.height());
let inter_width = dest_width as usize;
for y in 0..src.height() as usize {
let Some(src_row) = src
.data()
.get(y.saturating_mul(src_width).saturating_mul(4)..)
.and_then(|rest| rest.get(..src_width.saturating_mul(4)))
else {
continue;
};
let Some(inter_row) = inter
.data_mut()
.get_mut(y.saturating_mul(inter_width).saturating_mul(4)..)
.and_then(|rest| rest.get_mut(..inter_width.saturating_mul(4)))
else {
continue;
};
for (taps, out) in x_taps
.iter()
.zip(inter_row.as_chunks_mut::<4>().0.iter_mut())
{
let mut acc = [0_u32; 4];
for (i, weight) in taps.weights.iter().enumerate() {
let weight = weight.get();
let Some(px) = taps
.start()
.checked_add(i)
.and_then(|sx| sx.checked_mul(4))
.and_then(|at| src_row.get(at..at.checked_add(4)?))
else {
continue;
};
for (slot, &channel) in acc.iter_mut().zip(px.iter()) {
*slot += weight * u32::from(channel);
}
}
for (slot, a) in out.iter_mut().zip(acc) {
#[expect(
clippy::cast_possible_truncation,
reason = "the weights sum to Weight::ONE and each channel is \
a byte, so every accumulator is at most 255 << 16"
)]
let byte = (a >> 16) as u8;
*slot = byte;
}
}
}
let mut dest = Pixmap::new(dest_width, dest_height);
let inter_data = inter.data();
for (y, taps) in y_taps.iter().enumerate() {
let Some(dest_row) = dest
.data_mut()
.get_mut(y.saturating_mul(inter_width).saturating_mul(4)..)
.and_then(|rest| rest.get_mut(..inter_width.saturating_mul(4)))
else {
continue;
};
let rows: Vec<(u32, &[u8])> = taps
.weights
.iter()
.enumerate()
.filter_map(|(i, &weight)| {
let weight = weight.get();
let sy = taps.start().checked_add(i)?;
let at = sy.checked_mul(inter_width)?.checked_mul(4)?;
let row = inter_data.get(at..at.checked_add(inter_width.checked_mul(4)?)?)?;
Some((weight, row))
})
.collect();
for (x, out) in dest_row.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let at = x.saturating_mul(4);
let mut acc = [0_u32; 4];
for &(weight, row) in &rows {
let Some(px) = row.get(at..at.saturating_add(4)) else {
continue;
};
for (slot, &channel) in acc.iter_mut().zip(px.iter()) {
*slot += weight * u32::from(channel);
}
}
for (slot, a) in out.iter_mut().zip(acc) {
#[expect(
clippy::cast_possible_truncation,
reason = "the weights sum to Weight::ONE and each channel is \
a byte, so every accumulator is at most 255 << 16"
)]
let byte = (a >> 16) as u8;
*slot = byte;
}
}
}
dest
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Placement {
Exact {
x: f64,
y: f64,
},
Filtered(kurbo::Affine),
Snapped(SnappedRect),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SnappedRect {
pub left: i32,
pub top: i32,
pub width: i32,
pub height: i32,
}
impl SnappedRect {
#[must_use]
pub fn transform(self, src_width: u32, src_height: u32) -> kurbo::Affine {
if src_width == 0 || src_height == 0 {
return kurbo::Affine::IDENTITY;
}
let sx = f64::from(self.width) / f64::from(src_width);
let sy = f64::from(self.height) / f64::from(src_height);
kurbo::Affine::translate((f64::from(self.left), f64::from(self.top)))
* kurbo::Affine::scale_non_uniform(sx, sy)
}
}
impl Placement {
#[must_use]
pub fn transform_for(self, src_width: u32, src_height: u32) -> kurbo::Affine {
match self {
Self::Exact { x, y } => kurbo::Affine::translate((x, y)),
Self::Filtered(t) => t,
Self::Snapped(rect) => rect.transform(src_width, src_height),
}
}
#[must_use]
pub fn quality(self, wanted: crate::device::ImageQuality) -> crate::device::ImageQuality {
match self {
Self::Exact { .. } => crate::device::ImageQuality::Nearest,
Self::Filtered(_) | Self::Snapped(_) => wanted,
}
}
}
const EXACTNESS: f64 = 1e-9;
#[must_use]
#[expect(
clippy::many_single_char_names,
reason = "a..f are the six affine matrix coefficients, named as in the PDF `cm` operands"
)]
pub fn placement_for(to_device: kurbo::Affine, src_width: u32, src_height: u32) -> Placement {
let [a, b, c, d, e, f] = to_device.as_coeffs();
let integral = |v: f64| v.is_finite() && (v - v.round()).abs() <= EXACTNESS;
let unit = |v: f64| (v - 1.0).abs() <= EXACTNESS;
let zero = |v: f64| v.abs() <= EXACTNESS;
if unit(a) && zero(b) && zero(c) && unit(d) && integral(e) && integral(f) {
return Placement::Exact {
x: e.round(),
y: f.round(),
};
}
if zero(b)
&& zero(c)
&& a.abs() > 1.0
&& d.abs() > 1.0
&& let Some(rect) = snapped_for(to_device, src_width, src_height)
{
return Placement::Snapped(rect);
}
Placement::Filtered(to_device)
}
#[must_use]
fn snapped_for(to_device: kurbo::Affine, src_width: u32, src_height: u32) -> Option<SnappedRect> {
let [a, _, _, d, _, _] = to_device.as_coeffs();
if !a.is_finite() || !d.is_finite() || a == 0.0 || d == 0.0 {
return None;
}
if src_width == 0 || src_height == 0 {
return None;
}
let unit = to_device.transform_rect_bbox(kurbo::Rect::new(
0.0,
0.0,
f64::from(src_width),
f64::from(src_height),
));
if !unit.x0.is_finite() || !unit.y0.is_finite() || !unit.x1.is_finite() || !unit.y1.is_finite()
{
return None;
}
let settle = |v: f64| {
let r = v.round();
if (v - r).abs() <= EXACTNESS { r } else { v }
};
let unit = kurbo::Rect::new(
settle(unit.x0),
settle(unit.y0),
settle(unit.x1),
settle(unit.y1),
);
let rect = crate::path::outer_rect(unit);
let (w, h) = (
rect.right.checked_sub(rect.left)?,
rect.bottom.checked_sub(rect.top)?,
);
if w <= 0 || h <= 0 {
return None;
}
let width = if a < 0.0 { -w } else { w };
let height = if d < 0.0 { -h } else { h };
Some(SnappedRect {
left: if width > 0 { rect.left } else { rect.right },
top: if height > 0 { rect.top } else { rect.bottom },
width,
height,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SnappedReduction {
size: (u32, u32),
origin: (i32, i32),
}
impl SnappedReduction {
#[must_use]
pub const fn size(self) -> (u32, u32) {
self.size
}
#[must_use]
pub fn transform(self) -> kurbo::Affine {
kurbo::Affine::translate((f64::from(self.origin.0), f64::from(self.origin.1)))
}
}
#[must_use]
#[expect(
clippy::many_single_char_names,
reason = "a..d are the four affine scale/shear coefficients, named as in the PDF `cm` operands"
)]
pub fn snapped_reduction(
to_device: kurbo::Affine,
src_width: u32,
src_height: u32,
) -> Option<SnappedReduction> {
if src_width > MAX_SOURCE_AXIS
|| src_height > MAX_SOURCE_AXIS
|| !pdfrum_page::image_area_is_workable(src_width, src_height)
{
return None;
}
let [a, b, c, d, _, _] = to_device.as_coeffs();
if b.abs() > EXACTNESS || c.abs() > EXACTNESS || a <= 0.0 || d <= 0.0 || a >= 1.0 || d >= 1.0 {
return None;
}
let rect = snapped_for(to_device, src_width, src_height)?;
let (w, h) = (
u32::try_from(rect.width).ok()?,
u32::try_from(rect.height).ok()?,
);
(w > 0 && h > 0 && w < src_width && h < src_height).then_some(SnappedReduction {
size: (w, h),
origin: (rect.left, rect.top),
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Reduction {
Snapped(SnappedReduction),
Footprint {
size: (u32, u32),
transform: kurbo::Affine,
},
None(kurbo::Affine),
}
impl Reduction {
#[must_use]
pub const fn size(self, src_width: u32, src_height: u32) -> (u32, u32) {
match self {
Self::Snapped(snapped) => snapped.size(),
Self::Footprint { size, .. } => size,
Self::None(_) => (src_width, src_height),
}
}
#[must_use]
pub fn transform(self) -> kurbo::Affine {
match self {
Self::Snapped(snapped) => snapped.transform(),
Self::Footprint { transform, .. } | Self::None(transform) => transform,
}
}
#[must_use]
pub const fn filters(self) -> bool {
!matches!(self, Self::None(_))
}
}
#[must_use]
pub fn reduction(
to_device: kurbo::Affine,
src_width: u32,
src_height: u32,
dest_width: f64,
dest_height: f64,
snap: bool,
) -> Reduction {
if snap && let Some(snapped) = snapped_reduction(to_device, src_width, src_height) {
return Reduction::Snapped(snapped);
}
match reduction_for(src_width, src_height, dest_width, dest_height) {
Some((new_w, new_h)) => Reduction::Footprint {
size: (new_w, new_h),
transform: reduction_transform(to_device, src_width, src_height, new_w, new_h),
},
None => Reduction::None(to_device),
}
}
const MAX_SOURCE_AXIS: u32 = 1 << 16;
#[must_use]
pub fn reduction_for(
src_width: u32,
src_height: u32,
dest_width: f64,
dest_height: f64,
) -> Option<(u32, u32)> {
if src_width > MAX_SOURCE_AXIS
|| src_height > MAX_SOURCE_AXIS
|| !pdfrum_page::image_area_is_workable(src_width, src_height)
{
return None;
}
let new_w = reduced_len(src_width, dest_width);
let new_h = reduced_len(src_height, dest_height);
let (new_w, new_h) = match (new_w, new_h) {
(None, None) => return None,
(w, h) => (w.unwrap_or(src_width), h.unwrap_or(src_height)),
};
(new_w != 0 && new_h != 0).then_some((new_w, new_h))
}
#[must_use]
pub fn reduction_transform(
to_device: kurbo::Affine,
src_width: u32,
src_height: u32,
new_w: u32,
new_h: u32,
) -> kurbo::Affine {
let sx = f64::from(src_width) / f64::from(new_w);
let sy = f64::from(src_height) / f64::from(new_h);
to_device * kurbo::Affine::scale_non_uniform(sx, sy)
}
#[must_use]
pub fn prescale(
src: &Pixmap,
to_device: kurbo::Affine,
dest_width: f64,
dest_height: f64,
) -> Option<(Pixmap, kurbo::Affine)> {
let (new_w, new_h) = reduction_for(src.width(), src.height(), dest_width, dest_height)?;
let reduced = reduce_to(src, new_w, new_h);
Some((
reduced,
reduction_transform(to_device, src.width(), src.height(), new_w, new_h),
))
}
#[cfg(test)]
mod tests {
#[test]
fn a_source_larger_than_a_gigapixel_is_not_reduced() {
assert_eq!(super::reduction_for(65_536, 65_536, 100.0, 100.0), None);
assert_eq!(
super::snapped_reduction(kurbo::Affine::scale(0.001), 65_536, 65_536),
None
);
assert!(super::reduction_for(4_000, 4_000, 100.0, 100.0).is_some());
}
use kurbo::Affine;
use super::*;
fn ramp(width: u32, height: u32) -> Pixmap {
let mut px = Pixmap::new(width, height);
for y in 0..height {
for x in 0..width {
#[expect(
clippy::cast_possible_truncation,
reason = "test fixture dimensions are small"
)]
let v = ((x + y * width) % 256) as u8;
px.set_pixel(x, y, [v, v, v, 255]);
}
}
px
}
#[test]
fn weights_sum_to_one_per_destination_pixel() {
for (src, dest) in [(455_u32, 159_u32), (100, 7), (9, 4), (1000, 999), (5, 1)] {
for taps in axis_taps(src, dest) {
let total: u32 = taps.weights.iter().map(|w| w.get()).sum();
assert_eq!(total, Weight::ONE.get(), "src {src} dest {dest}");
}
}
}
#[test]
fn every_source_pixel_is_tapped_at_an_exact_ratio() {
let taps = axis_taps(8, 2);
let [first, second] = taps.as_slice() else {
panic!("two destination pixels");
};
assert_eq!(first.first, 0);
let quarter = Weight(Weight::ONE.get() / 4);
assert_eq!(
&*first.weights,
[quarter, quarter, quarter, quarter, Weight(0)]
);
assert_eq!(second.first, 4);
assert_eq!(&*second.weights, [quarter; 4]);
}
#[test]
fn the_exact_table_is_pinned_where_the_float_one_drifted() {
let weights = |src, dest| -> Vec<Vec<u32>> {
axis_taps(src, dest)
.iter()
.map(|t| t.weights.iter().map(|w| w.get()).collect())
.collect()
};
assert_eq!(
weights(7, 1),
vec![vec![9362, 9363, 9362, 9362, 9362, 9363, 9362]]
);
assert_eq!(
weights(10, 2),
vec![
vec![13107, 13107, 13108, 13107, 13107, 0],
vec![13107, 13107, 13108, 13107, 13107],
]
);
assert_eq!(weights(5, 1), vec![vec![13107, 13107, 13108, 13107, 13107]]);
for (src, dest) in [(7_u32, 1_u32), (10, 2), (5, 1)] {
for row in weights(src, dest) {
assert_eq!(
row.iter().sum::<u32>(),
Weight::ONE.get(),
"{src} -> {dest}"
);
}
}
}
#[test]
fn the_weight_sum_holds_over_the_upstream_unit_tests_grid() {
for src in [1_u32, 2, 187, 256, 809, 1110] {
for dest in [1_u32, 2, 337, 512, 808, 2550] {
for taps in axis_taps(src, dest) {
let total: u32 = taps.weights.iter().map(|w| w.get()).sum();
assert_eq!(total, Weight::ONE.get(), "src {src} dest {dest}");
assert!(
taps.start() + taps.weights.len() <= src as usize + 1,
"src {src} dest {dest}: taps run past the source"
);
}
}
}
}
#[test]
fn an_exact_halving_is_the_mean_of_each_two_by_two_block() {
let mut src = Pixmap::new(2, 2);
src.set_pixel(0, 0, [0, 0, 0, 255]);
src.set_pixel(1, 0, [100, 100, 100, 255]);
src.set_pixel(0, 1, [200, 200, 200, 255]);
src.set_pixel(1, 1, [255, 255, 255, 255]);
let out = reduce_to(&src, 1, 1);
assert_eq!(out.width(), 1);
assert_eq!(out.height(), 1);
let px = out.pixel(0, 0).expect("one pixel");
assert_eq!(px, [138, 138, 138, 255]);
}
#[test]
fn a_flat_field_survives_any_reduction_exactly() {
for (dw, dh) in [(1_u32, 1_u32), (3, 7), (13, 5), (64, 64)] {
let mut src = Pixmap::new(100, 100);
for y in 0..100 {
for x in 0..100 {
src.set_pixel(x, y, [77, 77, 77, 255]);
}
}
let out = reduce_to(&src, dw, dh);
for y in 0..dh {
for x in 0..dw {
assert_eq!(
out.pixel(x, y),
Some([77, 77, 77, 255]),
"flat field at {dw}x{dh} pixel ({x},{y})"
);
}
}
}
}
#[test]
fn an_enlargement_is_declined() {
let src = ramp(4, 4);
assert!(prescale(&src, Affine::IDENTITY, 40.0, 40.0).is_none());
assert!(prescale(&src, Affine::IDENTITY, 4.0, 4.0).is_none());
}
#[test]
fn one_axis_reducing_leaves_the_other_alone() {
let src = ramp(64, 8);
let (out, _) = prescale(&src, Affine::IDENTITY, 16.0, 8.0).expect("x reduces");
assert_eq!(out.width(), 16);
assert_eq!(out.height(), 8);
}
#[test]
fn the_returned_transform_covers_the_same_device_rect() {
let src = ramp(100, 50);
let placement = Affine::scale_non_uniform(0.25, 0.25);
let (out, t) = prescale(&src, placement, 25.0, 12.0).expect("reduces");
let src_rect = placement.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, 100.0, 50.0));
let out_rect = t.transform_rect_bbox(kurbo::Rect::new(
0.0,
0.0,
f64::from(out.width()),
f64::from(out.height()),
));
assert!((src_rect.width() - out_rect.width()).abs() < 1e-9);
assert!((src_rect.height() - out_rect.height()).abs() < 1e-9);
assert!((src_rect.x0 - out_rect.x0).abs() < 1e-9);
assert!((src_rect.y0 - out_rect.y0).abs() < 1e-9);
}
#[test]
fn a_sub_pixel_destination_reduces_to_one_pixel() {
let src = ramp(32, 32);
let (out, _) = prescale(&src, Affine::IDENTITY, 0.4, 0.4).expect("reduces");
assert_eq!((out.width(), out.height()), (1, 1));
}
#[test]
fn transparency_is_averaged_in_premultiplied_space() {
let mut src = Pixmap::new(2, 1);
src.set_pixel(0, 0, [255, 255, 255, 255]);
src.set_pixel(1, 0, [0, 0, 0, 0]);
let out = reduce_to(&src, 1, 1);
assert_eq!(out.pixel(0, 0), Some([127, 127, 127, 127]));
}
#[test]
fn a_degenerate_axis_yields_an_empty_result_rather_than_panicking() {
let src = Pixmap::new(0, 0);
assert!(prescale(&src, Affine::IDENTITY, 10.0, 10.0).is_none());
let src = ramp(4, 4);
assert!(prescale(&src, Affine::IDENTITY, f64::NAN, f64::NAN).is_none());
}
#[test]
fn the_fused_reduction_is_the_two_call_one() {
use pdfrum_page::{ImageData, Pixels, Samples};
let kinds = |w: u32, h: u32| -> Vec<(&'static str, Samples)> {
let n = (w * h) as usize;
let packed = |bpc: u32, components: usize, seed: usize| -> Samples {
let depth = pdfrum_page::Depth::new(bpc).expect("a real depth");
let pitch = (w as usize * components * bpc as usize).div_ceil(8);
let data: Box<[u8]> = (0..pitch * h as usize)
.map(|i| u8::try_from(i * seed % 251).unwrap_or(0))
.collect();
Samples::Packed(pdfrum_page::Packed::new(
data,
depth,
components,
pitch,
w,
h,
&pdfrum_page::ColorSpace::DeviceGray,
None,
))
};
let mut v: Vec<(&'static str, Samples)> = vec![
("packed-1", packed(1, 1, 31)),
("packed-2", packed(2, 1, 41)),
("packed-4", packed(4, 1, 43)),
("packed-8", packed(8, 1, 47)),
("packed-16", packed(16, 1, 59)),
("packed-rgb8", packed(8, 3, 61)),
("packed-cmyk8", packed(8, 4, 67)),
];
v.extend(
vec![
(
"gray",
Pixels::Gray8(
(0..n)
.map(|i| u8::try_from(i * 37 % 251).unwrap_or(0))
.collect(),
),
),
(
"rgb",
Pixels::Rgb8(
(0..n * 3)
.map(|i| u8::try_from(i * 53 % 251).unwrap_or(0))
.collect(),
),
),
(
"cmyk",
Pixels::Cmyk8(
(0..n * 4)
.map(|i| u8::try_from(i * 29 % 251).unwrap_or(0))
.collect(),
),
),
(
"indexed",
Pixels::Indexed {
indices: (0..n)
.map(|i| u8::try_from(i * 17 % 256).unwrap_or(0))
.collect(),
palette: (0..=255u8)
.map(|v| pdfrum_page::Rgb {
r: f32::from(v) / 255.0,
g: f32::from(255 - v) / 255.0,
b: 0.25,
})
.collect(),
},
),
]
.into_iter()
.map(|(name, p)| (name, Samples::Whole(p))),
);
v
};
for (w, h, dw, dh) in [
(64_u32, 40_u32, 8_u32, 5_u32),
(137, 85, 17, 11),
(455, 455, 159, 159),
(9, 9, 1, 1),
(100, 7, 7, 1),
(5, 3, 4, 2),
] {
for (name, samples) in kinds(w, h) {
let image = ImageData {
width: w,
height: h,
samples,
mask: None,
matte: None,
interpolate: false,
};
let fill = crate::color::Argb::opaque(255, 255, 255);
let two_call = reduce_to(&crate::image::to_pixmap(&image, fill, None), dw, dh);
let fused = convert_and_reduce(&image, fill, None, dw, dh);
assert_eq!(fused.data(), two_call.data(), "{name} {w}x{h} -> {dw}x{dh}");
}
}
}
#[test]
fn the_fused_reduction_carries_a_stencils_colour() {
use pdfrum_page::{BitImage, ImageData, Pixels, Samples};
let (w, h) = (16_u32, 16_u32);
let row_bytes = (w as usize).div_ceil(8);
let image = ImageData {
width: w,
height: h,
samples: Samples::Whole(Pixels::Stencil(BitImage {
width: w,
height: h,
row_bytes,
bits: (0..row_bytes * h as usize)
.map(|i| u8::try_from(i * 73 % 256).unwrap_or(0))
.collect(),
})),
mask: None,
matte: None,
interpolate: false,
};
let fill = crate::color::Argb::opaque(200, 100, 50);
for (dw, dh) in [(4_u32, 4_u32), (8, 3), (1, 1)] {
let two_call = reduce_to(&crate::image::to_pixmap(&image, fill, None), dw, dh);
let fused = convert_and_reduce(&image, fill, None, dw, dh);
assert_eq!(fused.data(), two_call.data(), "stencil -> {dw}x{dh}");
}
}
#[test]
fn only_a_whole_pixel_unit_placement_is_exact() {
use crate::device::ImageQuality;
let exact = |t: Affine| matches!(placement_for(t, 8, 8), Placement::Exact { .. });
assert!(exact(Affine::IDENTITY));
assert!(exact(Affine::translate((13.0, -7.0))));
assert!(exact(Affine::translate((13.0 + 1e-12, 4.0 - 1e-12))));
assert!(!exact(Affine::scale_non_uniform(1.001, 1.0)));
assert!(!exact(Affine::scale_non_uniform(1.0, 0.999)));
assert!(!exact(Affine::scale(2.0)));
assert!(!exact(Affine::scale_non_uniform(1.0, -1.0)));
assert!(!exact(Affine::rotate(0.5)));
assert!(!exact(Affine::new([1.0, 0.0, 0.3, 1.0, 0.0, 0.0])));
assert!(!exact(Affine::translate((0.5, 0.0))));
assert!(!exact(Affine::translate((0.0, 0.25))));
assert!(!exact(Affine::translate((f64::NAN, 0.0))));
let t = Affine::translate((3.0, 4.0));
assert_eq!(
placement_for(t, 8, 8).quality(ImageQuality::Bilinear),
ImageQuality::Nearest
);
assert_eq!(placement_for(t, 8, 8).transform_for(8, 8), t);
let f = Affine::rotate(0.5);
assert_eq!(
placement_for(f, 8, 8).quality(ImageQuality::Bilinear),
ImageQuality::Bilinear
);
assert_eq!(placement_for(f, 8, 8).transform_for(8, 8), f);
}
#[test]
fn an_axis_aligned_stretch_snaps_to_the_outer_rect() {
let t = Affine::translate((208.25, 37.0)) * Affine::scale_non_uniform(2.543_625, 4.0);
let Placement::Snapped(rect) = placement_for(t, 140, 140) else {
panic!("an axis-aligned stretch must snap");
};
assert_eq!(rect.left, 208);
assert_eq!(rect.width, 357);
assert_eq!(rect.top, 37);
assert_eq!(rect.height, 560);
}
#[test]
fn an_integral_footprint_does_not_ceil_to_an_extra_column() {
let (w, h) = (364_u32, 140_u32);
let placement = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 105.0])
* Affine::new([273.0, 0.0, 0.0, 105.0, 0.0, 0.0])
* Affine::new([1.0 / f64::from(w), 0.0, 0.0, -1.0 / f64::from(h), 0.0, 1.0]);
let right = placement.as_coeffs()[0] * f64::from(w);
assert!(
right > 273.0,
"the ulp this test exists for is gone: {right}"
);
assert!(right < 273.000_1);
let snapped = snapped_reduction(placement, w, h).expect("an axis-aligned reduction");
assert_eq!(snapped.size(), (273, 105));
assert_eq!(Some(snapped.size()), reduction_for(w, h, 273.0, 105.0));
}
#[test]
fn a_mirrored_stretch_keeps_upstreams_signs() {
let t = Affine::translate((10.0, 20.0)) * Affine::scale_non_uniform(-2.0, -3.0);
let Placement::Snapped(rect) = placement_for(t, 4, 4) else {
panic!("an axis-aligned stretch must snap");
};
assert!(rect.width < 0, "a negative `a` mirrors x");
assert!(rect.height < 0, "a negative `d` mirrors y");
assert_eq!(rect.left, 10);
assert_eq!(rect.top, 20);
assert_eq!(rect.width, -8);
assert_eq!(rect.height, -12);
}
#[test]
fn the_gray_reduction_is_the_rgba_reduction_on_a_gray_image() {
for (w, h, dw, dh) in [
(64_u32, 40_u32, 8_u32, 5_u32),
(137, 85, 17, 11),
(1339, 81, 392, 11),
(455, 455, 159, 159),
(9, 9, 1, 1),
(100, 7, 7, 1),
(1000, 999, 999, 998),
(5, 3, 4, 2),
] {
let plane: Vec<u8> = (0..w * h)
.map(|i| u8::try_from(i * 37 % 251).unwrap_or(0))
.collect();
let mut rgba = Pixmap::new(w, h);
for (slot, &v) in rgba
.data_mut()
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(plane.iter())
{
slot.copy_from_slice(&[v, v, v, v]);
}
let gray = reduce_gray_to(&plane, w, h, dw, dh);
let four = reduce_to(&rgba, dw, dh);
assert_eq!(gray.len(), (dw as usize) * (dh as usize), "{w}x{h}");
for (i, &g) in gray.iter().enumerate() {
assert_eq!(
four.data().get(i * 4..i * 4 + 4),
Some(&[g, g, g, g][..]),
"{w}x{h} -> {dw}x{dh}, sample {i}"
);
}
}
}
#[test]
fn a_gray_plane_of_the_wrong_length_reduces_to_zeroes() {
assert_eq!(reduce_gray_to(&[1, 2, 3], 4, 4, 2, 2), vec![0; 4]);
assert_eq!(reduce_gray_to(&[], 0, 0, 2, 2), vec![0; 4]);
assert!(reduce_gray_to(&[1; 16], 4, 4, 0, 2).is_empty());
}
#[test]
fn a_snapped_reduction_places_exactly() {
let at = Affine::translate((37.421, 88.913)) * Affine::scale_non_uniform(0.31, 0.28);
let Reduction::Snapped(snapped) = reduction(at, 1181, 1772, 366.11, 496.16, true) else {
panic!("an axis-aligned two-axis reduction snaps");
};
let (w, h) = snapped.size();
assert!(w > 0 && h > 0 && w < 1181 && h < 1772);
assert!(matches!(
placement_for(snapped.transform(), w, h),
Placement::Exact { .. }
));
assert_eq!(Reduction::Snapped(snapped).size(1181, 1772), (w, h));
assert!(Reduction::Snapped(snapped).filters());
}
#[test]
fn a_type3_draw_keeps_the_footprint_reduction() {
let at = Affine::translate((37.421, 88.913)) * Affine::scale_non_uniform(0.31, 0.28);
let snapped = reduction(at, 1181, 1772, 366.11, 496.16, true);
let unsnapped = reduction(at, 1181, 1772, 366.11, 496.16, false);
assert!(matches!(snapped, Reduction::Snapped(_)));
let Reduction::Footprint { size, transform } = unsnapped else {
panic!("without the snap the footprint rule applies");
};
assert_eq!(
size,
reduction_for(1181, 1772, 366.11, 496.16).expect("reduces")
);
assert_eq!(transform, unsnapped.transform());
assert!(unsnapped.filters());
}
#[test]
fn a_rotated_or_enlarging_draw_is_not_snapped() {
let rotated = Affine::rotate(0.4) * Affine::scale(0.3);
assert!(!matches!(
reduction(rotated, 800, 600, 240.0, 180.0, true),
Reduction::Snapped(_)
));
let grown = Affine::scale(4.0);
let up = reduction(grown, 8, 8, 32.0, 32.0, true);
assert_eq!(up, Reduction::None(grown));
assert_eq!(up.size(8, 8), (8, 8));
assert!(!up.filters());
}
}