use pdfrum_page::{BlendMode, PageObject, Transparency};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GroupInputs {
pub has_soft_mask: bool,
pub group_alpha: f32,
pub blend: BlendMode,
pub group_is_isolated: bool,
pub initial_alpha: f32,
}
impl GroupInputs {
#[must_use]
pub fn of(object: &PageObject, initial_alpha: f32) -> Self {
let state = object.state();
let image_has_own_mask = match object {
PageObject::Image(img) => img.object.image.mask.is_some(),
_ => false,
};
let (group_alpha, group_is_isolated) = match object {
PageObject::Form(form) => (state.general.fill_alpha, form.object.transparency.isolated),
_ => (1.0, false),
};
Self {
has_soft_mask: state.general.soft_mask.is_some() && !image_has_own_mask,
group_alpha,
blend: state.general.blend,
group_is_isolated,
initial_alpha,
}
}
}
#[must_use]
#[expect(
clippy::float_cmp,
reason = "an exact `== 1.0f` is the upstream predicate: an alpha even one \
ulp below opaque takes the offscreen path there, and an epsilon \
here would silently take the other branch for those files"
)]
pub fn needs_offscreen(inputs: GroupInputs) -> bool {
!(!inputs.has_soft_mask
&& inputs.group_alpha == 1.0
&& matches!(inputs.blend, BlendMode::Normal | BlendMode::Compatible)
&& !inputs.group_is_isolated
&& inputs.initial_alpha == 1.0)
}
#[must_use]
pub fn needs_backdrop(transparency: Transparency) -> bool {
!transparency.isolated
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GroupFinish {
pub group_alpha: Option<f32>,
pub initial_alpha: Option<f32>,
}
impl GroupFinish {
#[must_use]
#[expect(
clippy::float_cmp,
reason = "an exact `!= 1.0f` is the upstream guard; an epsilon would \
skip the alpha multiply for a near-opaque value PDFium applies"
)]
pub fn of(inputs: GroupInputs, transparency: Transparency, in_group: bool) -> Self {
Self {
group_alpha: transparency.group.then_some(inputs.group_alpha),
initial_alpha: (inputs.initial_alpha != 1.0 && !in_group)
.then_some(inputs.initial_alpha),
}
}
pub fn apply(&self, pixmap: &mut crate::pixmap::Pixmap) {
if let Some(a) = self.group_alpha {
pixmap.multiply_alpha(a);
}
if let Some(a) = self.initial_alpha {
pixmap.multiply_alpha(a);
}
}
}
#[cfg(test)]
mod tests {
use pdfrum_page::GraphicsState;
use super::*;
fn composite_transparency(enclosing: Transparency, is_form: bool) -> Transparency {
Transparency {
group: enclosing.group || is_form,
..enclosing
}
}
fn state_inside_group(state: &GraphicsState) -> GraphicsState {
let mut inner = state.clone();
inner.general.enter_transparency_group();
inner
}
fn plain() -> GroupInputs {
GroupInputs {
has_soft_mask: false,
group_alpha: 1.0,
blend: BlendMode::Normal,
group_is_isolated: false,
initial_alpha: 1.0,
}
}
#[test]
fn needs_offscreen_predicate_all_clauses() {
assert!(!needs_offscreen(plain()));
assert!(needs_offscreen(GroupInputs {
has_soft_mask: true,
..plain()
}));
assert!(needs_offscreen(GroupInputs {
group_alpha: 0.5,
..plain()
}));
assert!(needs_offscreen(GroupInputs {
blend: BlendMode::Multiply,
..plain()
}));
assert!(needs_offscreen(GroupInputs {
group_is_isolated: true,
..plain()
}));
assert!(needs_offscreen(GroupInputs {
initial_alpha: 0.5,
..plain()
}));
}
#[test]
fn a_non_isolated_group_with_nothing_else_draws_directly() {
assert!(!needs_offscreen(GroupInputs {
group_is_isolated: false,
..plain()
}));
}
#[test]
fn compatible_counts_as_normal() {
assert!(!needs_offscreen(GroupInputs {
blend: BlendMode::Compatible,
..plain()
}));
}
#[test]
fn isolated_starts_transparent_non_isolated_copies_the_backdrop() {
assert!(!needs_backdrop(Transparency {
isolated: true,
..Transparency::default()
}));
assert!(needs_backdrop(Transparency {
isolated: false,
..Transparency::default()
}));
}
#[test]
fn mask_then_group_alpha_then_initial_alpha_order() {
let inputs = GroupInputs {
group_alpha: 0.5,
initial_alpha: 0.5,
..plain()
};
let group = Transparency {
group: true,
..Transparency::default()
};
let finish = GroupFinish::of(inputs, group, false);
assert_eq!(finish.group_alpha, Some(0.5));
assert_eq!(finish.initial_alpha, Some(0.5));
let nested = GroupFinish::of(inputs, group, true);
assert_eq!(nested.group_alpha, Some(0.5));
assert_eq!(nested.initial_alpha, None);
}
#[test]
fn a_non_group_form_skips_the_group_alpha() {
let inputs = GroupInputs {
group_alpha: 0.5,
..plain()
};
let finish = GroupFinish::of(inputs, Transparency::default(), false);
assert_eq!(finish.group_alpha, None);
}
#[test]
fn alphas_apply_in_sequence_and_truncate() {
let finish = GroupFinish {
group_alpha: Some(0.5),
initial_alpha: Some(0.5),
};
let mut p =
crate::pixmap::Pixmap::filled(1, 1, peniko::Color::from_rgba8(255, 255, 255, 255));
finish.apply(&mut p);
assert_eq!(p.pixel(0, 0).map(|px| px[3]), Some(63));
}
#[test]
fn a_form_composites_under_the_enclosing_transparency_with_group_forced() {
let enclosing = Transparency {
group: false,
isolated: true,
knockout: false,
};
let out = composite_transparency(enclosing, true);
assert!(out.group, "a form forces the group bit on");
assert!(out.isolated, "but the isolated bit stays the enclosing one");
}
#[test]
#[expect(
clippy::float_cmp,
reason = "entering a group writes the literal 1.0; exact equality is \
what pins that the alpha was reset rather than scaled"
)]
fn entering_a_group_clears_what_it_already_consumed() {
let mut state = GraphicsState::default();
state.general.fill_alpha = 0.25;
state.general.blend = BlendMode::Multiply;
let inner = state_inside_group(&state);
assert_eq!(inner.general.fill_alpha, 1.0);
assert_eq!(inner.general.blend, BlendMode::Normal);
assert!(inner.general.soft_mask.is_none());
}
}