Skip to main content

rasterrocket_render/
transparency.rs

1//! Transparency group compositing — replaces `Splash::beginTransparencyGroup`,
2//! `Splash::endTransparencyGroup`, and `Splash::paintTransparencyGroup`.
3//!
4//! # PDF transparency model (§11.3–11.4)
5//!
6//! A **transparency group** is an intermediate compositing surface.  The caller:
7//!
8//! 1. Calls [`begin_group`] to allocate a fresh group bitmap and push it onto the
9//!    stack.  All subsequent paint operations target that group.
10//! 2. Renders into the group normally (fill, stroke, image, shading, glyph calls
11//!    on the group bitmap).
12//! 3. Calls [`paint_group`] (or [`discard_group`] on error) to pop the group and
13//!    composite it back into the underlying bitmap.
14//!
15//! # Isolated vs. non-isolated groups
16//!
17//! | Flag | Effect |
18//! |------|--------|
19//! | `isolated = true` | Group starts with a transparent background (alpha = 0). |
20//! | `isolated = false` | Group is pre-initialised with the backdrop's colours. |
21//!
22//! Knockout groups clear the accumulated alpha on each object; non-knockout groups
23//! accumulate.
24//!
25//! # Soft masks
26//!
27//! When `soft_mask_type != SoftMaskType::None`, the group is later used as a
28//! luminosity or alpha soft mask rather than being composited directly.  Call
29//! [`extract_soft_mask`] on the finished [`GroupBitmap`] to obtain the mask bytes,
30//! then store them in [`crate::GraphicsState::soft_mask`] after wrapping in `AnyBitmap`.
31//!
32//! # C++ equivalents
33//!
34//! - `Splash::beginTransparencyGroup`
35//! - `Splash::endTransparencyGroup`
36//! - `Splash::paintTransparencyGroup`
37
38use std::sync::Arc;
39
40use crate::bitmap::Bitmap;
41use crate::clip::Clip;
42use crate::pipe::{self, PipeSrc, PipeState};
43use color::Pixel;
44use color::convert::div255;
45
46// ── Public types ──────────────────────────────────────────────────────────────
47
48/// Whether the group's soft-mask channel is alpha-based or luminosity-based.
49#[derive(Copy, Clone, Debug, PartialEq, Eq)]
50pub enum SoftMaskType {
51    /// Not a soft mask — group is composited normally.
52    None,
53    /// Soft mask based on the group's alpha channel.
54    Alpha,
55    /// Soft mask based on the perceived luminance of the group's RGB pixels.
56    ///
57    /// Only meaningful for RGB (3-byte) groups.  For all other pixel modes
58    /// [`extract_soft_mask`] falls back to the alpha plane.
59    Luminosity,
60}
61
62/// Parameters for one transparency group, collected before [`begin_group`].
63#[derive(Clone, Debug)]
64pub struct GroupParams {
65    /// Left edge of the group bounding box in device pixels (inclusive).
66    pub x_min: i32,
67    /// Top edge of the group bounding box in device pixels (inclusive).
68    pub y_min: i32,
69    /// Right edge of the group bounding box in device pixels (inclusive).
70    pub x_max: i32,
71    /// Bottom edge of the group bounding box in device pixels (inclusive).
72    pub y_max: i32,
73    /// `true` → group starts transparent; `false` → backdrop is copied in.
74    pub isolated: bool,
75    /// `true` → each object within the group clears accumulated alpha first.
76    pub knockout: bool,
77    /// Role of this group's output — controls [`extract_soft_mask`] behaviour.
78    pub soft_mask_type: SoftMaskType,
79}
80
81/// A group bitmap together with its compositing metadata.
82///
83/// Returned by [`begin_group`]; passed to [`paint_group`] or [`discard_group`].
84pub struct GroupBitmap<P: Pixel> {
85    /// The rendered group content.
86    pub bitmap: Bitmap<P>,
87    /// Clip region at the time the group was opened (restored on pop).
88    pub saved_clip: Clip,
89    /// Compositing parameters recorded at `begin_group` time.
90    pub params: GroupParams,
91    /// Per-pixel alpha plane (one byte per pixel, matching `bitmap`'s pixel
92    /// count).  For an isolated group, this starts at zero; for a non-isolated
93    /// group it is copied from the parent's alpha plane.
94    pub alpha: Vec<u8>,
95    /// For non-isolated groups: a snapshot of the parent alpha at the time the
96    /// group was opened, used as `alpha0` during the compositing pass.
97    pub alpha0: Option<Arc<[u8]>>,
98}
99
100impl<P: Pixel> GroupBitmap<P> {
101    /// Returns the `(width, height)` of the group bitmap in pixels.
102    #[must_use]
103    pub const fn dims(&self) -> (u32, u32) {
104        (self.bitmap.width, self.bitmap.height)
105    }
106}
107
108// ── Group lifecycle ───────────────────────────────────────────────────────────
109
110/// Open a new transparency group and return a group bitmap to render into.
111///
112/// - `parent` is the current destination bitmap; its alpha plane is read when
113///   `!params.isolated` to initialise the group's alpha.
114/// - The returned [`GroupBitmap`] becomes the new render target until
115///   [`paint_group`] or [`discard_group`] is called.
116///
117/// The group bounding box is clamped to `parent` dimensions; a zero-size
118/// bounding box (after clamping) is silently promoted to 1×1.
119///
120/// # Panics
121///
122/// Panics (in debug mode) if the bounding box is inverted (`x_min` > `x_max`
123/// or `y_min` > `y_max`).
124#[must_use]
125pub fn begin_group<P: Pixel>(
126    parent: &Bitmap<P>,
127    clip: &Clip,
128    params: GroupParams,
129) -> GroupBitmap<P> {
130    debug_assert!(
131        params.x_min <= params.x_max,
132        "begin_group: inverted x range [{}, {}]",
133        params.x_min,
134        params.x_max
135    );
136    debug_assert!(
137        params.y_min <= params.y_max,
138        "begin_group: inverted y range [{}, {}]",
139        params.y_min,
140        params.y_max
141    );
142
143    // Clamp bounding box to parent dimensions so the group never over-allocates.
144    // saturating_add(1): x_max == i32::MAX must not wrap.
145    #[expect(clippy::cast_sign_loss, reason = "clamped to [0, parent.width)")]
146    let gx0 = (params.x_min.max(0) as u32).min(parent.width.saturating_sub(1));
147    #[expect(clippy::cast_sign_loss, reason = "clamped to [0, parent.height)")]
148    let gy0 = (params.y_min.max(0) as u32).min(parent.height.saturating_sub(1));
149    #[expect(clippy::cast_sign_loss, reason = "clamped to (0, parent.width]")]
150    let gx1 = (params.x_max.saturating_add(1).max(0) as u32).min(parent.width);
151    #[expect(clippy::cast_sign_loss, reason = "clamped to (0, parent.height]")]
152    let gy1 = (params.y_max.saturating_add(1).max(0) as u32).min(parent.height);
153
154    let gw = gx1.saturating_sub(gx0).max(1);
155    let gh = gy1.saturating_sub(gy0).max(1);
156    // Safe: gw/gh are u32 derived from parent dims (≤ u32::MAX); usize widening
157    // before multiplication prevents overflow on any realistic bitmap size.
158    let pixel_count = gw as usize * gh as usize;
159    let ncomps = P::BYTES;
160
161    // Allocate the group bitmap with an alpha plane.
162    let mut bitmap = Bitmap::<P>::new(gw, gh, 4, true);
163
164    // For non-isolated groups, copy the parent backdrop into the group bitmap
165    // and snapshot the full parent alpha as alpha0 (used during paint_group).
166    let (alpha0, alpha) = if params.isolated {
167        (None, vec![0u8; pixel_count])
168    } else {
169        // Fuse pixel-copy and alpha-copy into one row loop.
170        let mut a = vec![255u8; pixel_count];
171
172        for gy in 0..gh {
173            let py = gy0 + gy;
174            if py >= parent.height {
175                break;
176            }
177
178            // Number of pixels actually available from the parent in this row.
179            let copy_w = (gw as usize).min((parent.width as usize).saturating_sub(gx0 as usize));
180            let group_row_off = gy as usize * gw as usize;
181
182            // Copy pixel data: parent row [gx0, gx0+copy_w) → group row [0, copy_w).
183            let src = parent.row_bytes(py);
184            let src_off = gx0 as usize * ncomps;
185            let dst = bitmap.row_bytes_mut(gy);
186            dst[..copy_w * ncomps].copy_from_slice(&src[src_off..src_off + copy_w * ncomps]);
187
188            // Copy alpha: same x-range.
189            if let Some(pa) = parent.alpha_plane() {
190                let px_start = py as usize * parent.width as usize + gx0 as usize;
191                a[group_row_off..group_row_off + copy_w]
192                    .copy_from_slice(&pa[px_start..px_start + copy_w]);
193            }
194            // else: parent has no alpha plane → treat as fully opaque (255, already filled).
195        }
196
197        // Snapshot the full parent alpha for use as alpha0 in paint_group.
198        let snap: Arc<[u8]> = parent.alpha_plane().map_or_else(
199            || vec![255u8; parent.width as usize * parent.height as usize].into(),
200            std::convert::Into::into,
201        );
202
203        (Some(snap), a)
204    };
205
206    GroupBitmap {
207        bitmap,
208        saved_clip: clip.clone_shared(),
209        params,
210        alpha,
211        alpha0,
212    }
213}
214
215/// Composite a finished group back into the parent bitmap and return the saved clip.
216///
217/// `pipe` controls blend mode, opacity, and transfer for the compositing step.
218/// The group's own alpha plane is folded into the effective source alpha as
219/// `eff_a = div255(group_alpha × pipe.a_input)`.
220///
221/// Pixels with `group_alpha == 0` are skipped entirely (no-op fast path).
222///
223/// After this call `group` is consumed and its bitmap is dropped.
224pub fn paint_group<P: Pixel>(
225    parent: &mut Bitmap<P>,
226    group: GroupBitmap<P>,
227    pipe: &PipeState<'_>,
228) -> Clip {
229    let gw = group.bitmap.width;
230    let gh = group.bitmap.height;
231    let ncomps = P::BYTES;
232    let alpha = &group.alpha;
233
234    // Cache parent dimensions to avoid re-borrowing inside the inner loop.
235    let parent_width = parent.width;
236    let parent_height = parent.height;
237
238    // Top-left of the group in parent coordinates (always ≥ 0 — clamped in begin_group).
239    #[expect(
240        clippy::cast_sign_loss,
241        reason = "begin_group clamped x_min/y_min to ≥ 0 before allocating the group"
242    )]
243    let (px0, py0) = (
244        group.params.x_min.max(0) as u32,
245        group.params.y_min.max(0) as u32,
246    );
247
248    for gy in 0..gh {
249        let py = py0 + gy;
250        if py >= parent_height {
251            break;
252        }
253
254        let g_row = group.bitmap.row_bytes(gy);
255        let alpha_row_off = gy as usize * gw as usize;
256        let g_alpha = &alpha[alpha_row_off..alpha_row_off + gw as usize];
257
258        // Work out the x-extent that overlaps the parent this row.
259        let x_count = (gw as usize).min((parent_width as usize).saturating_sub(px0 as usize));
260
261        if x_count == 0 {
262            continue;
263        }
264
265        let (p_row, mut p_alpha) = parent.row_and_alpha_mut(py);
266
267        // Process each pixel in the overlap region.
268        // gx drives px0+gx (parent x), g_off=gx*ncomps, and g_alpha[gx] — all
269        // three use the same index, so an enumerate() iterator would not be cleaner.
270        #[expect(
271            clippy::needless_range_loop,
272            reason = "gx indexes g_alpha, g_off, and parent x simultaneously; enumerate() adds noise"
273        )]
274        for gx in 0..x_count {
275            let px = px0 as usize + gx;
276
277            let g_src_a = g_alpha[gx];
278            if g_src_a == 0 {
279                continue; // fully transparent — leave parent unchanged
280            }
281
282            let g_off = gx * ncomps;
283            let p_off = px * ncomps;
284
285            // Effective source alpha = group alpha × overall pipe opacity.
286            let eff_a = div255(u32::from(g_src_a) * u32::from(pipe.a_input));
287
288            let pixel_pipe = PipeState {
289                a_input: eff_a,
290                ..*pipe
291            };
292            let src = PipeSrc::Solid(&g_row[g_off..g_off + ncomps]);
293            let dst_pix = &mut p_row[p_off..p_off + ncomps];
294            let dst_alpha: Option<&mut [u8]> = p_alpha.as_mut().map(|a| &mut a[px..=px]);
295
296            // px is usize (derived from u32 parent coords); py is u32.
297            // PDF page dimensions are bounded by 14400 pt ≈ 200K px at 1440 dpi,
298            // well within i32::MAX, so these casts are always safe in practice.
299            #[expect(
300                clippy::cast_possible_wrap,
301                clippy::cast_possible_truncation,
302                reason = "px/py originate from u32 parent dimensions; \
303                          PDF pages are always < 2^31 px in any real scenario"
304            )]
305            pipe::render_span::<P>(
306                &pixel_pipe,
307                &src,
308                dst_pix,
309                dst_alpha,
310                None,
311                px as i32,
312                px as i32,
313                py as i32,
314            );
315        }
316    }
317
318    group.saved_clip
319}
320
321/// Discard a group without compositing it (used on error paths).
322///
323/// Returns the clip that was saved when the group was opened so the caller can
324/// restore graphics state cleanly.
325#[must_use]
326pub fn discard_group<P: Pixel>(group: GroupBitmap<P>) -> Clip {
327    group.saved_clip
328}
329
330// ── Soft mask extraction ──────────────────────────────────────────────────────
331
332/// Convert a finished group bitmap into a single-channel soft mask.
333///
334/// Returns one byte per pixel (`width × height` bytes, row-major, top-down):
335///
336/// | `soft_mask_type`         | Output                                        |
337/// |--------------------------|-----------------------------------------------|
338/// | [`SoftMaskType::None`]   | All 255 (fully opaque — no masking).          |
339/// | [`SoftMaskType::Alpha`]  | Group alpha plane, verbatim.                  |
340/// | [`SoftMaskType::Luminosity`] | BT.709 luma `(77R + 151G + 28B + 128) >> 8` per RGB pixel. Fallback to alpha for non-RGB groups. |
341///
342/// For [`SoftMaskType::Luminosity`], only 3-byte RGB groups compute a true luma
343/// value.  All other pixel modes (gray, CMYK, `DeviceN`) fall back to the alpha
344/// plane because their channel bytes do not map to R, G, B.
345#[must_use]
346pub fn extract_soft_mask<P: Pixel>(group: &GroupBitmap<P>) -> Vec<u8> {
347    let GroupBitmap {
348        bitmap,
349        alpha,
350        params,
351        ..
352    } = group;
353    let pixel_count = bitmap.width as usize * bitmap.height as usize;
354
355    match params.soft_mask_type {
356        SoftMaskType::None => vec![255u8; pixel_count],
357
358        SoftMaskType::Alpha => alpha.clone(),
359
360        SoftMaskType::Luminosity => {
361            // Only true RGB (exactly 3 bytes/pixel = Rgb8) carries R, G, B in
362            // channels 0, 1, 2.  CMYK and DeviceN also have ≥ 3 bytes but their
363            // channels are ink densities, not light intensities — computing luma
364            // from them would be wrong.  Fall back to alpha for all non-RGB modes.
365            if P::BYTES != 3 {
366                return alpha.clone();
367            }
368
369            let mut mask = Vec::with_capacity(pixel_count);
370            for y in 0..bitmap.height {
371                let row = bitmap.row_bytes(y);
372                for x in 0..bitmap.width as usize {
373                    let off = x * 3;
374                    let r = i32::from(row[off]);
375                    let g = i32::from(row[off + 1]);
376                    let b = i32::from(row[off + 2]);
377                    // BT.709 integer luma; coefficients sum to 256, so the result
378                    // is always in [0, 255] — the cast is exact.
379                    // Max value: (256*255 + 0x80) >> 8 = (65280 + 128) >> 8 = 255.
380                    #[expect(
381                        clippy::cast_possible_truncation,
382                        clippy::cast_sign_loss,
383                        reason = "77+151+28 = 256; luma = weighted sum of [0,255] values; \
384                                  max = (256*255+128)>>8 = 255, min = 0"
385                    )]
386                    mask.push(((77 * r + 151 * g + 28 * b + 0x80) >> 8) as u8);
387                }
388            }
389            debug_assert_eq!(
390                mask.len(),
391                pixel_count,
392                "extract_soft_mask: loop produced {} bytes, expected {} ({w}×{h})",
393                mask.len(),
394                pixel_count,
395                w = bitmap.width,
396                h = bitmap.height,
397            );
398            mask
399        }
400    }
401}
402
403// ── Tests ─────────────────────────────────────────────────────────────────────
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::bitmap::Bitmap;
409    use crate::testutil::{make_clip, simple_pipe};
410    use color::Rgb8;
411
412    fn default_params(x_min: i32, y_min: i32, x_max: i32, y_max: i32) -> GroupParams {
413        GroupParams {
414            x_min,
415            y_min,
416            x_max,
417            y_max,
418            isolated: true,
419            knockout: false,
420            soft_mask_type: SoftMaskType::None,
421        }
422    }
423
424    /// An isolated group starts transparent.
425    #[test]
426    fn isolated_group_starts_transparent() {
427        let parent: Bitmap<Rgb8> = Bitmap::new(8, 8, 4, true);
428        let clip = make_clip(8, 8);
429        let params = default_params(0, 0, 7, 7);
430        let group = begin_group::<Rgb8>(&parent, &clip, params);
431        assert!(
432            group.alpha.iter().all(|&a| a == 0),
433            "isolated group must start fully transparent"
434        );
435    }
436
437    /// A non-isolated group copies the parent alpha.
438    #[test]
439    fn non_isolated_group_copies_parent_alpha() {
440        let mut parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
441        if let Some(a) = parent.alpha_plane_mut() {
442            a.fill(128);
443        }
444        let clip = make_clip(4, 4);
445        let mut params = default_params(0, 0, 3, 3);
446        params.isolated = false;
447        let group = begin_group::<Rgb8>(&parent, &clip, params);
448        assert!(
449            group.alpha.iter().all(|&a| a == 128),
450            "non-isolated group must copy parent alpha"
451        );
452    }
453
454    /// Painting a solid-white opaque group over a black parent yields white.
455    #[test]
456    fn paint_group_opaque_white_over_black() {
457        let mut parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
458        let clip = make_clip(4, 4);
459        let pipe = simple_pipe();
460
461        let params = default_params(1, 1, 2, 2); // 2×2 group at (1,1)
462        let mut group = begin_group::<Rgb8>(&parent, &clip, params);
463
464        // Fill the group with white pixels and full alpha.
465        for y in 0..group.bitmap.height {
466            let row = group.bitmap.row_bytes_mut(y);
467            for chunk in row.chunks_exact_mut(3) {
468                chunk.copy_from_slice(&[255, 255, 255]);
469            }
470        }
471        group.alpha.fill(255);
472
473        let _clip = paint_group::<Rgb8>(&mut parent, group, &pipe);
474
475        assert_eq!(parent.row(1)[1].r, 255, "pixel (1,1) R should be white");
476        assert_eq!(parent.row(1)[2].r, 255, "pixel (1,2) R should be white");
477    }
478
479    /// Discarding a group returns the saved clip without painting.
480    #[test]
481    fn discard_group_does_not_paint() {
482        let parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
483        let clip = make_clip(4, 4);
484        let params = default_params(0, 0, 3, 3);
485        let mut group = begin_group::<Rgb8>(&parent, &clip, params);
486
487        // Fill group with red.
488        for y in 0..group.bitmap.height {
489            let row = group.bitmap.row_bytes_mut(y);
490            for chunk in row.chunks_exact_mut(3) {
491                chunk.copy_from_slice(&[255, 0, 0]);
492            }
493        }
494        group.alpha.fill(255);
495
496        let _saved = discard_group(group);
497
498        assert_eq!(parent.row(0)[0].r, 0, "discard must not paint");
499    }
500
501    /// `extract_soft_mask` with `SoftMaskType::Alpha` returns the group's alpha plane.
502    #[test]
503    fn extract_soft_mask_alpha_returns_alpha_plane() {
504        let parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
505        let clip = make_clip(4, 4);
506        let mut params = default_params(0, 0, 3, 3);
507        params.soft_mask_type = SoftMaskType::Alpha;
508        let mut group = begin_group::<Rgb8>(&parent, &clip, params);
509        group.alpha.fill(200);
510
511        let mask = extract_soft_mask::<Rgb8>(&group);
512        assert!(
513            mask.iter().all(|&v| v == 200),
514            "alpha soft mask must match alpha plane"
515        );
516    }
517
518    /// `extract_soft_mask` with `SoftMaskType::Luminosity` computes BT.709 luma from RGB.
519    #[test]
520    fn extract_soft_mask_luminosity_computes_luma() {
521        let parent: Bitmap<Rgb8> = Bitmap::new(2, 1, 4, true);
522        let clip = make_clip(2, 1);
523        let mut params = default_params(0, 0, 1, 0);
524        params.soft_mask_type = SoftMaskType::Luminosity;
525        let mut group = begin_group::<Rgb8>(&parent, &clip, params);
526
527        // Pixel 0: white (255, 255, 255) → luma = 255.
528        // Pixel 1: black (0, 0, 0)      → luma = 0.
529        let row = group.bitmap.row_bytes_mut(0);
530        row[..3].copy_from_slice(&[255, 255, 255]);
531        row[3..6].copy_from_slice(&[0, 0, 0]);
532        group.alpha.fill(255);
533
534        let mask = extract_soft_mask::<Rgb8>(&group);
535        assert_eq!(mask.len(), 2);
536        assert_eq!(mask[0], 255, "white → luma=255");
537        assert_eq!(mask[1], 0, "black → luma=0");
538    }
539
540    /// A transparent group pixel (alpha=0) leaves the parent unchanged.
541    #[test]
542    fn transparent_group_pixel_is_skipped() {
543        let mut parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
544        // Set parent pixel (0,0) to blue.
545        parent.row_bytes_mut(0)[..3].copy_from_slice(&[0, 0, 255]);
546        let clip = make_clip(4, 4);
547        let pipe = simple_pipe();
548        let params = default_params(0, 0, 0, 0); // 1×1 group
549        let group = begin_group::<Rgb8>(&parent, &clip, params);
550        // group.alpha is all 0 (isolated, not painted into).
551        let _saved = paint_group::<Rgb8>(&mut parent, group, &pipe);
552        assert_eq!(
553            parent.row(0)[0].b,
554            255,
555            "transparent group pixel must not paint"
556        );
557    }
558
559    /// `extract_soft_mask` luminosity falls back to alpha for non-RGB modes (CMYK).
560    #[test]
561    fn extract_soft_mask_luminosity_fallback_for_cmyk() {
562        use color::Cmyk8;
563        let parent: Bitmap<Cmyk8> = Bitmap::new(2, 1, 4, true);
564        let clip = Clip::new(0.0, 0.0, 1.999, 0.999, false);
565        let mut params = default_params(0, 0, 1, 0);
566        params.soft_mask_type = SoftMaskType::Luminosity;
567        let mut group = begin_group::<Cmyk8>(&parent, &clip, params);
568        group.alpha.fill(77);
569
570        let mask = extract_soft_mask::<Cmyk8>(&group);
571        // For CMYK, luminosity falls back to alpha.
572        assert!(
573            mask.iter().all(|&v| v == 77),
574            "CMYK luminosity soft mask must fall back to alpha"
575        );
576    }
577
578    /// `x_max == i32::MAX` must not overflow during `begin_group`.
579    #[test]
580    fn begin_group_x_max_i32_max_does_not_overflow() {
581        let parent: Bitmap<Rgb8> = Bitmap::new(4, 4, 4, true);
582        let clip = make_clip(4, 4);
583        // Very large bounding box; should be clamped silently to parent bounds.
584        let params = default_params(0, 0, i32::MAX, i32::MAX);
585        let group = begin_group::<Rgb8>(&parent, &clip, params);
586        // Group is clamped to parent size: 4×4.
587        assert_eq!(group.dims(), (4, 4));
588    }
589}