mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 22:55:29 +00:00
Type: COLR radial gradients with a negative radius draw from their circle of radius 0
A radial gradient whose color line runs past 0 or 1 can get a circle with a negative radius once its stops are normalized to 0..1. Circles of a negative radius are not drawn: only the side of the circle of radius 0 (the tip of the cone) where the radius is positive is painted (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). Before: - As vector art, a padded gradient with a stop before 0 and its centers apart was focused on the start circle's center instead of the tip, and one with its centers together got stop offsets below 0. - Drawn into an image (a glyph with a sweep gradient, a Porter-Duff composite or a repeating gradient), any such gradient filled the glyph with its first stop's color. Both now recast the circles so neither radius is negative. With pad, the stops on the side that is not drawn become one stop at the tip with the color there; stops at the tip stay, so a sharp change of color there keeps the color on the drawn side. With repeat and reflect, the circles move by whole periods of the color line to the drawn side. When no circle is drawn, nothing is painted.
This commit is contained in:
+101
-6
@@ -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<GradientStop>) -> Option<(Vec<GradientStop>, 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<GradientStop>,
|
||||
extend: Extend,
|
||||
) -> Option<(Point, f64, Point, f64, Vec<GradientStop>)> {
|
||||
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<GradientStop> = 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<peniko::ColorStop> = 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())
|
||||
|
||||
@@ -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<f32> = 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<Color> = 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() {
|
||||
|
||||
Reference in New Issue
Block a user