Skip to main content

rasterrocket_render/pipe/
general.rs

1//! General pipe: soft mask, non-normal blend modes, non-isolated/knockout groups.
2//!
3//! This is the "everything else" path, matching `Splash::pipeRun` (the general
4//! case, not one of the specialised `pipeRunSimple*` or `pipeRunAA*` variants).
5//!
6//! # Compositing formula (PDF spec §11.3)
7//!
8//! Given source alpha `a_src`, destination alpha `a_dst`, and source/dest colours:
9//!
10//! ```text
11//! a_result = a_src + a_dst - div255(a_src * a_dst)           (isolated, non-knockout)
12//! c_result = ((a_result - a_src) * c_dst + a_src * blend(c_src, c_dst)) / a_result
13//! ```
14//!
15//! For blend mode `Normal`, `blend(c_src, c_dst) = c_src` so the formula reduces
16//! to the standard Porter-Duff over.
17
18use std::cell::RefCell;
19
20use crate::pipe::{self, PipeSrc, PipeState, blend};
21use crate::types::BlendMode;
22use color::Pixel;
23use color::convert::div255;
24
25const MAX_COMPS: usize = 8; // DeviceN8: 4 CMYK + 4 spot = 8 bytes
26
27// Per-thread scratch buffer for pattern spans — grow-never-shrink.
28thread_local! {
29    static PAT_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
30}
31
32/// General-purpose compositing span function.
33///
34/// Handles soft mask, blend modes, non-isolated groups, knockout, and overprint.
35/// Slower than `simple` or `aa` but covers every case.
36///
37/// # Preconditions (checked with `debug_assert!`)
38///
39/// - `x1 >= x0`
40/// - `dst_pixels.len() == (x1 - x0 + 1) * P::BYTES`
41/// - If `shape` is `Some`, `shape.len() == x1 - x0 + 1`
42/// - If `pipe.soft_mask` is `Some`, `soft_mask.len() == x1 - x0 + 1`
43#[expect(
44    clippy::too_many_arguments,
45    reason = "mirrors C++ SplashPipe API; all parameters are necessary"
46)]
47pub(crate) fn render_span_general<P: Pixel>(
48    pipe: &PipeState<'_>,
49    src: &PipeSrc<'_>,
50    dst_pixels: &mut [u8],
51    dst_alpha: Option<&mut [u8]>,
52    shape: Option<&[u8]>,
53    x0: i32,
54    x1: i32,
55    y: i32,
56) {
57    debug_assert!(x1 >= x0, "render_span_general: x1={x1} < x0={x0}");
58    #[expect(
59        clippy::cast_sign_loss,
60        reason = "x1 >= x0 is asserted above, so x1 - x0 + 1 >= 1 > 0"
61    )]
62    let count = (x1 - x0 + 1) as usize;
63    let ncomps = P::BYTES;
64
65    debug_assert_eq!(dst_pixels.len(), count * ncomps);
66    if let Some(sh) = shape {
67        debug_assert_eq!(sh.len(), count);
68    }
69    if let Some(sm) = pipe.soft_mask {
70        debug_assert_eq!(sm.len(), count, "soft_mask length must equal span count");
71    }
72
73    let a_input = u32::from(pipe.a_input);
74    let is_nonseparable = matches!(
75        pipe.blend_mode,
76        BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity
77    );
78    let is_cmyk_like = ncomps == 4 || ncomps == 8;
79
80    // Convenience: get optional slice at index i.
81    let shape_at = |i: usize| shape.map_or(0xFFu8, |s| s[i]);
82    let soft_mask_at = |i: usize| pipe.soft_mask.map_or(0xFFu8, |s| s[i]);
83    let alpha0_at = |i: usize| pipe.alpha0.map(|a| a[i]);
84
85    match src {
86        PipeSrc::Solid(color) => {
87            debug_assert_eq!(color.len(), ncomps);
88            render_span_general_inner(
89                pipe,
90                |_i| color,
91                dst_pixels,
92                dst_alpha,
93                shape,
94                count,
95                ncomps,
96                a_input,
97                is_nonseparable,
98                is_cmyk_like,
99                &shape_at,
100                &soft_mask_at,
101                &alpha0_at,
102            );
103        }
104        PipeSrc::Pattern(pat) => {
105            PAT_BUF.with(|cell| {
106                let mut buf = cell.borrow_mut();
107                buf.resize(count * ncomps, 0);
108                pat.fill_span(y, x0, x1, &mut buf[..count * ncomps]);
109                render_span_general_inner(
110                    pipe,
111                    |i| &buf[i * ncomps..(i + 1) * ncomps],
112                    dst_pixels,
113                    dst_alpha,
114                    shape,
115                    count,
116                    ncomps,
117                    a_input,
118                    is_nonseparable,
119                    is_cmyk_like,
120                    &shape_at,
121                    &soft_mask_at,
122                    &alpha0_at,
123                );
124            });
125        }
126    }
127}
128
129#[expect(
130    clippy::too_many_arguments,
131    reason = "all params necessary; closure eliminates solid/pattern duplication"
132)]
133#[expect(
134    clippy::too_many_lines,
135    reason = "compositing formula has many branches that cannot be meaningfully split"
136)]
137#[expect(
138    clippy::single_match_else,
139    reason = "both Some and None arms have substantial independent logic; if-let would be less clear"
140)]
141fn render_span_general_inner<'src>(
142    pipe: &PipeState<'_>,
143    src_px_at: impl Fn(usize) -> &'src [u8],
144    dst_pixels: &mut [u8],
145    dst_alpha: Option<&mut [u8]>,
146    shape: Option<&[u8]>,
147    count: usize,
148    ncomps: usize,
149    a_input: u32,
150    is_nonseparable: bool,
151    is_cmyk_like: bool,
152    shape_at: &dyn Fn(usize) -> u8,
153    soft_mask_at: &dyn Fn(usize) -> u8,
154    alpha0_at: &dyn Fn(usize) -> Option<u8>,
155) {
156    let has_soft_mask = pipe.soft_mask.is_some();
157    let has_shape = shape.is_some();
158
159    match dst_alpha {
160        Some(dst_alpha) => {
161            debug_assert_eq!(dst_alpha.len(), count);
162            for i in 0..count {
163                let src_px = src_px_at(i);
164                let dst_px = &mut dst_pixels[i * ncomps..(i + 1) * ncomps];
165                let a_dst = u32::from(dst_alpha[i]);
166                let shape_v = u32::from(shape_at(i));
167                let soft_v = u32::from(soft_mask_at(i));
168
169                // Source alpha (PDF spec §11.3.6 eq 11.1).
170                let a_src = compute_a_src(a_input, soft_v, shape_v, has_soft_mask, has_shape);
171
172                // Knockout: clear destination alpha before the colour formula so that
173                // a_dst_eff (read on line below) reflects the zeroed value.
174                // This applies to both isolated and non-isolated knockout groups.
175                if pipe.knockout && shape_v >= u32::from(pipe.knockout_opacity) {
176                    dst_alpha[i] = 0;
177                }
178
179                // Non-isolated group colour correction (PDF spec §11.4.8).
180                // c_src_corrected = c_src + (c_src - c_dst) * (a_dst * 255 / shape - a_dst) / 255.
181                // shape_v in [1, 255] (from u8), a_dst in [0, 255]:
182                //   t_max = (255 * 255) / 1 - 0 = 65025 ≤ i32::MAX, so the cast is lossless.
183                let mut c_src_corr: [u8; MAX_COMPS] = [0; MAX_COMPS];
184                let c_src: &[u8] = if pipe.non_isolated_group && shape_v != 0 {
185                    let t = (a_dst * 255) / shape_v - a_dst;
186                    let t_i = t.cast_signed(); // t ≤ (255*255)/1 - 0 = 65025 ≪ i32::MAX
187                    for j in 0..ncomps {
188                        let v = i32::from(src_px[j])
189                            + (i32::from(src_px[j]) - i32::from(dst_px[j])) * t_i / 255;
190                        #[expect(
191                            clippy::cast_sign_loss,
192                            reason = "value is clamped to [0, 255] above"
193                        )]
194                        {
195                            c_src_corr[j] = v.clamp(0, 255) as u8;
196                        }
197                    }
198                    &c_src_corr[..ncomps]
199                } else {
200                    src_px
201                };
202
203                // Blend function.
204                let mut c_blend: [u8; MAX_COMPS] = [0; MAX_COMPS];
205                if pipe.blend_mode != BlendMode::Normal {
206                    apply_blend_fn(
207                        pipe.blend_mode,
208                        c_src,
209                        dst_px,
210                        &mut c_blend[..ncomps],
211                        is_cmyk_like,
212                        is_nonseparable,
213                    );
214                }
215
216                // Result alpha.
217                let a_dst_eff = u32::from(dst_alpha[i]); // may have been cleared by knockout.
218                let (a_result, alpha_i, alpha_im1) =
219                    compute_alphas(a_src, a_dst_eff, shape_v, alpha0_at(i), pipe.knockout);
220
221                // Result colour.
222                if a_result == 0 {
223                    dst_px.fill(0);
224                } else {
225                    // alpha_i >= a_result > 0: safe to divide.
226                    debug_assert!(alpha_i > 0, "alpha_i must be > 0 when a_result > 0");
227                    for j in 0..ncomps {
228                        let c_src_j = u32::from(c_src[j]);
229                        let c_dst_j = u32::from(dst_px[j]);
230                        let c_b_j = u32::from(c_blend[j]);
231
232                        let c = if pipe.blend_mode == BlendMode::Normal {
233                            // No blend function: standard Porter-Duff.
234                            ((alpha_i - a_src) * c_dst_j + a_src * c_src_j) / alpha_i
235                        } else {
236                            // With blend function.
237                            ((alpha_i - a_src) * c_dst_j
238                                + a_src * ((255 - alpha_im1) * c_src_j + alpha_im1 * c_b_j) / 255)
239                                / alpha_i
240                        };
241                        #[expect(
242                            clippy::cast_possible_truncation,
243                            reason = "c is a weighted average of values ≤ 255, so c ≤ 255"
244                        )]
245                        {
246                            dst_px[j] = c as u8;
247                        }
248                    }
249                    finish_pixel(pipe, dst_px, src_px, ncomps);
250                }
251
252                #[expect(
253                    clippy::cast_possible_truncation,
254                    reason = "a_result is clamped to ≤ 255 in compute_alphas"
255                )]
256                {
257                    dst_alpha[i] = a_result as u8;
258                }
259            }
260        }
261        None => {
262            // No separate alpha plane: aDest = 0xFF implicitly.
263            // Simplifies: aResult = aSrc + 255 - div255(aSrc * 255) = 255, alpha_i = 255.
264            // Non-isolated and knockout modes require a dst_alpha plane to carry the
265            // group alpha; those states are meaningless without one.
266            debug_assert!(
267                !pipe.non_isolated_group && !pipe.knockout,
268                "non_isolated_group/knockout require dst_alpha; None arm uses implicit a_dst=255"
269            );
270            for i in 0..count {
271                let src_px = src_px_at(i);
272                let dst_px = &mut dst_pixels[i * ncomps..(i + 1) * ncomps];
273                let shape_v = u32::from(shape_at(i));
274                let soft_v = u32::from(soft_mask_at(i));
275
276                let a_src = compute_a_src(a_input, soft_v, shape_v, has_soft_mask, has_shape);
277                // a_src is derived from u8 inputs via div255, so always ≤ 255.
278                // The assert catches any future change that widens the input path.
279                debug_assert!(a_src <= 255, "a_src={a_src} out of [0, 255]");
280
281                let mut c_blend: [u8; MAX_COMPS] = [0; MAX_COMPS];
282                if pipe.blend_mode != BlendMode::Normal {
283                    apply_blend_fn(
284                        pipe.blend_mode,
285                        src_px,
286                        dst_px,
287                        &mut c_blend[..ncomps],
288                        is_cmyk_like,
289                        is_nonseparable,
290                    );
291                }
292
293                for j in 0..ncomps {
294                    let c_src_j = u32::from(src_px[j]);
295                    let c_dst_j = u32::from(dst_px[j]);
296                    let c_b_j = u32::from(c_blend[j]);
297
298                    // With implicit a_dst=255: alpha_i=255, alpha_im1=255.
299                    // General formula simplifies to div255((255-a_src)*c_dst + a_src*c_b).
300                    // 255 - a_src: safe because a_src ≤ 255 (asserted above).
301                    let c = if pipe.blend_mode == BlendMode::Normal {
302                        u32::from(div255((255 - a_src) * c_dst_j + a_src * c_src_j))
303                    } else {
304                        u32::from(div255((255 - a_src) * c_dst_j + a_src * c_b_j))
305                    };
306                    #[expect(
307                        clippy::cast_possible_truncation,
308                        reason = "c is a weighted average of values ≤ 255, so c ≤ 255"
309                    )]
310                    {
311                        dst_px[j] = c as u8;
312                    }
313                }
314                finish_pixel(pipe, dst_px, src_px, ncomps);
315            }
316        }
317    }
318}
319
320/// Compute the effective source alpha for one pixel (PDF spec §11.3.6 eq 11.1).
321///
322/// Combines `a_input` with the soft mask and/or shape coverage according to the
323/// rules: soft mask and shape are multiplied together via `div255`; if either is
324/// absent its default is 1.0 (== 0xFF).
325#[inline]
326fn compute_a_src(
327    a_input: u32,
328    soft_v: u32,
329    shape_v: u32,
330    has_soft_mask: bool,
331    has_shape: bool,
332) -> u32 {
333    if has_soft_mask {
334        if has_shape {
335            u32::from(div255(u32::from(div255(a_input * soft_v)) * shape_v))
336        } else {
337            u32::from(div255(a_input * soft_v))
338        }
339    } else if has_shape {
340        u32::from(div255(a_input * shape_v))
341    } else {
342        a_input
343    }
344}
345
346/// Compute result alpha and the two intermediate alphas used in the colour formula.
347///
348/// Returns `(a_result, alpha_i, alpha_im1)`.
349///
350/// Matches the C++ `pipeRun` alpha logic for isolated/non-isolated, knockout/non-knockout.
351#[expect(
352    clippy::option_if_let_else,
353    reason = "if-let form is clearer than map_or_else for this multi-branch alpha computation"
354)]
355fn compute_alphas(
356    a_src: u32,
357    a_dst: u32,
358    shape: u32,
359    alpha0: Option<u8>,
360    knockout: bool,
361) -> (u32, u32, u32) {
362    if let Some(a0) = alpha0 {
363        let a0 = u32::from(a0);
364        if knockout {
365            // Non-isolated, knockout.
366            let a_result = a_src + u32::from(div255(a_dst * (255 - shape)));
367            let alpha_i = a_result + a0 - u32::from(div255(a_result * a0));
368            (a_result.min(255), alpha_i.min(255), a0)
369        } else {
370            // Non-isolated, non-knockout.
371            let a_result = a_src + a_dst - u32::from(div255(a_src * a_dst));
372            let alpha_i = a_result + a0 - u32::from(div255(a_result * a0));
373            let alpha_im1 = a0 + a_dst - u32::from(div255(a0 * a_dst));
374            (a_result.min(255), alpha_i.min(255), alpha_im1.min(255))
375        }
376    } else if knockout {
377        // Isolated, knockout.
378        let a_result = a_src + u32::from(div255(a_dst * (255 - shape)));
379        (a_result.min(255), a_result.min(255), 0)
380    } else {
381        // Isolated, non-knockout (most common).
382        let a_result = a_src + a_dst - u32::from(div255(a_src * a_dst));
383        (a_result.min(255), a_result.min(255), a_dst)
384    }
385}
386
387/// Apply the blend function and write into `c_blend`.
388fn apply_blend_fn(
389    mode: BlendMode,
390    src: &[u8],
391    dst: &[u8],
392    c_blend: &mut [u8],
393    is_cmyk_like: bool,
394    is_nonseparable: bool,
395) {
396    debug_assert_eq!(src.len(), dst.len());
397    debug_assert_eq!(src.len(), c_blend.len());
398    let ncomps = src.len();
399
400    if is_cmyk_like {
401        // Subtractive complement: invert all channels, blend in additive space, re-invert.
402        // Fill all ncomps so spot channels (j >= 4) are correctly inverted.
403        let mut src2 = [0u8; MAX_COMPS];
404        let mut dst2 = [0u8; MAX_COMPS];
405        for j in 0..ncomps {
406            src2[j] = 255 - src[j];
407            dst2[j] = 255 - dst[j];
408        }
409
410        if is_nonseparable {
411            let s3 = [src2[0], src2[1], src2[2]];
412            let d3 = [dst2[0], dst2[1], dst2[2]];
413            let r3 = blend::apply_nonseparable_rgb(mode, s3, d3);
414            c_blend[0] = 255 - r3[0];
415            c_blend[1] = 255 - r3[1];
416            c_blend[2] = 255 - r3[2];
417            // K/spot channel: for Luminosity, use src K; for others use dst K (PDF §11.3.5).
418            if ncomps >= 4 {
419                c_blend[3] = 255
420                    - (if mode == BlendMode::Luminosity {
421                        src2[3]
422                    } else {
423                        dst2[3]
424                    });
425            }
426            for j in 4..ncomps {
427                // Spot channels pass through dst (same rule as K for non-Luminosity).
428                c_blend[j] = 255 - dst2[j];
429            }
430        } else {
431            blend::apply_separable(
432                mode,
433                &src2[..ncomps.min(4)],
434                &dst2[..ncomps.min(4)],
435                &mut c_blend[..ncomps.min(4)],
436            );
437            for v in &mut c_blend[..ncomps.min(4)] {
438                *v = 255 - *v;
439            }
440            // Spot channels (j >= 4): not blended, pass dst through unchanged.
441            c_blend[4..ncomps].copy_from_slice(&dst[4..ncomps]);
442        }
443    } else if is_nonseparable {
444        // RGB/Gray additive space.
445        let n = ncomps.min(3);
446        let mut s3 = [0u8; 3];
447        let mut d3 = [0u8; 3];
448        s3[..n].copy_from_slice(&src[..n]);
449        d3[..n].copy_from_slice(&dst[..n]);
450        // Mono: replicate the single channel to all three.
451        if ncomps == 1 {
452            s3[1] = s3[0];
453            s3[2] = s3[0];
454            d3[1] = d3[0];
455            d3[2] = d3[0];
456        }
457        let r3 = blend::apply_nonseparable_rgb(mode, s3, d3);
458        c_blend[..n].copy_from_slice(&r3[..n]);
459    } else {
460        blend::apply_separable(mode, src, dst, c_blend);
461    }
462}
463
464/// Apply transfer and conditional overprint at the end of each pixel's colour computation.
465///
466/// Called after the colour formula writes all channels of `dst_px` but before
467/// the alpha plane is updated.  Order matters: transfer must precede overprint
468/// so the transfer LUT sees the blended colour, not the restored source.
469#[inline]
470fn finish_pixel(pipe: &PipeState<'_>, dst_px: &mut [u8], src_px: &[u8], ncomps: usize) {
471    pipe::apply_transfer_in_place(pipe, dst_px);
472    if pipe.overprint_mask != 0xFFFF_FFFF {
473        apply_overprint(pipe, dst_px, src_px, ncomps);
474    }
475}
476
477/// Apply overprint: for channels where the bit in `overprint_mask` is 0,
478/// the channel is not painted.
479///
480/// Only `overprint_additive = true` is implemented.  Replace-mode overprint
481/// requires the caller to preserve the pre-blend destination bytes, which the
482/// current call structure does not support.
483///
484/// # Panics
485///
486/// Panics if `pipe.overprint_additive` is `false`; replace-mode overprint is
487/// not yet implemented.
488fn apply_overprint(pipe: &PipeState<'_>, dst_px: &mut [u8], src_px: &[u8], ncomps: usize) {
489    if pipe.overprint_additive {
490        for j in 0..ncomps {
491            // Channels whose bit is 0 in the mask are not painted; dst already holds
492            // the correct value, so skip them.
493            if pipe.overprint_mask & (1 << j) == 0 {
494                continue;
495            }
496            // Additive overprint: accumulate into the destination, clamped to 255.
497            dst_px[j] = (u16::from(dst_px[j]) + u16::from(src_px[j])).min(255) as u8;
498        }
499    } else {
500        // Replace overprint: channels not in mask should keep the original dst value,
501        // but the pre-blend destination has already been overwritten.  Restoring it
502        // here would require the caller to pass the pre-blend dst separately.
503        // Panic loudly (in both debug and release) rather than silently producing
504        // wrong output.  Callers must either set overprint_additive=true or pass the
505        // original dst bytes before calling apply_overprint.
506        panic!(
507            "general pipe: replace overprint (mask={:#010x}) is not yet implemented; \
508             use overprint_additive=true or preserve pre-blend dst in the caller",
509            pipe.overprint_mask,
510        );
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn compute_a_src_no_mask_no_shape_returns_a_input() {
520        assert_eq!(compute_a_src(200, 0xFF, 0xFF, false, false), 200);
521    }
522
523    #[test]
524    fn compute_a_src_shape_zero_gives_zero() {
525        assert_eq!(compute_a_src(255, 0xFF, 0, false, true), 0);
526    }
527
528    #[test]
529    fn compute_a_src_soft_mask_scales_alpha() {
530        let result = compute_a_src(255, 128, 0xFF, true, false);
531        let expected = u32::from(div255(255 * 128));
532        assert_eq!(result, expected);
533    }
534
535    #[test]
536    fn compute_a_src_soft_and_shape_combines_both() {
537        let result = compute_a_src(255, 128, 128, true, true);
538        let expected = u32::from(div255(u32::from(div255(255 * 128)) * 128));
539        assert_eq!(result, expected);
540    }
541    use crate::pipe::{PipeSrc, PipeState};
542    use crate::state::TransferSet;
543    use color::{Gray8, Rgb8, TransferLut};
544
545    fn normal_pipe(a: u8) -> PipeState<'static> {
546        PipeState {
547            blend_mode: BlendMode::Normal,
548            a_input: a,
549            overprint_mask: 0xFFFF_FFFF,
550            overprint_additive: false,
551            transfer: TransferSet::identity_rgb(),
552            soft_mask: None,
553            alpha0: None,
554            knockout: false,
555            knockout_opacity: 255,
556            non_isolated_group: false,
557        }
558    }
559
560    #[test]
561    fn opaque_src_over_any_dst_gives_src() {
562        let pipe = normal_pipe(255);
563        let src_color = [200u8, 100, 50];
564        let src = PipeSrc::Solid(&src_color);
565
566        let mut dst = vec![10u8, 20, 30];
567        let mut alpha = vec![128u8];
568
569        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);
570
571        // a_src = 255, a_result = 255; c = (0 * c_dst + 255 * c_src) / 255 = c_src.
572        assert_eq!(&dst, &[200, 100, 50]);
573        assert_eq!(alpha[0], 255);
574    }
575
576    #[test]
577    fn transparent_src_leaves_dst_unchanged() {
578        let pipe = normal_pipe(0);
579        let src = PipeSrc::Solid(&[255u8, 255, 255]);
580
581        let mut dst = vec![10u8, 20, 30];
582        let mut alpha = vec![200u8];
583
584        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);
585
586        // a_src = 0; a_result = 0 + 200 - 0 = 200.
587        // c = (200 * c_dst + 0) / 200 = c_dst.
588        assert_eq!(&dst, &[10, 20, 30]);
589        assert_eq!(alpha[0], 200);
590    }
591
592    #[test]
593    fn blend_multiply_with_dst() {
594        let mut pipe = normal_pipe(255);
595        pipe.blend_mode = BlendMode::Multiply;
596
597        // src = 128 (grey), dst = 200.
598        let src = PipeSrc::Solid(&[128u8, 128, 128]);
599        let mut dst = vec![200u8, 200, 200];
600        let mut alpha = vec![255u8];
601
602        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);
603
604        // With a_src=255, a_dst=255: a_result=255, alpha_i=255, alpha_im1=255.
605        // c = ((255-255)*200 + 255*((255-255)*128 + 255*Multiply(128,200)/255)) / 255
606        //   = Multiply(128, 200)
607        //   = div255(128 * 200) ≈ 100.
608        let v = dst[0];
609        assert!((i32::from(v) - 100).abs() <= 1, "expected ~100, got {v}");
610    }
611
612    #[test]
613    fn compute_alphas_isolated_non_knockout() {
614        // a_src=128, a_dst=200.
615        let (ar, ai, aim1) = compute_alphas(128, 200, 255, None, false);
616        // a_result = 128 + 200 - div255(128 * 200) ≈ 228.
617        assert!((226..=230).contains(&ar), "a_result={ar}");
618        assert_eq!(ai, ar, "isolated: alpha_i == a_result");
619        assert_eq!(aim1, 200, "isolated non-knockout: alpha_im1 == a_dst");
620    }
621
622    #[test]
623    fn soft_mask_modulates_alpha() {
624        // soft_mask[0] = 128 → a_src = div255(255 * 128) ≈ 128.
625        let soft = vec![128u8];
626        let mut dst = vec![0u8; 3];
627        let mut alpha = vec![0u8];
628
629        // We need a pipe with a soft_mask reference.
630        // Since PipeState has a 'bmp lifetime, we store soft_mask as a slice reference.
631        let pipe = PipeState {
632            blend_mode: BlendMode::Normal,
633            a_input: 255,
634            overprint_mask: 0xFFFF_FFFF,
635            overprint_additive: false,
636            transfer: TransferSet::identity_rgb(),
637            soft_mask: Some(soft.as_slice()),
638            alpha0: None,
639            knockout: false,
640            knockout_opacity: 255,
641            non_isolated_group: false,
642        };
643
644        let src = PipeSrc::Solid(&[255u8, 255, 255]);
645        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);
646
647        // a_src ≈ 128; a_dst = 0; a_result ≈ 128.
648        // c = (0 * 0 + 128 * 255) / 128 = 255.
649        assert_eq!(dst[0], 255);
650        assert!((i32::from(alpha[0]) - 128).abs() <= 2, "alpha={}", alpha[0]);
651    }
652
653    #[test]
654    fn gray_transfer_lut_applied_correctly() {
655        // Regression: the old apply_transfer_channel used rgb[0] for channel 0
656        // regardless of pixel mode, so gray transfer was silently wrong.
657        // Build an inverting gray LUT and a pass-through rgb LUT; the general
658        // pipe over a Gray8 pixel must invert the output via the gray table.
659        static DN: [[u8; 256]; 8] = [TransferLut::IDENTITY.0; 8];
660        let id = TransferLut::IDENTITY.as_array();
661        let inv = TransferLut::INVERTED.as_array();
662        let transfer = TransferSet {
663            gray: inv, // inverting gray transfer
664            rgb: [id; 3],
665            cmyk: [id; 4],
666            device_n: &DN,
667        };
668        let pipe = PipeState {
669            blend_mode: BlendMode::Normal,
670            a_input: 255,
671            overprint_mask: 0xFFFF_FFFF,
672            overprint_additive: false,
673            transfer,
674            soft_mask: None,
675            alpha0: None,
676            knockout: false,
677            knockout_opacity: 255,
678            non_isolated_group: false,
679        };
680
681        // Opaque gray source value 100; after inverting transfer: 155.
682        let src_color = [100u8];
683        let src = PipeSrc::Solid(&src_color);
684        let mut dst = vec![0u8; 1];
685        let mut alpha = vec![0u8; 1];
686        render_span_general::<Gray8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);
687        assert_eq!(dst[0], 155, "gray transfer must use gray LUT, not rgb[0]");
688    }
689}