Skip to content

Commit c63248e

Browse files
authored
Merge pull request #703 from zxq82lm/fix/hslcolor-hue-wrap
fix: wrap hue in HSLColor + add from_degrees
2 parents 48b01c4 + 3809561 commit c63248e

File tree

1 file changed

+126
-5
lines changed

1 file changed

+126
-5
lines changed

plotters/src/style/color.rs

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use plotters_backend::{BackendColor, BackendStyle};
66
#[cfg(feature = "serialization")]
77
use serde::{Deserialize, Serialize};
88

9+
use std::fmt;
910
use std::marker::PhantomData;
1011

1112
/// Any color representation
@@ -139,15 +140,67 @@ impl BackendStyle for RGBColor {
139140
#[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))]
140141
pub struct HSLColor(pub f64, pub f64, pub f64);
141142

143+
/// Errors that can occur when constructing an `HSLColor`.
144+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145+
pub enum HSLColorError {
146+
/// Hue (or degrees input) must be finite.
147+
NonFiniteHue,
148+
/// Saturation must be in the closed interval `[0, 1]`.
149+
SaturationOutOfRange,
150+
/// Lightness must be in the closed interval `[0, 1]`.
151+
LightnessOutOfRange,
152+
}
153+
154+
impl fmt::Display for HSLColorError {
155+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156+
match self {
157+
HSLColorError::NonFiniteHue => f.write_str("hue must be finite"),
158+
HSLColorError::SaturationOutOfRange => f.write_str("saturation must be in [0, 1]"),
159+
HSLColorError::LightnessOutOfRange => f.write_str("lightness must be in [0, 1]"),
160+
}
161+
}
162+
}
163+
164+
impl std::error::Error for HSLColorError {}
165+
166+
impl HSLColor {
167+
/// Creates an `HSLColor` from normalized components, returning an error if any are out of range.
168+
pub fn try_new(h: f64, s: f64, l: f64) -> Result<Self, HSLColorError> {
169+
if !h.is_finite() {
170+
return Err(HSLColorError::NonFiniteHue);
171+
}
172+
if !s.is_finite() || s < 0.0 || s > 1.0 {
173+
return Err(HSLColorError::SaturationOutOfRange);
174+
}
175+
if !l.is_finite() || l < 0.0 || l > 1.0 {
176+
return Err(HSLColorError::LightnessOutOfRange);
177+
}
178+
Ok(Self(h, s, l))
179+
}
180+
181+
/// Creates an `HSLColor` from degrees, wrapping into `[0, 360)` before normalizing.
182+
/// Prefer this helper when specifying hue in degrees. Returns an error if saturation
183+
/// or lightness fall outside `[0, 1]` or if the input hue is non-finite.
184+
#[inline]
185+
pub fn from_degrees(h_deg: f64, s: f64, l: f64) -> Result<Self, HSLColorError> {
186+
if !h_deg.is_finite() {
187+
return Err(HSLColorError::NonFiniteHue);
188+
}
189+
Self::try_new(h_deg.rem_euclid(360.0) / 360.0, s, l)
190+
}
191+
}
192+
142193
impl Color for HSLColor {
143194
#[inline(always)]
144195
#[allow(clippy::many_single_char_names)]
145196
fn to_backend_color(&self) -> BackendColor {
146-
let (h, s, l) = (
147-
self.0.clamp(0.0, 1.0),
148-
self.1.clamp(0.0, 1.0),
149-
self.2.clamp(0.0, 1.0),
150-
);
197+
// Hue is expected normalized in [0,1); wrap to keep negative or slightly
198+
// out-of-range inputs usable, but do not reinterpret raw degrees.
199+
let h = self.0.rem_euclid(1.0);
200+
201+
// Saturation & lightness remain clamped to valid ranges
202+
let s = self.1.clamp(0.0, 1.0);
203+
let l = self.2.clamp(0.0, 1.0);
151204

152205
if s == 0.0 {
153206
let value = (l * 255.0).round() as u8;
@@ -189,3 +242,71 @@ impl Color for HSLColor {
189242
}
190243
}
191244
}
245+
246+
#[cfg(test)]
247+
mod hue_robustness_tests {
248+
use super::*;
249+
250+
#[test]
251+
fn degrees_passed_via_helper_should_work_for_common_cases() {
252+
let red = HSLColor::from_degrees(0.0, 1.0, 0.5)
253+
.unwrap()
254+
.to_backend_color()
255+
.rgb;
256+
assert_eq!(red, (255, 0, 0));
257+
258+
let green = HSLColor::from_degrees(120.0, 1.0, 0.5)
259+
.unwrap()
260+
.to_backend_color()
261+
.rgb;
262+
assert_eq!(green, (0, 255, 0));
263+
264+
let blue = HSLColor::from_degrees(240.0, 1.0, 0.5)
265+
.unwrap()
266+
.to_backend_color()
267+
.rgb;
268+
assert_eq!(blue, (0, 0, 255));
269+
}
270+
271+
#[test]
272+
fn from_degrees_wraps_and_matches_normalized() {
273+
let normalized = HSLColor(120.0 / 360.0, 1.0, 0.5).to_backend_color().rgb;
274+
let via_helper = HSLColor::from_degrees(120.0, 1.0, 0.5)
275+
.unwrap()
276+
.to_backend_color()
277+
.rgb;
278+
assert_eq!(normalized, via_helper);
279+
280+
let wrap_positive =
281+
HSLColor::from_degrees(720.0, 1.0, 0.5).unwrap().to_backend_color().rgb;
282+
let wrap_negative =
283+
HSLColor::from_degrees(-120.0, 1.0, 0.5).unwrap().to_backend_color().rgb;
284+
let canonical =
285+
HSLColor::from_degrees(0.0, 1.0, 0.5).unwrap().to_backend_color().rgb;
286+
287+
assert_eq!(wrap_positive, canonical);
288+
assert_eq!(
289+
wrap_negative,
290+
HSLColor::from_degrees(240.0, 1.0, 0.5)
291+
.unwrap()
292+
.to_backend_color()
293+
.rgb
294+
);
295+
}
296+
297+
#[test]
298+
fn from_degrees_rejects_out_of_range_components() {
299+
assert!(matches!(
300+
HSLColor::from_degrees(0.0, -0.1, 0.5),
301+
Err(HSLColorError::SaturationOutOfRange)
302+
));
303+
assert!(matches!(
304+
HSLColor::from_degrees(0.0, 0.5, 1.1),
305+
Err(HSLColorError::LightnessOutOfRange)
306+
));
307+
assert!(matches!(
308+
HSLColor::from_degrees(f64::INFINITY, 0.5, 0.5),
309+
Err(HSLColorError::NonFiniteHue)
310+
));
311+
}
312+
}

0 commit comments

Comments
 (0)