From 660acb12ff8c07e5f3ba28192cf0d1e2e41bccb2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:09:12 -0800 Subject: [PATCH 1/2] 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. --- crates/text/src/colr.rs | 107 ++++++++++++++++++++++++++++++-- crates/text/src/tests/colr.rs | 111 +++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 7 deletions(-) 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() { From 648aedbe37745b99a8ef0e232a5bd88c2d1cc26a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 05:45:32 -0800 Subject: [PATCH 2/2] Type: a COLR glyph whose radial gradient draws no circle draws nothing A radial gradient whose circles both have the same negative radius (as a variable font's instance can give) draws no circle. A glyph painted with only that gradient was read as no colour glyph, so layout drew its outline instead. It is now colour art that draws nothing. --- crates/text/src/colr.rs | 12 ++++++++++-- crates/text/src/test_fonts.rs | 6 ++++++ crates/text/src/tests/colr.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index 80aa3132cf..281ef8a65f 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -148,6 +148,9 @@ struct Tree<'a> { painted: Option, /// 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))) @@ -314,7 +318,11 @@ impl<'a> Tree<'a> { self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette"); return None; } - let (c0, r0, c1, r1, mut stops) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend)?; + 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"); diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index bdb818d3f3..7abae3f0eb 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1939,6 +1939,12 @@ pub fn colr_variable_font() -> Option> { 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> { + 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> { let ps = family.replace(' ', ""); diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index 26e5fd4e6a..c6c4696f16 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -467,6 +467,38 @@ fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_f } } +/// 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 = [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::>()); +} + /// 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};