oxedyne/fe2o3/fe2o3_graphics/src/colour.rs
20.2 KiB, 52 runs
created by r1870400018:13938, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Colours and alpha compositing. |
| 2 | //! |
| 3 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 4 | //! Anthropic Claude |
| 5 | |
| 6 | use oxedyne_fe2o3_core::prelude::*; |
| 7 | |
| 8 | /// An 8-bit-per-channel colour with straight, non-premultiplied alpha. |
| 9 | /// |
| 10 | /// Straight alpha is stored rather than premultiplied because it is what a PNG carries and what a |
| 11 | /// caller names a colour with. The premultiplication happens inside [`Rgba::over`], where it |
| 12 | /// belongs. |
| 13 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] |
| 14 | pub struct Rgba { |
| 15 | pub r: u8, |
| 16 | pub g: u8, |
| 17 | pub b: u8, |
| 18 | pub a: u8, // 0 transparent to 255 opaque |
| 19 | } |
| 20 | |
| 21 | impl Rgba { |
| 22 | |
| 23 | pub const TRANSPARENT: Self = Self { r: 0, g: 0, b: 0, a: 0 }; |
| 24 | pub const BLACK: Self = Self { r: 0, g: 0, b: 0, a: 255 }; |
| 25 | pub const WHITE: Self = Self { r: 255, g: 255, b: 255, a: 255 }; |
| 26 | |
| 27 | pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { |
| 28 | Self { r, g, b, a } |
| 29 | } |
| 30 | |
| 31 | pub const fn opaque(r: u8, g: u8, b: u8) -> Self { |
| 32 | Self { r, g, b, a: 255 } |
| 33 | } |
| 34 | |
| 35 | /// Parses a colour from a hexadecimal string, with or without a leading `#`, in either `rgb`, |
| 36 | /// `rrggbb` or `rrggbbaa` form. |
| 37 | pub fn from_hex(s: &str) -> Outcome<Self> { |
| 38 | let h = s.strip_prefix('#').unwrap_or(s); |
| 39 | let nyb = |c: u8| -> Outcome<u8> { |
| 40 | match c { |
| 41 | b'0'..=b'9' => Ok(c - b'0'), |
| 42 | b'a'..=b'f' => Ok(c - b'a' + 10), |
| 43 | b'A'..=b'F' => Ok(c - b'A' + 10), |
| 44 | _ => Err(err!( |
| 45 | "'{}' is not a hexadecimal digit, in the colour \"{}\".", c as char, s; |
| 46 | Invalid, Input)), |
| 47 | } |
| 48 | }; |
| 49 | let b = h.as_bytes(); |
| 50 | match b.len() { |
| 51 | 3 => Ok(Self::opaque( |
| 52 | res!(nyb(b[0])) * 17, |
| 53 | res!(nyb(b[1])) * 17, |
| 54 | res!(nyb(b[2])) * 17, |
| 55 | )), |
| 56 | 6 => Ok(Self::opaque( |
| 57 | (res!(nyb(b[0])) << 4) | res!(nyb(b[1])), |
| 58 | (res!(nyb(b[2])) << 4) | res!(nyb(b[3])), |
| 59 | (res!(nyb(b[4])) << 4) | res!(nyb(b[5])), |
| 60 | )), |
| 61 | 8 => Ok(Self::new( |
| 62 | (res!(nyb(b[0])) << 4) | res!(nyb(b[1])), |
| 63 | (res!(nyb(b[2])) << 4) | res!(nyb(b[3])), |
| 64 | (res!(nyb(b[4])) << 4) | res!(nyb(b[5])), |
| 65 | (res!(nyb(b[6])) << 4) | res!(nyb(b[7])), |
| 66 | )), |
| 67 | n => Err(err!( |
| 68 | "A hexadecimal colour has 3, 6 or 8 digits, but \"{}\" has {}.", s, n; |
| 69 | Invalid, Input)), |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Renders this colour as a hexadecimal string with a leading `#`. |
| 74 | /// |
| 75 | /// The inverse of [`Rgba::from_hex`]: an opaque colour comes back as `#rrggbb`, and one with |
| 76 | /// alpha as `#rrggbbaa`, so a colour written out and read back in is the colour it began as. The |
| 77 | /// three-digit short form is never emitted, since most colours do not fit it and a writer that |
| 78 | /// only sometimes shortened would be the harder thing to reason about. |
| 79 | pub fn to_hex(&self) -> String { |
| 80 | if self.is_opaque() { |
| 81 | fmt!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) |
| 82 | } else { |
| 83 | fmt!("#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | pub fn is_transparent(&self) -> bool { |
| 88 | self.a == 0 |
| 89 | } |
| 90 | |
| 91 | pub fn is_opaque(&self) -> bool { |
| 92 | self.a == 255 |
| 93 | } |
| 94 | |
| 95 | /// The coverage runs from 0 to 1. This is how the rasteriser's anti-aliasing reaches the |
| 96 | /// pixel: a pixel the shape half covers is painted with a colour of half the alpha. |
| 97 | pub fn with_coverage(&self, cov: f32) -> Self { |
| 98 | let c = cov.clamp(0.0, 1.0); |
| 99 | Self { |
| 100 | a: ((self.a as f32) * c + 0.5) as u8, |
| 101 | ..*self |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// Composites this colour, as the source, over `dst`, the destination: Porter-Duff source-over. |
| 106 | /// |
| 107 | /// Both operands carry straight alpha, so each is premultiplied, combined, and un-premultiplied |
| 108 | /// on the way out. |
| 109 | pub fn over(&self, dst: Self) -> Self { |
| 110 | if self.is_opaque() || dst.is_transparent() { |
| 111 | return *self; |
| 112 | } |
| 113 | if self.is_transparent() { |
| 114 | return dst; |
| 115 | } |
| 116 | let sa = (self.a as f32) / 255.0; |
| 117 | let da = (dst.a as f32) / 255.0; |
| 118 | let oa = sa + da * (1.0 - sa); // Output alpha, never zero here. |
| 119 | let chan = |s: u8, d: u8| -> u8 { |
| 120 | let sc = (s as f32) / 255.0; |
| 121 | let dc = (d as f32) / 255.0; |
| 122 | let oc = (sc * sa + dc * da * (1.0 - sa)) / oa; |
| 123 | (oc * 255.0 + 0.5).clamp(0.0, 255.0) as u8 |
| 124 | }; |
| 125 | Self { |
| 126 | r: chan(self.r, dst.r), |
| 127 | g: chan(self.g, dst.g), |
| 128 | b: chan(self.b, dst.b), |
| 129 | a: (oa * 255.0 + 0.5).clamp(0.0, 255.0) as u8, |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /// The WCAG relative luminance of this colour, from 0 for black to 1 for white. |
| 134 | /// |
| 135 | /// Each channel is taken back from the display-encoded sRGB the colour is stored in to the |
| 136 | /// linear-light value the eye weighs, by the sRGB transfer function, and the three are then |
| 137 | /// combined with the luminance weights the standard gives. This is the quantity two colours' |
| 138 | /// contrast is measured from, so it is worked in `f64`: the contrast of near-black text against |
| 139 | /// black turns on small differences the wider type keeps. |
| 140 | /// |
| 141 | /// Alpha is ignored. Luminance is a property of a colour once it is on the screen, and a colour |
| 142 | /// with alpha is not yet on the screen -- a caller wanting the luminance of a translucent colour |
| 143 | /// over a background should composite it with [`Rgba::over`] first. |
| 144 | pub fn relative_luminance(&self) -> f64 { |
| 145 | // The sRGB transfer function, taking one channel from encoded 0..255 to linear 0..1. |
| 146 | let lin = |c: u8| -> f64 { |
| 147 | let c = (c as f64) / 255.0; |
| 148 | if c <= 0.03928 { |
| 149 | c / 12.92 |
| 150 | } else { |
| 151 | ((c + 0.055) / 1.055).powf(2.4) |
| 152 | } |
| 153 | }; |
| 154 | 0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b) |
| 155 | } |
| 156 | |
| 157 | /// The WCAG contrast ratio between this colour and another, from 1 for two equal colours to 21 |
| 158 | /// for black against white. |
| 159 | /// |
| 160 | /// The ratio is `(L1 + 0.05) / (L2 + 0.05)`, where `L1` is the lighter of the two relative |
| 161 | /// luminances and `L2` the darker, and the `0.05` is the flare the standard adds for the light a |
| 162 | /// real screen reflects even where it shows black. The result does not depend on which colour is |
| 163 | /// named first: the brighter is always taken as `L1`. |
| 164 | /// |
| 165 | /// WCAG asks 4.5 of body text and 3 of large text for its AA level, and 7 and 4.5 for AAA. |
| 166 | pub fn contrast_ratio(&self, other: &Self) -> f64 { |
| 167 | let a = self.relative_luminance(); |
| 168 | let b = other.relative_luminance(); |
| 169 | let (hi, lo) = if a >= b { (a, b) } else { (b, a) }; |
| 170 | (hi + 0.05) / (lo + 0.05) |
| 171 | } |
| 172 | |
| 173 | /// This colour as it would look to an eye with a colour-vision deficiency. |
| 174 | /// |
| 175 | /// The simulation is a single linear map of the sRGB channels, the standard matrix for each of |
| 176 | /// the three dichromacies (see [`ColourVision`]). It is the approximation used across the web |
| 177 | /// tooling that checks a palette for the roughly one man in twelve who cannot tell red from |
| 178 | /// green: not a model of the retina, but enough to show whether two colours a design leans on |
| 179 | /// collapse into one for a viewer who lacks a cone. Alpha is carried through unchanged. |
| 180 | pub fn simulate(&self, cvd: ColourVision) -> Self { |
| 181 | let m = cvd.matrix(); |
| 182 | // The channels stay in sRGB: these matrices are fitted to the encoded values, not to |
| 183 | // linear light, so no transfer function is applied on the way through. |
| 184 | let (r, g, b) = (self.r as f32, self.g as f32, self.b as f32); |
| 185 | let ch = |row: [f32; 3]| -> u8 { |
| 186 | (row[0] * r + row[1] * g + row[2] * b + 0.5).clamp(0.0, 255.0) as u8 |
| 187 | }; |
| 188 | Self { |
| 189 | r: ch(m[0]), |
| 190 | g: ch(m[1]), |
| 191 | b: ch(m[2]), |
| 192 | a: self.a, |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | /// One colour of a gradient, at a position along it. |
| 198 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 199 | pub struct Stop { |
| 200 | pub at: f32, // zero at the start of the gradient, one at the end |
| 201 | pub colour: Rgba, |
| 202 | } |
| 203 | |
| 204 | impl Stop { |
| 205 | |
| 206 | pub fn new(at: f32, colour: Rgba) -> Self { |
| 207 | Self { at, colour } |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /// A paint whose colour varies with position, as a concrete pair rather than a trait object. |
| 212 | /// |
| 213 | /// Both forms carry their stops in the same order and are read the same way, so the only difference |
| 214 | /// between them is what "along the gradient" means: a distance along an axis, or a distance from a |
| 215 | /// centre. A position before the first stop takes the first stop's colour and one past the last |
| 216 | /// takes the last's, which is the padding an SVG gradient does unless it is told otherwise. |
| 217 | #[derive(Clone, Debug, PartialEq)] |
| 218 | pub enum Gradient { |
| 219 | Linear { // along the line from one point to another, in the path's own coordinates |
| 220 | from: (f32, f32), // where the gradient starts |
| 221 | to: (f32, f32), // and where it ends |
| 222 | stops: Vec<Stop>, // the colours along it, which need not be sorted |
| 223 | }, |
| 224 | Radial { // outwards from a centre, in the path's own coordinates |
| 225 | centre: (f32, f32), // position zero |
| 226 | radius: f32, // where position one is reached; must be positive |
| 227 | stops: Vec<Stop>, // the colours along it, which need not be sorted |
| 228 | }, |
| 229 | } |
| 230 | |
| 231 | impl Gradient { |
| 232 | |
| 233 | /// A gradient of two colours along a line. |
| 234 | pub fn two(from: (f32, f32), to: (f32, f32), start: Rgba, end: Rgba) -> Self { |
| 235 | Self::Linear { |
| 236 | from, |
| 237 | to, |
| 238 | stops: vec![Stop::new(0.0, start), Stop::new(1.0, end)], |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /// The stops, in the order they were given. |
| 243 | pub fn stops(&self) -> &[Stop] { |
| 244 | match self { |
| 245 | Self::Linear { stops, .. } => stops, |
| 246 | Self::Radial { stops, .. } => stops, |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /// Checks the gradient can be sampled, and sorts its stops into order. |
| 251 | /// |
| 252 | /// A gradient with no stops paints nothing and one whose radius is not positive divides by |
| 253 | /// zero, so both are refused here rather than at a pixel. The stops are sorted because the |
| 254 | /// sampling walks them in order and a caller listing them out of order means the positions |
| 255 | /// rather than the order they typed. |
| 256 | pub fn prepare(&self) -> Outcome<Self> { |
| 257 | let mut g = self.clone(); |
| 258 | let stops = match &mut g { |
| 259 | Self::Linear { stops, .. } => stops, |
| 260 | Self::Radial { radius, stops, .. } => { |
| 261 | if !(*radius > 0.0) || !radius.is_finite() { |
| 262 | return Err(err!( |
| 263 | "A radial gradient's radius is {}, and must be a positive number.", radius; |
| 264 | Invalid, Input)); |
| 265 | } |
| 266 | stops |
| 267 | }, |
| 268 | }; |
| 269 | if stops.is_empty() { |
| 270 | return Err(err!("A gradient must carry at least one stop, and carries none."; |
| 271 | Invalid, Input, Missing)); |
| 272 | } |
| 273 | for s in stops.iter() { |
| 274 | if !s.at.is_finite() { |
| 275 | return Err(err!("A gradient stop sits at {}, which is not a position.", s.at; |
| 276 | Invalid, Input)); |
| 277 | } |
| 278 | } |
| 279 | stops.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap_or(std::cmp::Ordering::Equal)); |
| 280 | Ok(g) |
| 281 | } |
| 282 | |
| 283 | /// Where the point `(x, y)` falls along the gradient, from zero to one, unclamped. |
| 284 | /// |
| 285 | /// A linear gradient whose two points coincide has no direction and no length, so every point |
| 286 | /// is at its end: the whole shape takes the last stop's colour, which is what a gradient of no |
| 287 | /// extent degenerates to and is better than a division by zero. |
| 288 | pub fn position(&self, x: f32, y: f32) -> f32 { |
| 289 | match self { |
| 290 | Self::Linear { from, to, .. } => { |
| 291 | let (dx, dy) = (to.0 - from.0, to.1 - from.1); |
| 292 | let len2 = dx * dx + dy * dy; |
| 293 | if len2 <= 0.0 { |
| 294 | return 1.0; |
| 295 | } |
| 296 | ((x - from.0) * dx + (y - from.1) * dy) / len2 |
| 297 | }, |
| 298 | Self::Radial { centre, radius, .. } => { |
| 299 | let (dx, dy) = (x - centre.0, y - centre.1); |
| 300 | (dx * dx + dy * dy).sqrt() / radius |
| 301 | }, |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | /// The colour at a position along the gradient, the stops taken as already sorted. |
| 306 | /// |
| 307 | /// Interpolation is linear in straight, non-premultiplied sRGB on all four channels, which is |
| 308 | /// what an SVG gradient specifies and so what a caller comparing against a browser will see. |
| 309 | pub fn sample(&self, t: f32) -> Rgba { |
| 310 | let stops = self.stops(); |
| 311 | let first = match stops.first() { |
| 312 | Some(s) => s, |
| 313 | None => return Rgba::TRANSPARENT, |
| 314 | }; |
| 315 | if t <= first.at { |
| 316 | return first.colour; |
| 317 | } |
| 318 | let last = &stops[stops.len() - 1]; |
| 319 | if t >= last.at { |
| 320 | return last.colour; |
| 321 | } |
| 322 | for pair in stops.windows(2) { |
| 323 | let (a, b) = (&pair[0], &pair[1]); |
| 324 | if t >= a.at && t <= b.at { |
| 325 | let span = b.at - a.at; |
| 326 | // Two stops at the same position are a hard edge, and the second wins. |
| 327 | if span <= 0.0 { |
| 328 | return b.colour; |
| 329 | } |
| 330 | let f = (t - a.at) / span; |
| 331 | let ch = |p: u8, q: u8| -> u8 { |
| 332 | ((p as f32) + ((q as f32) - (p as f32)) * f + 0.5).clamp(0.0, 255.0) as u8 |
| 333 | }; |
| 334 | return Rgba { |
| 335 | r: ch(a.colour.r, b.colour.r), |
| 336 | g: ch(a.colour.g, b.colour.g), |
| 337 | b: ch(a.colour.b, b.colour.b), |
| 338 | a: ch(a.colour.a, b.colour.a), |
| 339 | }; |
| 340 | } |
| 341 | } |
| 342 | last.colour |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | /// A form of colour blindness, for [`Rgba::simulate`] to show a colour through. |
| 347 | /// |
| 348 | /// The three dichromacies, each the loss of one of the eye's three cones. Protanopia and |
| 349 | /// deuteranopia are the two red-green kinds and together much the most common; tritanopia, the |
| 350 | /// blue-yellow kind, is rare. |
| 351 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 352 | pub enum ColourVision { |
| 353 | Protanopia, // red-blind: the long-wavelength cone is missing |
| 354 | Deuteranopia, // green-blind: the medium-wavelength cone is missing |
| 355 | Tritanopia, // blue-blind: the short-wavelength cone is missing |
| 356 | } |
| 357 | |
| 358 | impl ColourVision { |
| 359 | |
| 360 | /// The simulation matrix for this deficiency: three rows, each the weights that make one output |
| 361 | /// channel from the three input channels. |
| 362 | /// |
| 363 | /// These are the widely used dichromat matrices that operate directly on sRGB. Each row sums to |
| 364 | /// one, so a grey is left where it was and only the hues that the missing cone distinguished are |
| 365 | /// folded together. |
| 366 | fn matrix(&self) -> [[f32; 3]; 3] { |
| 367 | match self { |
| 368 | Self::Protanopia => [ |
| 369 | [0.567, 0.433, 0.000], |
| 370 | [0.558, 0.442, 0.000], |
| 371 | [0.000, 0.242, 0.758], |
| 372 | ], |
| 373 | Self::Deuteranopia => [ |
| 374 | [0.625, 0.375, 0.000], |
| 375 | [0.700, 0.300, 0.000], |
| 376 | [0.000, 0.300, 0.700], |
| 377 | ], |
| 378 | Self::Tritanopia => [ |
| 379 | [0.950, 0.050, 0.000], |
| 380 | [0.000, 0.433, 0.567], |
| 381 | [0.000, 0.475, 0.525], |
| 382 | ], |
| 383 | } |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | #[cfg(test)] |
| 388 | mod tests { |
| 389 | use super::*; |
| 390 | |
| 391 | #[test] |
| 392 | fn test_opaque_source_replaces_destination_00() { |
| 393 | let src = Rgba::new(10, 20, 30, 255); |
| 394 | assert_eq!(src.over(Rgba::WHITE), src); |
| 395 | } |
| 396 | |
| 397 | #[test] |
| 398 | fn test_transparent_source_leaves_destination_01() { |
| 399 | assert_eq!(Rgba::TRANSPARENT.over(Rgba::WHITE), Rgba::WHITE); |
| 400 | } |
| 401 | |
| 402 | #[test] |
| 403 | fn test_half_alpha_black_over_white_is_grey_02() { |
| 404 | let src = Rgba::new(0, 0, 0, 128); |
| 405 | let out = src.over(Rgba::WHITE); |
| 406 | assert_eq!(out.a, 255); |
| 407 | // 128/255 of the way from white to black. |
| 408 | assert!(out.r >= 126 && out.r <= 128, "expected mid grey, found {}", out.r); |
| 409 | } |
| 410 | |
| 411 | #[test] |
| 412 | fn test_coverage_scales_alpha_03() { |
| 413 | let c = Rgba::new(1, 2, 3, 200).with_coverage(0.5); |
| 414 | assert_eq!(c.a, 100); |
| 415 | assert_eq!((c.r, c.g, c.b), (1, 2, 3)); |
| 416 | } |
| 417 | |
| 418 | #[test] |
| 419 | fn test_hex_forms_04() -> Outcome<()> { |
| 420 | assert_eq!(res!(Rgba::from_hex("#fff")), Rgba::WHITE); |
| 421 | assert_eq!(res!(Rgba::from_hex("000000")), Rgba::BLACK); |
| 422 | assert_eq!(res!(Rgba::from_hex("#0a141e80")), Rgba::new(10, 20, 30, 128)); |
| 423 | assert!(Rgba::from_hex("#xyz").is_err()); |
| 424 | assert!(Rgba::from_hex("#ffff").is_err()); |
| 425 | Ok(()) |
| 426 | } |
| 427 | |
| 428 | #[test] |
| 429 | fn test_hex_round_trips_through_the_reader_05() -> Outcome<()> { |
| 430 | // A colour written out and read back is the colour it began as: to_hex is the inverse of |
| 431 | // from_hex, opaque in six digits and translucent in eight. |
| 432 | for c in [ |
| 433 | Rgba::BLACK, |
| 434 | Rgba::WHITE, |
| 435 | Rgba::new(10, 20, 30, 255), |
| 436 | Rgba::new(10, 20, 30, 128), |
| 437 | Rgba::new(1, 2, 3, 0), |
| 438 | ] { |
| 439 | assert_eq!(res!(Rgba::from_hex(&c.to_hex())), c, "{} did not round trip", c.to_hex()); |
| 440 | } |
| 441 | assert_eq!(Rgba::WHITE.to_hex(), "#ffffff"); |
| 442 | assert_eq!(Rgba::new(10, 20, 30, 128).to_hex(), "#0a141e80"); |
| 443 | Ok(()) |
| 444 | } |
| 445 | |
| 446 | #[test] |
| 447 | fn test_black_on_white_is_the_maximum_contrast_06() { |
| 448 | // The published anchor: black on white is exactly 21:1, and white on itself is 1:1. These |
| 449 | // are the two ends of the WCAG scale and fix both the luminances and the ratio formula. |
| 450 | let bw = Rgba::BLACK.contrast_ratio(&Rgba::WHITE); |
| 451 | assert!((bw - 21.0).abs() < 1e-6, "black on white should be 21:1, found {}", bw); |
| 452 | let ww = Rgba::WHITE.contrast_ratio(&Rgba::WHITE); |
| 453 | assert!((ww - 1.0).abs() < 1e-6, "white on white should be 1:1, found {}", ww); |
| 454 | } |
| 455 | |
| 456 | #[test] |
| 457 | fn test_the_contrast_ratio_does_not_depend_on_order_07() { |
| 458 | // The lighter colour is always taken as L1, so naming the pair either way gives one answer. |
| 459 | let a = Rgba::new(0x76, 0x76, 0x76, 255); |
| 460 | assert_eq!(a.contrast_ratio(&Rgba::WHITE), Rgba::WHITE.contrast_ratio(&a)); |
| 461 | } |
| 462 | |
| 463 | #[test] |
| 464 | fn test_the_aa_reference_grey_meets_the_threshold_08() { |
| 465 | // #767676 on white is the grey WCAG's own reference gives as ~4.54:1 -- the darkest grey |
| 466 | // that clears the 4.5:1 AA bar for body text. This checks the sRGB linearisation against a |
| 467 | // published value, not against the formula restated. |
| 468 | let grey = Rgba::new(0x76, 0x76, 0x76, 255); |
| 469 | let r = grey.contrast_ratio(&Rgba::WHITE); |
| 470 | assert!((r - 4.54).abs() < 0.02, "#767676 on white should be ~4.54:1, found {}", r); |
| 471 | } |
| 472 | |
| 473 | #[test] |
| 474 | fn test_a_grey_is_unmoved_by_a_deficiency_09() { |
| 475 | // Every simulation matrix has rows that sum to one, so an achromatic colour, which loses no |
| 476 | // hue because it has none, comes back where it was. |
| 477 | let grey = Rgba::new(128, 128, 128, 200); |
| 478 | for cvd in [ColourVision::Protanopia, ColourVision::Deuteranopia, ColourVision::Tritanopia] { |
| 479 | let out = grey.simulate(cvd); |
| 480 | assert!((out.r as i32 - 128).abs() <= 1, "{:?} moved a grey to {}", cvd, out.r); |
| 481 | assert!((out.g as i32 - 128).abs() <= 1, "{:?} moved a grey to {}", cvd, out.g); |
| 482 | assert!((out.b as i32 - 128).abs() <= 1, "{:?} moved a grey to {}", cvd, out.b); |
| 483 | assert_eq!(out.a, 200, "alpha must be carried through"); |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | #[test] |
| 488 | fn test_red_and_green_collapse_under_protanopia_10() { |
| 489 | // The point of the simulation: a red and a green a design might rely on to differ become |
| 490 | // nearly the same colour to a red-green-blind eye. Measured as a distance through the RGB |
| 491 | // cube, which turns on hue and not just brightness, the gap between them falls to a fraction |
| 492 | // of what it was once both are seen through protanopia. |
| 493 | let red = Rgba::new(230, 40, 40, 255); |
| 494 | let green = Rgba::new(40, 180, 40, 255); |
| 495 | let dist = |a: Rgba, b: Rgba| -> f32 { |
| 496 | let dr = a.r as f32 - b.r as f32; |
| 497 | let dg = a.g as f32 - b.g as f32; |
| 498 | let db = a.b as f32 - b.b as f32; |
| 499 | (dr * dr + dg * dg + db * db).sqrt() |
| 500 | }; |
| 501 | let normal = dist(red, green); |
| 502 | let seen = dist( |
| 503 | red.simulate(ColourVision::Protanopia), |
| 504 | green.simulate(ColourVision::Protanopia), |
| 505 | ); |
| 506 | assert!( |
| 507 | seen < 0.4 * normal, |
| 508 | "red and green stood {} apart but should collapse under protanopia, found {}", |
| 509 | normal, seen, |
| 510 | ); |
| 511 | } |
| 512 | #[test] |
| 513 | fn test_a_gradient_pads_at_both_ends_11() -> Outcome<()> { |
| 514 | let g = res!(Gradient::two((10.0, 0.0), (20.0, 0.0), Rgba::BLACK, Rgba::WHITE).prepare()); |
| 515 | // Before the start and after the end, the end stops hold rather than repeating or |
| 516 | // reflecting, which is what an SVG gradient does unless told otherwise. |
| 517 | req!(g.sample(g.position(0.0, 0.0)), Rgba::BLACK); |
| 518 | req!(g.sample(g.position(-500.0, 0.0)), Rgba::BLACK); |
| 519 | req!(g.sample(g.position(20.0, 0.0)), Rgba::WHITE); |
| 520 | req!(g.sample(g.position(500.0, 0.0)), Rgba::WHITE); |
| 521 | req!(g.sample(g.position(15.0, 0.0)), Rgba::new(128, 128, 128, 255)); |
| 522 | Ok(()) |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn test_a_gradient_is_read_along_its_axis_and_not_across_it_12() -> Outcome<()> { |
| 527 | // The axis runs down the page, so moving across it must change nothing at all. A sampler |
| 528 | // that took a distance rather than a projection would shade this in rings. |
| 529 | let g = res!(Gradient::two((0.0, 0.0), (0.0, 100.0), Rgba::BLACK, Rgba::WHITE).prepare()); |
| 530 | let at = g.sample(g.position(0.0, 40.0)); |
| 531 | req!(g.sample(g.position(-300.0, 40.0)), at); |
| 532 | req!(g.sample(g.position(900.0, 40.0)), at); |
| 533 | Ok(()) |
| 534 | } |
| 535 | |
| 536 | #[test] |
| 537 | fn test_a_radial_gradient_is_read_as_a_distance_13() -> Outcome<()> { |
| 538 | let g = res!(Gradient::Radial { |
| 539 | centre: (50.0, 50.0), |
| 540 | radius: 10.0, |
| 541 | stops: vec![Stop::new(0.0, Rgba::BLACK), Stop::new(1.0, Rgba::WHITE)], |
| 542 | }.prepare()); |
| 543 | req!(g.sample(g.position(50.0, 50.0)), Rgba::BLACK); |
| 544 | // Every point at the radius is at the end, whichever way it lies from the centre. |
| 545 | req!(g.sample(g.position(60.0, 50.0)), Rgba::WHITE); |
| 546 | req!(g.sample(g.position(50.0, 40.0)), Rgba::WHITE); |
| 547 | // The position is the distance and not its square: half way out is half way along. |
| 548 | req!(g.sample(g.position(55.0, 50.0)), Rgba::new(128, 128, 128, 255)); |
| 549 | Ok(()) |
| 550 | } |
| 551 | |
| 552 | #[test] |
| 553 | fn test_a_gradient_refuses_what_it_cannot_sample_14() { |
| 554 | let none = Gradient::Linear { from: (0.0, 0.0), to: (1.0, 0.0), stops: Vec::new() }; |
| 555 | assert!(none.prepare().is_err(), "a gradient with no stops must be refused"); |
| 556 | let flat = Gradient::Radial { centre: (0.0, 0.0), radius: 0.0, stops: vec![ |
| 557 | Stop::new(0.0, Rgba::BLACK)] }; |
| 558 | assert!(flat.prepare().is_err(), "a radial gradient of no radius must be refused"); |
| 559 | } |
| 560 | |
| 561 | #[test] |
| 562 | fn test_stops_out_of_order_are_sorted_rather_than_believed_15() -> Outcome<()> { |
| 563 | let g = res!(Gradient::Linear { |
| 564 | from: (0.0, 0.0), |
| 565 | to: (10.0, 0.0), |
| 566 | stops: vec![ |
| 567 | Stop::new(1.0, Rgba::WHITE), |
| 568 | Stop::new(0.0, Rgba::BLACK), |
| 569 | ], |
| 570 | }.prepare()); |
| 571 | req!(g.sample(0.0), Rgba::BLACK); |
| 572 | req!(g.sample(1.0), Rgba::WHITE); |
| 573 | req!(g.sample(0.5), Rgba::new(128, 128, 128, 255)); |
| 574 | Ok(()) |
| 575 | } |
| 576 | |
| 577 | } |