diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index a3c6bf6e80..8e7a203ac0 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -306,13 +306,13 @@ impl<'a> Tree<'a> { self.unsupported.get_or_insert("a repeating or reflecting gradient"); return None; } - let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1)); + let (c0, r0, c1, r1, mut stops) = + non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), stops(color_stops, self), *extend)?; let concentric = (c0 - c1).hypot() < 1e-6; if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 { self.unsupported.get_or_insert("a radial gradient from a circle"); return None; } - let mut stops = stops(color_stops, self); if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) { self.unsupported.get_or_insert("a radial gradient with stops past its circles"); return None; @@ -357,6 +357,87 @@ fn normalized(mut stops: Vec) -> Option<(Vec, f32, f Some((stops, first, if span > 0.0 { last } else { first + 1.0 })) } +/// A radial gradient's circles (`c0`, `r0`) and (`c1`, `r1`) and color line `stops` (0 and 1 at +/// the circles) recast so that neither circle has a negative radius, as normalizing a color line +/// to 0..1 can give. Circles of a negative radius are not drawn, only those on the side of the +/// circle of radius 0 (the tip of the cone) where the radius is positive. With pad, the stops on +/// the other side become one stop at the tip with the color there; with repeat and reflect, the +/// circles move along by whole periods of the color line to the drawn side +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). `None` +/// when no circle is drawn. +fn non_negative_radii( + c0: Point, + r0: f64, + c1: Point, + r1: f64, + mut stops: Vec, + extend: Extend, +) -> Option<(Point, f64, Point, f64, Vec)> { + if (r0 >= 0.0 && r1 >= 0.0) || stops.is_empty() { + return Some((c0, r0, c1, r1, stops)); + } + if r0 == r1 { + return None; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let tip = r0 / (r0 - r1); + let grows = r1 > r0; + let (from, to, stops) = match extend { + Extend::Repeat | Extend::Reflect => { + // A reflected color line repeats every 2: once forward, then back. + let (period, stops) = if extend == Extend::Reflect { + let forward = stops.iter().map(|s| GradientStop { offset: s.offset / 2.0, ..s.clone() }); + let back = stops.iter().rev().map(|s| GradientStop { offset: 1.0 - s.offset / 2.0, ..s.clone() }); + (2.0, forward.chain(back).collect()) + } else { + (1.0, stops) + }; + let from = if grows { (tip / period).ceil() * period } else { (tip / period).floor() * period - period }; + (from, from + period, stops) + } + _ => { + // The color line runs from 0 to 1, and further where its stops do. + let first = stops.first().map_or(0.0, |s| f64::from(s.offset).min(0.0)); + let last = stops.last().map_or(1.0, |s| f64::from(s.offset).max(1.0)); + let (from, to) = match (grows, tip) { + (true, t) if t < last => (t, last), + (true, t) => (t, t + 1.0), + (false, t) if t > first => (first, t), + (false, t) => (t - 1.0, t), + }; + let line = Gradient { stops, ..Gradient::default() }; + // The color at `w`, as a stop at `offset` of the new line. + let at = |w: f64, offset: f32| { + let (color, opacity) = line.sample(w as f32); + GradientStop { opacity, ..GradientStop::new(offset, color) } + }; + // Stops at the tip stay on the drawn side of its stop, so a sharp change of color there + // keeps the color on that side. + let mut kept: Vec = line + .stops + .iter() + .filter(|s| (from - 1e-6..=to + 1e-6).contains(&f64::from(s.offset))) + .map(|s| GradientStop { offset: ((f64::from(s.offset) - from) / (to - from)).clamp(0.0, 1.0) as f32, ..s.clone() }) + .collect(); + if grows { + kept.insert(0, at(tip, 0.0)); + if kept.last().is_none_or(|s| s.offset < 1.0) { + kept.push(at(to, 1.0)); + } + } else { + kept.push(at(tip, 1.0)); + if kept.first().is_none_or(|s| s.offset > 0.0) { + kept.insert(0, at(from, 0.0)); + } + } + (from, to, kept) + } + }; + let circle = |w: f64| (c0.lerp(c1, w), (r0 + w * (r1 - r0)).max(0.0)); + let ((c0, r0), (c1, r1)) = (circle(from), circle(to)); + Some((c0, r0, c1, r1, stops)) +} + impl ColorPainter for Tree<'_> { fn push_transform(&mut self, transform: Transform) { let t = self.transform() * affine(&transform); @@ -549,10 +630,24 @@ impl ColorPainter for Pixels<'_, '_> { Brush::LinearGradient { p0, p1, color_stops, extend: e } => { peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into() } - Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1) - .with_stops(stops(color_stops).as_slice()) - .with_extend(extend(e)) - .into(), + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => { + let line = color_stops + .iter() + .map(|s| { + let (c, a) = colour(&self.palette, self.foreground, s.palette_index, s.alpha).unwrap_or((Color::BLACK, 0.0)); + GradientStop { opacity: a, ..GradientStop::new(s.offset, c) } + }) + .collect(); + let Some((c0, r0, c1, r1, line)) = non_negative_radii(point(c0), f64::from(r0), point(c1), f64::from(r1), line, e) else { return }; + let stops: Vec = line + .iter() + .map(|s| { + let [r, g, b, a] = s.color.to_rgba8(s.opacity); + peniko::ColorStop::from((s.offset, peniko::Color::from_rgba8(r, g, b, a))) + }) + .collect(); + peniko::Gradient::new_two_point_radial(c0, r0 as f32, c1, r1 as f32).with_stops(stops.as_slice()).with_extend(extend(e)).into() + } Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians()) .with_stops(stops(color_stops).as_slice()) diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index 212882ce7a..1516596f26 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -25,7 +25,7 @@ use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{Node, NodeKind}; use crate::colr::{ColourContent, colour_glyph}; -use crate::test_fonts::{COLR_GREEN, COLR_RED}; +use crate::test_fonts::{COLR_BLUE, COLR_GREEN, COLR_RED}; const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 }; @@ -362,6 +362,115 @@ fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() { assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out"); } +/// A padded radial gradient whose color line starts before 0 starts at the circle of radius 0: +/// circles of a negative radius are not drawn, so the stops before that circle become one stop +/// there with the color between them +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). With its +/// centers apart, the gradient is focused on the tip of its cone; with its centers together, it +/// is centered there with no stop below 0. +#[test] +fn a_radial_gradient_with_a_stop_before_0_starts_at_its_circle_of_radius_0() { + use vectorcraft_doc::color::{Gradient, GradientStop}; + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let half = F2Dot14::from_f32(0.5); + let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, half), ColorStop::new(one, 1, one)]); + let w = FWord::new; + // Half-opaque red at -0.5 and blue at 1: at 0, a third of the way from red to blue, in color + // and opacity. + let red = GradientStop { opacity: 0.5, ..GradientStop::new(0.0, rgb(COLR_RED)) }; + let tip = Gradient { stops: vec![red, GradientStop::new(1.0, rgb(COLR_BLUE))], ..Gradient::default() }.sample(1.0 / 3.0); + assert!((tip.1 - 2.0 / 3.0).abs() < 1e-6, "{tip:?}"); + for (family, c1_x, focal) in [("Colrtest Tip", 250, Some(300.0)), ("Colrtest Dot", 300, None)] { + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(c1_x), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("{family}: a gradient") }; + let offsets: Vec = g.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 1.0], "{family}"); + let first = &g.gradient.stops[0]; + assert_eq!((first.color, first.opacity), tip, "{family}: the color at the circle of radius 0"); + assert_eq!(g.gradient.stops[1].color, rgb(COLR_BLUE), "{family}"); + let geom = g.geom.unwrap(); + assert!((geom.start.x - f64::from(c1_x)).abs() < 1e-3 && (geom.start.y + 300.0).abs() < 1e-3, "{family}: {geom:?}"); + assert!(((geom.end - geom.start).hypot() - 400.0).abs() < 1e-3, "{family}: {geom:?}"); + assert_eq!(geom.focal.map(|f| ((f.x * 1e3).round() / 1e3, (f.y * 1e3).round() / 1e3)), focal.map(|x| (x, -300.0)), "{family}"); + } +} + +/// A sharp change of color at the circle of radius 0 of a gradient whose color line starts +/// before 0 keeps the color beyond it: red up to 0, then green fading to blue. +#[test] +fn a_sharp_change_of_color_at_the_circle_of_radius_0_keeps_the_color_beyond_it() { + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let (one, zero) = (F2Dot14::from_f32(1.0), F2Dot14::from_f32(0.0)); + let stops = vec![ + ColorStop::new(F2Dot14::from_f32(-1.0), 0, one), + ColorStop::new(zero, 0, one), + ColorStop::new(zero, 2, one), + ColorStop::new(one, 1, one), + ]; + let line = ColorLine::new(Extend::Pad, 4, stops); + let w = FWord::new; + let font = v1_font("Colrtest Edge", COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") }; + let stops = &g.gradient.stops; + let colors: Vec = stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [rgb(COLR_RED), rgb(COLR_RED), rgb(COLR_GREEN), rgb(COLR_BLUE)], "{stops:?}"); + assert!(stops[..3].iter().all(|s| s.offset.abs() < 1e-5) && stops[3].offset == 1.0, "{stops:?}"); +} + +/// A glyph drawn into an image draws a radial gradient with a circle of a negative radius from the +/// circle of radius 0, as vector art does: with pad, the color at the tip of the cone and the +/// colors on its drawn side, not one solid color. A composite that keeps only its backdrop makes +/// the glyph an image. Red runs to blue; the circles are around the `O`'s middle and larger than it. +#[test] +fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_from_that_circle() { + use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let w = FWord::new; + type Check = fn([u8; 3]) -> bool; + // Red at -0.5 (a start circle of radius -250 once normalized): the colors run from a third of + // the way to blue at the tip to blue. Blue at 1.5 (an end circle of radius -250): they run + // from red at the outer circle, which lies outside the `O`, to two thirds of the way to blue + // at the tip. Either way every opaque pixel holds some blue. + let cases: [(&str, f32, f32, u16, u16, Check); 2] = + [("Colrtest Tip Image", -0.5, 1.0, 0, 500, |[r, _, b]| b > 20 && r < 240), ("Colrtest Rim Image", 0.0, 1.5, 500, 0, |[_, _, b]| b > 20)]; + for (family, first, last, r0, r1, check) in cases { + let line = + ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(first), 0, one), ColorStop::new(F2Dot14::from_f32(last), 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + let radial = ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(r0), w(290), w(330), UfWord::new(r1)); + ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Dest, ColrPaint::glyph(radial, o)) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let pixels: Vec<[u8; 3]> = pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(pixels.len() > 100, "{family}: the `O` is drawn"); + let wrong: Vec<_> = pixels.iter().filter(|p| !check(**p)).take(5).collect(); + assert!(wrong.is_empty(), "{family}: {wrong:?}"); + } + // Repeat and reflect draw their bands, not one color. + for (family, extend) in [("Colrtest Repeat Image", Extend::Repeat), ("Colrtest Reflect Image", Extend::Reflect)] { + let line = ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, one), ColorStop::new(one, 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(0), w(290), w(330), UfWord::new(100)), o) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let colors: std::collections::HashSet<[u8; 3]> = + pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(colors.len() > 10, "{family}: {colors:?}"); + } +} + /// A glyph whose layers don't use the foreground colour is read once, whatever the type's fill. #[test] fn a_glyph_without_the_foreground_colour_is_read_once_for_every_fill() {