Skip to main content

pdfrum_render/
blend.rs

1//! The sixteen PDF blend modes (ISO 32000-1 §11.3.5), computed the way the
2//! oracle does: all-integer, truncating, unclamped.
3//!
4//! **Part of the backend seam.** A backend that writes its own pixels
5//! composites with [`composite_solid`] and [`composite_premultiplied`] rather
6//! than with its rasterizer library's, which rounds differently.
7//!
8//! These are the *engine's* blend functions, used where the engine
9//! composites its own offscreen buffers; on-device compositing goes through
10//! each rasterizer's native blend modes, which round differently by ±1.
11//! Where a value is a *decision* rather than rasterization it comes from
12//! here, so every backend sees the same bytes.
13
14// Sources: `core/fxge/dib/blend.cpp` and `cfx_scanlinecompositor.cpp:41-121`.
15// `pdfrum-raster-agg` is the in-tree proof that a backend can composite
16// through this module alone. The ±1 rounding difference is what Tier B's
17// threshold absorbs and Tier C's edge budget names.
18//
19// Three formulas resist re-derivation and are reproduced literally:
20// `Overlay` is `HardLight` with its arguments swapped, `HardLight`'s
21// threshold is `s < 128` rather than `2s <= 255`, and `SoftLight` reads a
22// 256-entry table that is *not* a square root.
23
24use pdfrum_page::BlendMode;
25
26/// ISO 32000-1 §11.3.5.2's auxiliary `D(x)`, tabulated at 8-bit precision,
27/// transcribed verbatim.
28///
29/// The closed form is `D(x) = if x <= 0.25 { ((16x - 12)x + 4)x } else {
30/// sqrt(x) }` and `kColorSqrt[i] == round(255 * D(i / 255))` for all 256
31/// entries — but the low branch is a cubic, not a root: entry `1` is `3`
32/// where a plain `round(255 * sqrt(1/255))` would give `16`. A rewrite that
33/// "simplifies" this to a square root is wrong by 17 counts, not by rounding.
34pub(crate) const COLOR_SQRT: [u8; 256] = [
35    0x00, 0x03, 0x07, 0x0B, 0x0F, 0x12, 0x16, 0x19, 0x1D, 0x20, 0x23, 0x26, 0x29, 0x2C, 0x2F, 0x32,
36    0x35, 0x37, 0x3A, 0x3C, 0x3F, 0x41, 0x43, 0x46, 0x48, 0x4A, 0x4C, 0x4E, 0x50, 0x52, 0x54, 0x56,
37    0x57, 0x59, 0x5B, 0x5C, 0x5E, 0x60, 0x61, 0x63, 0x64, 0x65, 0x67, 0x68, 0x69, 0x6B, 0x6C, 0x6D,
38    0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E,
39    0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E,
40    0x8F, 0x90, 0x91, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C,
41    0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA4, 0xA5, 0xA6, 0xA7, 0xA7, 0xA8,
42    0xA9, 0xAA, 0xAA, 0xAB, 0xAC, 0xAD, 0xAD, 0xAE, 0xAF, 0xB0, 0xB0, 0xB1, 0xB2, 0xB3, 0xB3, 0xB4,
43    0xB5, 0xB5, 0xB6, 0xB7, 0xB7, 0xB8, 0xB9, 0xBA, 0xBA, 0xBB, 0xBC, 0xBC, 0xBD, 0xBE, 0xBE, 0xBF,
44    0xC0, 0xC0, 0xC1, 0xC2, 0xC2, 0xC3, 0xC4, 0xC4, 0xC5, 0xC6, 0xC6, 0xC7, 0xC7, 0xC8, 0xC9, 0xC9,
45    0xCA, 0xCB, 0xCB, 0xCC, 0xCC, 0xCD, 0xCE, 0xCE, 0xCF, 0xD0, 0xD0, 0xD1, 0xD1, 0xD2, 0xD3, 0xD3,
46    0xD4, 0xD4, 0xD5, 0xD6, 0xD6, 0xD7, 0xD7, 0xD8, 0xD9, 0xD9, 0xDA, 0xDA, 0xDB, 0xDC, 0xDC, 0xDD,
47    0xDD, 0xDE, 0xDE, 0xDF, 0xE0, 0xE0, 0xE1, 0xE1, 0xE2, 0xE2, 0xE3, 0xE4, 0xE4, 0xE5, 0xE5, 0xE6,
48    0xE6, 0xE7, 0xE7, 0xE8, 0xE9, 0xE9, 0xEA, 0xEA, 0xEB, 0xEB, 0xEC, 0xEC, 0xED, 0xED, 0xEE, 0xEE,
49    0xEF, 0xF0, 0xF0, 0xF1, 0xF1, 0xF2, 0xF2, 0xF3, 0xF3, 0xF4, 0xF4, 0xF5, 0xF5, 0xF6, 0xF6, 0xF7,
50    0xF7, 0xF8, 0xF8, 0xF9, 0xF9, 0xFA, 0xFA, 0xFB, 0xFB, 0xFC, 0xFC, 0xFD, 0xFD, 0xFE, 0xFE, 0xFF,
51];
52
53/// One separable blend mode's per-channel function, on `0..=255` inputs.
54///
55/// Non-separable modes have no per-channel form; [`blend_rgb`] handles all
56/// sixteen and is what callers should reach for. Results are *not* clamped,
57/// exactly as upstream leaves them.
58#[must_use]
59#[expect(
60    clippy::match_same_arms,
61    reason = "Normal/Compatible returning the source is the blend function; \
62              the non-separable arm returning it is an unreachable fallback \
63              (upstream NOTREACHED()s). Merging them would erase that \
64              distinction and hide the day one of the two changes."
65)]
66pub(crate) fn blend_channel(mode: BlendMode, back: i32, src: i32) -> i32 {
67    match mode {
68        BlendMode::Normal | BlendMode::Compatible => src,
69        BlendMode::Multiply => src * back / 255,
70        BlendMode::Screen => src + back - src * back / 255,
71        // Literally HardLight with the arguments swapped, not its own formula.
72        BlendMode::Overlay => blend_channel(BlendMode::HardLight, src, back),
73        BlendMode::Darken => src.min(back),
74        BlendMode::Lighten => src.max(back),
75        BlendMode::ColorDodge => {
76            if src == 255 {
77                255
78            } else {
79                (back * 255 / (255 - src)).min(255)
80            }
81        }
82        BlendMode::ColorBurn => {
83            if src == 0 {
84                0
85            } else {
86                255 - ((255 - back) * 255 / src).min(255)
87            }
88        }
89        BlendMode::HardLight => {
90            if src < 128 {
91                (src * back * 2) / 255
92            } else {
93                blend_channel(BlendMode::Screen, back, 2 * src - 255)
94            }
95        }
96        BlendMode::SoftLight => {
97            if src < 128 {
98                // Two sequential divides, not one by 65025: the truncations
99                // differ and the difference is visible.
100                back - (255 - 2 * src) * back * (255 - back) / 255 / 255
101            } else {
102                #[expect(
103                    clippy::cast_sign_loss,
104                    reason = "the clamp lower bound is 0, so the value is non-negative"
105                )]
106                let idx = back.clamp(0, 255) as usize;
107                let d = i32::from(COLOR_SQRT.get(idx).copied().unwrap_or(0));
108                back + (2 * src - 255) * (d - back) / 255
109            }
110        }
111        BlendMode::Difference => (back - src).abs(),
112        BlendMode::Exclusion => back + src - 2 * back * src / 255,
113        // Non-separable: no per-channel form. Upstream NOTREACHED()s here.
114        BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity => src,
115    }
116}
117
118/// `Lum(c) = (r*30 + g*59 + b*11) / 100` — integer, truncating.
119#[must_use]
120fn lum(c: [i32; 3]) -> i32 {
121    (c[0] * 30 + c[1] * 59 + c[2] * 11) / 100
122}
123
124/// `Sat(c) = max - min`.
125#[must_use]
126fn sat(c: [i32; 3]) -> i32 {
127    c.iter().copied().max().unwrap_or(0) - c.iter().copied().min().unwrap_or(0)
128}
129
130/// `ClipColor`, with the ordering that makes it load-bearing.
131///
132/// `l`, `n` and `x` are computed **once, from the pre-clip colour**; the
133/// `x > 255` branch then operates on channels the `n < 0` branch may already
134/// have rewritten, while still dividing by the original `x - l`. Recomputing
135/// either bound between the branches changes the result.
136#[must_use]
137fn clip_color(mut c: [i32; 3]) -> [i32; 3] {
138    let l = lum(c);
139    let n = c.iter().copied().min().unwrap_or(0);
140    let x = c.iter().copied().max().unwrap_or(0);
141    if n < 0 && l != n {
142        for ch in &mut c {
143            *ch = l + ((*ch - l) * l / (l - n));
144        }
145    }
146    if x > 255 && x != l {
147        for ch in &mut c {
148            *ch = l + ((*ch - l) * (255 - l) / (x - l));
149        }
150    }
151    c
152}
153
154/// `SetLum(c, l)`: shift every channel by `l - Lum(c)`, then clip.
155#[must_use]
156fn set_lum(mut c: [i32; 3], l: i32) -> [i32; 3] {
157    let d = l - lum(c);
158    for ch in &mut c {
159        *ch += d;
160    }
161    clip_color(c)
162}
163
164/// `SetSat(c, s)`: rescale to the requested saturation, or collapse to black
165/// when the colour has none to rescale.
166#[must_use]
167fn set_sat(mut c: [i32; 3], s: i32) -> [i32; 3] {
168    let min = c.iter().copied().min().unwrap_or(0);
169    let max = c.iter().copied().max().unwrap_or(0);
170    if min == max {
171        return [0, 0, 0];
172    }
173    for ch in &mut c {
174        *ch = (*ch - min) * s / (max - min);
175    }
176    c
177}
178
179/// Blend one RGB triple over another, separable and non-separable alike.
180///
181/// Inputs are `0..=255`; the result is clamped on the way out because a
182/// caller is storing bytes, while upstream's own intermediate values are not.
183#[must_use]
184pub(crate) fn blend_rgb(mode: BlendMode, back: [u8; 3], src: [u8; 3]) -> [u8; 3] {
185    let b = back.map(i32::from);
186    let s = src.map(i32::from);
187    let out = match mode {
188        BlendMode::Hue => set_lum(set_sat(s, sat(b)), lum(b)),
189        BlendMode::Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
190        BlendMode::Color => set_lum(s, lum(b)),
191        BlendMode::Luminosity => set_lum(b, lum(s)),
192        separable => [
193            blend_channel(separable, b[0], s[0]),
194            blend_channel(separable, b[1], s[1]),
195            blend_channel(separable, b[2], s[2]),
196        ],
197    };
198    out.map(|v| {
199        #[expect(
200            clippy::cast_sign_loss,
201            reason = "the clamp lower bound is 0, so the value fits u8 exactly"
202        )]
203        let byte = v.clamp(0, 255) as u8;
204        byte
205    })
206}
207
208/// The straight-alpha source-over composite the oracle performs, returning
209/// the new `(rgb, alpha)` of the destination.
210///
211/// The `dest.a == 0` short circuit is the structural difference from a
212/// textbook Porter-Duff implementation: over a fully transparent backdrop the
213/// source is copied verbatim and **no blending happens at all** — which is
214/// also what ISO 32000 §11.3.6 requires, reached by another route.
215#[must_use]
216pub(crate) fn composite_straight(
217    dest: ([u8; 3], u8),
218    src: ([u8; 3], u8),
219    mode: BlendMode,
220) -> ([u8; 3], u8) {
221    let (dest_rgb, dest_a) = dest;
222    let (src_rgb, src_a) = src;
223    if dest_a == 0 {
224        return (src_rgb, src_a);
225    }
226    if src_a == 0 {
227        return dest;
228    }
229    let da = u32::from(dest_a);
230    let sa = u32::from(src_a);
231    // `da + sa - da*sa/255` is upstream's union of two 0..=255 alphas; with
232    // both operands bounded by 255 the truncating divide makes the result
233    // 0..=255 too, so the narrowing is exact rather than wrapping.
234    #[expect(
235        clippy::cast_possible_truncation,
236        reason = "the alpha union of two 0..=255 values is itself 0..=255"
237    )]
238    let out_a = (da + sa - da * sa / 255) as u8;
239    if out_a == 0 {
240        return (dest_rgb, 0);
241    }
242    let ratio = (sa * 255 / u32::from(out_a)).min(255) as u8;
243    let blended = blend_rgb(mode, dest_rgb, src_rgb);
244    let mut out = [0u8; 3];
245    for i in 0..3 {
246        let (Some(&d), Some(&s), Some(&bl)) = (dest_rgb.get(i), src_rgb.get(i), blended.get(i))
247        else {
248            continue;
249        };
250        // The (1 - alpha_b) * Cs term, then the ratio merge.
251        let to_source = crate::pixmap::alpha_merge(s, bl, dest_a);
252        if let Some(slot) = out.get_mut(i) {
253            *slot = crate::pixmap::alpha_merge(d, to_source, ratio);
254        }
255    }
256    (out, out_a)
257}
258
259/// Composite one **premultiplied** RGBA8 source pixel over a premultiplied
260/// destination pixel, blending with `mode` and scaling the source by
261/// `coverage`.
262///
263/// `coverage` is the rasterizer's antialiasing byte, folded into the source
264/// alpha by a truncating product. A zero coverage or a zero source alpha
265/// leaves the destination untouched, which is what lets a caller blend
266/// unconditionally.
267///
268/// A source that carries a *straight* colour — every
269/// [`Brush::Solid`](crate::device::Brush) — must use [`composite_solid`]
270/// instead: premultiplying it first quantises it.
271///
272/// ```
273/// use pdfrum_page::BlendMode;
274/// use pdfrum_render::blend::composite_premultiplied;
275///
276/// let dest = [0, 0, 255, 255];
277/// let src = [255, 0, 0, 255];
278///
279/// // Full coverage and a fully opaque source replace the destination.
280/// assert_eq!(
281///     composite_premultiplied(dest, src, 255, BlendMode::default()),
282///     [255, 0, 0, 255],
283/// );
284/// // Zero coverage leaves it untouched, so a caller can blend always.
285/// assert_eq!(composite_premultiplied(dest, src, 0, BlendMode::default()), dest);
286/// ```
287#[must_use]
288pub fn composite_premultiplied(
289    dest: [u8; 4],
290    src: [u8; 4],
291    coverage: u8,
292    mode: BlendMode,
293) -> [u8; 4] {
294    // This is `composite_straight` wearing the buffer layout both rasterizer
295    // backends and every offscreen target in this crate actually use, and it
296    // exists so there is exactly one place the blend arithmetic lives. It
297    // un-premultiplies, delegates, and premultiplies back rather than deriving
298    // a second premultiplied formula — the round trip costs two divides on a
299    // pixel that is being blended anyway, and a second formula is a second
300    // thing to keep in step with the oracle.
301    //
302    // The coverage fold is the same truncating product the oracle folds a
303    // clip mask in with.
304    //
305    // # The opaque-destination fast path
306    //
307    // The general route below is four divides and two multiplies per channel:
308    // it un-premultiplies both pixels, blends them straight, and
309    // premultiplies the result back. For **`BlendMode::Normal` over an opaque
310    // destination** the whole of that collapses, algebraically and exactly, to
311    // one `alpha_merge` per channel. Substituting `dest_a = 255` and
312    // `mode = Normal` into `composite_straight`:
313    //
314    // - `blend_rgb(Normal, ..)` is the identity on the source
315    //   (`blend_channel`'s first arm), so `blended == src_rgb`;
316    // - `out_a = 255 + sa - 255*sa/255 = 255`, so the result is opaque and the
317    //   premultiply-back is the identity;
318    // - `ratio = sa * 255 / 255 = sa`;
319    // - `to_source = alpha_merge(s, blended, 255) = s`, because `blended` *is*
320    //   `s`;
321    // - and the channel is therefore `alpha_merge(d, s, sa)`.
322    //
323    // The un-premultiply of the destination is also the identity at
324    // `da == 255`, so the only surviving work is un-premultiplying the source
325    // and one `alpha_merge`. That is not an approximation and not a "close
326    // enough": it is the same expression with a constant folded in, and
327    // `the_opaque_normal_fast_path_is_exhaustively_identical` checks all
328    // 256^3 relevant inputs against the general route rather than asserting it
329    // here.
330    //
331    // It is worth a fast path because it is not a corner case: a PDF page
332    // renders onto an opaque white backdrop by default, so *every* pixel of
333    // *every* ordinary fill, stroke, glyph blit and image draw on a page with
334    // no transparency group takes exactly this branch. Measured on the
335    // corpus, it is 60% of `render-exact`'s `text` class and 78% of its
336    // `shading` class.
337    let (Some(&sr), Some(&sg), Some(&sb), Some(&sa)) =
338        (src.first(), src.get(1), src.get(2), src.get(3))
339    else {
340        return dest;
341    };
342    let src_alpha = crate::pixmap::mul255(sa, coverage);
343    if src_alpha == 0 {
344        return dest;
345    }
346    if matches!(mode, BlendMode::Normal | BlendMode::Compatible)
347        && dest.get(3) == Some(&255)
348        && let (Some(&dr), Some(&dg), Some(&db)) = (dest.first(), dest.get(1), dest.get(2))
349    {
350        let [ur, ug, ub] = crate::pixmap::unpremultiply_rgb(sr, sg, sb, sa);
351        return [
352            crate::pixmap::alpha_merge(dr, ur, src_alpha),
353            crate::pixmap::alpha_merge(dg, ug, src_alpha),
354            crate::pixmap::alpha_merge(db, ub, src_alpha),
355            255,
356        ];
357    }
358    let (Some(&dr), Some(&dg), Some(&db), Some(&da)) =
359        (dest.first(), dest.get(1), dest.get(2), dest.get(3))
360    else {
361        return dest;
362    };
363    let src_rgb = crate::pixmap::unpremultiply_rgb(sr, sg, sb, sa);
364    let dest_rgb = crate::pixmap::unpremultiply_rgb(dr, dg, db, da);
365    let (rgb, alpha) = composite_straight((dest_rgb, da), (src_rgb, src_alpha), mode);
366    let (Some(&r), Some(&g), Some(&b)) = (rgb.first(), rgb.get(1), rgb.get(2)) else {
367        return dest;
368    };
369    [
370        premultiply_channel(r, alpha),
371        premultiply_channel(g, alpha),
372        premultiply_channel(b, alpha),
373        alpha,
374    ]
375}
376
377/// Composite a **straight** RGBA8 source pixel over a premultiplied
378/// destination pixel, blending with `mode` and scaling the source by
379/// `coverage`.
380///
381/// The same composite as [`composite_premultiplied`], entered one step
382/// earlier — and that step is not free: premultiplying a straight colour and
383/// un-premultiplying it back **quantises it**, because a premultiplied byte
384/// at alpha `a` can only express `a + 1` of the 256 straight values. Every
385/// caller holding a straight colour — every
386/// [`Brush::Solid`](crate::device::Brush) — must use this; a source already
387/// premultiplied (an image sample, a composited layer) has no straight
388/// colour to preserve and uses [`composite_premultiplied`].
389///
390/// ```
391/// use pdfrum_page::BlendMode;
392/// use pdfrum_render::blend::composite_solid;
393///
394/// let dest = [0, 0, 255, 255];
395///
396/// // A straight colour, entered one step earlier so the round trip
397/// // through premultiplication cannot quantise it.
398/// assert_eq!(
399///     composite_solid(dest, [221, 0, 0], 255, 255, BlendMode::default()),
400///     [221, 0, 0, 255],
401/// );
402/// ```
403#[must_use]
404pub fn composite_solid(
405    dest: [u8; 4],
406    src_rgb: [u8; 3],
407    src_a: u8,
408    coverage: u8,
409    mode: BlendMode,
410) -> [u8; 4] {
411    // At the alpha the form-field highlight uses — 100/255 — the
412    // representable reds near `221` are `219`, `222`, `224`: `221` is not
413    // among them, so the round trip that stores it lands on `219` and the
414    // tint composites a count low wherever a solid colour is drawn below full
415    // alpha.
416    //
417    // The oracle never takes that step at all. Its AGG render targets are
418    // `FXDIB_Format::kBgra` — **straight** alpha; `CFX_DIBitmap::PreMultiply`
419    // exists only behind `PDF_USE_SKIA`. So a solid fill's colour reaches
420    // `CFX_ScanlineCompositor` exactly as the content stream stated it, and
421    // this is the entry point that reproduces that.
422    let src_alpha = crate::pixmap::mul255(src_a, coverage);
423    if src_alpha == 0 {
424        return dest;
425    }
426    let (Some(&dr), Some(&dg), Some(&db), Some(&da)) =
427        (dest.first(), dest.get(1), dest.get(2), dest.get(3))
428    else {
429        return dest;
430    };
431    if matches!(mode, BlendMode::Normal | BlendMode::Compatible) && da == 255 {
432        // The same collapse `composite_premultiplied` documents, minus the
433        // un-premultiply it no longer has to undo.
434        let (Some(&sr), Some(&sg), Some(&sb)) = (src_rgb.first(), src_rgb.get(1), src_rgb.get(2))
435        else {
436            return dest;
437        };
438        return [
439            crate::pixmap::alpha_merge(dr, sr, src_alpha),
440            crate::pixmap::alpha_merge(dg, sg, src_alpha),
441            crate::pixmap::alpha_merge(db, sb, src_alpha),
442            255,
443        ];
444    }
445    let dest_rgb = crate::pixmap::unpremultiply_rgb(dr, dg, db, da);
446    let (rgb, alpha) = composite_straight((dest_rgb, da), (src_rgb, src_alpha), mode);
447    let (Some(&r), Some(&g), Some(&b)) = (rgb.first(), rgb.get(1), rgb.get(2)) else {
448        return dest;
449    };
450    [
451        premultiply_channel(r, alpha),
452        premultiply_channel(g, alpha),
453        premultiply_channel(b, alpha),
454        alpha,
455    ]
456}
457
458/// `c * a / 255`, **rounding** — premultiplication that survives the trip
459/// back.
460///
461/// The truncating [`mul255`](crate::pixmap::mul255) is the oracle's product
462/// wherever the oracle itself performs one, and it stays that everywhere else.
463/// Here it is wrong for a structural reason: this is not one of the oracle's
464/// products at all, it is the *storage* half of a round trip our premultiplied
465/// buffers impose and the oracle's straight ones do not. Its inverse,
466/// [`unpremultiply_rgb`](crate::pixmap::unpremultiply_rgb), rounds, so
467/// truncating on the way in would make the pair lose a count on most values
468/// instead of none.
469///
470/// Measured: a straight `145` at alpha `223` premultiplies to `126` truncating
471/// and `127` rounding, and only `127` comes back as `145`. That count is
472/// visible in the corpus — it is every pixel of `alpha_composite`'s overlap.
473#[must_use]
474fn premultiply_channel(c: u8, a: u8) -> u8 {
475    #[expect(
476        clippy::cast_possible_truncation,
477        reason = "(255*255 + 127)/255 == 255 is the maximum, so the quotient fits u8"
478    )]
479    let byte = ((u32::from(c) * u32::from(a) + 127) / 255) as u8;
480    byte
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    /// The general route, with the opaque-Normal fast path deliberately not
488    /// taken.
489    ///
490    /// A transcription of [`composite_premultiplied`]'s body from the
491    /// `src_alpha == 0` check onward, which is what the fast path claims to be
492    /// equal to. It is spelt out here rather than reached by a flag on the
493    /// real function because a flag would be a branch in the hot path that
494    /// exists only for a test.
495    fn general_route(dest: [u8; 4], src: [u8; 4], coverage: u8, mode: BlendMode) -> [u8; 4] {
496        let [sr, sg, sb, sa] = src;
497        let [dr, dg, db, da] = dest;
498        let src_alpha = crate::pixmap::mul255(sa, coverage);
499        if src_alpha == 0 {
500            return dest;
501        }
502        let src_rgb = crate::pixmap::unpremultiply_rgb(sr, sg, sb, sa);
503        let dest_rgb = crate::pixmap::unpremultiply_rgb(dr, dg, db, da);
504        let (rgb, alpha) = composite_straight((dest_rgb, da), (src_rgb, src_alpha), mode);
505        let [r, g, b] = rgb;
506        [
507            premultiply_channel(r, alpha),
508            premultiply_channel(g, alpha),
509            premultiply_channel(b, alpha),
510            alpha,
511        ]
512    }
513
514    #[test]
515    fn the_opaque_normal_fast_path_is_exhaustively_identical() {
516        // The claim in `composite_premultiplied`'s docs is that for Normal
517        // over an opaque destination the general route's four divides collapse
518        // to one `alpha_merge` per channel *exactly*. This is a performance
519        // change in a parity engine, so "exactly" is checked rather than
520        // reasoned about: every source alpha, every source channel value and
521        // every destination channel value, on one channel — the three channels
522        // are independent in this branch, which is itself why one channel
523        // suffices and is checked by the mixed-channel case below.
524        for sa in 0..=255u8 {
525            for s in (0..=255u8).step_by(1) {
526                for d in (0..=255u8).step_by(17) {
527                    let src = [
528                        crate::pixmap::mul255(s, sa),
529                        crate::pixmap::mul255(s, sa),
530                        crate::pixmap::mul255(s, sa),
531                        sa,
532                    ];
533                    let dest = [d, d, d, 255];
534                    assert_eq!(
535                        composite_premultiplied(dest, src, 255, BlendMode::Normal),
536                        general_route(dest, src, 255, BlendMode::Normal),
537                        "sa={sa} s={s} d={d}"
538                    );
539                }
540            }
541        }
542    }
543
544    #[test]
545    fn the_fast_path_holds_with_unequal_channels_and_partial_coverage() {
546        // The exhaustive case above moves all three channels together, which
547        // would hide a formula that accidentally read the wrong slot. This one
548        // moves them independently, and sweeps `coverage` as well — the byte
549        // the rasterizer folds in, which reaches the fast path through
550        // `src_alpha` rather than being multiplied in afterwards.
551        for sa in [0u8, 1, 63, 128, 200, 254, 255] {
552            for cov in [0u8, 1, 64, 127, 128, 200, 255] {
553                for (r, g, b) in [(0u8, 128u8, 255u8), (255, 1, 77), (13, 200, 4)] {
554                    let src = [
555                        crate::pixmap::mul255(r, sa),
556                        crate::pixmap::mul255(g, sa),
557                        crate::pixmap::mul255(b, sa),
558                        sa,
559                    ];
560                    for dest in [[0u8, 0, 0, 255], [255, 255, 255, 255], [9, 180, 70, 255]] {
561                        assert_eq!(
562                            composite_premultiplied(dest, src, cov, BlendMode::Normal),
563                            general_route(dest, src, cov, BlendMode::Normal),
564                            "sa={sa} cov={cov} rgb=({r},{g},{b}) dest={dest:?}"
565                        );
566                        assert_eq!(
567                            composite_premultiplied(dest, src, cov, BlendMode::Compatible),
568                            general_route(dest, src, cov, BlendMode::Compatible),
569                            "Compatible: sa={sa} cov={cov}"
570                        );
571                    }
572                }
573            }
574        }
575    }
576
577    #[test]
578    fn a_non_opaque_destination_does_not_take_the_fast_path() {
579        // The guard is `dest.a == 255`, and it has to be: at any lower alpha
580        // the output alpha is no longer 255, the premultiply-back stops being
581        // the identity, and the collapse the fast path performs is invalid.
582        // Checked by construction — every non-opaque destination must agree
583        // with the general route, which it does only by not taking the branch.
584        for da in [0u8, 1, 100, 254] {
585            for sa in [1u8, 90, 255] {
586                let src = [
587                    crate::pixmap::mul255(200, sa),
588                    crate::pixmap::mul255(50, sa),
589                    crate::pixmap::mul255(7, sa),
590                    sa,
591                ];
592                let dest = [
593                    crate::pixmap::mul255(30, da),
594                    crate::pixmap::mul255(220, da),
595                    crate::pixmap::mul255(90, da),
596                    da,
597                ];
598                assert_eq!(
599                    composite_premultiplied(dest, src, 255, BlendMode::Normal),
600                    general_route(dest, src, 255, BlendMode::Normal),
601                    "da={da} sa={sa}"
602                );
603            }
604        }
605    }
606
607    #[test]
608    fn every_other_blend_mode_still_takes_the_general_route() {
609        // The fast path is guarded on the mode as well as the alpha, and the
610        // guard names two modes out of sixteen. This walks the other fourteen
611        // over an opaque destination — the exact shape that would wrongly
612        // match if the mode check were dropped — and requires each to agree
613        // with the general route.
614        let modes = [
615            BlendMode::Multiply,
616            BlendMode::Screen,
617            BlendMode::Overlay,
618            BlendMode::Darken,
619            BlendMode::Lighten,
620            BlendMode::ColorDodge,
621            BlendMode::ColorBurn,
622            BlendMode::HardLight,
623            BlendMode::SoftLight,
624            BlendMode::Difference,
625            BlendMode::Exclusion,
626            BlendMode::Hue,
627            BlendMode::Saturation,
628            BlendMode::Color,
629            BlendMode::Luminosity,
630        ];
631        for mode in modes {
632            for sa in [1u8, 128, 255] {
633                let src = [
634                    crate::pixmap::mul255(180, sa),
635                    crate::pixmap::mul255(60, sa),
636                    crate::pixmap::mul255(240, sa),
637                    sa,
638                ];
639                let dest = [40u8, 200, 90, 255];
640                assert_eq!(
641                    composite_premultiplied(dest, src, 255, mode),
642                    general_route(dest, src, 255, mode),
643                    "{mode:?} sa={sa}"
644                );
645            }
646        }
647    }
648
649    #[test]
650    fn premultiplied_composite_agrees_with_the_straight_one() {
651        // The premultiplied spelling must be the straight one in a different
652        // buffer layout, not a second formula that drifts from it.
653        let dest_straight = ([10u8, 200, 30], 255u8);
654        let src_straight = ([250u8, 40, 90], 128u8);
655        let (want_rgb, want_a) =
656            composite_straight(dest_straight, src_straight, BlendMode::Multiply);
657        let premul = |(rgb, a): ([u8; 3], u8)| -> [u8; 4] {
658            [
659                crate::pixmap::mul255(rgb[0], a),
660                crate::pixmap::mul255(rgb[1], a),
661                crate::pixmap::mul255(rgb[2], a),
662                a,
663            ]
664        };
665        let got = composite_premultiplied(
666            premul(dest_straight),
667            premul(src_straight),
668            255,
669            BlendMode::Multiply,
670        );
671        let want = premul((want_rgb, want_a));
672        for i in 0..4 {
673            let (Some(&g), Some(&w)) = (got.get(i), want.get(i)) else {
674                continue;
675            };
676            assert!(g.abs_diff(w) <= 1, "channel {i}: {got:?} vs {want:?}");
677        }
678    }
679
680    #[test]
681    fn premultiplying_survives_the_trip_back() {
682        // The storage round trip a premultiplied buffer imposes must not lose
683        // a count, or every composited pixel drifts one low against a golden
684        // taken from the oracle's straight buffer. The witness that found
685        // this is 145 at alpha 223 -- `alpha_composite`'s overlap colour.
686        assert_eq!(premultiply_channel(145, 223), 127);
687        assert_eq!(
688            crate::pixmap::unpremultiply_rgb(127, 127, 127, 223),
689            [145, 145, 145]
690        );
691        // Premultiplication is genuinely lossy at low alpha — at alpha 1 the
692        // whole colour range collapses onto two representable values — so the
693        // property is not exactness but *optimality*: the round trip lands
694        // within the quantisation step the alpha imposes, and it is never
695        // worse than the truncating spelling. Exhaustively.
696        let mut rounding_wins = 0u32;
697        for a in 1..=255u8 {
698            let step = 255_u32.div_ceil(u32::from(a));
699            for c in 0..=255u8 {
700                let rounded =
701                    crate::pixmap::unpremultiply_rgb(premultiply_channel(c, a), 0, 0, a)[0];
702                let truncated =
703                    crate::pixmap::unpremultiply_rgb(crate::pixmap::mul255(c, a), 0, 0, a)[0];
704                let rounded_err = u32::from(rounded).abs_diff(u32::from(c));
705                let truncated_err = u32::from(truncated).abs_diff(u32::from(c));
706                assert!(rounded_err <= step, "c={c} a={a} drifted {rounded_err}");
707                assert!(
708                    rounded_err <= truncated_err,
709                    "c={c} a={a}: rounding lost to truncating"
710                );
711                if rounded_err < truncated_err {
712                    rounding_wins += 1;
713                }
714            }
715        }
716        // And it is not a distinction without a difference: rounding is
717        // strictly better on a large share of the 65 280 pairs.
718        assert!(
719            rounding_wins > 20_000,
720            "only {rounding_wins} pairs improved"
721        );
722    }
723
724    #[test]
725    fn zero_coverage_leaves_the_destination_alone() {
726        let dest = [1u8, 2, 3, 255];
727        assert_eq!(
728            composite_premultiplied(dest, [255, 255, 255, 255], 0, BlendMode::Normal),
729            dest
730        );
731    }
732
733    #[test]
734    fn full_coverage_opaque_source_replaces() {
735        let out =
736            composite_premultiplied([0, 0, 0, 255], [10, 20, 30, 255], 255, BlendMode::Normal);
737        assert_eq!(out, [10, 20, 30, 255]);
738    }
739
740    #[test]
741    fn coverage_scales_the_source_alpha_truncating() {
742        // Half coverage over an empty destination copies the source at half
743        // alpha, and the product truncates the way the oracle's does.
744        let out = composite_premultiplied([0, 0, 0, 0], [255, 0, 0, 255], 128, BlendMode::Normal);
745        assert_eq!(out[3], 128, "coverage becomes the alpha");
746    }
747
748    #[test]
749    fn the_form_field_highlight_composites_to_the_goldens_tint() {
750        // `FPDF_SetFormFieldHighlightColor(.., 0xFFE4DD)` with the default
751        // alpha 100: `FX_COLORREF` is BGR, so the straight colour is
752        // (0xDD, 0xE4, 0xFF), and over the page's opaque white the oracle's
753        // truncating `AlphaMerge` gives (241, 244, 255) — the tint every form
754        // golden in the corpus carries over its fields.
755        let white = [255u8, 255, 255, 255];
756        assert_eq!(
757            composite_solid(white, [0xDD, 0xE4, 0xFF], 100, 255, BlendMode::Normal),
758            [241, 244, 255, 255]
759        );
760        // And the premultiplied entry point cannot reach it: 221 is not one
761        // of the 101 straight reds a premultiplied byte at alpha 100 can hold,
762        // so storing it quantises down to 219 and the merge lands at 240.
763        // That one count, over 2482 px.
764        let stored = crate::pixmap::premultiply(peniko::Color::from_rgba8(0xDD, 0xE4, 0xFF, 100));
765        assert_eq!(
766            composite_premultiplied(white, stored, 255, BlendMode::Normal),
767            [240, 244, 255, 255],
768            "the round trip through premultiplied storage is what lost the count"
769        );
770    }
771
772    #[test]
773    fn a_solid_source_agrees_with_the_premultiplied_route_wherever_storage_is_lossless() {
774        // `composite_solid` is not a *different* composite, it is the same one
775        // entered before the lossy step. Where premultiplied storage happens
776        // to be exact — full alpha, and every straight value a lower alpha can
777        // represent — the two must return identical bytes, in every mode and
778        // at every coverage. Anything else would mean the new entry point had
779        // changed the arithmetic rather than skipped a quantisation.
780        let modes = [
781            BlendMode::Normal,
782            BlendMode::Multiply,
783            BlendMode::Screen,
784            BlendMode::Darken,
785            BlendMode::HardLight,
786            BlendMode::SoftLight,
787            BlendMode::Difference,
788            BlendMode::Luminosity,
789        ];
790        for mode in modes {
791            for sa in [1u8, 51, 85, 128, 170, 204, 255] {
792                for cov in [1u8, 64, 128, 255] {
793                    for rgb in [[0u8, 0, 0], [255, 255, 255], [51, 102, 153]] {
794                        let src = [
795                            crate::pixmap::mul255(rgb[0], sa),
796                            crate::pixmap::mul255(rgb[1], sa),
797                            crate::pixmap::mul255(rgb[2], sa),
798                            sa,
799                        ];
800                        // Only compare where storage really is lossless.
801                        if crate::pixmap::unpremultiply_rgb(src[0], src[1], src[2], sa) != rgb {
802                            continue;
803                        }
804                        for dest in [[255u8, 255, 255, 255], [0, 0, 0, 255], [40, 90, 20, 128]] {
805                            assert_eq!(
806                                composite_solid(dest, rgb, sa, cov, mode),
807                                composite_premultiplied(dest, src, cov, mode),
808                                "{mode:?} sa={sa} cov={cov} rgb={rgb:?} dest={dest:?}"
809                            );
810                        }
811                    }
812                }
813            }
814        }
815    }
816
817    #[test]
818    fn a_solid_source_at_zero_alpha_or_coverage_leaves_the_destination_alone() {
819        let dest = [1u8, 2, 3, 255];
820        assert_eq!(
821            composite_solid(dest, [255, 255, 255], 0, 255, BlendMode::Normal),
822            dest
823        );
824        assert_eq!(
825            composite_solid(dest, [255, 255, 255], 255, 0, BlendMode::Normal),
826            dest
827        );
828    }
829
830    #[test]
831    fn color_sqrt_is_piecewise_d_not_sqrt() {
832        // The table tracks ISO 32000-1 §11.3.5.2's piecewise
833        // `D(x) = if x <= 0.25 { ((16x-12)x+4)x } else { sqrt(x) }`, but it is
834        // **not** any closed form of it: 35 of the 256 entries differ from
835        // `round(255*D)` and 102 from `trunc(255*D)`, so it is hand-tuned and
836        // the transcription is the authority. The round-trip is not exact
837        // for all 256. What must hold is that no entry drifts more than one
838        // count from
839        // `D`, which pins the transcription against a typo.
840        for (i, entry) in COLOR_SQRT.iter().enumerate() {
841            #[expect(clippy::cast_precision_loss, reason = "i < 256 is exact in f64")]
842            let x = i as f64 / 255.0;
843            let d = if x <= 0.25 {
844                ((16.0 * x - 12.0) * x + 4.0) * x
845            } else {
846                x.sqrt()
847            };
848            let scaled = 255.0 * d;
849            let drift = (f64::from(*entry) - scaled).abs();
850            assert!(drift <= 1.0, "entry {i}: table {entry} vs D {scaled}");
851        }
852        // The low branch is a cubic, so entry 1 is 3. A "simplification" to a
853        // plain square root would put 16 there — wrong by 17 counts, not by
854        // rounding — and this assertion is what makes that fail loudly.
855        assert_eq!(COLOR_SQRT.get(1).copied(), Some(3));
856        #[expect(
857            clippy::cast_possible_truncation,
858            clippy::cast_sign_loss,
859            reason = "255*sqrt(1/255) rounds to 16, well inside u8"
860        )]
861        let naive = (255.0 * (1.0f64 / 255.0).sqrt()).round() as u8;
862        assert_eq!(naive, 16, "the wrong answer a plain sqrt would give");
863    }
864
865    #[test]
866    fn overlay_is_hardlight_swapped() {
867        for b in [0i32, 1, 63, 127, 128, 200, 255] {
868            for s in [0i32, 1, 63, 127, 128, 200, 255] {
869                assert_eq!(
870                    blend_channel(BlendMode::Overlay, b, s),
871                    blend_channel(BlendMode::HardLight, s, b),
872                    "back={b} src={s}"
873                );
874            }
875        }
876    }
877
878    #[test]
879    fn hardlight_threshold_128() {
880        // s == 127 takes the low branch, s == 128 the high one. A `2s <= 255`
881        // reading would put 127 and 128 on the same side of the split at
882        // s = 127.5 and agree here, but disagrees for a 0.5-scaled input;
883        // the direct assertion is the safe one.
884        assert_eq!(
885            blend_channel(BlendMode::HardLight, 200, 127),
886            (127 * 200 * 2) / 255
887        );
888        assert_eq!(
889            blend_channel(BlendMode::HardLight, 200, 128),
890            blend_channel(BlendMode::Screen, 200, 1)
891        );
892    }
893
894    #[test]
895    fn softlight_divides_twice() {
896        // The low branch spells its scaling as `/255/255`, not `/65025`.
897        // Exhaustively, that is not a truncation trap: on this branch the
898        // numerator is always non-negative (since `src < 128` makes
899        // `255 - 2*src >= 1`), and truncating twice by 255 equals truncating
900        // once by 65025 for every non-negative value. The spelling is still
901        // ported verbatim, and this test pins the equivalence so a future
902        // negative-input path cannot silently change meaning.
903        for back in 0..=255i32 {
904            for src in 0..128i32 {
905                let n = (255 - 2 * src) * back * (255 - back);
906                assert_eq!(
907                    back - n / 255 / 255,
908                    back - n / 65025,
909                    "back={back} src={src}"
910                );
911                assert_eq!(
912                    blend_channel(BlendMode::SoftLight, back, src),
913                    back - n / 255 / 255
914                );
915            }
916        }
917    }
918
919    #[test]
920    fn softlight_high_branch_uses_the_table() {
921        let (back, src) = (7i32, 200i32);
922        let d = i32::from(COLOR_SQRT.get(7).copied().unwrap_or(0));
923        assert_eq!(
924            blend_channel(BlendMode::SoftLight, back, src),
925            back + (2 * src - 255) * (d - back) / 255
926        );
927    }
928
929    #[test]
930    fn separable_formulas() {
931        // Ports core/fxge/dib/blend_unittest.cpp's per-mode expectations.
932        assert_eq!(blend_channel(BlendMode::Normal, 100, 200), 200);
933        assert_eq!(
934            blend_channel(BlendMode::Multiply, 100, 200),
935            200 * 100 / 255
936        );
937        assert_eq!(
938            blend_channel(BlendMode::Screen, 100, 200),
939            200 + 100 - 200 * 100 / 255
940        );
941        assert_eq!(blend_channel(BlendMode::Darken, 100, 200), 100);
942        assert_eq!(blend_channel(BlendMode::Lighten, 100, 200), 200);
943        assert_eq!(blend_channel(BlendMode::Difference, 100, 200), 100);
944        assert_eq!(
945            blend_channel(BlendMode::Exclusion, 100, 200),
946            100 + 200 - 2 * 100 * 200 / 255
947        );
948        assert_eq!(blend_channel(BlendMode::ColorDodge, 100, 255), 255);
949        assert_eq!(blend_channel(BlendMode::ColorBurn, 100, 0), 0);
950    }
951
952    #[test]
953    fn clip_color_ordering() {
954        // A colour that trips both branches: the x > 255 arm must still use
955        // the pre-clip `l` and `x` while reading channels the n < 0 arm wrote.
956        let c = [-50, 128, 300];
957        let l = lum(c);
958        let n = -50;
959        let x = 300;
960        let mut manual = c;
961        for ch in &mut manual {
962            *ch = l + ((*ch - l) * l / (l - n));
963        }
964        for ch in &mut manual {
965            *ch = l + ((*ch - l) * (255 - l) / (x - l));
966        }
967        assert_eq!(clip_color(c), manual);
968    }
969
970    #[test]
971    fn transparent_backdrop_skips_blend() {
972        // dest.a == 0 copies the source verbatim, whatever the mode.
973        for mode in [
974            BlendMode::Multiply,
975            BlendMode::Difference,
976            BlendMode::Luminosity,
977        ] {
978            assert_eq!(
979                composite_straight(([9, 9, 9], 0), ([1, 2, 3], 200), mode),
980                ([1, 2, 3], 200)
981            );
982        }
983    }
984
985    #[test]
986    fn nonseparable_modes_use_the_backdrop_luminosity() {
987        // Luminosity(back, src) takes the source's luminosity onto the
988        // backdrop's colour; with a black source the result is black.
989        assert_eq!(
990            blend_rgb(BlendMode::Luminosity, [10, 20, 30], [0, 0, 0]),
991            [0, 0, 0]
992        );
993        // Color(src, Lum(back)) keeps the source's hue and saturation. The
994        // target luminosity is only *approximately* preserved, because
995        // ClipColor rescales channels driven past the 0/255 boundary using
996        // the pre-clip bounds — which is precisely the ordering §6.2 calls
997        // out as amplifying rounding on these four modes.
998        let out = blend_rgb(BlendMode::Color, [128, 128, 128], [255, 0, 0]);
999        let target = lum([128, 128, 128]);
1000        assert!(
1001            lum(out.map(i32::from)).abs_diff(target) <= 3,
1002            "Color landed at {:?}, luminosity {} vs {target}",
1003            out,
1004            lum(out.map(i32::from))
1005        );
1006        // Saturation and Hue both read the backdrop's luminosity too.
1007        for mode in [BlendMode::Hue, BlendMode::Saturation] {
1008            let out = blend_rgb(mode, [40, 40, 40], [200, 10, 10]);
1009            assert!(
1010                lum(out.map(i32::from)).abs_diff(lum([40, 40, 40])) <= 3,
1011                "{mode:?}"
1012            );
1013        }
1014    }
1015}