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}