| 1 | //! COLRv1 glyphs, which swash can't paint: skrifa walks the paint graph and this paints it |
| 2 | //! on the CPU, in premultiplied sRGB as browsers blend it, into an image the atlas takes as |
| 3 | //! any colour glyph. |
| 4 | |
| 5 | use skrifa::{ |
| 6 | FontRef, GlyphId, MetadataProvider, |
| 7 | color::{Brush, ColorGlyphFormat, ColorPainter, ColorStop, CompositeMode, Extend, Transform}, |
| 8 | instance::{LocationRef, Size}, |
| 9 | outline::{DrawSettings, OutlinePen}, |
| 10 | raw::TableProvider, |
| 11 | raw::types::{BoundingBox, F2Dot14}, |
| 12 | }; |
| 13 | use swash::{ |
| 14 | scale::image::{Content, Image}, |
| 15 | zeno::{Command, Mask, Placement, Vector}, |
| 16 | }; |
| 17 | |
| 18 | /// The colour image of glyph `glyph` at `size` pixels per em, moved right and up by |
| 19 | /// `offset` pixels; `None` where the font has no COLRv1 paint for it. |
| 20 | pub(super) fn paint( |
| 21 | data: &[u8], |
| 22 | index: u32, |
| 23 | glyph: u16, |
| 24 | size: f32, |
| 25 | coords: &[i16], |
| 26 | offset: [f32; 2], |
| 27 | ) -> Option<Image> { |
| 28 | let font = FontRef::from_index(data, index).ok()?; |
| 29 | let glyph = GlyphId::from(glyph); |
| 30 | let color = font |
| 31 | .color_glyphs() |
| 32 | .get_with_format(glyph, ColorGlyphFormat::ColrV1)?; |
| 33 | let coords: Vec<F2Dot14> = coords.iter().map(|&c| F2Dot14::from_bits(c)).collect(); |
| 34 | let location = LocationRef::new(&coords); |
| 35 | let bounds = color |
| 36 | .bounding_box(location, Size::new(size)) |
| 37 | .or_else(|| font.metrics(Size::new(size), location).bounds)?; |
| 38 | let left = (bounds.x_min + offset[0]).floor(); |
| 39 | let top = (bounds.y_max + offset[1]).ceil(); |
| 40 | let width = ((bounds.x_max + offset[0]).ceil() - left) as usize; |
| 41 | let height = (top - (bounds.y_min + offset[1]).floor()) as usize; |
| 42 | if width == 0 || height == 0 || width * height > 1 << 22 { |
| 43 | return None; |
| 44 | } |
| 45 | let scale = size / f32::from(font.head().ok()?.units_per_em()); |
| 46 | let palette = font |
| 47 | .color_palettes() |
| 48 | .get(0) |
| 49 | .map(|palette| { |
| 50 | palette |
| 51 | .colors() |
| 52 | .iter() |
| 53 | .map(|c| [c.red, c.green, c.blue, c.alpha].map(|v| f32::from(v) / 255.0)) |
| 54 | .collect() |
| 55 | }) |
| 56 | .unwrap_or_default(); |
| 57 | let mut painter = Painter { |
| 58 | font: &font, |
| 59 | location, |
| 60 | palette, |
| 61 | width, |
| 62 | height, |
| 63 | transforms: vec![Transform { |
| 64 | xx: scale, |
| 65 | yy: -scale, |
| 66 | dx: offset[0] - left, |
| 67 | dy: top - offset[1], |
| 68 | ..Transform::default() |
| 69 | }], |
| 70 | clips: vec![vec![1.0; width * height]], |
| 71 | layers: vec![(vec![[0.0; 4]; width * height], CompositeMode::SrcOver)], |
| 72 | }; |
| 73 | color.paint(location, &mut painter).ok()?; |
| 74 | let (pixels, _) = painter.layers.pop()?; |
| 75 | Some(Image { |
| 76 | content: Content::Color, |
| 77 | placement: Placement { |
| 78 | left: left as i32, |
| 79 | top: top as i32, |
| 80 | width: width as u32, |
| 81 | height: height as u32, |
| 82 | }, |
| 83 | data: pixels |
| 84 | .iter() |
| 85 | .flat_map(|&[r, g, b, a]| { |
| 86 | let straight = |v: f32| match a > 0.0 { |
| 87 | true => ((v / a).clamp(0.0, 1.0) * 255.0).round() as u8, |
| 88 | false => 0, |
| 89 | }; |
| 90 | [ |
| 91 | straight(r), |
| 92 | straight(g), |
| 93 | straight(b), |
| 94 | (a * 255.0).round() as u8, |
| 95 | ] |
| 96 | }) |
| 97 | .collect(), |
| 98 | ..Image::default() |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | /// Paints into layers of premultiplied pixels; transforms map font units to pixels, y down. |
| 103 | struct Painter<'a> { |
| 104 | font: &'a FontRef<'a>, |
| 105 | location: LocationRef<'a>, |
| 106 | palette: Vec<[f32; 4]>, |
| 107 | width: usize, |
| 108 | height: usize, |
| 109 | transforms: Vec<Transform>, |
| 110 | /// Coverage, each the intersection of those below it. |
| 111 | clips: Vec<Vec<f32>>, |
| 112 | layers: Vec<(Vec<[f32; 4]>, CompositeMode)>, |
| 113 | } |
| 114 | |
| 115 | impl Painter<'_> { |
| 116 | fn transform(&self) -> Transform { |
| 117 | *self.transforms.last().expect("the pixel transform stays") |
| 118 | } |
| 119 | |
| 120 | /// Narrows the clip to the path `commands` describes, in pixels. |
| 121 | fn clip(&mut self, commands: &[Command]) { |
| 122 | let mut coverage = vec![0; self.width * self.height]; |
| 123 | Mask::new(commands) |
| 124 | .size(self.width as u32, self.height as u32) |
| 125 | .render_into(&mut coverage, None); |
| 126 | let clip = self.clips.last().expect("the whole image stays a clip"); |
| 127 | let clip = clip |
| 128 | .iter() |
| 129 | .zip(coverage) |
| 130 | .map(|(outer, inner)| outer * f32::from(inner) / 255.0) |
| 131 | .collect(); |
| 132 | self.clips.push(clip); |
| 133 | } |
| 134 | |
| 135 | /// The premultiplied colour of palette entry `index` at `alpha`; 0xFFFF, the text's |
| 136 | /// colour, is black, as colour glyphs aren't tinted. |
| 137 | fn color(&self, index: u16, alpha: f32) -> [f32; 4] { |
| 138 | let [r, g, b, a] = match index { |
| 139 | 0xFFFF => [0.0, 0.0, 0.0, 1.0], |
| 140 | _ => self |
| 141 | .palette |
| 142 | .get(usize::from(index)) |
| 143 | .copied() |
| 144 | .unwrap_or_default(), |
| 145 | }; |
| 146 | let a = a * alpha; |
| 147 | [r * a, g * a, b * a, a] |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | impl ColorPainter for Painter<'_> { |
| 152 | fn push_transform(&mut self, transform: Transform) { |
| 153 | self.transforms.push(self.transform() * transform); |
| 154 | } |
| 155 | |
| 156 | fn pop_transform(&mut self) { |
| 157 | if self.transforms.len() > 1 { |
| 158 | self.transforms.pop(); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | fn push_clip_glyph(&mut self, glyph: GlyphId) { |
| 163 | let mut pen = Pen { |
| 164 | transform: self.transform(), |
| 165 | commands: Vec::new(), |
| 166 | }; |
| 167 | if let Some(outline) = self.font.outline_glyphs().get(glyph) { |
| 168 | let _ = outline.draw( |
| 169 | DrawSettings::unhinted(Size::unscaled(), self.location), |
| 170 | &mut pen, |
| 171 | ); |
| 172 | } |
| 173 | self.clip(&pen.commands); |
| 174 | } |
| 175 | |
| 176 | fn push_clip_box(&mut self, clip: BoundingBox<f32>) { |
| 177 | let mut pen = Pen { |
| 178 | transform: self.transform(), |
| 179 | commands: Vec::new(), |
| 180 | }; |
| 181 | pen.move_to(clip.x_min, clip.y_min); |
| 182 | pen.line_to(clip.x_max, clip.y_min); |
| 183 | pen.line_to(clip.x_max, clip.y_max); |
| 184 | pen.line_to(clip.x_min, clip.y_max); |
| 185 | pen.close(); |
| 186 | self.clip(&pen.commands); |
| 187 | } |
| 188 | |
| 189 | fn pop_clip(&mut self) { |
| 190 | if self.clips.len() > 1 { |
| 191 | self.clips.pop(); |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | fn fill(&mut self, brush: Brush<'_>) { |
| 196 | let shade = Shade::new(self, brush); |
| 197 | let inverse = invert(self.transform()); |
| 198 | let clip = self.clips.last().expect("the whole image stays a clip"); |
| 199 | let (layer, _) = self.layers.last_mut().expect("the image stays a layer"); |
| 200 | for (i, (pixel, &coverage)) in layer.iter_mut().zip(clip).enumerate() { |
| 201 | if coverage <= 0.0 { |
| 202 | continue; |
| 203 | } |
| 204 | let (x, y) = ((i % self.width) as f32 + 0.5, (i / self.width) as f32 + 0.5); |
| 205 | let Some(source) = shade.at(inverse.transform(x, y)) else { |
| 206 | continue; |
| 207 | }; |
| 208 | let behind = 1.0 - source[3] * coverage; |
| 209 | *pixel = std::array::from_fn(|c| source[c] * coverage + pixel[c] * behind); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | fn push_layer(&mut self, mode: CompositeMode) { |
| 214 | self.layers |
| 215 | .push((vec![[0.0; 4]; self.width * self.height], mode)); |
| 216 | } |
| 217 | |
| 218 | fn pop_layer(&mut self) { |
| 219 | if self.layers.len() < 2 { |
| 220 | return; |
| 221 | } |
| 222 | let (source, mode) = self.layers.pop().expect("checked above"); |
| 223 | let (backdrop, _) = self.layers.last_mut().expect("checked above"); |
| 224 | for (b, s) in backdrop.iter_mut().zip(source) { |
| 225 | *b = composite(mode, s, *b); |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | /// `source` over `backdrop` in `mode`, both premultiplied. |
| 231 | fn composite(mode: CompositeMode, s: [f32; 4], b: [f32; 4]) -> [f32; 4] { |
| 232 | use CompositeMode::*; |
| 233 | let (sa, ba) = (s[3], b[3]); |
| 234 | let porter_duff = |fs: f32, fb: f32| std::array::from_fn(|c| s[c] * fs + b[c] * fb); |
| 235 | // W3C Compositing: a separable blend of unpremultiplied channels, then source-over. |
| 236 | let blend = |f: fn(f32, f32) -> f32| { |
| 237 | let mut out: [f32; 4] = std::array::from_fn(|c| { |
| 238 | let mixed = match sa > 0.0 && ba > 0.0 { |
| 239 | true => sa * ba * f(s[c] / sa, b[c] / ba), |
| 240 | false => 0.0, |
| 241 | }; |
| 242 | s[c] * (1.0 - ba) + b[c] * (1.0 - sa) + mixed |
| 243 | }); |
| 244 | out[3] = sa + ba - sa * ba; |
| 245 | out |
| 246 | }; |
| 247 | match mode { |
| 248 | Clear => [0.0; 4], |
| 249 | Src => s, |
| 250 | Dest => b, |
| 251 | DestOver => porter_duff(1.0 - ba, 1.0), |
| 252 | SrcIn => porter_duff(ba, 0.0), |
| 253 | DestIn => porter_duff(0.0, sa), |
| 254 | SrcOut => porter_duff(1.0 - ba, 0.0), |
| 255 | DestOut => porter_duff(0.0, 1.0 - sa), |
| 256 | SrcAtop => porter_duff(ba, 1.0 - sa), |
| 257 | DestAtop => porter_duff(1.0 - ba, sa), |
| 258 | Xor => porter_duff(1.0 - ba, 1.0 - sa), |
| 259 | Plus => std::array::from_fn(|c| (s[c] + b[c]).min(1.0)), |
| 260 | Screen => blend(|s, b| s + b - s * b), |
| 261 | Multiply => blend(|s, b| s * b), |
| 262 | Darken => blend(f32::min), |
| 263 | Lighten => blend(f32::max), |
| 264 | Difference => blend(|s, b| (s - b).abs()), |
| 265 | Exclusion => blend(|s, b| s + b - 2.0 * s * b), |
| 266 | Overlay => blend(|s, b| hard_light(b, s)), |
| 267 | HardLight => blend(hard_light), |
| 268 | SoftLight => blend(|s, b| { |
| 269 | if s <= 0.5 { |
| 270 | b - (1.0 - 2.0 * s) * b * (1.0 - b) |
| 271 | } else { |
| 272 | let d = match b <= 0.25 { |
| 273 | true => ((16.0 * b - 12.0) * b + 4.0) * b, |
| 274 | false => b.sqrt(), |
| 275 | }; |
| 276 | b + (2.0 * s - 1.0) * (d - b) |
| 277 | } |
| 278 | }), |
| 279 | ColorDodge => blend(|s, b| match s < 1.0 { |
| 280 | true => (b / (1.0 - s)).min(1.0), |
| 281 | false => f32::from(b > 0.0), |
| 282 | }), |
| 283 | ColorBurn => blend(|s, b| match s > 0.0 { |
| 284 | true => 1.0 - ((1.0 - b) / s).min(1.0), |
| 285 | false => f32::from(b >= 1.0), |
| 286 | }), |
| 287 | // The HSL modes, rare in emoji, and unknown modes paint as source-over. |
| 288 | _ => porter_duff(1.0, 1.0 - sa), |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | fn hard_light(s: f32, b: f32) -> f32 { |
| 293 | match s <= 0.5 { |
| 294 | true => b * 2.0 * s, |
| 295 | false => { |
| 296 | let s = 2.0 * s - 1.0; |
| 297 | b + s - b * s |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | fn invert(m: Transform) -> Transform { |
| 303 | let det = m.xx * m.yy - m.xy * m.yx; |
| 304 | if det.abs() < f32::EPSILON { |
| 305 | return Transform { |
| 306 | xx: 0.0, |
| 307 | yy: 0.0, |
| 308 | ..Transform::default() |
| 309 | }; |
| 310 | } |
| 311 | let (xx, xy, yx, yy) = (m.yy / det, -m.xy / det, -m.yx / det, m.xx / det); |
| 312 | Transform { |
| 313 | xx, |
| 314 | yx, |
| 315 | xy, |
| 316 | yy, |
| 317 | dx: -(xx * m.dx + xy * m.dy), |
| 318 | dy: -(yx * m.dx + yy * m.dy), |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /// A brush resolved against the palette, evaluated at points in its own space. |
| 323 | enum Shade { |
| 324 | Solid([f32; 4]), |
| 325 | Linear { |
| 326 | p0: [f32; 2], |
| 327 | along: [f32; 2], |
| 328 | line: Line, |
| 329 | }, |
| 330 | Radial { |
| 331 | c0: [f32; 2], |
| 332 | r0: f32, |
| 333 | cd: [f32; 2], |
| 334 | dr: f32, |
| 335 | line: Line, |
| 336 | }, |
| 337 | Sweep { |
| 338 | c0: [f32; 2], |
| 339 | start: f32, |
| 340 | span: f32, |
| 341 | line: Line, |
| 342 | }, |
| 343 | } |
| 344 | |
| 345 | impl Shade { |
| 346 | fn new(painter: &Painter, brush: Brush<'_>) -> Self { |
| 347 | let line = |stops: &[ColorStop], extend| Line { |
| 348 | stops: { |
| 349 | let mut stops: Vec<(f32, [f32; 4])> = stops |
| 350 | .iter() |
| 351 | .map(|s| (s.offset, painter.color(s.palette_index, s.alpha))) |
| 352 | .collect(); |
| 353 | stops.sort_by(|a, b| a.0.total_cmp(&b.0)); |
| 354 | stops |
| 355 | }, |
| 356 | extend, |
| 357 | }; |
| 358 | match brush { |
| 359 | Brush::Solid { |
| 360 | palette_index, |
| 361 | alpha, |
| 362 | } => Shade::Solid(painter.color(palette_index, alpha)), |
| 363 | Brush::LinearGradient { |
| 364 | p0, |
| 365 | p1, |
| 366 | color_stops, |
| 367 | extend, |
| 368 | } => { |
| 369 | let d = [p1.x - p0.x, p1.y - p0.y]; |
| 370 | let length = d[0] * d[0] + d[1] * d[1]; |
| 371 | Shade::Linear { |
| 372 | p0: [p0.x, p0.y], |
| 373 | along: match length > 0.0 { |
| 374 | true => d.map(|v| v / length), |
| 375 | false => [0.0; 2], |
| 376 | }, |
| 377 | line: line(color_stops, extend), |
| 378 | } |
| 379 | } |
| 380 | Brush::RadialGradient { |
| 381 | c0, |
| 382 | r0, |
| 383 | c1, |
| 384 | r1, |
| 385 | color_stops, |
| 386 | extend, |
| 387 | } => Shade::Radial { |
| 388 | c0: [c0.x, c0.y], |
| 389 | r0, |
| 390 | cd: [c1.x - c0.x, c1.y - c0.y], |
| 391 | dr: r1 - r0, |
| 392 | line: line(color_stops, extend), |
| 393 | }, |
| 394 | Brush::SweepGradient { |
| 395 | c0, |
| 396 | start_angle, |
| 397 | end_angle, |
| 398 | color_stops, |
| 399 | extend, |
| 400 | } => Shade::Sweep { |
| 401 | c0: [c0.x, c0.y], |
| 402 | start: start_angle, |
| 403 | span: end_angle - start_angle, |
| 404 | line: line(color_stops, extend), |
| 405 | }, |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | fn at(&self, (x, y): (f32, f32)) -> Option<[f32; 4]> { |
| 410 | match self { |
| 411 | Shade::Solid(color) => Some(*color), |
| 412 | Shade::Linear { p0, along, line } => { |
| 413 | Some(line.at((x - p0[0]) * along[0] + (y - p0[1]) * along[1])) |
| 414 | } |
| 415 | Shade::Radial { |
| 416 | c0, |
| 417 | r0, |
| 418 | cd, |
| 419 | dr, |
| 420 | line, |
| 421 | } => { |
| 422 | // The greatest t whose circle, of radius r0 + t·dr ≥ 0, passes through the |
| 423 | // point: a two-point conical gradient, as Skia and the spec draw it. |
| 424 | let p = [x - c0[0], y - c0[1]]; |
| 425 | let a = cd[0] * cd[0] + cd[1] * cd[1] - dr * dr; |
| 426 | let b = p[0] * cd[0] + p[1] * cd[1] + r0 * dr; |
| 427 | let c = p[0] * p[0] + p[1] * p[1] - r0 * r0; |
| 428 | let radius = |t: f32| r0 + t * dr >= 0.0; |
| 429 | let t = if a.abs() < 1e-6 { |
| 430 | Some(c / (2.0 * b)).filter(|t| t.is_finite() && radius(*t)) |
| 431 | } else { |
| 432 | let discriminant = b * b - a * c; |
| 433 | (discriminant >= 0.0) |
| 434 | .then(|| { |
| 435 | let root = discriminant.sqrt(); |
| 436 | let (t1, t2) = ((b + root) / a, (b - root) / a); |
| 437 | let (high, low) = (t1.max(t2), t1.min(t2)); |
| 438 | [high, low].into_iter().find(|t| radius(*t)) |
| 439 | }) |
| 440 | .flatten() |
| 441 | }; |
| 442 | t.map(|t| line.at(t)) |
| 443 | } |
| 444 | Shade::Sweep { |
| 445 | c0, |
| 446 | start, |
| 447 | span, |
| 448 | line, |
| 449 | } => { |
| 450 | let angle = (y - c0[1]).atan2(x - c0[0]).to_degrees().rem_euclid(360.0); |
| 451 | (*span != 0.0).then(|| line.at((angle - start) / span)) |
| 452 | } |
| 453 | } |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | /// A colour line: stops by offset, extended past 0 and 1 by `extend`. |
| 458 | struct Line { |
| 459 | stops: Vec<(f32, [f32; 4])>, |
| 460 | extend: Extend, |
| 461 | } |
| 462 | |
| 463 | impl Line { |
| 464 | fn at(&self, t: f32) -> [f32; 4] { |
| 465 | let t = match self.extend { |
| 466 | Extend::Repeat => t.rem_euclid(1.0), |
| 467 | Extend::Reflect => 1.0 - (t.rem_euclid(2.0) - 1.0).abs(), |
| 468 | _ => t, |
| 469 | }; |
| 470 | let Some(first) = self.stops.first() else { |
| 471 | return [0.0; 4]; |
| 472 | }; |
| 473 | if t <= first.0 { |
| 474 | return first.1; |
| 475 | } |
| 476 | for pair in self.stops.windows(2) { |
| 477 | let ((t0, c0), (t1, c1)) = (pair[0], pair[1]); |
| 478 | if t <= t1 { |
| 479 | let f = match t1 > t0 { |
| 480 | true => (t - t0) / (t1 - t0), |
| 481 | false => 1.0, |
| 482 | }; |
| 483 | return std::array::from_fn(|c| c0[c] + (c1[c] - c0[c]) * f); |
| 484 | } |
| 485 | } |
| 486 | self.stops.last().expect("checked above").1 |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | /// Collects an outline as pixel-space path commands. |
| 491 | struct Pen { |
| 492 | transform: Transform, |
| 493 | commands: Vec<Command>, |
| 494 | } |
| 495 | |
| 496 | impl Pen { |
| 497 | fn point(&self, x: f32, y: f32) -> Vector { |
| 498 | let (x, y) = self.transform.transform(x, y); |
| 499 | Vector::new(x, y) |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | impl OutlinePen for Pen { |
| 504 | fn move_to(&mut self, x: f32, y: f32) { |
| 505 | self.commands.push(Command::MoveTo(self.point(x, y))); |
| 506 | } |
| 507 | |
| 508 | fn line_to(&mut self, x: f32, y: f32) { |
| 509 | self.commands.push(Command::LineTo(self.point(x, y))); |
| 510 | } |
| 511 | |
| 512 | fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { |
| 513 | self.commands |
| 514 | .push(Command::QuadTo(self.point(cx0, cy0), self.point(x, y))); |
| 515 | } |
| 516 | |
| 517 | fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { |
| 518 | self.commands.push(Command::CurveTo( |
| 519 | self.point(cx0, cy0), |
| 520 | self.point(cx1, cy1), |
| 521 | self.point(x, y), |
| 522 | )); |
| 523 | } |
| 524 | |
| 525 | fn close(&mut self) { |
| 526 | self.commands.push(Command::Close); |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | #[cfg(test)] |
| 531 | mod tests { |
| 532 | #[test] |
| 533 | fn colrv1_glyphs_paint_their_gradients_in_colour() { |
| 534 | // Noto Color Emoji's 😀 alone (assets/NotoColorEmoji-OFL.txt). |
| 535 | let font = include_bytes!("../../assets/NotoColorEmoji-Grinning.ttf"); |
| 536 | let image = super::paint(font, 0, 1, 64.0, &[], [0.0, 0.0]).expect("a COLRv1 glyph"); |
| 537 | assert_eq!(image.content, swash::scale::image::Content::Color); |
| 538 | let p = image.placement; |
| 539 | let pixel = |x: u32, y: u32| { |
| 540 | let i = ((y * p.width + x) * 4) as usize; |
| 541 | [0, 1, 2, 3].map(|c| image.data[i + c]) |
| 542 | }; |
| 543 | // The face's radial gradient: yellow, opaque, lighter above than below. |
| 544 | let [r, g, b, a] = pixel(p.width / 2, p.height / 6); |
| 545 | assert!( |
| 546 | a == 255 && r > 240 && g > 180 && b < 100, |
| 547 | "{:?}", |
| 548 | [r, g, b, a] |
| 549 | ); |
| 550 | assert!(pixel(p.width / 2, p.height / 6)[1] > pixel(p.width / 8, p.height * 3 / 5)[1]); |
| 551 | assert_eq!(pixel(0, 0)[3], 0, "outside the face is clear"); |
| 552 | } |
| 553 | } |