Skip to main content

rasterrocket_color/
convert.rs

1//! Shared arithmetic primitives used throughout the rasterizer.
2//!
3//! All compositing math lives here — never copy-pasted into callers.
4//!
5//! # Functions
6//!
7//! **Integer blend math**
8//! - [`div255`] — fast approximate division by 255
9//! - [`lerp_u8`] — bilinear interpolation between two bytes
10//!
11//! **Color-space conversion (u8 domain)**
12//! - [`cmyk_to_rgb`] — simple subtractive CMYK → RGB
13//! - [`cmyk_to_rgb_reflectance`] — reflectance formula for raw JPEG/CMYK pixels
14//!
15//! **Color-space conversion (f64 → u8, normalised PDF values)**
16//! - [`gray_to_u8`] — normalised grey \[0,1\] → byte
17//! - [`rgb_to_bytes`] — normalised RGB \[0,1\] → 3-byte array
18//! - [`cmyk_to_rgb_bytes`] — normalised CMYK \[0,1\] → RGB bytes via PDF §10.3.3
19//!
20//! **Geometry rounding**
21//! - [`splash_floor`] — floor toward −∞, returns i32
22//! - [`splash_ceil`] — ceil toward +∞, returns i32
23//! - [`splash_round`] — round half-integers toward +∞
24
25// ── Integer blend math ────────────────────────────────────────────────────────
26
27/// Fast approximate division by 255.
28///
29/// Uses the identity `(x + (x >> 8) + 0x80) >> 8` which gives the nearest
30/// integer to `x / 255.0` for all `x` in the valid input range.
31///
32/// # Valid input range
33///
34/// `x` must be in \[0, 65535\]. Inputs larger than 65535 are not meaningful
35/// (the maximum product of two u8 values is 255 × 255 = 65025), and values
36/// above 65279 saturate: the formula yields 256 which is clamped to 255.
37///
38/// # Output range
39///
40/// Always \[0, 255\].
41///
42/// # Saturation note
43///
44/// For `x` in \[65280, 65535\] the unmasked result would be 256; the `.min(255)`
45/// clamp makes those values return 255. In practice `x` is always a product
46/// `a * b` with `a, b ∈ [0, 255]`, so the maximum is 65025 and the clamp is
47/// never reached.
48#[inline]
49#[must_use]
50pub fn div255(x: u32) -> u8 {
51    // The intermediate value (x + (x>>8) + 0x80) can reach at most
52    // 65535 + 255 + 128 = 65918, which fits in u32. The right-shift by 8
53    // gives at most 257; clamping to 255 makes the cast to u8 always safe.
54    let shifted = (x + (x >> 8) + 0x80) >> 8;
55    // `shifted ≤ 257` before clamping; `.min(255)` makes the `as u8` cast lossless.
56    shifted.min(255) as u8
57}
58
59/// Bilinear interpolation between two `u8` values.
60///
61/// Computes `a * (1 − t/256) + b * (t/256)` using integer arithmetic via
62/// [`div255`].
63///
64/// # Valid input range
65///
66/// `t` must be in \[0, 256\]. Values outside this range are a caller bug:
67/// `256 - t` would wrap (u32 subtraction), producing a nonsensical result.
68/// A `debug_assert!` catches this in debug builds.
69///
70/// # Output range
71///
72/// Always \[0, 255\].
73///
74/// # Endpoints
75///
76/// - `t = 0` → `div255(a * 256)`, which equals `a` within ±1.  The `div255`
77///   approximation means the result can be off by 1 for some values of `a`
78///   (e.g. `lerp_u8(128, _, 0)` returns 129). If callers need exact identity
79///   at `t = 0` they should special-case it.
80/// - `t = 256` → `a * 0 + b * 256`; after `div255` this rounds to `b` within
81///   ±1 (inherent to the `div255` approximation).
82#[inline]
83#[must_use]
84pub fn lerp_u8(a: u8, b: u8, t: u32) -> u8 {
85    debug_assert!(t <= 256, "lerp_u8: t={t} out of range [0, 256]");
86    div255(u32::from(a) * (256 - t) + u32::from(b) * t)
87}
88
89// ── Color space conversion ────────────────────────────────────────────────────
90
91/// CMYK → RGB using the simple subtractive model.
92///
93/// `R = 255 − (C + K)`, clamped to \[0, 255\], and similarly for G and B.
94///
95/// # Arguments
96///
97/// All inputs in \[0, 255\].
98///
99/// # Output range
100///
101/// Each output channel is in \[0, 255\].
102///
103/// # Saturation note
104///
105/// When `c + k > 255` the sum would exceed 255, so `saturating_sub` clamps
106/// the result to 0. This correctly models full ink coverage producing black.
107///
108/// # Distinction from other CMYK variants
109///
110/// - [`cmyk_to_rgb_reflectance`]: uses the reflectance formula
111///   `R = (255−C)×(255−K)/255` (rounded), for raw JPEG/CMYK pixel data.
112/// - `rasterrocket_interp::renderer::color::cmyk_to_rgb_bytes`: takes normalised f64
113///   inputs per PDF §10.3.3 (`R = 1−min(1, C+K)`), for PDF colour operators.
114#[inline]
115#[must_use]
116pub const fn cmyk_to_rgb(c: u8, m: u8, y: u8, k: u8) -> (u8, u8, u8) {
117    // Chained `u8::saturating_sub` stays in `u8` space — once a channel
118    // saturates to 0, the second subtraction is a no-op, so the result
119    // equals `255u32.saturating_sub(c + k)` clamped to `u8`.
120    (
121        255u8.saturating_sub(c).saturating_sub(k),
122        255u8.saturating_sub(m).saturating_sub(k),
123        255u8.saturating_sub(y).saturating_sub(k),
124    )
125}
126
127/// Blend one ink channel against the key: `((255−ink) × (255−k) + 127) / 255`.
128///
129/// Max product is `255 × 255 + 127 = 65 152`, which divides to 255 — fits `u8`.
130#[inline]
131fn reflectance_blend(ink: u8, inv_k: u32) -> u8 {
132    // `(255−ink)×(255−k) ≤ 65025` and `+127` then `/255` keeps the result
133    // in `[0, 255]`. `.expect` matches the workspace convention for proving
134    // small-domain u32→u8 narrowings (see `color::transfer`, `raster::state`).
135    u8::try_from((u32::from(255 - ink) * inv_k + 127) / 255)
136        .expect("reflectance_blend: ((255−ink)×inv_k+127)/255 ≤ 255")
137}
138
139/// CMYK → RGB via the reflectance formula: `R = (255−C)×(255−K)/255` (rounded).
140///
141/// Used for raw JPEG/CMYK pixel data where channels represent ink density.
142/// The `+127` bias before dividing by 255 removes truncation error; the
143/// numerator `(255−ch)×(255−k)+127 ≤ 255×255+127 = 65152` fits in `u32`.
144///
145/// # Distinction from other CMYK variants
146///
147/// - [`cmyk_to_rgb`]: simple saturating-subtract `R = 255−(C+K)`.
148///   Faster but less accurate for mid-tones.
149/// - [`cmyk_to_rgb_bytes`]: takes normalised `f64` inputs per PDF §10.3.3.
150///
151/// All inputs and outputs in \[0, 255\].
152#[inline]
153#[must_use]
154pub fn cmyk_to_rgb_reflectance(c: u8, m: u8, y: u8, k: u8) -> (u8, u8, u8) {
155    let inv_k = u32::from(255 - k);
156    (
157        reflectance_blend(c, inv_k),
158        reflectance_blend(m, inv_k),
159        reflectance_blend(y, inv_k),
160    )
161}
162
163// ── f64 → u8 conversions ─────────────────────────────────────────────────────
164
165/// Convert a normalised PDF value \[0.0, 1.0\] to a `u8` byte.
166///
167/// Clamps then rounds. `f64::clamp(NaN, 0.0, 1.0)` returns `NaN` (clamp does
168/// not sanitise NaN); the subsequent `as u8` cast then saturates `NaN` to 0
169/// per Rust's float-to-int rules, so NaN inputs map to 0.
170/// Used for PDF colour operators where channel components are normalised floats.
171#[inline]
172#[must_use]
173#[expect(
174    clippy::cast_possible_truncation,
175    clippy::cast_sign_loss,
176    reason = "value is clamped to [0, 1] and scaled to [0.0, 255.0]; round() output fits u8"
177)]
178pub fn gray_to_u8(v: f64) -> u8 {
179    (v.clamp(0.0, 1.0) * 255.0).round() as u8
180}
181
182/// Convert three normalised PDF RGB components to `[r, g, b]` bytes.
183///
184/// Each channel is clamped to \[0.0, 1.0\] independently.
185#[inline]
186#[must_use]
187pub fn rgb_to_bytes(r: f64, g: f64, b: f64) -> [u8; 3] {
188    [gray_to_u8(r), gray_to_u8(g), gray_to_u8(b)]
189}
190
191/// Convert PDF CMYK \[0.0, 1.0\] to RGB bytes via PDF §10.3.3 formula.
192///
193/// `R = 1 − min(1, C + K)`, clamped per channel.
194///
195/// # Distinction from other CMYK variants
196///
197/// - [`cmyk_to_rgb`]: takes `u8` inputs with saturating-subtract.
198/// - [`cmyk_to_rgb_reflectance`]: takes `u8` inputs with reflectance product formula.
199/// - This function: takes normalised `f64` inputs for use with PDF colour operators.
200#[inline]
201#[must_use]
202#[expect(
203    clippy::many_single_char_names,
204    reason = "CMYK and RGB are conventional single-letter colour channel names"
205)]
206pub fn cmyk_to_rgb_bytes(c: f64, m: f64, y: f64, k: f64) -> [u8; 3] {
207    let k = k.clamp(0.0, 1.0);
208    let r = 1.0 - (c.clamp(0.0, 1.0) + k).min(1.0);
209    let g = 1.0 - (m.clamp(0.0, 1.0) + k).min(1.0);
210    let b = 1.0 - (y.clamp(0.0, 1.0) + k).min(1.0);
211    rgb_to_bytes(r, g, b)
212}
213
214// ── Geometry rounding (matching SplashMath.h portable fallbacks) ──────────────
215
216/// Saturating cast of an integer-valued `f64` to `i32`.
217///
218/// Non-finite inputs map to `i32::MAX` for `+∞` and `i32::MIN` for `-∞` / NaN.
219/// Finite values outside the `i32` range saturate at the nearest endpoint.
220#[inline]
221fn saturate_f64_to_i32(x: f64) -> i32 {
222    if !x.is_finite() {
223        return if x == f64::INFINITY {
224            i32::MAX
225        } else {
226            i32::MIN
227        };
228    }
229    // x is finite: casting to i64 is well-defined for any finite f64 whose
230    // magnitude fits in i64 (which covers all practical PDF coordinates);
231    // try_from saturates the rare case of very large floats.
232    #[expect(
233        clippy::cast_possible_truncation,
234        reason = "f64 → i64 cast; try_from on the next line saturates out-of-range values"
235    )]
236    let v = x as i64;
237    i32::try_from(v).unwrap_or(if v > 0 { i32::MAX } else { i32::MIN })
238}
239
240/// Floor toward −∞, returning `i32`.
241///
242/// Equivalent to C++ `splashFloor` — matches the portable fallback path.
243///
244/// # Valid input range
245///
246/// Any `f64`. For PDF coordinates, values are always finite and well within
247/// i32 range.
248///
249/// # Edge cases
250///
251/// - Finite values outside \[`i32::MIN`, `i32::MAX`\]: saturate to
252///   `i32::MIN` or `i32::MAX` respectively.
253/// - `NaN` or ±infinity: `is_finite()` check returns `i32::MIN` for any
254///   non-finite input (conservatively safe — callers must not rely on this
255///   specific value for non-finite inputs).
256///
257/// # Panic
258///
259/// Never panics.
260#[inline]
261#[must_use]
262pub fn splash_floor(x: f64) -> i32 {
263    saturate_f64_to_i32(x.floor())
264}
265
266/// Ceil toward +∞, returning `i32`.
267///
268/// Equivalent to C++ `splashCeil` — matches the portable fallback path.
269///
270/// # Valid input range
271///
272/// Any `f64`. See [`splash_floor`] for edge-case behaviour.
273///
274/// # Edge cases
275///
276/// Same as [`splash_floor`]: non-finite inputs return `i32::MAX` (for +∞) or
277/// `i32::MIN` (for −∞ and NaN).
278///
279/// # Panic
280///
281/// Never panics.
282#[inline]
283#[must_use]
284pub fn splash_ceil(x: f64) -> i32 {
285    saturate_f64_to_i32(x.ceil())
286}
287
288/// Round half-integers toward +∞, returning `i32`.
289///
290/// Implements `floor(x + 0.5)`. This means:
291/// - 0.5 rounds to 1 (toward +∞).
292/// - −0.5 rounds to 0 (toward +∞, i.e. not away from zero).
293///
294/// Equivalent to C++ `splashRound`.
295///
296/// # Valid input range
297///
298/// Any `f64`. See [`splash_floor`] for edge-case behaviour on non-finite inputs.
299///
300/// # Panic
301///
302/// Never panics.
303#[inline]
304#[must_use]
305pub fn splash_round(x: f64) -> i32 {
306    splash_floor(x + 0.5)
307}
308
309// ─────────────────────────────────────────────────────────────────────────────
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn div255_exhaustive() {
317        for x in 0u32..=65535 {
318            let got = f64::from(div255(x));
319            // div255 returns u8, so saturate the expected value at 255.
320            let expected = (f64::from(x) / 255.0).round().min(255.0);
321            assert!(
322                (got - expected).abs() <= 1.0,
323                "div255({x}) = {got}, expected ≈ {expected}"
324            );
325        }
326    }
327
328    #[test]
329    fn div255_boundary_products() {
330        // all a*b products where a,b ∈ [0,255]
331        for a in 0u32..=255 {
332            for b in 0u32..=255 {
333                let got = f64::from(div255(a * b));
334                let expected = (f64::from(a) * f64::from(b) / 255.0).round();
335                assert!(
336                    (got - expected).abs() <= 1.0,
337                    "div255({a}*{b}) = {got}, expected ≈ {expected}"
338                );
339            }
340        }
341    }
342
343    #[test]
344    fn lerp_endpoints() {
345        // t=0 must return exactly a.
346        assert_eq!(lerp_u8(100, 200, 0), 100);
347        // t=256: a*(256-256) + b*256; div255(200*256) = div255(51200).
348        // 51200/255 ≈ 200.78, rounds to 201 — within ±1 of b=200.
349        let v = lerp_u8(100, 200, 256);
350        assert!(
351            (i32::from(v) - 200).abs() <= 1,
352            "lerp t=256 gave {v}, expected ≈200"
353        );
354    }
355
356    /// `lerp_u8` with `t=0` returns `div255(a * 256)`, which is within ±1 of `a`.
357    /// The result must not depend on `b`.
358    #[test]
359    fn lerp_t0_near_a() {
360        for a in 0u8..=255 {
361            let v0 = lerp_u8(a, 0, 0);
362            let v255 = lerp_u8(a, 255, 0);
363            // Result must be independent of b.
364            assert_eq!(
365                v0, v255,
366                "lerp_u8({a}, b, 0) must not depend on b: got {v0} vs {v255}"
367            );
368            // Result must be within ±1 of a (div255 approximation).
369            assert!(
370                (i32::from(v0) - i32::from(a)).abs() <= 1,
371                "lerp_u8({a}, _, 0) = {v0}, expected within ±1 of {a}"
372            );
373        }
374    }
375
376    #[test]
377    fn splash_floor_ceil_round() {
378        let cases = [
379            (0.0f64, 0, 0, 0),
380            (0.5, 0, 1, 1),
381            (0.9, 0, 1, 1),
382            (1.0, 1, 1, 1),
383            (-0.1, -1, 0, 0),
384            (-0.5, -1, 0, 0),
385            (-0.6, -1, 0, -1),
386            (-1.0, -1, -1, -1),
387        ];
388        for (x, fl, ce, ro) in cases {
389            assert_eq!(splash_floor(x), fl, "floor({x})");
390            assert_eq!(splash_ceil(x), ce, "ceil({x})");
391            assert_eq!(splash_round(x), ro, "round({x})");
392        }
393    }
394
395    /// Half-integer tie-breaking: 0.5 → 1, −0.5 → 0 (toward +∞).
396    #[test]
397    fn splash_round_half_integers() {
398        assert_eq!(splash_round(0.5), 1, "0.5 rounds toward +inf");
399        assert_eq!(splash_round(-0.5), 0, "-0.5 rounds toward +inf (i.e. 0)");
400        assert_eq!(splash_round(1.5), 2);
401        assert_eq!(splash_round(-1.5), -1);
402    }
403
404    /// Non-finite inputs must not invoke UB and must return a defined sentinel.
405    #[test]
406    fn splash_floor_ceil_round_non_finite() {
407        // +∞ — INFINITY + 0.5 is still INFINITY, so splash_round goes to i32::MAX.
408        assert_eq!(splash_floor(f64::INFINITY), i32::MAX);
409        assert_eq!(splash_ceil(f64::INFINITY), i32::MAX);
410        assert_eq!(splash_round(f64::INFINITY), i32::MAX);
411        // −∞
412        assert_eq!(splash_floor(f64::NEG_INFINITY), i32::MIN);
413        assert_eq!(splash_ceil(f64::NEG_INFINITY), i32::MIN);
414        assert_eq!(splash_round(f64::NEG_INFINITY), i32::MIN);
415        // NaN — treated as non-positive (returns i32::MIN). NaN + 0.5 is NaN
416        // so splash_round also returns i32::MIN.
417        assert_eq!(splash_floor(f64::NAN), i32::MIN);
418        assert_eq!(splash_ceil(f64::NAN), i32::MIN);
419        assert_eq!(splash_round(f64::NAN), i32::MIN);
420    }
421
422    // ── gray_to_u8 ────────────────────────────────────────────────────────────
423
424    #[test]
425    fn gray_extremes() {
426        assert_eq!(gray_to_u8(0.0), 0);
427        assert_eq!(gray_to_u8(1.0), 255);
428    }
429
430    #[test]
431    fn gray_clamped() {
432        assert_eq!(gray_to_u8(-1.0), 0);
433        assert_eq!(gray_to_u8(2.0), 255);
434    }
435
436    /// NaN must map to 0 — `f64::clamp` returns NaN unchanged, then Rust's
437    /// float-to-int saturation maps NaN → 0.
438    #[test]
439    fn gray_nan_is_zero() {
440        assert_eq!(gray_to_u8(f64::NAN), 0);
441    }
442
443    // ── cmyk_to_rgb_bytes ─────────────────────────────────────────────────────
444
445    #[test]
446    fn cmyk_bytes_black() {
447        assert_eq!(cmyk_to_rgb_bytes(0.0, 0.0, 0.0, 1.0), [0, 0, 0]);
448    }
449
450    #[test]
451    fn cmyk_bytes_white() {
452        assert_eq!(cmyk_to_rgb_bytes(0.0, 0.0, 0.0, 0.0), [255, 255, 255]);
453    }
454
455    /// NaN inputs in the formula `1 − min(c+k, 1)` go through `f64::min`,
456    /// which returns the non-NaN argument (`1.0`), so `1 − 1 = 0` lands in
457    /// the affected channel. With `k = NaN`, every channel's expression
458    /// involves the NaN, so every output byte is 0.
459    #[test]
460    fn cmyk_bytes_nan_channel_is_zero() {
461        assert_eq!(cmyk_to_rgb_bytes(f64::NAN, 0.0, 0.0, 0.0), [0, 255, 255]);
462        assert_eq!(cmyk_to_rgb_bytes(0.0, 0.0, 0.0, f64::NAN), [0, 0, 0]);
463    }
464
465    // ── cmyk_to_rgb_reflectance ───────────────────────────────────────────────
466
467    /// `cmyk_to_rgb_reflectance` — all-zero ink (no ink) must produce white.
468    #[test]
469    fn cmyk_reflectance_no_ink_is_white() {
470        assert_eq!(cmyk_to_rgb_reflectance(0, 0, 0, 0), (255, 255, 255));
471    }
472
473    /// `cmyk_to_rgb_reflectance` — full K (key/black) must produce black.
474    #[test]
475    fn cmyk_reflectance_full_k_is_black() {
476        assert_eq!(cmyk_to_rgb_reflectance(0, 0, 0, 255), (0, 0, 0));
477    }
478
479    /// `cmyk_to_rgb_reflectance` — C=255, no K → R=0, G=B=255.
480    #[test]
481    fn cmyk_reflectance_full_cyan_no_k() {
482        let (r, g, b) = cmyk_to_rgb_reflectance(255, 0, 0, 0);
483        assert_eq!(r, 0);
484        assert_eq!(g, 255);
485        assert_eq!(b, 255);
486    }
487
488    /// `cmyk_to_rgb_reflectance` — midtone C=128 gives R ≈ 127–128.
489    #[test]
490    fn cmyk_reflectance_midtone() {
491        let (r, g, b) = cmyk_to_rgb_reflectance(128, 0, 0, 0);
492        assert!((127..=128).contains(&r), "r={r}");
493        assert_eq!(g, 255);
494        assert_eq!(b, 255);
495    }
496
497    /// `cmyk_to_rgb` saturation: when c+k > 255 the channel must be 0.
498    #[test]
499    fn cmyk_saturation() {
500        // c=200, k=200 → c+k=400 → saturating_sub → 0, red=clip255(0)=0
501        let (r, g, b) = cmyk_to_rgb(200, 0, 0, 200);
502        assert_eq!(r, 0, "saturated red channel must be 0");
503        assert_eq!(g, 55, "green = 255 - 200 = 55");
504        assert_eq!(b, 55, "blue  = 255 - 200 = 55");
505
506        // Full black: all channels 0.
507        let (r, g, b) = cmyk_to_rgb(0, 0, 0, 255);
508        assert_eq!((r, g, b), (0, 0, 0));
509
510        // No ink: all channels 255.
511        let (r, g, b) = cmyk_to_rgb(0, 0, 0, 0);
512        assert_eq!((r, g, b), (255, 255, 255));
513    }
514}