diff --git a/src/translation.rs b/src/translation.rs
index 5d47857..a31e049 100644
--- a/src/translation.rs
+++ b/src/translation.rs
@@ -487,6 +487,13 @@ impl Sampler {
ThemeEntryIterator::new()
}
+ /// Determine whether this sampler's color theme is a dark theme.
+ pub fn is_dark_theme(&self) -> bool {
+ let yf = self.theme[0].to(ColorSpace::Xyz)[1];
+ let yb = self.theme[1].to(ColorSpace::Xyz)[1];
+ yf > yb
+ }
+
/// Resolve the terminal color to a high-resolution color.
///
/// A custom type conversion function provides the same functionality as
@@ -522,6 +529,329 @@ impl Sampler {
self.do_resolve(color)
}
+ /// Convert the high-resolution color into an ANSI color.
+ ///
+ /// If the current theme meets the requirements for hue/lightness search,
+ /// this method forwards to [`Sampler::to_ansi_hue_lightness`]. Otherwise,
+ /// it falls back on [`Sampler::to_closest_ansi`]. Use
+ /// [`Sampler::supports_hue_lightness`] to test whether the current theme
+ /// supports hue-lightness search.
+ pub fn to_ansi(&self, color: &Color) -> AnsiColor {
+ self.to_ansi_hue_lightness(color)
+ .unwrap_or_else(|| self.to_closest_ansi(color))
+ }
+
+ /// Determine whether this sampler instance supports color translation with
+ /// the hue/lightness search algorithm.
+ pub fn supports_hue_lightness(&self) -> bool {
+ self.hue_lightness_table.is_some()
+ }
+
+ /// Convert the high-resolution color to ANSI based on Oklab's hue (h) and
+ /// revised lightness (Lr).
+ ///
+ /// This method performs all color comparisons in the cylindrical version of
+ /// the revised Oklab color space. For grays, it finds the ANSI gray with
+ /// the closest revised lightness. For colors, this method first finds the
+ /// pair of regular and bright ANSI colors with the closest hue and then
+ /// selects the color with the closest lightness.
+ ///
+ /// This method requires that concrete theme colors and abstract ANSI colors
+ /// are (loosely) aligned. Notably, the color values for pairs of regular
+ /// and bright ANSI colors must be in order red, yellow, green, cyan, blue,
+ /// and magenta when traversing hues counter-clockwise, i.e., with
+ /// increasing hue magnitude. Note that this does allow hues to be
+ /// arbitrarily shifted along the circle. Furthermore, it does not prescribe
+ /// an order for regular and bright versions of the same abstract ANSI
+ /// color. If the theme colors passed to this sampler's constructor did not
+ /// meet this requirement, this method returns `None`.
+ ///
+ /// # Examples
+ ///
+ /// The documentation for [`Sampler::to_closest_ansi`] gives the example of
+ /// two colors that yield subpar results with an exhaustive search for the
+ /// closest color and then sketches an alternative approach that searches
+ /// for the closest hue.
+ ///
+ /// The algorithm implemented by this method goes well beyond that sketch by
+ /// not only leveraging color pragmatics (i.e., their coordinates) but also
+ /// their semantics. Hence, it first searches for one out of six pairs of
+ /// regular and bright ANSI colors with the closest hue and then picks the
+ /// one out of two colors with the closest lightness.
+ ///
+ /// As this example illustrates, that strategy works well for the light
+ /// orange colors from [`Sampler::to_closest_ansi`]. They both match the
+ /// yellow pair by hue and then bright yellow by lightness. Alas, it is no
+ /// panacea because color themes may not observe the necessary semantic
+ /// constraints. This method detects such cases and returns `None`.
+ /// [`Sampler::to_ansi`] instead automatically falls back onto searching for
+ /// the closest color.
+ ///
+ /// ```
+ /// # use prettypretty::{Color, ColorFormatError, ColorSpace, Sampler};
+ /// # use prettypretty::{VGA_COLORS, OkVersion};
+ /// # use std::str::FromStr;
+ /// let sampler = Sampler::new(
+ /// OkVersion::Revised, VGA_COLORS.clone());
+ ///
+ /// let orange1 = Color::from_str("#ffa563")?;
+ /// let ansi = sampler.to_ansi_hue_lightness(&orange1);
+ /// assert_eq!(ansi.unwrap(), AnsiColor::BrightYellow);
+ ///
+ /// let orange2 = Color::from_str("#ff9600")?;
+ /// let ansi = sampler.to_ansi_hue_lightness(&orange2);
+ /// assert_eq!(ansi.unwrap(), AnsiColor::BrightYellow);
+ /// # Ok::<(), ColorFormatError>(())
+ /// ```
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ pub fn to_ansi_hue_lightness(&self, color: &Color) -> Option {
+ self.hue_lightness_table
+ .as_ref()
+ .map(|t| t.find_match(color))
+ }
+
+ /// Find the ANSI color that comes closest to the given color.
+ ///
+ /// # Examples
+ ///
+ /// The example code below matches the shades of orange `#ffa563` and
+ /// `#ff9600` to ANSI colors under the default VGA theme in both Oklab and
+ /// Oklrab. In both versions of the color space, the first orange
+ /// consistently matches ANSI white and the second orange consistently
+ /// matches bright red. Visually, the second match seems reasonable given
+ /// that there are at most 12 colors and 4 grays to pick from. But the first
+ /// match seems off. Gray simply isn't a satisfactory replacement for a
+ /// (more or less) saturated color.
+ ///
+ /// ```
+ /// # use prettypretty::{Color, ColorFormatError, ColorSpace, Sampler};
+ /// # use prettypretty::{VGA_COLORS, OkVersion};
+ /// # use std::str::FromStr;
+ /// let original_sampler = Sampler::new(
+ /// OkVersion::Original, VGA_COLORS.clone());
+ ///
+ /// let orange1 = Color::from_str("#ffa563")?;
+ /// let ansi = original_sampler.to_closest_ansi(&orange1);
+ /// assert_eq!(ansi, AnsiColor::White);
+ ///
+ /// let orange2 = Color::from_str("#ff9600")?;
+ /// let ansi = original_sampler.to_closest_ansi(&orange2);
+ /// assert_eq!(ansi, AnsiColor::BrightRed);
+ /// // ---------------------------------------------------------------------
+ /// let revised_sampler = Sampler::new(
+ /// OkVersion::Revised, VGA_COLORS.clone());
+ ///
+ /// let ansi = revised_sampler.to_closest_ansi(&orange1);
+ /// assert_eq!(ansi, AnsiColor::White);
+ ///
+ /// let ansi = revised_sampler.to_closest_ansi(&orange2);
+ /// assert_eq!(ansi, AnsiColor::BrightRed);
+ /// # Ok::<(), ColorFormatError>(())
+ /// ```
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// That isn't just my subjective judgement, but human color perception is
+ /// more sensitive to changes in hue than chroma or lightness. By that
+ /// standard, the match actually is pretty poor. To see that, consider the
+ /// figure below showing the chroma/hue plane. It plots the 12 ANSI colors
+ /// (as circles), the 4 ANSI grays (as one circle with averaged lightness),
+ /// and the 2 orange tones (as narrow diamonds) on that plane (hence the
+ /// 12+4+2 in the title). As it turns out, `#ffa563` is located right next
+ /// to the default theme's ANSI yellow, which really is a dark orange or
+ /// brown. The primary difference between the two colors are neither chroma
+ /// (0.13452 vs 0.1359) nor hue (55.6 vs 54.1) but lightness only (0.79885
+ /// vs 0.54211). Depending on the use case, the theme's yellow may be an
+ /// acceptable match. Otherwise the bright red probably is a better match
+ /// than a chromaless gray tone.
+ ///
+ /// ![The colors plotted on Oklab's chroma and hue plane](https://raw.githubusercontent.com/apparebit/prettypretty/main/docs/figures/vga-colors.svg)
+ ///
+ /// Reflecting that same observation about color perception, the [CSS Color
+ /// 4](https://www.w3.org/TR/css-color-4/#gamut-mapping) gamut-mapping
+ /// algorithm improves on MINDE algorithms (Minimum Delta-E) such as this
+ /// method's closest match in Oklab by systematically reducing chroma and
+ /// tolerating small lightness and hue variations (caused by clipping).
+ /// Given the extremely limited color repertoire, we can't use a similar,
+ /// directed search. But we should do better than brute-force search.
+ ///
+ /// Let's explore that idea a little further. Since the revised lightness is
+ /// more accurate, we'll be comparing colors in Oklrch. We start by
+ /// preparing a list with the color values for the 16 extended ANSI colors
+ /// in that color space. That, by the way, is pretty much what
+ /// [`Sampler::new`] does as well.
+ /// ```
+ /// # use prettypretty::{AnsiColor, Color, ColorFormatError, ColorSpace, VGA_COLORS};
+ /// # use std::str::FromStr;
+ /// let ansi_colors: Vec = (0..=15)
+ /// .map(|n| VGA_COLORS[n + 2].to(ColorSpace::Oklrch))
+ /// .collect();
+ /// ```
+ ///
+ /// Next, we need a function that calculates the distance between the
+ /// coordinates of two colors in Oklrch. Since we are exploring non-MINDE
+ /// approaches, we focus on hue alone and use the minimum degree of
+ /// separation as a metric. Degrees being circular, computing the remainder
+ /// of the difference is not enough. We need to consider both differences.
+ ///
+ /// The function uses prettypretty's [`Float`], which serves as alias to
+ /// either `f64` (the default) or `f32` (when the `f32` feature is enabled).
+ ///
+ /// ```
+ /// use prettypretty::Float;
+ /// fn minimum_degrees_of_separation(c1: &[Float; 3], c2: &[Float; 3]) -> Float {
+ /// (c1[2] - c2[2]).rem_euclid(360.0)
+ /// .min((c2[2] - c1[2]).rem_euclid(360.0))
+ /// }
+ /// ```
+ ///
+ /// That's it. We have everything we need. All that's left to do is to
+ /// instantiate the same orange again and find the closest matching color on
+ /// our list with the new distance metric.
+ ///
+ /// ```
+ /// # use prettypretty::{AnsiColor, Color, ColorFormatError, ColorSpace, VGA_COLORS, Float};
+ /// # use std::str::FromStr;
+ /// # let ansi_colors: Vec = (0..=15)
+ /// # .map(|n| VGA_COLORS[n + 2].to(ColorSpace::Oklrch))
+ /// # .collect();
+ /// # fn minimum_degrees_of_separation(c1: &[Float; 3], c2: &[Float; 3]) -> Float {
+ /// # (c1[2] - c2[2]).rem_euclid(360.0)
+ /// # .min((c2[2] - c1[2]).rem_euclid(360.0))
+ /// # }
+ /// let orange = Color::from_str("#ffa563")?;
+ /// let closest = orange.find_closest(
+ /// &ansi_colors,
+ /// ColorSpace::Oklrch,
+ /// minimum_degrees_of_separation,
+ /// ).unwrap();
+ /// assert_eq!(closest, 3);
+ /// # Ok::<(), ColorFormatError>(())
+ /// ```
+ ///
+ ///
+ ///
+ /// The hue-based comparison picks ANSI color 3, VGA's orange yellow, just
+ /// as expected. It appears that our hue-based proof-of-concept works.
+ /// However, a production-ready version does need to account for lightness,
+ /// too. The method to do so is [`Sampler::to_ansi_hue_lightness`].
+ pub fn to_closest_ansi(&self, color: &Color) -> AnsiColor {
+ use crate::core::{delta_e_ok, find_closest};
+
+ let color = color.to(self.space);
+ find_closest(color.as_ref(), &self.ansi, delta_e_ok)
+ .map(|idx| AnsiColor::try_from(idx as u8).unwrap())
+ .unwrap()
+ }
+
+ /// Convert the high-resolution color to an ANSI color in RGB.
+ ///
+ /// This method performs a conversion from high-resolution color to ANSI
+ /// color solely based on linear RGB coordinates. Since the ANSI colors
+ /// essentially are 3-bit RGB colors with an additional bit for brightness,
+ /// it converts the given color to linear sRGB, clipping out of gamut
+ /// coordinates, and then rounds each coordinate to 0 or 1. It determines
+ /// whether to set the brightness bit based on a heuristically weighted sum
+ /// of the individual coordinates.
+ ///
+ /// The above algorithm uses *linear* sRGB because gamma-corrected sRGB, by
+ /// definition, skews the coordinate space and hence is ill-suited to
+ /// manipulation based on component magnitude. Alas, that is a common
+ /// mistake.
+ ///
+ /// While the algorithm does seem a bit odd, it is an improved version of
+ /// the approach implemented by
+ /// [Chalk](https://github.com/chalk/chalk/blob/main/source/vendor/ansi-styles/index.js),
+ /// only one of the most popular terminal color libraries for JavaScript.
+ pub fn to_ansi_rgb(&self, color: &Color) -> AnsiColor {
+ self.do_to_ansi_rgb(color)
+ }
+
+ /// Find the 8-bit color that comes closest to the given color.
+ ///
+ /// # Examples
+ ///
+ /// The example below converts every color of the RGB cube embedded in 8-bit
+ /// colors to a high-resolution color in sRGB, which is validated by the
+ /// first two assertions, and then uses a sampler to convert that color back
+ /// to an embedded RGB color. The result is the original color, now wrapped
+ /// as a terminal color, which is validated by the third assertion. The
+ /// example demonstrates that the 216 colors in the embedded RGB cube still
+ /// are closest to themselves after conversion to Oklrch.
+ ///
+ /// ```
+ /// # use prettypretty::{Color, ColorSpace, VGA_COLORS, TerminalColor, Float};
+ /// # use prettypretty::{EmbeddedRgb, OutOfBoundsError, Sampler, OkVersion};
+ /// # use prettypretty::assert_close_enough;
+ /// let sampler = Sampler::new(OkVersion::Revised, VGA_COLORS.clone());
+ ///
+ /// for r in 0..5 {
+ /// for g in 0..5 {
+ /// for b in 0..5 {
+ /// let embedded = EmbeddedRgb::new(r, g, b)?;
+ /// let color = Color::from(embedded);
+ /// assert_eq!(color.space(), ColorSpace::Srgb);
+ ///
+ /// let c1 = if r == 0 {
+ /// 0.0
+ /// } else {
+ /// (55.0 + 40.0 * (r as Float)) / 255.0
+ /// };
+ /// assert_close_enough!(color[0], c1);
+ ///
+ /// let result = sampler.to_closest_8bit(&color);
+ /// assert_eq!(result, TerminalColor::Rgb6 { color: embedded });
+ /// }
+ /// }
+ /// }
+ /// # Ok::<(), OutOfBoundsError>(())
+ /// ```
+ pub fn to_closest_8bit(&self, color: &Color) -> TerminalColor {
+ use crate::core::{delta_e_ok, find_closest};
+
+ let color = color.to(self.space);
+ let index = find_closest(
+ color.as_ref(),
+ self.eight_bit.last_chunk::<240>().unwrap(),
+ delta_e_ok,
+ )
+ .map(|idx| idx as u8 + 16)
+ .unwrap();
+
+ TerminalColor::from(index)
+ }
+
+ /// Find the 8-bit color that comes closest to the given color.
+ ///
+ /// This method comparse *all* 8-bit colors including ANSI colors.
+ pub fn to_closest_8bit_with_ansi(&self, color: &Color) -> TerminalColor {
+ use crate::core::{delta_e_ok, find_closest};
+
+ let color = color.to(self.space);
+ let index = find_closest(color.as_ref(), &self.eight_bit, delta_e_ok)
+ .map(|idx| idx as u8 + 16)
+ .unwrap();
+
+ TerminalColor::from(index)
+ }
+
/// Cap the terminal color by the fidelity.
///
/// This method ensures that the terminal color can be rendered by a
@@ -578,6 +908,17 @@ impl Sampler {
ThemeEntryIterator::new()
}
+ /// Determine whether this sampler's color theme is a dark theme.
+ ///
+ /// The Y component of a color in XYZ represents it luminance. This method
+ /// exploits that property of XYZ and checks whether the default foreground
+ /// has a larger luminance than the default background color.
+ pub fn is_dark_theme(&self) -> bool {
+ let yf = self.theme[0].to(ColorSpace::Xyz)[1];
+ let yb = self.theme[1].to(ColorSpace::Xyz)[1];
+ yf > yb
+ }
+
/// Resolve the terminal color to a high-resolution color.
///
/// # Examples
@@ -611,46 +952,6 @@ impl Sampler {
self.do_resolve(color)
}
- /// Cap the terminal color by the fidelity.
- ///
- /// This method ensures that the terminal color can be rendered by a
- /// terminal with the given fidelity level. Depending on fidelity, it
- /// returns the following result:
- ///
- /// * Plain-text or no-color
- /// * `None`
- /// * ANSI colors
- /// * Downsampled 8-bit and 24-bit colors
- /// * Unmodified ANSI colors
- /// * 8-bit
- /// * Downsampled 24-bit colors
- /// * Unmodified ANSI and 8-bit colors
- /// * Full
- /// * Unmodified colors
- ///
- /// The Python version has a custom type conversion function and also
- /// accepts all kinds of terminal colors, whether wrapper or not.
- pub fn cap(
- &self,
- color: impl Into,
- fidelity: Fidelity,
- ) -> Option {
- self.do_cap(color, fidelity)
- }
-}
-
-impl Sampler {
- /// Determine whether this sampler's color theme is a dark theme.
- ///
- /// The Y component of a color in XYZ represents it luminance. This method
- /// exploits that property of XYZ and checks whether the default foreground
- /// has a larger luminance than the default background color.
- pub fn is_dark_theme(&self) -> bool {
- let yf = self.theme[0].to(ColorSpace::Xyz)[1];
- let yb = self.theme[1].to(ColorSpace::Xyz)[1];
- yf > yb
- }
-
/// Convert the high-resolution color into an ANSI color.
///
/// If the current theme meets the requirements for hue/lightness search,
@@ -903,19 +1204,7 @@ impl Sampler {
/// [Chalk](https://github.com/chalk/chalk/blob/main/source/vendor/ansi-styles/index.js),
/// only one of the most popular terminal color libraries for JavaScript.
pub fn to_ansi_rgb(&self, color: &Color) -> AnsiColor {
- let color = color.to(ColorSpace::LinearSrgb).clip();
- let [r, g, b] = color.as_ref();
- let mut index = ((b.round() as u8) << 2) + ((g.round() as u8) << 1) + (r.round() as u8);
- // When we get to the threshold below, the color has already been
- // selected and can only be brightened. A threshold of 2 or 3 produces
- // the least bad results. In any case, prettypretty.grid's output shows
- // large striped rectangles, with 4x24 cells black/blue and 2x24 cells
- // green/cyan above 4x12 cells red/magenta and 2x12 cells yellow/white.
- if index >= 3 {
- index += 8;
- }
-
- AnsiColor::try_from(index).unwrap()
+ self.do_to_ansi_rgb(color)
}
/// Find the 8-bit color that comes closest to the given color.
@@ -990,6 +1279,35 @@ impl Sampler {
TerminalColor::from(index)
}
+ /// Cap the terminal color by the fidelity.
+ ///
+ /// This method ensures that the terminal color can be rendered by a
+ /// terminal with the given fidelity level. Depending on fidelity, it
+ /// returns the following result:
+ ///
+ /// * Plain-text or no-color
+ /// * `None`
+ /// * ANSI colors
+ /// * Downsampled 8-bit and 24-bit colors
+ /// * Unmodified ANSI colors
+ /// * 8-bit
+ /// * Downsampled 24-bit colors
+ /// * Unmodified ANSI and 8-bit colors
+ /// * Full
+ /// * Unmodified colors
+ ///
+ /// The Python version has a custom type conversion function and also
+ /// accepts all kinds of terminal colors, whether wrapper or not.
+ pub fn cap(
+ &self,
+ color: impl Into,
+ fidelity: Fidelity,
+ ) -> Option {
+ self.do_cap(color, fidelity)
+ }
+}
+
+impl Sampler {
#[inline]
fn do_resolve(&self, color: impl Into) -> Color {
match color.into() {
@@ -1001,6 +1319,22 @@ impl Sampler {
}
}
+ fn do_to_ansi_rgb(&self, color: &Color) -> AnsiColor {
+ let color = color.to(ColorSpace::LinearSrgb).clip();
+ let [r, g, b] = color.as_ref();
+ let mut index = ((b.round() as u8) << 2) + ((g.round() as u8) << 1) + (r.round() as u8);
+ // When we get to the threshold below, the color has already been
+ // selected and can only be brightened. A threshold of 2 or 3 produces
+ // the least bad results. In any case, prettypretty.grid's output shows
+ // large striped rectangles, with 4x24 cells black/blue and 2x24 cells
+ // green/cyan above 4x12 cells red/magenta and 2x12 cells yellow/white.
+ if index >= 3 {
+ index += 8;
+ }
+
+ AnsiColor::try_from(index).unwrap()
+ }
+
fn do_cap(&self, color: impl Into, fidelity: Fidelity) -> Option {
let color = color.into();
match fidelity {