Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for transparency #162

Merged
merged 11 commits into from
May 26, 2022
6 changes: 5 additions & 1 deletion src/cli/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ pub fn build_cli() -> App<'static, 'static> {
\n - 789\
\n - 'rgb(119, 136, 153)'\
\n - '119,136,153'\
\n - 'hsl(210, 14.3%, 53.3%)'",
\n - 'hsl(210, 14.3%, 53.3%)'\n\
Alpha transparency is also supported:\
\n - '#77889980'\
\n - 'rgba(119, 136, 153, 0.5)'\
\n - 'hsla(210, 14.3%, 53.3%, 50%)'",
)
.required(false)
.multiple(true);
Expand Down
6 changes: 5 additions & 1 deletion src/cli/hdcanvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ impl Canvas {
) {
for i in 0..height {
for j in 0..width {
*self.pixel_mut(row + i, col + j) = Some(color.clone());
let px = self.pixel_mut(row + i, col + j);
*px = Some(match px {
Some(backdrop) => backdrop.composite(color),
None => color.clone(),
});
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ impl Output<'_> {
let text_position_x: usize = checkerboard_size + 2 * config.padding;
let text_position_y: usize = 0;

let mut canvas = Canvas::new(checkerboard_size, 51, config.brush);
let mut canvas = Canvas::new(checkerboard_size, 60, config.brush);
superhawk610 marked this conversation as resolved.
Show resolved Hide resolved
canvas.draw_checkerboard(
checkerboard_position_y,
checkerboard_position_x,
Expand Down
43 changes: 42 additions & 1 deletion src/helper.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::cmp::Ordering;
use std::{
cmp::Ordering,
fmt::{self, Display},
};

use crate::types::Scalar;

Expand Down Expand Up @@ -48,6 +51,35 @@ pub fn interpolate_angle(a: Scalar, b: Scalar, fraction: Fraction) -> Scalar {
mod_positive(interpolate(shortest.0, shortest.1, fraction), 360.0)
}

// `format!`-style format strings only allow specifying a fixed floating
// point precision, e.g. `{:.3}` to print 3 decimal places. This always
// displays trailing zeroes, while web colors generally omit them. For
// example, we'd prefer to print `0.5` as `0.5` instead of `0.500`.
//
// Note that this will round using omitted decimal places:
//
// MaxPrecision::wrap(3, 0.5004) //=> 0.500
// MaxPrecision::wrap(3, 0.5005) //=> 0.501
//
pub struct MaxPrecision {
precision: u32,
inner: f64,
}

impl MaxPrecision {
pub fn wrap(precision: u32, inner: f64) -> Self {
Self { precision, inner }
}
}

impl Display for MaxPrecision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let pow_10 = 10u32.pow(self.precision) as f64;
let rounded = (self.inner * pow_10).round() / pow_10;
write!(f, "{}", rounded)
}
}

#[test]
fn test_interpolate() {
assert_eq!(0.0, interpolate_angle(0.0, 90.0, Fraction::from(0.0)));
Expand All @@ -63,3 +95,12 @@ fn test_interpolate_angle() {
assert_eq!(0.0, interpolate_angle(10.0, 350.0, Fraction::from(0.5)));
assert_eq!(0.0, interpolate_angle(350.0, 10.0, Fraction::from(0.5)));
}

#[test]
fn test_max_precision() {
assert_eq!(format!("{}", MaxPrecision::wrap(3, 0.5)), "0.5");
assert_eq!(format!("{}", MaxPrecision::wrap(3, 0.51)), "0.51");
assert_eq!(format!("{}", MaxPrecision::wrap(3, 0.512)), "0.512");
assert_eq!(format!("{}", MaxPrecision::wrap(3, 0.5124)), "0.512");
assert_eq!(format!("{}", MaxPrecision::wrap(3, 0.5125)), "0.513");
}
175 changes: 152 additions & 23 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ pub mod parser;
pub mod random;
mod types;

use std::fmt;
use std::{fmt, str::FromStr};

use colorspace::ColorSpace;
pub use helper::Fraction;
use helper::{clamp, interpolate, interpolate_angle, mod_positive};
use helper::{clamp, interpolate, interpolate_angle, mod_positive, MaxPrecision};
use types::{Hue, Scalar};

/// The representation of a color.
Expand Down Expand Up @@ -141,14 +141,30 @@ impl Color {
HSLA::from(self)
}

/// Format the color as a HSL-representation string (`hsl(123, 50.3%, 80.1%)`).
/// Format the color as a HSL-representation string (`hsla(123, 50.3%, 80.1%, 0.4)`). If the
/// alpha channel is `1.0`, the simplified `hsl()` format will be used instead.
pub fn to_hsl_string(&self, format: Format) -> String {
let space = if format == Format::Spaces { " " } else { "" };
let (a_prefix, a) = if self.alpha == 1.0 {
("", "".to_string())
} else {
(
"a",
format!(
",{space}{alpha}",
alpha = MaxPrecision::wrap(3, self.alpha),
space = space
),
)
};
format!(
"hsl({:.0},{space}{:.1}%,{space}{:.1}%)",
self.hue.value(),
100.0 * self.saturation,
100.0 * self.lightness,
space = if format == Format::Spaces { " " } else { "" }
"hsl{a_prefix}({h:.0},{space}{s:.1}%,{space}{l:.1}%{a})",
space = space,
a_prefix = a_prefix,
h = self.hue.value(),
s = 100.0 * self.saturation,
l = 100.0 * self.lightness,
a = a,
)
}

Expand All @@ -158,15 +174,31 @@ impl Color {
RGBA::<u8>::from(self)
}

/// Format the color as a RGB-representation string (`rgb(255, 127, 0)`).
/// Format the color as a RGB-representation string (`rgba(255, 127, 0, 0.5)`). If the alpha channel
/// is `1.0`, the simplified `rgb()` format will be used instead.
pub fn to_rgb_string(&self, format: Format) -> String {
let rgba = RGBA::<u8>::from(self);
let space = if format == Format::Spaces { " " } else { "" };
let (a_prefix, a) = if self.alpha == 1.0 {
("", "".to_string())
} else {
(
"a",
format!(
",{space}{alpha}",
alpha = MaxPrecision::wrap(3, rgba.alpha),
space = space
),
)
};
format!(
"rgb({r},{space}{g},{space}{b})",
"rgb{a_prefix}({r},{space}{g},{space}{b}{a})",
space = space,
a_prefix = a_prefix,
r = rgba.r,
g = rgba.g,
b = rgba.b,
space = if format == Format::Spaces { " " } else { "" }
a = a,
)
}

Expand All @@ -189,27 +221,49 @@ impl Color {
)
}

/// Format the color as a floating point RGB-representation string (`rgb(1.0, 0.5, 0)`).
/// Format the color as a floating point RGB-representation string (`rgb(1.0, 0.5, 0)`). If the alpha channel
/// is `1.0`, the simplified `rgb()` format will be used instead.
pub fn to_rgb_float_string(&self, format: Format) -> String {
let rgba = RGBA::<f64>::from(self);
let space = if format == Format::Spaces { " " } else { "" };
let (a_prefix, a) = if self.alpha == 1.0 {
("", "".to_string())
} else {
(
"a",
format!(
",{space}{alpha}",
alpha = MaxPrecision::wrap(3, rgba.alpha),
space = space
),
)
};
format!(
"rgb({r:.3},{space}{g:.3},{space}{b:.3})",
"rgb{a_prefix}({r:.3},{space}{g:.3},{space}{b:.3}{a})",
space = space,
a_prefix = a_prefix,
r = rgba.r,
g = rgba.g,
b = rgba.b,
space = if format == Format::Spaces { " " } else { "" }
a = a,
)
}

/// Format the color as a RGB-representation string (`#fc0070`).
/// Format the color as a RGB-representation string (`#fc0070`). The output will contain 6 hex
/// digits if the alpha channel is `1.0`, or 8 hex digits otherwise.
pub fn to_rgb_hex_string(&self, leading_hash: bool) -> String {
let rgba = self.to_rgba();
format!(
"{}{:02x}{:02x}{:02x}",
"{}{:02x}{:02x}{:02x}{}",
if leading_hash { "#" } else { "" },
rgba.r,
rgba.g,
rgba.b
rgba.b,
if rgba.alpha == 1.0 {
"".to_string()
} else {
format!("{:02x}", (rgba.alpha * 255.).round() as u8)
}
)
}

Expand Down Expand Up @@ -249,15 +303,26 @@ impl Color {
Lab::from(self)
}

/// Format the color as a Lab-representation string (`Lab(41, 83, -93)`).
/// Format the color as a Lab-representation string (`Lab(41, 83, -93, 0.5)`). If the alpha channel
/// is `1.0`, it won't be included in the output.
pub fn to_lab_string(&self, format: Format) -> String {
let lab = Lab::from(self);
let space = if format == Format::Spaces { " " } else { "" };
format!(
"Lab({l:.0},{space}{a:.0},{space}{b:.0})",
"Lab({l:.0},{space}{a:.0},{space}{b:.0}{alpha})",
l = lab.l,
a = lab.a,
b = lab.b,
space = if format == Format::Spaces { " " } else { "" }
space = space,
alpha = if self.alpha == 1.0 {
"".to_string()
} else {
format!(
",{space}{alpha}",
alpha = MaxPrecision::wrap(3, self.alpha),
space = space
)
}
)
}

Expand All @@ -268,15 +333,26 @@ impl Color {
LCh::from(self)
}

/// Format the color as a LCh-representation string (`LCh(0.3, 0.2, 0.1)`).
/// Format the color as a LCh-representation string (`LCh(0.3, 0.2, 0.1, 0.5)`). If the alpha channel
/// is `1.0`, it won't be included in the output.
pub fn to_lch_string(&self, format: Format) -> String {
let lch = LCh::from(self);
let space = if format == Format::Spaces { " " } else { "" };
format!(
"LCh({l:.0},{space}{c:.0},{space}{h:.0})",
"LCh({l:.0},{space}{c:.0},{space}{h:.0}{alpha})",
l = lch.l,
c = lch.c,
h = lch.h,
space = if format == Format::Spaces { " " } else { "" }
space = space,
alpha = if self.alpha == 1.0 {
"".to_string()
} else {
format!(
",{space}{alpha}",
alpha = MaxPrecision::wrap(3, self.alpha),
space = space
)
}
)
}

Expand Down Expand Up @@ -547,6 +623,36 @@ impl Color {
.mix(&C::from_color(other), fraction)
.into_color()
}

/// Alpha composite two colors, placing the second over the first.
pub fn composite(&self, source: &Color) -> Color {
superhawk610 marked this conversation as resolved.
Show resolved Hide resolved
let backdrop = self.to_rgba();
let source = source.to_rgba();

// Composite A over B (see https://en.wikipedia.org/wiki/Alpha_compositing)
//
// αo = αa + αb(1 - αa)
//
// Ca * αa + Cb * αb(1 - αa)
// Co = -------------------------
// αo
//
// αo: output alpha
// αa, αb: A/B alpha
// Co: output color
// Ca, Cb: A/B color
//
fn composite_channel(c_a: u8, a_a: f64, c_b: u8, a_b: f64, a_o: f64) -> u8 {
((c_a as f64 * a_a + c_b as f64 * a_b * (1.0 - a_a)) / a_o).floor() as u8
}

let a = source.alpha + backdrop.alpha * (1.0 - source.alpha);
let r = composite_channel(source.r, source.alpha, backdrop.r, backdrop.alpha, a);
let g = composite_channel(source.g, source.alpha, backdrop.g, backdrop.alpha, a);
let b = composite_channel(source.b, source.alpha, backdrop.b, backdrop.alpha, a);

Color::from_rgba(r, g, b, a)
}
}

// by default Colors will be printed into HSLA fromat
Expand All @@ -568,6 +674,14 @@ impl PartialEq for Color {
}
}

impl FromStr for Color {
type Err = &'static str;

fn from_str(s: &str) -> Result<Self, Self::Err> {
parser::parse_color(s).ok_or("invalid color string")
}
}

impl From<&HSLA> for Color {
fn from(color: &HSLA) -> Self {
Color {
Expand Down Expand Up @@ -1669,4 +1783,19 @@ mod tests {
let c3 = Color::from_rgb(143, 111, 76);
assert_eq!("cmyk(0, 22, 47, 44)", c3.to_cmyk_string(Format::Spaces));
}

#[test]
fn alpha_roundtrip_hex_to_decimal() {
// We use a max of 3 decimal places when displaying RGB floating point
// alpha values. This test insures that is sufficient to "roundtrip"
// from hex (0 < n < 255) to float (0 < n < 1) and back again,
// e.g. hex `80` is float `0.502`, which parses to hex `80`, and so on.
for alpha_int in 0..255 {
let hex_string = format!("#000000{:02x}", alpha_int);
let parsed_from_hex = hex_string.parse::<Color>().unwrap();
let rgba_string = parsed_from_hex.to_rgb_float_string(Format::Spaces);
let parsed_from_rgba = rgba_string.parse::<Color>().unwrap();
assert_eq!(hex_string, parsed_from_rgba.to_rgb_hex_string(true));
}
Comment on lines +1787 to +1799
Copy link
Contributor Author

@superhawk610 superhawk610 May 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I think this should provide proper coverage. This asserts that:

  • for every hex alpha value, 0 < n < 255 (00 - fe)
  • parsing a color with this alpha, in hex notation
  • outputting that color as rgba(), with alpha as decimal
  • parsing that color
  • outputting that color using hex notation
  • generates the same value as the original hex input string

For example, #00000080 parses to a color that stringifies to rgba(0, 0, 0, 0.502), that in turn parses to a color that stringifies to #00000080. This actually caught a bug, where I was outputting alpha hex values using (f64 * 255.) as u8, when I should have been using (f64 * 255.).round() as u8.

I also threw in an impl FromStr for Color that calls parser::parse_color, that's not required, just thought it may be useful.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thank you!

}
}
Loading