diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4080d..6017ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This release has an [MSRV][] of 1.86. ### Added +- `AlphaColor::::from_hex` and `OpaqueColor::::from_hex`, which can be used to convert const colors from an in-editor color picker. ([#217][] by [@l0uisgrange][] and [@DJMcNab][]) - Add a `const` `DynamicColor::new` constructor for convenience, taking a color space tag and color components, and setting default `Flags`. ([#219][] by [@tomcur][]) ### Changed @@ -175,6 +176,7 @@ This is the initial release. [@ajakubowicz-canva]: https://github.com/ajakubowicz-canva [@alvinisspicy]: https://github.com/alvinisspicy [@DJMcNab]: https://github.com/DJMcNab +[@l0uisgrange]: https://github.com/l0uisgrange [@LaurenzV]: https://github.com/LaurenzV [@MightyBurger]: https://github.com/MightyBurger [@raphlinus]: https://github.com/raphlinus @@ -234,6 +236,7 @@ This is the initial release. [#202]: https://github.com/linebender/color/pull/202 [#210]: https://github.com/linebender/color/pull/210 [#211]: https://github.com/linebender/color/pull/211 +[#217]: https://github.com/linebender/color/pull/217 [#218]: https://github.com/linebender/color/pull/218 [#219]: https://github.com/linebender/color/pull/219 diff --git a/color/src/lib.rs b/color/src/lib.rs index a7fbb20..ac61741 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -109,6 +109,7 @@ mod impl_bytemuck; #[cfg(all(not(feature = "std"), not(test)))] mod floatfuncs; +use crate::parse::{get_4bit_hex_channels, rgba_from_4bit_hex}; pub use chromaticity::Chromaticity; pub use color::{AlphaColor, HueDirection, OpaqueColor, PremulColor}; pub use colorspace::{ @@ -189,6 +190,44 @@ impl AlphaColor { let components = [u8_to_f32(r), u8_to_f32(g), u8_to_f32(b), 1.]; Self::new(components) } + + /// Create an sRGB color from a hexadecimal string, such as `"#8a2be2"` (). + /// + /// Certain code editors may provide a color picker for input strings of this format, making this method + /// preferable to [`from_rgb8`](`Self::from_rgb8`) or [`from_rgba8`](`Self::from_rgba8`) for colors which may need to be experimented with. + /// + /// The leading `#` in the input is optional, but it is recommended to include it. + /// The input is provided in RGBA order, and valid inputs are of the form `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA`. + /// `A-F` in the input string may be upper or lowercase. + /// + /// This function is designed for use in const contexts; for user-provided values, you can use + /// [`parse_color`], which covers a wider variety of input forms in CSS syntax, or + /// [`try_from_hex`](Self::try_from_hex) which returns an error instead of panicking. + /// + /// # Example + /// + /// ``` + /// # use color::{AlphaColor, Srgb}; + /// const BUTTON_COLOR: AlphaColor = AlphaColor::from_hex("#8a2be2"); + /// ``` + /// + /// # Panics + /// + /// If the input string contains anything other than an optional `#` and 3, 4, 6, or 8 hexadecimal digits. + pub const fn from_hex(hex: &str) -> Self { + let this = Rgba8::from_hex(hex); + Self::from_rgba8(this.r, this.g, this.b, this.a) + } + + /// Create a color from a hexadecimal string, such as `"#8a2be2"` (). + /// + /// Same as [`from_hex`](Self::from_hex), but returns an error in cases where that method panics. + pub const fn try_from_hex(hex: &str) -> Result { + match Rgba8::try_from_hex(hex) { + Ok(it) => Ok(Self::from_rgba8(it.r, it.g, it.b, it.a)), + Err(err) => Err(err), + } + } } impl OpaqueColor { @@ -197,6 +236,69 @@ impl OpaqueColor { let components = [u8_to_f32(r), u8_to_f32(g), u8_to_f32(b)]; Self::new(components) } + + /// Create an sRGB color from a hexadecimal string, such as `"#8a2be2"` (). + /// + /// Certain code editors may provide a color picker for input strings of this format, making this method + /// preferable to [`from_rgb8`](`Self::from_rgb8`) for colors which may need to be experimented with. + /// + /// The leading `#` in the input is optional, but it is recommended to include it. + /// The input is provided in RGBA order, and valid inputs are of the form `#RGB`, `#RRGGBB` or `#RRGGBB`. + /// `A-F` in the input string may be upper or lowercase. + /// + /// This function is designed for use in const contexts; for user-provided values, you can use + /// [`parse_color`], which covers a wider variety of input forms in CSS syntax, or + /// [`try_from_hex`](Self::try_from_hex) which returns an error instead of panicking. + /// + /// # Example + /// + /// ``` + /// # use color::{OpaqueColor, Srgb}; + /// const BUTTON_COLOR: OpaqueColor = OpaqueColor::from_hex("#8a2be2"); + /// ``` + /// + /// # Panics + /// + /// If the input string contains anything other than an optional `#` and 3, or 6 hexadecimal digits. + pub const fn from_hex(hex: &str) -> Self { + match Self::try_from_hex(hex) { + Ok(color) => color, + Err(ParseError::WrongNumberOfHexDigits) => { + panic!("An invalid number of hexadecimal digits was provided."); + } + Err(ParseError::ExpectedEndOfString) => { + panic!("Input to from_hex contains characters after hexadecimal digits."); + } + Err(_) => { + unreachable!() + } + } + } + + /// Create a color from a hexadecimal string, such as `"#8a2be2"` (). + /// + /// Same as [`from_hex`](Self::from_hex), but returns an error in cases where that method panics. + pub const fn try_from_hex(mut hex: &str) -> Result { + // Strip an optional '#' from the start. We can't use `strip_prefix` as it isn't const. + if !hex.is_empty() && hex.as_bytes()[0] == b'#' { + hex = hex.split_at(1).1; + } + + let bit_hex = get_4bit_hex_channels(hex); + match bit_hex { + Ok((count, channels)) => { + if count != hex.len() { + return Err(ParseError::ExpectedEndOfString); + } + if count != 3 && count != 6 { + return Err(ParseError::WrongNumberOfHexDigits); + } + let this = rgba_from_4bit_hex(channels); + Ok(Self::from_rgb8(this.r, this.g, this.b)) + } + Err(e) => Err(e), + } + } } impl PremulColor { @@ -223,3 +325,82 @@ impl PremulColor { fn ensure_libm_dependency_used() -> f32 { libm::sqrtf(4_f32) } + +#[cfg(test)] +mod tests { + use super::*; + + const ALPHA_FROM_HEX_IS_CONST: AlphaColor = AlphaColor::from_hex("#8a2be2"); + + #[test] + fn alpha_from_hex() { + let color = AlphaColor::from_hex("#8a2be2"); + assert_eq!( + color.to_rgba8(), + Rgba8::from_u8_array([0x8a, 0x2b, 0xe2, 0xff]) + ); + assert_eq!(color, ALPHA_FROM_HEX_IS_CONST); + + let with = AlphaColor::from_hex("#aabbcc"); + let without = AlphaColor::from_hex("aabbcc"); + assert_eq!(with, without); + + let short = AlphaColor::from_hex("#abc"); + let long = AlphaColor::from_hex("#aabbcc"); + assert_eq!(short, long); + + let short_alpha = AlphaColor::from_hex("#abcd"); + let long_alpha = AlphaColor::from_hex("#aabbccdd"); + assert_eq!(short_alpha, long_alpha); + + let lower = AlphaColor::from_hex("#8a2be28f"); + let upper = AlphaColor::from_hex("#8A2BE28F"); + assert_eq!(lower, upper); + } + + #[test] + fn alpha_try_from_hex_errors() { + // 'g' is not a valid hex digit + assert!(AlphaColor::::try_from_hex("#gg0000").is_err()); + // 5 digit color isn't defined. + assert!(AlphaColor::::try_from_hex("#12345").is_err()); + assert!(AlphaColor::::try_from_hex("").is_err()); + } + + const OPAQUE_FROM_HEX_IS_CONST: OpaqueColor = OpaqueColor::from_hex("#8a2be2"); + + #[test] + fn opaque_from_hex() { + let color = OpaqueColor::from_hex("#8a2be2"); + assert_eq!( + color.to_rgba8(), + Rgba8::from_u8_array([0x8a, 0x2b, 0xe2, 0xff]) + ); + assert_eq!(color, OPAQUE_FROM_HEX_IS_CONST); + + let with = OpaqueColor::from_hex("#aabbcc"); + let without = OpaqueColor::from_hex("aabbcc"); + assert_eq!(with, without); + + let short = OpaqueColor::from_hex("#abc"); + let long = OpaqueColor::from_hex("#aabbcc"); + assert_eq!(short, long); + + let lower = OpaqueColor::from_hex("#8a2be2"); + let upper = OpaqueColor::from_hex("#8A2BE2"); + assert_eq!(lower, upper); + } + + #[test] + fn opaque_try_from_hex_errors() { + // 'g' is not a valid hex digit + assert!(OpaqueColor::try_from_hex("#gg0000").is_err()); + // 5 digit color isn't defined. + assert!(OpaqueColor::try_from_hex("#12345").is_err()); + // 4 digit color isn't allowed for an opaque color. + assert!(OpaqueColor::try_from_hex("#123f").is_err()); + // 8 digit color isn't allowed for an opaque color. + assert!(OpaqueColor::try_from_hex("#12233480").is_err()); + assert!(OpaqueColor::try_from_hex("").is_err()); + } +} diff --git a/color/src/parse.rs b/color/src/parse.rs index 61e2cbc..5a03735 100644 --- a/color/src/parse.rs +++ b/color/src/parse.rs @@ -9,9 +9,9 @@ use core::fmt; use core::str; use core::str::FromStr; +use crate::Rgba8; use crate::{ AlphaColor, ColorSpace, ColorSpaceTag, DynamicColor, Flags, Missing, OpaqueColor, PremulColor, - Srgb, }; // TODO: maybe include string offset @@ -508,7 +508,7 @@ pub fn parse_color_prefix(s: &str) -> Result<(usize, DynamicColor), ParseError> if let Some(stripped) = s.strip_prefix('#') { let (ix, channels) = get_4bit_hex_channels(stripped)?; - let color = color_from_4bit_hex(channels); + let color = rgba_from_4bit_hex(channels).into(); // Hex colors are seen as if they are generated from the named `rgb()` color space // function. let mut color = DynamicColor::from_alpha_color(color); @@ -616,7 +616,7 @@ impl FromStr for PremulColor { /// /// Returns the parsed channels and the byte offset to the remainder of the string (i.e., the /// number of hex characters parsed). -const fn get_4bit_hex_channels(hex_str: &str) -> Result<(usize, [u8; 8]), ParseError> { +pub(crate) const fn get_4bit_hex_channels(hex_str: &str) -> Result<(usize, [u8; 8]), ParseError> { let mut hex = [0; 8]; let mut i = 0; @@ -651,14 +651,14 @@ const fn hex_from_ascii_byte(b: u8) -> Result { } } -const fn color_from_4bit_hex(components: [u8; 8]) -> AlphaColor { +pub(crate) const fn rgba_from_4bit_hex(components: [u8; 8]) -> Rgba8 { let [r0, r1, g0, g1, b0, b1, a0, a1] = components; - AlphaColor::from_rgba8( - (r0 << 4) | r1, - (g0 << 4) | g1, - (b0 << 4) | b1, - (a0 << 4) | a1, - ) + Rgba8 { + r: (r0 << 4) | r1, + g: (g0 << 4) | g1, + b: (b0 << 4) | b1, + a: (a0 << 4) | a1, + } } impl FromStr for ColorSpaceTag { diff --git a/color/src/rgba8.rs b/color/src/rgba8.rs index 2aab5ec..5b699d6 100644 --- a/color/src/rgba8.rs +++ b/color/src/rgba8.rs @@ -1,7 +1,10 @@ // Copyright 2024 the Color Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use crate::{AlphaColor, PremulColor, Srgb}; +use crate::{ + AlphaColor, ParseError, PremulColor, Srgb, + parse::{get_4bit_hex_channels, rgba_from_4bit_hex}, +}; /// A packed representation of sRGB colors. /// @@ -62,6 +65,66 @@ impl Rgba8 { pub const fn from_u32(packed_bytes: u32) -> Self { Self::from_u8_array(u32::to_ne_bytes(packed_bytes)) } + + /// Create an sRGB color from a hexadecimal string, such as `"#8a2be2"` (). + /// + /// Certain code editors may provide a color picker for input strings of this format, making this method + /// preferable to [`from_u8_array`](`Self::from_u8_array`) or [`from_u8_array`](`Self::from_u8_array`) for colors + /// which may need to be experimented with. + /// + /// The leading `#` in the input is optional, but it is recommended to include it. + /// The input is provided in RGBA order, and valid inputs are of the form `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA`. + /// `A-F` in the input string may be upper or lowercase. + /// + /// This function is designed for use in const contexts; for user-provided values, you can use + /// [`parse_color`](crate::parse_color), which covers a wider variety of input forms in CSS syntax, or + /// [`try_from_hex`](Self::try_from_hex) which returns an error instead of panicking. + /// + /// # Example + /// + /// ``` + /// # use color::Rgba8; + /// const BUTTON_COLOR: Rgba8 = Rgba8::from_hex("#8a2be2"); + /// ``` + /// + /// # Panics + /// + /// If the input string contains anything other than an optional `#` and 3, 4, 6, or 8 hexadecimal digits. + pub const fn from_hex(hex: &str) -> Self { + match Self::try_from_hex(hex) { + Ok(color) => color, + Err(ParseError::WrongNumberOfHexDigits) => { + panic!("An invalid number of hexadecimal digits was provided."); + } + Err(ParseError::ExpectedEndOfString) => { + panic!("Input to from_hex contains characters after hexadecimal digits."); + } + Err(_) => { + unreachable!() + } + } + } + + /// Create a color from a hexadecimal string, such as `"#8a2be2"` (). + /// + /// Same as [`from_hex`](Self::from_hex), but returns an error in cases where that method panics. + pub const fn try_from_hex(mut hex: &str) -> Result { + // Strip an optional '#' from the start. We can't use `strip_prefix` as it isn't const. + if !hex.is_empty() && hex.as_bytes()[0] == b'#' { + hex = hex.split_at(1).1; + } + + let bit_hex = get_4bit_hex_channels(hex); + match bit_hex { + Ok((count, channels)) => { + if count != hex.len() { + return Err(ParseError::ExpectedEndOfString); + } + Ok(rgba_from_4bit_hex(channels)) + } + Err(e) => Err(e), + } + } } impl From for AlphaColor { @@ -275,4 +338,38 @@ mod tests { let p = [0xaa, 0xbb, 0xcc, 0xff]; assert_eq!(PremulRgba8::from_u8_array(p), bytemuck::cast(p)); } + + const RGBA8_FROM_HEX_IS_CONST: Rgba8 = Rgba8::from_hex("#8a2be2"); + + #[test] + fn rgba8_from_hex() { + let color = Rgba8::from_hex("#8a2be2"); + assert_eq!(color, Rgba8::from_u8_array([0x8a, 0x2b, 0xe2, 0xff])); + assert_eq!(color, RGBA8_FROM_HEX_IS_CONST); + + let with = Rgba8::from_hex("#aabbcc"); + let without = Rgba8::from_hex("aabbcc"); + assert_eq!(with, without); + + let short = Rgba8::from_hex("#abc"); + let long = Rgba8::from_hex("#aabbcc"); + assert_eq!(short, long); + + let short_alpha = Rgba8::from_hex("#abcd"); + let long_alpha = Rgba8::from_hex("#aabbccdd"); + assert_eq!(short_alpha, long_alpha); + + let lower = Rgba8::from_hex("#8a2be28f"); + let upper = Rgba8::from_hex("#8A2BE28F"); + assert_eq!(lower, upper); + } + + #[test] + fn rgba8_try_from_hex_errors() { + // 'g' is not a valid hex digit + assert!(Rgba8::try_from_hex("#gg0000").is_err()); + // 5 digit color isn't defined. + assert!(Rgba8::try_from_hex("#12345").is_err()); + assert!(Rgba8::try_from_hex("").is_err()); + } }