rasterrocket-render 1.0.1

Software rasterizer — path fill, compositing, and AVX-512/AVX2/NEON SIMD for the rasterrocket PDF renderer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
//! General pipe: soft mask, non-normal blend modes, non-isolated/knockout groups.
//!
//! This is the "everything else" path, matching `Splash::pipeRun` (the general
//! case, not one of the specialised `pipeRunSimple*` or `pipeRunAA*` variants).
//!
//! # Compositing formula (PDF spec §11.3)
//!
//! Given source alpha `a_src`, destination alpha `a_dst`, and source/dest colours:
//!
//! ```text
//! a_result = a_src + a_dst - div255(a_src * a_dst)           (isolated, non-knockout)
//! c_result = ((a_result - a_src) * c_dst + a_src * blend(c_src, c_dst)) / a_result
//! ```
//!
//! For blend mode `Normal`, `blend(c_src, c_dst) = c_src` so the formula reduces
//! to the standard Porter-Duff over.

use std::cell::RefCell;

use crate::pipe::{self, PipeSrc, PipeState, blend};
use crate::types::BlendMode;
use color::Pixel;
use color::convert::div255;

const MAX_COMPS: usize = 8; // DeviceN8: 4 CMYK + 4 spot = 8 bytes

// Per-thread scratch buffer for pattern spans — grow-never-shrink.
thread_local! {
    static PAT_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}

/// General-purpose compositing span function.
///
/// Handles soft mask, blend modes, non-isolated groups, knockout, and overprint.
/// Slower than `simple` or `aa` but covers every case.
///
/// # Preconditions (checked with `debug_assert!`)
///
/// - `x1 >= x0`
/// - `dst_pixels.len() == (x1 - x0 + 1) * P::BYTES`
/// - If `shape` is `Some`, `shape.len() == x1 - x0 + 1`
/// - If `pipe.soft_mask` is `Some`, `soft_mask.len() == x1 - x0 + 1`
#[expect(
    clippy::too_many_arguments,
    reason = "mirrors C++ SplashPipe API; all parameters are necessary"
)]
pub(crate) fn render_span_general<P: Pixel>(
    pipe: &PipeState<'_>,
    src: &PipeSrc<'_>,
    dst_pixels: &mut [u8],
    dst_alpha: Option<&mut [u8]>,
    shape: Option<&[u8]>,
    x0: i32,
    x1: i32,
    y: i32,
) {
    debug_assert!(x1 >= x0, "render_span_general: x1={x1} < x0={x0}");
    #[expect(
        clippy::cast_sign_loss,
        reason = "x1 >= x0 is asserted above, so x1 - x0 + 1 >= 1 > 0"
    )]
    let count = (x1 - x0 + 1) as usize;
    let ncomps = P::BYTES;

    debug_assert_eq!(dst_pixels.len(), count * ncomps);
    if let Some(sh) = shape {
        debug_assert_eq!(sh.len(), count);
    }
    if let Some(sm) = pipe.soft_mask {
        debug_assert_eq!(sm.len(), count, "soft_mask length must equal span count");
    }

    let a_input = u32::from(pipe.a_input);
    let is_nonseparable = matches!(
        pipe.blend_mode,
        BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity
    );
    let is_cmyk_like = ncomps == 4 || ncomps == 8;

    // Convenience: get optional slice at index i.
    let shape_at = |i: usize| shape.map_or(0xFFu8, |s| s[i]);
    let soft_mask_at = |i: usize| pipe.soft_mask.map_or(0xFFu8, |s| s[i]);
    let alpha0_at = |i: usize| pipe.alpha0.map(|a| a[i]);

    match src {
        PipeSrc::Solid(color) => {
            debug_assert_eq!(color.len(), ncomps);
            render_span_general_inner(
                pipe,
                |_i| color,
                dst_pixels,
                dst_alpha,
                shape,
                count,
                ncomps,
                a_input,
                is_nonseparable,
                is_cmyk_like,
                &shape_at,
                &soft_mask_at,
                &alpha0_at,
            );
        }
        PipeSrc::Pattern(pat) => {
            PAT_BUF.with(|cell| {
                let mut buf = cell.borrow_mut();
                buf.resize(count * ncomps, 0);
                pat.fill_span(y, x0, x1, &mut buf[..count * ncomps]);
                render_span_general_inner(
                    pipe,
                    |i| &buf[i * ncomps..(i + 1) * ncomps],
                    dst_pixels,
                    dst_alpha,
                    shape,
                    count,
                    ncomps,
                    a_input,
                    is_nonseparable,
                    is_cmyk_like,
                    &shape_at,
                    &soft_mask_at,
                    &alpha0_at,
                );
            });
        }
    }
}

#[expect(
    clippy::too_many_arguments,
    reason = "all params necessary; closure eliminates solid/pattern duplication"
)]
#[expect(
    clippy::too_many_lines,
    reason = "compositing formula has many branches that cannot be meaningfully split"
)]
#[expect(
    clippy::single_match_else,
    reason = "both Some and None arms have substantial independent logic; if-let would be less clear"
)]
fn render_span_general_inner<'src>(
    pipe: &PipeState<'_>,
    src_px_at: impl Fn(usize) -> &'src [u8],
    dst_pixels: &mut [u8],
    dst_alpha: Option<&mut [u8]>,
    shape: Option<&[u8]>,
    count: usize,
    ncomps: usize,
    a_input: u32,
    is_nonseparable: bool,
    is_cmyk_like: bool,
    shape_at: &dyn Fn(usize) -> u8,
    soft_mask_at: &dyn Fn(usize) -> u8,
    alpha0_at: &dyn Fn(usize) -> Option<u8>,
) {
    let has_soft_mask = pipe.soft_mask.is_some();
    let has_shape = shape.is_some();

    match dst_alpha {
        Some(dst_alpha) => {
            debug_assert_eq!(dst_alpha.len(), count);
            for i in 0..count {
                let src_px = src_px_at(i);
                let dst_px = &mut dst_pixels[i * ncomps..(i + 1) * ncomps];
                let a_dst = u32::from(dst_alpha[i]);
                let shape_v = u32::from(shape_at(i));
                let soft_v = u32::from(soft_mask_at(i));

                // Source alpha (PDF spec §11.3.6 eq 11.1).
                let a_src = compute_a_src(a_input, soft_v, shape_v, has_soft_mask, has_shape);

                // Knockout: clear destination alpha before the colour formula so that
                // a_dst_eff (read on line below) reflects the zeroed value.
                // This applies to both isolated and non-isolated knockout groups.
                if pipe.knockout && shape_v >= u32::from(pipe.knockout_opacity) {
                    dst_alpha[i] = 0;
                }

                // Non-isolated group colour correction (PDF spec §11.4.8).
                // c_src_corrected = c_src + (c_src - c_dst) * (a_dst * 255 / shape - a_dst) / 255.
                // shape_v in [1, 255] (from u8), a_dst in [0, 255]:
                //   t_max = (255 * 255) / 1 - 0 = 65025 ≤ i32::MAX, so the cast is lossless.
                let mut c_src_corr: [u8; MAX_COMPS] = [0; MAX_COMPS];
                let c_src: &[u8] = if pipe.non_isolated_group && shape_v != 0 {
                    let t = (a_dst * 255) / shape_v - a_dst;
                    let t_i = t.cast_signed(); // t ≤ (255*255)/1 - 0 = 65025 ≪ i32::MAX
                    for j in 0..ncomps {
                        let v = i32::from(src_px[j])
                            + (i32::from(src_px[j]) - i32::from(dst_px[j])) * t_i / 255;
                        #[expect(
                            clippy::cast_sign_loss,
                            reason = "value is clamped to [0, 255] above"
                        )]
                        {
                            c_src_corr[j] = v.clamp(0, 255) as u8;
                        }
                    }
                    &c_src_corr[..ncomps]
                } else {
                    src_px
                };

                // Blend function.
                let mut c_blend: [u8; MAX_COMPS] = [0; MAX_COMPS];
                if pipe.blend_mode != BlendMode::Normal {
                    apply_blend_fn(
                        pipe.blend_mode,
                        c_src,
                        dst_px,
                        &mut c_blend[..ncomps],
                        is_cmyk_like,
                        is_nonseparable,
                    );
                }

                // Result alpha.
                let a_dst_eff = u32::from(dst_alpha[i]); // may have been cleared by knockout.
                let (a_result, alpha_i, alpha_im1) =
                    compute_alphas(a_src, a_dst_eff, shape_v, alpha0_at(i), pipe.knockout);

                // Result colour.
                if a_result == 0 {
                    dst_px.fill(0);
                } else {
                    // alpha_i >= a_result > 0: safe to divide.
                    debug_assert!(alpha_i > 0, "alpha_i must be > 0 when a_result > 0");
                    for j in 0..ncomps {
                        let c_src_j = u32::from(c_src[j]);
                        let c_dst_j = u32::from(dst_px[j]);
                        let c_b_j = u32::from(c_blend[j]);

                        let c = if pipe.blend_mode == BlendMode::Normal {
                            // No blend function: standard Porter-Duff.
                            ((alpha_i - a_src) * c_dst_j + a_src * c_src_j) / alpha_i
                        } else {
                            // With blend function.
                            ((alpha_i - a_src) * c_dst_j
                                + a_src * ((255 - alpha_im1) * c_src_j + alpha_im1 * c_b_j) / 255)
                                / alpha_i
                        };
                        #[expect(
                            clippy::cast_possible_truncation,
                            reason = "c is a weighted average of values ≤ 255, so c ≤ 255"
                        )]
                        {
                            dst_px[j] = c as u8;
                        }
                    }
                    finish_pixel(pipe, dst_px, src_px, ncomps);
                }

                #[expect(
                    clippy::cast_possible_truncation,
                    reason = "a_result is clamped to ≤ 255 in compute_alphas"
                )]
                {
                    dst_alpha[i] = a_result as u8;
                }
            }
        }
        None => {
            // No separate alpha plane: aDest = 0xFF implicitly.
            // Simplifies: aResult = aSrc + 255 - div255(aSrc * 255) = 255, alpha_i = 255.
            // Non-isolated and knockout modes require a dst_alpha plane to carry the
            // group alpha; those states are meaningless without one.
            debug_assert!(
                !pipe.non_isolated_group && !pipe.knockout,
                "non_isolated_group/knockout require dst_alpha; None arm uses implicit a_dst=255"
            );
            for i in 0..count {
                let src_px = src_px_at(i);
                let dst_px = &mut dst_pixels[i * ncomps..(i + 1) * ncomps];
                let shape_v = u32::from(shape_at(i));
                let soft_v = u32::from(soft_mask_at(i));

                let a_src = compute_a_src(a_input, soft_v, shape_v, has_soft_mask, has_shape);
                // a_src is derived from u8 inputs via div255, so always ≤ 255.
                // The assert catches any future change that widens the input path.
                debug_assert!(a_src <= 255, "a_src={a_src} out of [0, 255]");

                let mut c_blend: [u8; MAX_COMPS] = [0; MAX_COMPS];
                if pipe.blend_mode != BlendMode::Normal {
                    apply_blend_fn(
                        pipe.blend_mode,
                        src_px,
                        dst_px,
                        &mut c_blend[..ncomps],
                        is_cmyk_like,
                        is_nonseparable,
                    );
                }

                for j in 0..ncomps {
                    let c_src_j = u32::from(src_px[j]);
                    let c_dst_j = u32::from(dst_px[j]);
                    let c_b_j = u32::from(c_blend[j]);

                    // With implicit a_dst=255: alpha_i=255, alpha_im1=255.
                    // General formula simplifies to div255((255-a_src)*c_dst + a_src*c_b).
                    // 255 - a_src: safe because a_src ≤ 255 (asserted above).
                    let c = if pipe.blend_mode == BlendMode::Normal {
                        u32::from(div255((255 - a_src) * c_dst_j + a_src * c_src_j))
                    } else {
                        u32::from(div255((255 - a_src) * c_dst_j + a_src * c_b_j))
                    };
                    #[expect(
                        clippy::cast_possible_truncation,
                        reason = "c is a weighted average of values ≤ 255, so c ≤ 255"
                    )]
                    {
                        dst_px[j] = c as u8;
                    }
                }
                finish_pixel(pipe, dst_px, src_px, ncomps);
            }
        }
    }
}

/// Compute the effective source alpha for one pixel (PDF spec §11.3.6 eq 11.1).
///
/// Combines `a_input` with the soft mask and/or shape coverage according to the
/// rules: soft mask and shape are multiplied together via `div255`; if either is
/// absent its default is 1.0 (== 0xFF).
#[inline]
fn compute_a_src(
    a_input: u32,
    soft_v: u32,
    shape_v: u32,
    has_soft_mask: bool,
    has_shape: bool,
) -> u32 {
    if has_soft_mask {
        if has_shape {
            u32::from(div255(u32::from(div255(a_input * soft_v)) * shape_v))
        } else {
            u32::from(div255(a_input * soft_v))
        }
    } else if has_shape {
        u32::from(div255(a_input * shape_v))
    } else {
        a_input
    }
}

/// Compute result alpha and the two intermediate alphas used in the colour formula.
///
/// Returns `(a_result, alpha_i, alpha_im1)`.
///
/// Matches the C++ `pipeRun` alpha logic for isolated/non-isolated, knockout/non-knockout.
#[expect(
    clippy::option_if_let_else,
    reason = "if-let form is clearer than map_or_else for this multi-branch alpha computation"
)]
fn compute_alphas(
    a_src: u32,
    a_dst: u32,
    shape: u32,
    alpha0: Option<u8>,
    knockout: bool,
) -> (u32, u32, u32) {
    if let Some(a0) = alpha0 {
        let a0 = u32::from(a0);
        if knockout {
            // Non-isolated, knockout.
            let a_result = a_src + u32::from(div255(a_dst * (255 - shape)));
            let alpha_i = a_result + a0 - u32::from(div255(a_result * a0));
            (a_result.min(255), alpha_i.min(255), a0)
        } else {
            // Non-isolated, non-knockout.
            let a_result = a_src + a_dst - u32::from(div255(a_src * a_dst));
            let alpha_i = a_result + a0 - u32::from(div255(a_result * a0));
            let alpha_im1 = a0 + a_dst - u32::from(div255(a0 * a_dst));
            (a_result.min(255), alpha_i.min(255), alpha_im1.min(255))
        }
    } else if knockout {
        // Isolated, knockout.
        let a_result = a_src + u32::from(div255(a_dst * (255 - shape)));
        (a_result.min(255), a_result.min(255), 0)
    } else {
        // Isolated, non-knockout (most common).
        let a_result = a_src + a_dst - u32::from(div255(a_src * a_dst));
        (a_result.min(255), a_result.min(255), a_dst)
    }
}

/// Apply the blend function and write into `c_blend`.
fn apply_blend_fn(
    mode: BlendMode,
    src: &[u8],
    dst: &[u8],
    c_blend: &mut [u8],
    is_cmyk_like: bool,
    is_nonseparable: bool,
) {
    debug_assert_eq!(src.len(), dst.len());
    debug_assert_eq!(src.len(), c_blend.len());
    let ncomps = src.len();

    if is_cmyk_like {
        // Subtractive complement: invert all channels, blend in additive space, re-invert.
        // Fill all ncomps so spot channels (j >= 4) are correctly inverted.
        let mut src2 = [0u8; MAX_COMPS];
        let mut dst2 = [0u8; MAX_COMPS];
        for j in 0..ncomps {
            src2[j] = 255 - src[j];
            dst2[j] = 255 - dst[j];
        }

        if is_nonseparable {
            let s3 = [src2[0], src2[1], src2[2]];
            let d3 = [dst2[0], dst2[1], dst2[2]];
            let r3 = blend::apply_nonseparable_rgb(mode, s3, d3);
            c_blend[0] = 255 - r3[0];
            c_blend[1] = 255 - r3[1];
            c_blend[2] = 255 - r3[2];
            // K/spot channel: for Luminosity, use src K; for others use dst K (PDF §11.3.5).
            if ncomps >= 4 {
                c_blend[3] = 255
                    - (if mode == BlendMode::Luminosity {
                        src2[3]
                    } else {
                        dst2[3]
                    });
            }
            for j in 4..ncomps {
                // Spot channels pass through dst (same rule as K for non-Luminosity).
                c_blend[j] = 255 - dst2[j];
            }
        } else {
            blend::apply_separable(
                mode,
                &src2[..ncomps.min(4)],
                &dst2[..ncomps.min(4)],
                &mut c_blend[..ncomps.min(4)],
            );
            for v in &mut c_blend[..ncomps.min(4)] {
                *v = 255 - *v;
            }
            // Spot channels (j >= 4): not blended, pass dst through unchanged.
            c_blend[4..ncomps].copy_from_slice(&dst[4..ncomps]);
        }
    } else if is_nonseparable {
        // RGB/Gray additive space.
        let n = ncomps.min(3);
        let mut s3 = [0u8; 3];
        let mut d3 = [0u8; 3];
        s3[..n].copy_from_slice(&src[..n]);
        d3[..n].copy_from_slice(&dst[..n]);
        // Mono: replicate the single channel to all three.
        if ncomps == 1 {
            s3[1] = s3[0];
            s3[2] = s3[0];
            d3[1] = d3[0];
            d3[2] = d3[0];
        }
        let r3 = blend::apply_nonseparable_rgb(mode, s3, d3);
        c_blend[..n].copy_from_slice(&r3[..n]);
    } else {
        blend::apply_separable(mode, src, dst, c_blend);
    }
}

/// Apply transfer and conditional overprint at the end of each pixel's colour computation.
///
/// Called after the colour formula writes all channels of `dst_px` but before
/// the alpha plane is updated.  Order matters: transfer must precede overprint
/// so the transfer LUT sees the blended colour, not the restored source.
#[inline]
fn finish_pixel(pipe: &PipeState<'_>, dst_px: &mut [u8], src_px: &[u8], ncomps: usize) {
    pipe::apply_transfer_in_place(pipe, dst_px);
    if pipe.overprint_mask != 0xFFFF_FFFF {
        apply_overprint(pipe, dst_px, src_px, ncomps);
    }
}

/// Apply overprint: for channels where the bit in `overprint_mask` is 0,
/// the channel is not painted.
///
/// Only `overprint_additive = true` is implemented.  Replace-mode overprint
/// requires the caller to preserve the pre-blend destination bytes, which the
/// current call structure does not support.
///
/// # Panics
///
/// Panics if `pipe.overprint_additive` is `false`; replace-mode overprint is
/// not yet implemented.
fn apply_overprint(pipe: &PipeState<'_>, dst_px: &mut [u8], src_px: &[u8], ncomps: usize) {
    if pipe.overprint_additive {
        for j in 0..ncomps {
            // Channels whose bit is 0 in the mask are not painted; dst already holds
            // the correct value, so skip them.
            if pipe.overprint_mask & (1 << j) == 0 {
                continue;
            }
            // Additive overprint: accumulate into the destination, clamped to 255.
            dst_px[j] = (u16::from(dst_px[j]) + u16::from(src_px[j])).min(255) as u8;
        }
    } else {
        // Replace overprint: channels not in mask should keep the original dst value,
        // but the pre-blend destination has already been overwritten.  Restoring it
        // here would require the caller to pass the pre-blend dst separately.
        // Panic loudly (in both debug and release) rather than silently producing
        // wrong output.  Callers must either set overprint_additive=true or pass the
        // original dst bytes before calling apply_overprint.
        panic!(
            "general pipe: replace overprint (mask={:#010x}) is not yet implemented; \
             use overprint_additive=true or preserve pre-blend dst in the caller",
            pipe.overprint_mask,
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compute_a_src_no_mask_no_shape_returns_a_input() {
        assert_eq!(compute_a_src(200, 0xFF, 0xFF, false, false), 200);
    }

    #[test]
    fn compute_a_src_shape_zero_gives_zero() {
        assert_eq!(compute_a_src(255, 0xFF, 0, false, true), 0);
    }

    #[test]
    fn compute_a_src_soft_mask_scales_alpha() {
        let result = compute_a_src(255, 128, 0xFF, true, false);
        let expected = u32::from(div255(255 * 128));
        assert_eq!(result, expected);
    }

    #[test]
    fn compute_a_src_soft_and_shape_combines_both() {
        let result = compute_a_src(255, 128, 128, true, true);
        let expected = u32::from(div255(u32::from(div255(255 * 128)) * 128));
        assert_eq!(result, expected);
    }
    use crate::pipe::{PipeSrc, PipeState};
    use crate::state::TransferSet;
    use color::{Gray8, Rgb8, TransferLut};

    fn normal_pipe(a: u8) -> PipeState<'static> {
        PipeState {
            blend_mode: BlendMode::Normal,
            a_input: a,
            overprint_mask: 0xFFFF_FFFF,
            overprint_additive: false,
            transfer: TransferSet::identity_rgb(),
            soft_mask: None,
            alpha0: None,
            knockout: false,
            knockout_opacity: 255,
            non_isolated_group: false,
        }
    }

    #[test]
    fn opaque_src_over_any_dst_gives_src() {
        let pipe = normal_pipe(255);
        let src_color = [200u8, 100, 50];
        let src = PipeSrc::Solid(&src_color);

        let mut dst = vec![10u8, 20, 30];
        let mut alpha = vec![128u8];

        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);

        // a_src = 255, a_result = 255; c = (0 * c_dst + 255 * c_src) / 255 = c_src.
        assert_eq!(&dst, &[200, 100, 50]);
        assert_eq!(alpha[0], 255);
    }

    #[test]
    fn transparent_src_leaves_dst_unchanged() {
        let pipe = normal_pipe(0);
        let src = PipeSrc::Solid(&[255u8, 255, 255]);

        let mut dst = vec![10u8, 20, 30];
        let mut alpha = vec![200u8];

        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);

        // a_src = 0; a_result = 0 + 200 - 0 = 200.
        // c = (200 * c_dst + 0) / 200 = c_dst.
        assert_eq!(&dst, &[10, 20, 30]);
        assert_eq!(alpha[0], 200);
    }

    #[test]
    fn blend_multiply_with_dst() {
        let mut pipe = normal_pipe(255);
        pipe.blend_mode = BlendMode::Multiply;

        // src = 128 (grey), dst = 200.
        let src = PipeSrc::Solid(&[128u8, 128, 128]);
        let mut dst = vec![200u8, 200, 200];
        let mut alpha = vec![255u8];

        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);

        // With a_src=255, a_dst=255: a_result=255, alpha_i=255, alpha_im1=255.
        // c = ((255-255)*200 + 255*((255-255)*128 + 255*Multiply(128,200)/255)) / 255
        //   = Multiply(128, 200)
        //   = div255(128 * 200) ≈ 100.
        let v = dst[0];
        assert!((i32::from(v) - 100).abs() <= 1, "expected ~100, got {v}");
    }

    #[test]
    fn compute_alphas_isolated_non_knockout() {
        // a_src=128, a_dst=200.
        let (ar, ai, aim1) = compute_alphas(128, 200, 255, None, false);
        // a_result = 128 + 200 - div255(128 * 200) ≈ 228.
        assert!((226..=230).contains(&ar), "a_result={ar}");
        assert_eq!(ai, ar, "isolated: alpha_i == a_result");
        assert_eq!(aim1, 200, "isolated non-knockout: alpha_im1 == a_dst");
    }

    #[test]
    fn soft_mask_modulates_alpha() {
        // soft_mask[0] = 128 → a_src = div255(255 * 128) ≈ 128.
        let soft = vec![128u8];
        let mut dst = vec![0u8; 3];
        let mut alpha = vec![0u8];

        // We need a pipe with a soft_mask reference.
        // Since PipeState has a 'bmp lifetime, we store soft_mask as a slice reference.
        let pipe = PipeState {
            blend_mode: BlendMode::Normal,
            a_input: 255,
            overprint_mask: 0xFFFF_FFFF,
            overprint_additive: false,
            transfer: TransferSet::identity_rgb(),
            soft_mask: Some(soft.as_slice()),
            alpha0: None,
            knockout: false,
            knockout_opacity: 255,
            non_isolated_group: false,
        };

        let src = PipeSrc::Solid(&[255u8, 255, 255]);
        render_span_general::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);

        // a_src ≈ 128; a_dst = 0; a_result ≈ 128.
        // c = (0 * 0 + 128 * 255) / 128 = 255.
        assert_eq!(dst[0], 255);
        assert!((i32::from(alpha[0]) - 128).abs() <= 2, "alpha={}", alpha[0]);
    }

    #[test]
    fn gray_transfer_lut_applied_correctly() {
        // Regression: the old apply_transfer_channel used rgb[0] for channel 0
        // regardless of pixel mode, so gray transfer was silently wrong.
        // Build an inverting gray LUT and a pass-through rgb LUT; the general
        // pipe over a Gray8 pixel must invert the output via the gray table.
        static DN: [[u8; 256]; 8] = [TransferLut::IDENTITY.0; 8];
        let id = TransferLut::IDENTITY.as_array();
        let inv = TransferLut::INVERTED.as_array();
        let transfer = TransferSet {
            gray: inv, // inverting gray transfer
            rgb: [id; 3],
            cmyk: [id; 4],
            device_n: &DN,
        };
        let pipe = PipeState {
            blend_mode: BlendMode::Normal,
            a_input: 255,
            overprint_mask: 0xFFFF_FFFF,
            overprint_additive: false,
            transfer,
            soft_mask: None,
            alpha0: None,
            knockout: false,
            knockout_opacity: 255,
            non_isolated_group: false,
        };

        // Opaque gray source value 100; after inverting transfer: 155.
        let src_color = [100u8];
        let src = PipeSrc::Solid(&src_color);
        let mut dst = vec![0u8; 1];
        let mut alpha = vec![0u8; 1];
        render_span_general::<Gray8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 0, 0);
        assert_eq!(dst[0], 155, "gray transfer must use gray LUT, not rgb[0]");
    }
}