Merge pull request #7475 from dotCooCoo/fix/colr-radial-negative-start-radius

Type: COLR radial gradients with a negative radius draw from their circle of radius 0
This commit is contained in:
BFlat
2026-11-15 00:39:49 +07:00
committed by GitHub
3 changed files with 263 additions and 12 deletions
+115 -11
View File
@@ -148,6 +148,9 @@ struct Tree<'a> {
painted: Option<Rect>,
/// It paints with the foreground colour (palette index 0xFFFF).
foreground_used: bool,
/// A fill draws nothing (a radial gradient none of whose circles has a positive radius), so a
/// glyph with nothing else is colour art that draws nothing, not its outline.
drew_nothing: bool,
}
impl<'a> Tree<'a> {
@@ -166,6 +169,7 @@ impl<'a> Tree<'a> {
too_large: false,
painted: None,
foreground_used: false,
drew_nothing: false,
}
}
@@ -254,7 +258,7 @@ impl<'a> Tree<'a> {
self.close();
}
let root = self.groups.pop()?;
if root.children.is_empty() {
if root.children.is_empty() && !self.drew_nothing {
return None;
}
Some(Arc::new(Node::group(NO_ID, root.children)))
@@ -308,22 +312,27 @@ impl<'a> Tree<'a> {
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => {
let spread = spread(*extend);
let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1));
let line = stops(color_stops, self);
// Without a stop, the others would repeat over a different interval.
if !spread.is_pad() && line.len() < color_stops.len() {
self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette");
return None;
}
let Some((c0, r0, c1, r1, mut stops)) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend)
else {
self.drew_nothing = true;
return None;
};
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;
}
if !spread.is_pad() {
if stops.len() < color_stops.len() {
self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette");
return None;
}
// A run of the stops is the whole way from the center to the circle: one that
// starts at a circle (a first stop past the center comes as one) repeats over
// less of it. The normalized ends can be a rounding error off 0 and 1.
@@ -383,6 +392,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);
@@ -575,10 +665,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())
+6
View File
@@ -1939,6 +1939,12 @@ pub fn colr_variable_font() -> Option<Vec<u8>> {
with_colr(&variable_font()?, family_names(COLR_VARIABLE_FAMILY, &axis_names)?, &colr)
}
/// [`variable_font`] (one weight axis, 400 to 700) as `family` with `colr` and [`colr_font`]'s
/// palette. `None` when it can't be built.
pub fn colr_variable_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option<Vec<u8>> {
with_colr(&variable_font()?, family_names(family, &[])?, colr)
}
/// A `name` table for Regular of `family`, with `more` records.
fn family_names(family: &str, more: &[(u16, &str)]) -> Option<Vec<u8>> {
let ps = family.replace(' ', "");
+142 -1
View File
@@ -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 };
@@ -358,6 +358,147 @@ 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 radial gradient whose circles both have the same negative radius at a variable font's
/// instance draws no circle, so a glyph painted with only that gradient is colour art that draws
/// nothing, not its outline. The radii are 10 at the default weight and -10 at 700.
#[test]
fn a_radial_gradient_with_no_circle_drawn_leaves_the_glyph_empty() {
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Extend, VarColorLine, VarColorStop};
use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList};
use write_fonts::types::{F2Dot14, FWord, GlyphId16, UfWord};
let bytes = colr_font(false).unwrap();
let read = skrifa::FontRef::new(&bytes).unwrap();
let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap());
let (one, fixed) = (F2Dot14::from_f32(1.0), 0xFFFF_FFFF);
let line =
VarColorLine::new(Extend::Pad, 2, vec![VarColorStop::new(F2Dot14::from_f32(0.0), 0, one, fixed), VarColorStop::new(one, 1, one, fixed)]);
let w = FWord::new;
let radial = ColrPaint::var_radial_gradient(line, w(290), w(330), UfWord::new(10), w(290), w(330), UfWord::new(10), 0);
let mut colr = Colr::new(0, None, None, 0);
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), ColrPaint::glyph(radial, gid('O')))])).into();
// One 16-bit delta for each of x0, y0, r0, x1, y1 and r1, at full weight.
let deltas: Vec<u8> = [0i16, 0, -20, 0, 0, -20].iter().flat_map(|d| d.to_be_bytes()).collect();
let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), one, one)]);
let data = ItemVariationData::new(6, 1, vec![0], deltas);
colr.item_variation_store = Some(ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)])).into();
let font = crate::test_fonts::colr_variable_font_with("Colrtest No Circle", &colr).unwrap();
let font = skrifa::FontRef::new(&font).unwrap();
let bold = font.axes().location([("wght", 700.0)]);
let Some(ColourContent::Art(root)) = colour_glyph(&font, (&bold).into(), 1000.0, u32::from(gid(COLOUR_CHAR).to_u16()), FOREGROUND).0 else {
panic!("colour art")
};
assert!(children(&root).is_empty(), "{:?}", children(&root).iter().map(|c| c.kind_label()).collect::<Vec<_>>());
}
/// A COLRv1 color line through `stops` (offset, palette index) that extends by `extend`.
fn color_line(extend: write_fonts::tables::colr::Extend, stops: &[(f32, u16)]) -> write_fonts::tables::colr::ColorLine {
use write_fonts::tables::colr::{ColorLine, ColorStop};