use std::sync::Arc;
use crate::bitmap::Bitmap;
use crate::clip::Clip;
use crate::pipe::{self, PipeSrc, PipeState};
use color::Pixel;
use color::convert::div255;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SoftMaskType {
None,
Alpha,
Luminosity,
}
#[derive(Clone, Debug)]
pub struct GroupParams {
pub x_min: i32,
pub y_min: i32,
pub x_max: i32,
pub y_max: i32,
pub isolated: bool,
pub knockout: bool,
pub soft_mask_type: SoftMaskType,
}
pub struct GroupBitmap<P: Pixel> {
pub bitmap: Bitmap<P>,
pub saved_clip: Clip,
pub params: GroupParams,
pub alpha: Vec<u8>,
pub alpha0: Option<Arc<[u8]>>,
}
impl<P: Pixel> GroupBitmap<P> {
#[must_use]
pub const fn dims(&self) -> (u32, u32) {
(self.bitmap.width, self.bitmap.height)
}
}
#[must_use]
pub fn begin_group<P: Pixel>(
parent: &Bitmap<P>,
clip: &Clip,
params: GroupParams,
) -> GroupBitmap<P> {
debug_assert!(
params.x_min <= params.x_max,
"begin_group: inverted x range [{}, {}]",
params.x_min,
params.x_max
);
debug_assert!(
params.y_min <= params.y_max,
"begin_group: inverted y range [{}, {}]",
params.y_min,
params.y_max
);
#[expect(clippy::cast_sign_loss, reason = "clamped to [0, parent.width)")]
let gx0 = (params.x_min.max(0) as u32).min(parent.width.saturating_sub(1));
#[expect(clippy::cast_sign_loss, reason = "clamped to [0, parent.height)")]
let gy0 = (params.y_min.max(0) as u32).min(parent.height.saturating_sub(1));
#[expect(clippy::cast_sign_loss, reason = "clamped to (0, parent.width]")]
let gx1 = (params.x_max.saturating_add(1).max(0) as u32).min(parent.width);
#[expect(clippy::cast_sign_loss, reason = "clamped to (0, parent.height]")]
let gy1 = (params.y_max.saturating_add(1).max(0) as u32).min(parent.height);
let gw = gx1.saturating_sub(gx0).max(1);
let gh = gy1.saturating_sub(gy0).max(1);
let pixel_count = gw as usize * gh as usize;
let ncomps = P::BYTES;
let mut bitmap = Bitmap::<P>::new(gw, gh, 4, true);
let (alpha0, alpha) = if params.isolated {
(None, vec![0u8; pixel_count])
} else {
let mut a = vec![255u8; pixel_count];
for gy in 0..gh {
let py = gy0 + gy;
if py >= parent.height {
break;
}
let copy_w = (gw as usize).min((parent.width as usize).saturating_sub(gx0 as usize));
let group_row_off = gy as usize * gw as usize;
let src = parent.row_bytes(py);
let src_off = gx0 as usize * ncomps;
let dst = bitmap.row_bytes_mut(gy);
dst[..copy_w * ncomps].copy_from_slice(&src[src_off..src_off + copy_w * ncomps]);
if let Some(pa) = parent.alpha_plane() {
let px_start = py as usize * parent.width as usize + gx0 as usize;
a[group_row_off..group_row_off + copy_w]
.copy_from_slice(&pa[px_start..px_start + copy_w]);
}
}
let snap: Arc<[u8]> = parent.alpha_plane().map_or_else(
|| vec![255u8; parent.width as usize * parent.height as usize].into(),
std::convert::Into::into,
);
(Some(snap), a)
};
GroupBitmap {
bitmap,
saved_clip: clip.clone_shared(),
params,
alpha,
alpha0,
}
}
pub fn paint_group<P: Pixel>(
parent: &mut Bitmap<P>,
group: GroupBitmap<P>,
pipe: &PipeState<'_>,
) -> Clip {
let gw = group.bitmap.width;
let gh = group.bitmap.height;
let ncomps = P::BYTES;
let alpha = &group.alpha;
let parent_width = parent.width;
let parent_height = parent.height;
#[expect(
clippy::cast_sign_loss,
reason = "begin_group clamped x_min/y_min to ≥ 0 before allocating the group"
)]
let (px0, py0) = (
group.params.x_min.max(0) as u32,
group.params.y_min.max(0) as u32,
);
for gy in 0..gh {
let py = py0 + gy;
if py >= parent_height {
break;
}
let g_row = group.bitmap.row_bytes(gy);
let alpha_row_off = gy as usize * gw as usize;
let g_alpha = &alpha[alpha_row_off..alpha_row_off + gw as usize];
let x_count = (gw as usize).min((parent_width as usize).saturating_sub(px0 as usize));
if x_count == 0 {
continue;
}
let (p_row, mut p_alpha) = parent.row_and_alpha_mut(py);
#[expect(
clippy::needless_range_loop,
reason = "gx indexes g_alpha, g_off, and parent x simultaneously; enumerate() adds noise"
)]
for gx in 0..x_count {
let px = px0 as usize + gx;
let g_src_a = g_alpha[gx];
if g_src_a == 0 {
continue; }
let g_off = gx * ncomps;
let p_off = px * ncomps;
let eff_a = div255(u32::from(g_src_a) * u32::from(pipe.a_input));
let pixel_pipe = PipeState {
a_input: eff_a,
..*pipe
};
let src = PipeSrc::Solid(&g_row[g_off..g_off + ncomps]);
let dst_pix = &mut p_row[p_off..p_off + ncomps];
let dst_alpha: Option<&mut [u8]> = p_alpha.as_mut().map(|a| &mut a[px..=px]);
#[expect(
clippy::cast_possible_wrap,
clippy::cast_possible_truncation,
reason = "px/py originate from u32 parent dimensions; \
PDF pages are always < 2^31 px in any real scenario"
)]
pipe::render_span::<P>(
&pixel_pipe,
&src,
dst_pix,
dst_alpha,
None,
px as i32,
px as i32,
py as i32,
);
}
}
group.saved_clip
}
#[must_use]
pub fn discard_group<P: Pixel>(group: GroupBitmap<P>) -> Clip {
group.saved_clip
}
#[must_use]
pub fn extract_soft_mask<P: Pixel>(group: &GroupBitmap<P>) -> Vec<u8> {
let GroupBitmap {
bitmap,
alpha,
params,
..
} = group;
let pixel_count = bitmap.width as usize * bitmap.height as usize;
match params.soft_mask_type {
SoftMaskType::None => vec![255u8; pixel_count],
SoftMaskType::Alpha => alpha.clone(),
SoftMaskType::Luminosity => {
if P::BYTES != 3 {
return alpha.clone();
}
let mut mask = Vec::with_capacity(pixel_count);
for y in 0..bitmap.height {
let row = bitmap.row_bytes(y);
for x in 0..bitmap.width as usize {
let off = x * 3;
let r = i32::from(row[off]);
let g = i32::from(row[off + 1]);
let b = i32::from(row[off + 2]);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "77+151+28 = 256; luma = weighted sum of [0,255] values; \
max = (256*255+128)>>8 = 255, min = 0"
)]
mask.push(((77 * r + 151 * g + 28 * b + 0x80) >> 8) as u8);
}
}
debug_assert_eq!(
mask.len(),
pixel_count,
"extract_soft_mask: loop produced {} bytes, expected {} ({w}×{h})",
mask.len(),
pixel_count,
w = bitmap.width,
h = bitmap.height,
);
mask
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bitmap::Bitmap;
use crate::testutil::{make_clip, simple_pipe};
use color::Rgb8;
fn default_params(x_min: i32, y_min: i32, x_max: i32, y_max: i32) -> GroupParams {
GroupParams {
x_min,
y_min,
x_max,
y_max,
isolated: true,
knockout: false,
soft_mask_type: SoftMaskType::None,
}
}
#[test]
fn isolated_group_starts_transparent() {
let parent: Bitmap<Rgb8> = Bitmap::new(8, 8, 4, true);
let clip = make_clip(8, 8);
let params = default_params(0, 0, 7, 7);
let group = begin_group::<Rgb8>(&parent, &clip, params);
assert!(
group.alpha.iter().all(|&a| a == 0),
"isolated group must start fully transparent"
);
}
#[test]
fn non_isolated_group_copies_parent_alpha() {
let mut parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
if let Some(a) = parent.alpha_plane_mut() {
a.fill(128);
}
let clip = make_clip(4, 4);
let mut params = default_params(0, 0, 3, 3);
params.isolated = false;
let group = begin_group::<Rgb8>(&parent, &clip, params);
assert!(
group.alpha.iter().all(|&a| a == 128),
"non-isolated group must copy parent alpha"
);
}
#[test]
fn paint_group_opaque_white_over_black() {
let mut parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
let clip = make_clip(4, 4);
let pipe = simple_pipe();
let params = default_params(1, 1, 2, 2); let mut group = begin_group::<Rgb8>(&parent, &clip, params);
for y in 0..group.bitmap.height {
let row = group.bitmap.row_bytes_mut(y);
for chunk in row.chunks_exact_mut(3) {
chunk.copy_from_slice(&[255, 255, 255]);
}
}
group.alpha.fill(255);
let _clip = paint_group::<Rgb8>(&mut parent, group, &pipe);
assert_eq!(parent.row(1)[1].r, 255, "pixel (1,1) R should be white");
assert_eq!(parent.row(1)[2].r, 255, "pixel (1,2) R should be white");
}
#[test]
fn discard_group_does_not_paint() {
let parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
let clip = make_clip(4, 4);
let params = default_params(0, 0, 3, 3);
let mut group = begin_group::<Rgb8>(&parent, &clip, params);
for y in 0..group.bitmap.height {
let row = group.bitmap.row_bytes_mut(y);
for chunk in row.chunks_exact_mut(3) {
chunk.copy_from_slice(&[255, 0, 0]);
}
}
group.alpha.fill(255);
let _saved = discard_group(group);
assert_eq!(parent.row(0)[0].r, 0, "discard must not paint");
}
#[test]
fn extract_soft_mask_alpha_returns_alpha_plane() {
let parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
let clip = make_clip(4, 4);
let mut params = default_params(0, 0, 3, 3);
params.soft_mask_type = SoftMaskType::Alpha;
let mut group = begin_group::<Rgb8>(&parent, &clip, params);
group.alpha.fill(200);
let mask = extract_soft_mask::<Rgb8>(&group);
assert!(
mask.iter().all(|&v| v == 200),
"alpha soft mask must match alpha plane"
);
}
#[test]
fn extract_soft_mask_luminosity_computes_luma() {
let parent: Bitmap<Rgb8> = Bitmap::new(2, 1, 4, true);
let clip = make_clip(2, 1);
let mut params = default_params(0, 0, 1, 0);
params.soft_mask_type = SoftMaskType::Luminosity;
let mut group = begin_group::<Rgb8>(&parent, &clip, params);
let row = group.bitmap.row_bytes_mut(0);
row[..3].copy_from_slice(&[255, 255, 255]);
row[3..6].copy_from_slice(&[0, 0, 0]);
group.alpha.fill(255);
let mask = extract_soft_mask::<Rgb8>(&group);
assert_eq!(mask.len(), 2);
assert_eq!(mask[0], 255, "white → luma=255");
assert_eq!(mask[1], 0, "black → luma=0");
}
#[test]
fn transparent_group_pixel_is_skipped() {
let mut parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
parent.row_bytes_mut(0)[..3].copy_from_slice(&[0, 0, 255]);
let clip = make_clip(4, 4);
let pipe = simple_pipe();
let params = default_params(0, 0, 0, 0); let group = begin_group::<Rgb8>(&parent, &clip, params);
let _saved = paint_group::<Rgb8>(&mut parent, group, &pipe);
assert_eq!(
parent.row(0)[0].b,
255,
"transparent group pixel must not paint"
);
}
#[test]
fn extract_soft_mask_luminosity_fallback_for_cmyk() {
use color::Cmyk8;
let parent: Bitmap<Cmyk8> = Bitmap::new(2, 1, 4, true);
let clip = Clip::new(0.0, 0.0, 1.999, 0.999, false);
let mut params = default_params(0, 0, 1, 0);
params.soft_mask_type = SoftMaskType::Luminosity;
let mut group = begin_group::<Cmyk8>(&parent, &clip, params);
group.alpha.fill(77);
let mask = extract_soft_mask::<Cmyk8>(&group);
assert!(
mask.iter().all(|&v| v == 77),
"CMYK luminosity soft mask must fall back to alpha"
);
}
#[test]
fn begin_group_x_max_i32_max_does_not_overflow() {
let parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
let clip = make_clip(4, 4);
let params = default_params(0, 0, i32::MAX, i32::MAX);
let group = begin_group::<Rgb8>(&parent, &clip, params);
assert_eq!(group.dims(), (4, 4));
}
}