use crate::color::rgb_to_gray;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pixmap {
width: u32,
height: u32,
data: Vec<u8>,
}
impl Pixmap {
#[must_use]
pub fn new(width: u32, height: u32) -> Self {
Self::try_new(width, height).unwrap_or(Self {
width: 0,
height: 0,
data: Vec::new(),
})
}
#[must_use]
pub fn try_new(width: u32, height: u32) -> Option<Self> {
if !pdfrum_page::image_area_is_workable(width, height) {
return None;
}
let len = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, 0);
Some(Self {
width,
height,
data,
})
}
#[must_use]
pub fn filled(width: u32, height: u32, color: peniko::Color) -> Self {
let len = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
let px = premultiply(color);
let mut data = Vec::new();
if !pdfrum_page::image_area_is_workable(width, height)
|| data.try_reserve_exact(len).is_err()
{
return Self {
width: 0,
height: 0,
data,
};
}
if len >= 4 {
data.extend_from_slice(&px);
while data.len() * 2 <= len {
data.extend_from_within(..);
}
let rest = len - data.len();
data.extend_from_within(..rest);
}
Self {
width,
height,
data,
}
}
#[must_use]
pub fn from_vec(width: u32, height: u32, data: Vec<u8>) -> Option<Self> {
let len = (width as usize)
.checked_mul(height as usize)?
.checked_mul(4)?;
(data.len() == len).then_some(Self {
width,
height,
data,
})
}
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
#[must_use]
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn data_mut(&mut self) -> &mut [u8] {
&mut self.data
}
#[must_use]
pub fn into_data(self) -> Vec<u8> {
self.data
}
#[must_use]
pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
let i = self.index(x, y)?;
let px = self.data.get(i..i + 4)?;
Some([*px.first()?, *px.get(1)?, *px.get(2)?, *px.get(3)?])
}
pub(crate) fn reshape_keeping_pixels(&mut self, width: u32, height: u32) {
let len = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
self.width = width;
self.height = height;
if len <= self.data.len() {
self.data.truncate(len);
} else {
self.data.resize(len, 0);
}
}
pub fn set_pixel(&mut self, x: u32, y: u32, px: [u8; 4]) {
let Some(i) = self.index(x, y) else { return };
if let Some(slot) = self.data.get_mut(i..i + 4) {
slot.copy_from_slice(&px);
}
}
pub fn fill(&mut self, color: peniko::Color) {
let px = premultiply(color);
for chunk in self.data.as_chunks_mut::<4>().0 {
*chunk = px;
}
}
pub fn multiply_alpha(&mut self, alpha: f32) {
if alpha >= 1.0 {
return;
}
let a = alpha_byte_truncating(alpha);
for b in &mut self.data {
*b = mul255(*b, a);
}
}
pub fn remove_backdrop(&mut self, backdrop: &Self) {
if backdrop.width != self.width || backdrop.height != self.height {
return;
}
for (chunk, base) in self
.data
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(backdrop.data.as_chunks::<4>().0)
{
let (agn, a0) = (chunk[3], base[3]);
if agn <= a0 {
for b in chunk.iter_mut() {
*b = 0;
}
continue;
}
let (fa0, fagn) = (f32::from(a0) / 255.0, f32::from(agn) / 255.0);
let out_alpha = if fa0 >= 1.0 {
0.0
} else {
((fagn - fa0) / (1.0 - fa0)).clamp(0.0, 1.0)
};
for index in 0..3 {
let (Some(&cn), Some(&c0)) = (chunk.get(index), base.get(index)) else {
continue;
};
let un = |v: u8, a: f32| {
if a > 0.0 {
f32::from(v) / 255.0 / a
} else {
0.0
}
};
let (ucn, uc0) = (un(cn, fagn), un(c0, fa0));
let colour = uc0.mul_add(-(fa0 / fagn - fa0), ucn.mul_add(fa0 / fagn - fa0, ucn));
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the value to 0.0..=255.0"
)]
let byte = (colour.clamp(0.0, 1.0) * out_alpha * 255.0).round() as u8;
if let Some(slot) = chunk.get_mut(index) {
*slot = byte;
}
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the value to 0.0..=255.0"
)]
let alpha_byte = (out_alpha * 255.0).round() as u8;
if let Some(slot) = chunk.get_mut(3) {
*slot = alpha_byte;
}
}
}
pub fn knockout_over(&mut self, next: &Self) {
if next.width != self.width || next.height != self.height {
return;
}
for (chunk, over) in self
.data
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(next.data.as_chunks::<4>().0)
{
let a = over[3];
if a == 0 {
continue;
}
if a == u8::MAX {
chunk.copy_from_slice(over);
continue;
}
for (slot, &value) in chunk.iter_mut().zip(over.iter()) {
*slot = value.saturating_add(mul255(*slot, 255 - a));
}
}
}
pub(crate) fn knockout_replace(&mut self, next: &Self) {
if next.width != self.width || next.height != self.height {
return;
}
for (chunk, over) in self
.data
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(next.data.as_chunks::<4>().0)
{
let a = over[3];
if a == 0 {
continue;
}
chunk.copy_from_slice(over);
}
}
pub fn multiply_alpha_mask(&mut self, mask: &AlphaMask) {
if mask.width() != self.width || mask.height() != self.height {
return;
}
for (chunk, &m) in self.data.as_chunks_mut::<4>().0.iter_mut().zip(mask.data()) {
for b in chunk {
*b = mul255(*b, m);
}
}
}
#[must_use]
pub fn luminosity_mask(&self) -> AlphaMask {
let mut out = Vec::with_capacity(self.data.len() / 4);
for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
let [r, g, b] = unpremultiply_rgb(r, g, b, a);
out.push(rgb_to_gray(r, g, b));
}
AlphaMask::from_vec(self.width, self.height, out)
.unwrap_or_else(|| AlphaMask::new(self.width, self.height))
}
#[must_use]
pub fn alpha_mask(&self) -> AlphaMask {
let out: Vec<u8> = self.data.as_chunks::<4>().0.iter().map(|c| c[3]).collect();
AlphaMask::from_vec(self.width, self.height, out)
.unwrap_or_else(|| AlphaMask::new(self.width, self.height))
}
#[must_use]
pub fn to_straight_bgra(&self, opaque: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(self.data.len());
for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
let [r, g, b] = unpremultiply_rgb(r, g, b, a);
out.extend_from_slice(&[b, g, r, if opaque { 0xFF } else { a }]);
}
out
}
#[must_use]
pub fn to_straight_rgb(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.data.len() / 4 * 3);
for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
out.extend_from_slice(&unpremultiply_rgb(r, g, b, a));
}
out
}
#[must_use]
pub fn to_straight_rgba(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.data.len());
for &[r, g, b, a] in self.data.as_chunks::<4>().0 {
let [r, g, b] = unpremultiply_rgb(r, g, b, a);
out.extend_from_slice(&[r, g, b, a]);
}
out
}
fn index(&self, x: u32, y: u32) -> Option<usize> {
(x < self.width && y < self.height)
.then(|| (y as usize).checked_mul(self.width as usize))
.flatten()?
.checked_add(x as usize)?
.checked_mul(4)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlphaMask {
width: u32,
height: u32,
data: Vec<u8>,
}
impl AlphaMask {
#[must_use]
pub fn new(width: u32, height: u32) -> Self {
let len = (width as usize).saturating_mul(height as usize);
Self {
width,
height,
data: vec![0; len],
}
}
#[must_use]
pub fn filled(width: u32, height: u32, value: u8) -> Self {
let len = (width as usize).saturating_mul(height as usize);
Self {
width,
height,
data: vec![value; len],
}
}
#[must_use]
pub fn from_vec(width: u32, height: u32, data: Vec<u8>) -> Option<Self> {
let len = (width as usize).checked_mul(height as usize)?;
(data.len() == len).then_some(Self {
width,
height,
data,
})
}
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
#[must_use]
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn data_mut(&mut self) -> &mut [u8] {
&mut self.data
}
#[must_use]
pub fn into_data(self) -> Vec<u8> {
self.data
}
pub fn intersect(&mut self, other: &Self) {
if other.width != self.width || other.height != self.height {
return;
}
for (a, &b) in self.data.iter_mut().zip(&other.data) {
*a = mul255(*a, b);
}
}
pub fn apply_transfer(&mut self, table: &[u8; 256]) {
for b in &mut self.data {
*b = *table.get(*b as usize).unwrap_or(b);
}
}
#[must_use]
pub fn placed_in(&self, width: u32, height: u32, x: i32, y: i32) -> Self {
let mut out = Self::new(width, height);
let in_range = |v: i64, limit: u32| -> Option<usize> {
(v >= 0 && v < i64::from(limit))
.then(|| usize::try_from(v).ok())
.flatten()
};
for row in 0..self.height {
let Some(dy) = in_range(i64::from(row) + i64::from(y), height) else {
continue;
};
for col in 0..self.width {
let Some(dx) = in_range(i64::from(col) + i64::from(x), width) else {
continue;
};
let src = self
.data
.get((row as usize * self.width as usize) + col as usize);
let dst = out.data.get_mut((dy * width as usize) + dx);
if let (Some(&src), Some(dst)) = (src, dst) {
*dst = src;
}
}
}
out
}
}
#[must_use]
pub fn alpha_byte_truncating(alpha: f32) -> u8 {
if alpha.is_nan() {
return 0;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the NaN guard and the clamp bound the product to 0.0..=255.0; \
the truncation *is* the ported behaviour, not an accident"
)]
let byte = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
byte
}
#[must_use]
pub(crate) fn alpha_byte_rounding(alpha: f32) -> u8 {
if alpha.is_nan() {
return 0;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the NaN guard and the clamp bound the rounded product to 0..=255"
)]
let byte = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
byte
}
#[must_use]
pub fn mul255(a: u8, b: u8) -> u8 {
#[expect(
clippy::cast_possible_truncation,
reason = "255*255/255 == 255 is the maximum, so the quotient always fits u8"
)]
let byte = ((u32::from(a) * u32::from(b)) / 255) as u8;
byte
}
#[must_use]
pub fn alpha_merge(dest: u8, src: u8, alpha: u8) -> u8 {
let a = u32::from(alpha);
#[expect(
clippy::cast_possible_truncation,
reason = "the numerator is a convex combination of two 0..=255 bytes \
scaled by 255, so the quotient is itself 0..=255"
)]
let byte = ((u32::from(dest) * (255 - a) + u32::from(src) * a) / 255) as u8;
byte
}
#[must_use]
pub(crate) fn alpha_union(dest: u8, src: u8) -> u8 {
let merged = u32::from(dest) + u32::from(src) - (u32::from(dest) * u32::from(src)) / 255;
u8::try_from(merged).unwrap_or(u8::MAX)
}
#[must_use]
pub(crate) fn premultiply(color: peniko::Color) -> [u8; 4] {
let [r, g, b, a] = color.to_rgba8().to_u8_array();
[mul255(r, a), mul255(g, a), mul255(b, a), a]
}
#[must_use]
pub(crate) fn unpremultiply_rgb(r: u8, g: u8, b: u8, a: u8) -> [u8; 3] {
if a == 0 {
return [0, 0, 0];
}
if a == 255 {
return [r, g, b];
}
let a32 = u32::from(a);
let up = |c: u8| -> u8 { ((u32::from(c) * 255 + a32 / 2) / a32).min(255) as u8 };
[up(r), up(g), up(b)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_size_no_allocator_can_meet_comes_back_empty_rather_than_aborting() {
assert_eq!(Pixmap::try_new(131_071, 131_071), None);
assert_eq!(Pixmap::new(131_071, 131_071).width(), 0);
assert!(Pixmap::new(131_071, 131_071).data().is_empty());
assert_eq!(
Pixmap::filled(131_071, 131_071, peniko::Color::BLACK).width(),
0
);
assert_eq!(Pixmap::try_new(65_536, 65_536), None);
assert_eq!(Pixmap::try_new(3, 2).map(|p| p.data().len()), Some(24));
assert_eq!(Pixmap::new(3, 2).data().len(), 24);
}
#[test]
fn knockout_replaces_rather_than_blending() {
let red = peniko::Color::from_rgba8(255, 0, 0, 255);
let blue = peniko::Color::from_rgba8(0, 0, 255, 255);
let mut base = Pixmap::filled(1, 1, red);
base.knockout_over(&Pixmap::filled(1, 1, blue));
assert_eq!(base.pixel(0, 0), Some([0, 0, 255, 255]));
let mut base = Pixmap::filled(1, 1, red);
base.knockout_over(&Pixmap::new(1, 1));
assert_eq!(base.pixel(0, 0), Some([255, 0, 0, 255]));
}
#[test]
fn the_fill_stroke_knockout_keeps_the_strokes_alpha() {
let black_fill = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 0, 0, 255));
let translucent_stroke =
Pixmap::from_vec(1, 1, vec![148, 0, 0, 148]).expect("one premultiplied pixel");
let mut blended = black_fill.clone();
blended.knockout_over(&translucent_stroke);
assert_eq!(
blended.pixel(0, 0),
Some([148, 0, 0, 255]),
"knockout_over promotes the overlap to opaque, which is the defect"
);
let mut replaced = black_fill;
replaced.knockout_replace(&translucent_stroke);
assert_eq!(
replaced.pixel(0, 0),
Some([148, 0, 0, 148]),
"the stroke's own alpha must survive so the blit composites it once"
);
}
#[test]
fn a_mismatched_knockout_overlay_changes_nothing() {
let mut base = Pixmap::filled(2, 2, peniko::Color::from_rgba8(7, 8, 9, 255));
let before = base.clone();
base.knockout_over(&Pixmap::filled(3, 3, peniko::Color::BLACK));
assert_eq!(base, before);
}
#[test]
fn removing_the_backdrop_leaves_only_the_groups_own_contribution() {
let backdrop = Pixmap::filled(1, 1, peniko::Color::from_rgba8(90, 120, 150, 255));
let mut group = backdrop.clone();
group.remove_backdrop(&backdrop);
assert_eq!(group.pixel(0, 0), Some([0, 0, 0, 0]));
let half = Pixmap::filled(1, 1, peniko::Color::from_rgba8(45, 60, 75, 128));
let mut group = Pixmap::filled(1, 1, peniko::Color::from_rgba8(200, 40, 10, 255));
group.remove_backdrop(&half);
let after = group.pixel(0, 0).expect("one pixel");
assert_eq!(after[3], 255, "the group finished opaque over the backdrop");
assert_ne!(after, [45, 60, 75, 128]);
let mut group = Pixmap::filled(1, 1, peniko::Color::from_rgba8(200, 40, 10, 255));
group.remove_backdrop(&backdrop);
assert_eq!(group.pixel(0, 0), Some([0, 0, 0, 0]));
}
#[test]
fn removing_a_mismatched_backdrop_changes_nothing() {
let mut group = Pixmap::filled(2, 2, peniko::Color::from_rgba8(1, 2, 3, 200));
let before = group.clone();
group.remove_backdrop(&Pixmap::filled(3, 3, peniko::Color::BLACK));
assert_eq!(group, before);
}
#[test]
fn multiply_alpha_truncates() {
assert_eq!(alpha_byte_truncating(0.5), 127);
assert_eq!(alpha_byte_rounding(0.5), 128);
let mut p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 255, 255, 255));
p.multiply_alpha(0.5);
assert_eq!(p.pixel(0, 0), Some([127, 127, 127, 127]));
}
#[test]
fn multiply_alpha_one_is_exact_early_return() {
let mut p = Pixmap::filled(2, 2, peniko::Color::from_rgba8(10, 20, 30, 200));
let before = p.clone();
p.multiply_alpha(1.0);
assert_eq!(p, before);
}
#[test]
fn premul_roundtrip() {
for a in [0u8, 1, 64, 128, 254, 255] {
for c in [0u8, 1, 127, 128, 254, 255] {
let color = peniko::Color::from_rgba8(c, c, c, a);
let [pr, pg, pb, pa] = premultiply(color);
assert_eq!(pa, a);
let [ur, _, _] = unpremultiply_rgb(pr, pg, pb, pa);
if a == 0 {
assert_eq!(ur, 0);
} else {
let step = 255_u32.div_ceil(u32::from(a).max(1));
assert!(
u32::from(ur).abs_diff(u32::from(c)) <= step,
"a={a} c={c} ur={ur}"
);
}
}
}
}
#[test]
fn alpha_merge_truncates_and_does_not_clamp() {
assert_eq!(alpha_merge(0, 255, 128), 128);
assert_eq!(alpha_merge(255, 0, 128), 127);
assert_eq!(alpha_merge(100, 200, 0), 100);
assert_eq!(alpha_merge(100, 200, 255), 200);
}
#[test]
fn mask_intersect_is_truncating_product() {
let mut a = AlphaMask::filled(2, 1, 128);
let b = AlphaMask::filled(2, 1, 128);
a.intersect(&b);
assert_eq!(a.data(), &[64, 64]);
}
#[test]
fn mask_placed_in_pads_with_zero() {
let m = AlphaMask::filled(2, 2, 200);
let placed = m.placed_in(4, 4, 1, 1);
assert_eq!(placed.data().first().copied(), Some(0));
assert_eq!(placed.data().get(5).copied(), Some(200));
assert_eq!(placed.data().get(10).copied(), Some(200));
assert_eq!(placed.data().get(15).copied(), Some(0));
}
#[test]
fn opaque_output_forces_alpha_ff() {
let p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(1, 2, 3, 255));
assert_eq!(p.to_straight_bgra(true), vec![3, 2, 1, 0xFF]);
}
#[test]
fn luminosity_uses_ntsc_not_bt709() {
let p = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 0, 255, 255));
assert_eq!(p.luminosity_mask().data(), &[28]);
}
}
#[cfg(feature = "png")]
impl Pixmap {
pub fn encode_png(&self) -> Result<Vec<u8>, crate::Error> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, self.width, self.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&self.to_straight_rgba())?;
writer.finish()?;
Ok(out)
}
pub fn save_png(&self, path: impl AsRef<std::path::Path>) -> Result<(), crate::Error> {
std::fs::write(path, self.encode_png()?)?;
Ok(())
}
}
#[cfg(all(test, feature = "png"))]
mod png_tests {
use super::Pixmap;
#[test]
fn a_pixmap_round_trips_through_png() {
let pixmap = Pixmap::from_vec(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap();
let bytes = pixmap.encode_png().unwrap();
assert_eq!(bytes.get(..8), Some(b"\x89PNG\r\n\x1a\n".as_slice()));
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut out).unwrap();
assert_eq!(
(info.width, info.height, info.color_type),
(2, 1, png::ColorType::Rgba)
);
assert_eq!(
out.get(..info.buffer_size()),
Some(pixmap.to_straight_rgba().as_slice())
);
}
#[test]
fn unpremultiplied_alpha_is_what_reaches_the_file() {
let pixmap = Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 0, 0, 128));
assert_eq!(pixmap.data(), &[128, 0, 0, 128], "stored premultiplied");
let decoder = png::Decoder::new(std::io::Cursor::new(pixmap.encode_png().unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut out).unwrap();
assert_eq!(
out.get(..info.buffer_size()),
Some([255, 0, 0, 128].as_slice()),
"the file must carry straight alpha"
);
}
}