Fix color and gradient paints not rendering with their opacity attributes (#4438)

Fade color and gradient paints by their own opacity attributes
This commit is contained in:
Keavon Chambers
2026-08-16 15:52:27 -07:00
committed by GitHub
parent c507b35645
commit f635c56eab
3 changed files with 95 additions and 43 deletions

View File

@@ -533,13 +533,21 @@ impl Graphic {
}
Graphic::RasterCPU(_) | Graphic::RasterCPUList(_) => false,
Graphic::RasterGPU(_) | Graphic::RasterGPUList(_) => false,
Graphic::Color(item) => item.element().is_opaque(),
Graphic::ColorList(list) => list.element(0).is_some_and(|color| color.is_opaque()),
Graphic::Color(item) => item_opacity_is_full(item) && item.element().is_opaque(),
Graphic::ColorList(list) => !list.is_empty() && every_item_has_full_opacity(list) && list.iter_element_values().all(|color| color.is_opaque()),
// A `Clear` spread cuts off to transparency past the ends, leaving the rest of the region unpainted
Graphic::Gradient(item) => item.attribute_cloned_or_default::<GradientSpread>(ATTR_GRADIENT_SPREAD) != GradientSpread::Clear && item.element().iter().all(|stop| stop.color.is_opaque()),
Graphic::Gradient(item) => {
item_opacity_is_full(item)
&& item.attribute_cloned_or_default::<GradientSpread>(ATTR_GRADIENT_SPREAD) != GradientSpread::Clear
&& item.element().iter().all(|stop| stop.color.is_opaque())
}
Graphic::GradientList(list) => {
list.attribute_cloned_or_default::<GradientSpread>(ATTR_GRADIENT_SPREAD, 0) != GradientSpread::Clear
&& list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque()))
!list.is_empty()
&& every_item_has_full_opacity(list)
&& (0..list.len()).all(|index| {
list.attribute_cloned_or_default::<GradientSpread>(ATTR_GRADIENT_SPREAD, index) != GradientSpread::Clear
&& list.element(index).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque()))
})
}
Graphic::Text(_) | Graphic::TextList(_) => false,
}
@@ -562,11 +570,11 @@ impl Graphic {
list.attribute::<Appearance>(ATTR_APPEARANCE, index),
)
}),
Graphic::Color(item) => item.element().a() == 0.,
Graphic::ColorList(list) => list.iter_element_values().all(|color| color.a() == 0.),
Graphic::Color(item) => item_opacity_is_zero(item) || item.element().a() == 0.,
Graphic::ColorList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(|color| color.a() == 0.),
// A stopless ramp paints as solid black, matching `Gradient::evaluate`, so it counts as transparent only once it has stops
Graphic::Gradient(item) => !item.element().is_empty() && item.element().iter().all(|stop| stop.color.a() == 0.),
Graphic::GradientList(list) => list.iter_element_values().all(|stops| !stops.is_empty() && stops.iter().all(|stop| stop.color.a() == 0.)),
Graphic::Gradient(item) => item_opacity_is_zero(item) || (!item.element().is_empty() && item.element().iter().all(|stop| stop.color.a() == 0.)),
Graphic::GradientList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(|stops| !stops.is_empty() && stops.iter().all(|stop| stop.color.a() == 0.)),
// Their content is never inspected, so zeroed opacity is the only invisibility these can report
Graphic::RasterCPU(item) => item_opacity_is_zero(item),
Graphic::RasterGPU(item) => item_opacity_is_zero(item),

View File

@@ -50,15 +50,19 @@ pub trait RenderExt {
) -> Self::Output;
}
/// The paint attribute for a solid color, or `none` when the color is absent.
fn render_color_paint(color: Option<&Color>, target: PaintTarget) -> String {
let Some(color) = color else {
return format!(r#" {}="none""#, target.paint_attr());
};
/// The paint attribute for a solid color, or the SVG `none` keyword when the color is absent.
/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains.
fn render_color_paint(item: Option<ItemRef<'_, Color>>, target: PaintTarget, for_mask: bool) -> String {
let unpainted = || format!(r#" {}="none""#, target.paint_attr());
let Some(item) = item else { return unpainted() };
let Some(color) = item.element() else { return unpainted() };
let alpha = color.a() * item.paint_opacity(for_mask);
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.);
if alpha < 1. {
let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (alpha * 1000.).round() / 1000.);
}
result
@@ -74,15 +78,16 @@ impl RenderExt for List<Color> {
_element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
render_color_paint(self.element(0), target)
render_color_paint((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), target, render_params.for_mask)
}
}
/// Adds one gradient item's def into `svg_defs` and returns the gradient ID, or `None` when the item is absent.
fn render_gradient_paint(item: Option<ItemRef<'_, Gradient>>, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> Option<u64> {
/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains.
fn render_gradient_paint(item: Option<ItemRef<'_, Gradient>>, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2, for_mask: bool) -> Option<u64> {
let mut stop = String::new();
let item = item?;
@@ -91,7 +96,14 @@ fn render_gradient_paint(item: Option<ItemRef<'_, Gradient>>, svg_defs: &mut Str
let local_gradient_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM);
let settings = gradient_settings_from_item(item);
let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let (mut samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let paint_opacity = item.paint_opacity(for_mask);
if paint_opacity < 1. {
for (_, color, _) in &mut samples {
*color = color.with_alpha(color.a() * paint_opacity);
}
}
for (position, color, original_midpoint) in samples {
stop.push_str("<stop");
@@ -110,7 +122,11 @@ fn render_gradient_paint(item: Option<ItemRef<'_, Gradient>>, svg_defs: &mut Str
// A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec)
if stop.is_empty() {
stop.push_str(r##"<stop stop-color="#000000" />"##);
stop.push_str(r##"<stop stop-color="#000000""##);
if paint_opacity < 1. {
let _ = write!(stop, r#" stop-opacity="{}""#, (paint_opacity * 1000.).round() / 1000.);
}
stop.push_str(" />");
}
// Need to cancel out the element's transform as it is already applied to the path itself.
@@ -169,10 +185,16 @@ impl RenderExt for List<Gradient> {
element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
_target: PaintTarget,
) -> Self::Output {
render_gradient_paint((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), svg_defs, item_transform, element_transform)
render_gradient_paint(
(!self.is_empty()).then_some(ItemRef::ListItem(self, 0)),
svg_defs,
item_transform,
element_transform,
render_params.for_mask,
)
}
}
@@ -253,9 +275,9 @@ impl RenderExt for List<Graphic> {
let paint_attr = target.paint_attr();
match fill_graphic {
Some(Graphic::Color(item)) => render_color_paint(Some(item.element()), target),
Some(Graphic::Color(item)) => render_color_paint(Some(ItemRef::Item(item)), target, render_params.for_mask),
Some(Graphic::ColorList(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target),
Some(Graphic::Gradient(item)) => render_gradient_paint(Some(ItemRef::Item(item)), svg_defs, item_transform, element_transform)
Some(Graphic::Gradient(item)) => render_gradient_paint(Some(ItemRef::Item(item)), svg_defs, item_transform, element_transform, render_params.for_mask)
.map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##))
.unwrap_or_else(|| format!(r#" {paint_attr}="none""#)),
Some(Graphic::GradientList(gradient_list)) => gradient_list

View File

@@ -85,6 +85,14 @@ impl<'a, T> ItemRef<'a, T> {
}
}
/// The alpha multiplier this item's opacity attributes apply when it serves as a paint.
/// Fill opacity fades a paint just as opacity does, but a masker drops it so it cannot reach the content clipped to it.
pub(crate) fn paint_opacity(self, for_mask: bool) -> f32 {
let opacity_fill = if for_mask { 1. } else { self.attribute_cloned_or::<f64>(ATTR_OPACITY_FILL, 1.) };
(self.attribute_cloned_or::<f64>(ATTR_OPACITY, 1.) * opacity_fill) as f32
}
pub(crate) fn clone_item_attributes(self) -> core_types::list::ItemAttributeValues {
match self {
ItemRef::ListItem(list, index) => list.clone_item_attributes(index),
@@ -577,14 +585,28 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend {
}
}
fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
/// The Vello brush for one gradient item, paired with its placement transform.
/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains.
fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_item.element()?;
let gradient_form: GradientForm = gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_FORM);
let gradient_transform: DAffine2 = gradient_item.attribute_cloned_or_default(ATTR_TRANSFORM);
let settings = gradient_settings_from_item(gradient_item);
let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
let (mut samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
let paint_opacity = gradient_item.paint_opacity(for_mask);
if paint_opacity < 1. {
// A stopless ramp gets its black stop downstream, too late to be faded, so it needs one here instead
if samples.is_empty() {
samples.push((0., Color::BLACK, None));
}
for (_, color, _) in &mut samples {
*color = color.with_alpha(color.a() * paint_opacity);
}
}
let peniko_stops = peniko_color_stops(&samples);
@@ -1740,12 +1762,15 @@ fn render_vector_item_to_vello(
for paint_index in 0..fill_graphic.len() {
let Some(paint) = fill_graphic.element(paint_index) else { continue };
let solid_fill = |scene: &mut Scene, color: &Color| {
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
let solid_fill = |scene: &mut Scene, item: ItemRef<'_, Color>| {
let Some(color) = item.element() else { return };
let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask));
let fill = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
};
let gradient_fill = |scene: &mut Scene, gradient_item: ItemRef<'_, Gradient>| {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform) else {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else {
return;
};
@@ -1760,11 +1785,8 @@ fn render_vector_item_to_vello(
match paint {
Graphic::None(_) | Graphic::NoneList(_) => continue,
Graphic::Color(item) => solid_fill(scene, item.element()),
Graphic::ColorList(list) => {
let Some(color) = list.element(0) else { continue };
solid_fill(scene, color);
}
Graphic::Color(item) => solid_fill(scene, ItemRef::Item(item)),
Graphic::ColorList(list) => solid_fill(scene, ItemRef::ListItem(list, 0)),
Graphic::Gradient(item) => gradient_fill(scene, ItemRef::Item(item)),
Graphic::GradientList(list) => gradient_fill(scene, ItemRef::ListItem(list, 0)),
// Any other graphic content paints as a texture clipped to the path
@@ -1839,13 +1861,16 @@ fn render_vector_item_to_vello(
continue;
};
let solid_stroke = |scene: &mut Scene, color: &Color| {
let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
let solid_stroke = |scene: &mut Scene, item: ItemRef<'_, Color>| {
let Some(color) = item.element() else { return };
let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask));
let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path);
};
let gradient_stroke = |scene: &mut Scene, gradient_item: ItemRef<'_, Gradient>| {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform) else {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform, render_params.for_mask) else {
return;
};
let inverse_element_transform = if transform_is_invertible(element_transform) {
@@ -1860,11 +1885,8 @@ fn render_vector_item_to_vello(
match stroke_graphic {
Graphic::None(_) | Graphic::NoneList(_) => continue,
Graphic::Color(item) => solid_stroke(scene, item.element()),
Graphic::ColorList(list) => {
let Some(color) = list.element(0) else { continue };
solid_stroke(scene, color);
}
Graphic::Color(item) => solid_stroke(scene, ItemRef::Item(item)),
Graphic::ColorList(list) => solid_stroke(scene, ItemRef::ListItem(list, 0)),
Graphic::Gradient(item) => gradient_stroke(scene, ItemRef::Item(item)),
Graphic::GradientList(list) => gradient_stroke(scene, ItemRef::ListItem(list, 0)),
// Any other graphic content paints as a texture clipped to the stroked region