@@ -6,6 +6,7 @@ use plotters_backend::{BackendColor, BackendStyle};
66#[ cfg( feature = "serialization" ) ]
77use serde:: { Deserialize , Serialize } ;
88
9+ use std:: fmt;
910use std:: marker:: PhantomData ;
1011
1112/// Any color representation
@@ -139,15 +140,67 @@ impl BackendStyle for RGBColor {
139140#[ cfg_attr( feature = "serialization" , derive( Serialize , Deserialize ) ) ]
140141pub 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+
142193impl 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