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}