Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,20 @@ describe('processBackgroundImage', () => {
expect(result).toEqual([]);
});

it('should return empty array for non-finite color stop positions', () => {
expect(processBackgroundImage('linear-gradient(red NaN%, blue)')).toEqual(
[],
);
expect(
processBackgroundImage([
{
type: 'linear-gradient' as const,
colorStops: [{color: 'red', positions: ['NaN%']}, {color: 'blue'}],
},
]),
).toEqual([]);
});

it('should process unitless zero color stop position', () => {
const input = 'linear-gradient(red 0, blue 100%)';
const result = processBackgroundImage(input);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ export default function processBackgroundImage(
return result;
}

function isValidColorStopPosition(position: unknown): boolean {
if (typeof position === 'number') {
return Number.isFinite(position);
}
return (
typeof position === 'string' &&
position.endsWith('%') &&
Number.isFinite(parseFloat(position))
);
}

function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{
color: ColorStopColor,
position: ColorStopPosition,
Expand All @@ -210,10 +221,7 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{
positions.length === 1
) {
const position = positions[0];
if (
typeof position === 'number' ||
(typeof position === 'string' && position.endsWith('%'))
) {
if (isValidColorStopPosition(position)) {
processedColorStops.push({
color: null,
position,
Expand All @@ -230,10 +238,7 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{
}
if (positions != null && positions.length > 0) {
for (const position of positions) {
if (
typeof position === 'number' ||
(typeof position === 'string' && position.endsWith('%'))
) {
if (isValidColorStopPosition(position)) {
processedColorStops.push({
color: processedColor,
position,
Expand Down Expand Up @@ -810,10 +815,11 @@ function getPositionFromCSSValue(position: string) {
}

if (position.endsWith('px')) {
return parseFloat(position);
const value = parseFloat(position);
return Number.isFinite(value) ? value : null;
}

if (position.endsWith('%')) {
if (isValidColorStopPosition(position)) {
return position;
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/React/Fabric/Utils/RCTGradientUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ NS_ASSUME_NONNULL_BEGIN
(const std::vector<facebook::react::ColorStop> &)colorStops
gradientLineLength:(CGFloat)gradientLineLength;

+ (std::vector<facebook::react::ProcessedColorStop>)
clipColorStops:(const std::vector<facebook::react::ProcessedColorStop> &)colorStops
end:(facebook::react::Float)end;

// CAGradientLayer linear gradient squishes the non-square gradient to square gradient.
// This function fixes the "squished" effect.
// See https://stackoverflow.com/a/43176174 for more information.
Expand Down
80 changes: 78 additions & 2 deletions packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
#import <React/RCTAnimationUtils.h>
#import <React/RCTConversions.h>
#import <react/utils/FloatComparison.h>
#include <algorithm>
#include <iterator>
#include <limits>
#include <optional>
#import <vector>

Expand Down Expand Up @@ -143,19 +146,48 @@ CGSize calculateMultipliers(CGSize bounds)

} // namespace

// Like Blink, clamp to finite values so that interpolation never divides infinity by infinity.
static Float clampToFinite(Float position)
{
return std::clamp<Float>(position, -std::numeric_limits<float>::max(), std::numeric_limits<float>::max());
}

static std::optional<Float> resolveColorStopPosition(ValueUnit position, CGFloat gradientLineLength)
{
if (position.unit == UnitType::Point) {
return position.resolve(0.0f) / gradientLineLength;
return clampToFinite(gradientLineLength > 0 ? position.resolve(0.0f) / gradientLineLength : 0);
}

if (position.unit == UnitType::Percent) {
return position.resolve(1.0f);
return clampToFinite(position.resolve(1.0f));
}

return std::nullopt;
}

// Interpolates with premultiplied alpha, as CSS does.
// Spec: https://drafts.csswg.org/css-color-4/#interpolation-alpha
static SharedColor interpolateColor(const SharedColor &from, const SharedColor &to, float weight)
{
if (!from || !to) {
return from ? from : to;
}
auto start = colorComponentsFromColor(from);
auto end = colorComponentsFromColor(to);
auto alpha = start.alpha + (end.alpha - start.alpha) * weight;
if (alpha <= 0) {
return clearColor();
}
auto channel = [&](float startChannel, float endChannel) {
return (startChannel * start.alpha * (1 - weight) + endChannel * end.alpha * weight) / alpha;
};
return colorFromComponents(
{.red = channel(start.red, end.red),
.green = channel(start.green, end.green),
.blue = channel(start.blue, end.blue),
.alpha = alpha});
}

// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint section)
// Browsers add 9 intermediate color stops when a transition hint is present
// Algorithm is referred from Blink engine
Expand Down Expand Up @@ -336,6 +368,50 @@ @implementation RCTGradientUtils
return processColorTransitionHints(fixedColorStops);
}

// Spec: https://drafts.csswg.org/css-images-4/#conic-color-stops
// Color stops outside [0, end] are "never directly consulted for rendering", but "can affect the color of
// color-stops within the rendered region through interpolation". So they are replaced by stops at the range
// boundaries, colored as the gradient would be at that point.
+ (std::vector<ProcessedColorStop>)clipColorStops:(const std::vector<ProcessedColorStop> &)colorStops end:(Float)end
{
if (colorStops.empty() ||
(colorStops.front().position.value_or(0) >= 0 && colorStops.back().position.value_or(0) <= end)) {
return colorStops;
}

auto firstInside = std::find_if(
colorStops.begin(), colorStops.end(), [](const auto &stop) { return stop.position.value_or(0) >= 0; });
auto lastInside = std::find_if(
colorStops.rbegin(), colorStops.rend(), [end](const auto &stop) { return stop.position.value_or(0) <= end; });
if (firstInside == colorStops.end()) {
return {{.color = colorStops.back().color, .position = 0}, {.color = colorStops.back().color, .position = end}};
}
if (lastInside == colorStops.rend()) {
return {{.color = colorStops.front().color, .position = 0}, {.color = colorStops.front().color, .position = end}};
}

auto colorAt = [](const ProcessedColorStop &before, const ProcessedColorStop &after, Float position) {
auto weight = (position - before.position.value()) / (after.position.value() - before.position.value());
return interpolateColor(before.color, after.color, static_cast<float>(weight));
};

std::vector<ProcessedColorStop> clippedColorStops;
if (firstInside != colorStops.begin() && firstInside->position.value() > 0) {
clippedColorStops.push_back({.color = colorAt(*std::prev(firstInside), *firstInside, 0), .position = 0});
}
auto insideEnd = lastInside.base();
if (firstInside < insideEnd) {
clippedColorStops.insert(clippedColorStops.end(), firstInside, insideEnd);
}
if (insideEnd != colorStops.end() && std::prev(insideEnd)->position.value() < end) {
clippedColorStops.push_back({.color = colorAt(*std::prev(insideEnd), *insideEnd, end), .position = end});
}
if (clippedColorStops.size() == 1) {
clippedColorStops.push_back({.color = clippedColorStops.front().color, .position = end});
}
return clippedColorStops;
}

// CAGradientLayer linear gradient squishes the non-square gradient to square gradient.
// This function fixes the "squished" effect.
// See https://stackoverflow.com/a/43176174 for more information.
Expand Down
5 changes: 3 additions & 2 deletions packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient &
CGFloat dx = endPoint.x - startPoint.x;
CGFloat dy = endPoint.y - startPoint.y;
CGFloat gradientLineLength = sqrt(dx * dx + dy * dy);
const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops
gradientLineLength:gradientLineLength];
const auto fixedColorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops
gradientLineLength:gradientLineLength];
const auto colorStops = [RCTGradientUtils clipColorStops:fixedColorStops end:1];
NSMutableArray<id> *colors = [NSMutableArray array];
NSMutableArray<NSNumber *> *locations = [NSMutableArray array];
CGPoint relativeStartPoint = CGPointMake(startPoint.x / size.width, startPoint.y / size.height);
Expand Down
11 changes: 9 additions & 2 deletions packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,15 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const RadialGradient &
auto [radiusX, radiusY] =
GetRadialGradientRadius(isCircle, gradient.size, centerPoint.x, centerPoint.y, size.width, size.height);
const auto gradientLineLength = std::max(radiusX, radiusY);
const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops
gradientLineLength:gradientLineLength];
auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops gradientLineLength:gradientLineLength];
// Unlike negative stops, stops past the ending shape stay visible outside it, so the radius grows to fit them.
const Float radiusScale = colorStops.empty() ? 1 : std::max<Float>(colorStops.back().position.value_or(1), 1);
colorStops = [RCTGradientUtils clipColorStops:colorStops end:radiusScale];
for (auto &colorStop : colorStops) {
colorStop.position = colorStop.position.value_or(0) / radiusScale;
}
radiusX *= radiusScale;
radiusY *= radiusScale;
gradientLayer.startPoint = CGPointMake(centerPoint.x / size.width, centerPoint.y / size.height);
// endpoint.x is horizontal length and endpoint.y is vertical length
gradientLayer.endPoint = CGPointMake(
Expand Down
Loading
Loading