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}