AbstractRadialGradientBuilder.java
/*
This file is part of the iText (R) project.
Copyright (c) 1998-2026 Apryse Group NV
Authors: Apryse Software.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at https://itextpdf.com/sales. For AGPL licensing, see below.
AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itextpdf.kernel.colors.gradients;
import com.itextpdf.kernel.geom.AffineTransform;
import com.itextpdf.kernel.geom.Point;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfArray;
import com.itextpdf.kernel.pdf.colorspace.PdfColorSpace;
import com.itextpdf.kernel.pdf.colorspace.shading.AbstractPdfShading;
import com.itextpdf.kernel.pdf.colorspace.shading.PdfRadialShading;
import com.itextpdf.kernel.pdf.function.IPdfFunction;
import java.util.ArrayList;
import java.util.List;
/**
* Base class for radial gradient builders implementations.
*/
public abstract class AbstractRadialGradientBuilder extends AbstractGradientBuilder<RadialGradientPoint> {
// tan(t/2), with t == 2 deg we have tan(t/2) = 0.017455
private static final double TAN_CONSTANT = 0.017455;
/**
* {@inheritDoc}
*/
@Override
protected RadialGradientPoint[] createCoordsForNewDomain(double[] newDomain, RadialGradientPoint[] baseVector) {
double xDiff = baseVector[1].getX() - baseVector[0].getX();
double yDiff = baseVector[1].getY() - baseVector[0].getY();
double rDiff = baseVector[1].getRadius() - baseVector[0].getRadius();
RadialGradientPoint[] targetCoords = new RadialGradientPoint[] {
new RadialGradientPoint(baseVector[0]),
new RadialGradientPoint(baseVector[1])
};
targetCoords[0].getCenter().move(xDiff * newDomain[0], yDiff * newDomain[0]);
targetCoords[0].setRadius(targetCoords[0].getRadius() + rDiff * newDomain[0]);
targetCoords[1].getCenter().move(xDiff * (newDomain[1] - 1), yDiff * (newDomain[1] - 1));
targetCoords[1].setRadius(targetCoords[1].getRadius() + rDiff * (newDomain[1] - 1));
return targetCoords;
}
/**
* {@inheritDoc}
*/
@Override
protected RadialGradientPoint[] createCoveringCoordinates(Rectangle targetBoundingBox) {
// take one vertex as a center of both circles
// and bigger circles should cover the whole rectangle, i.e. radius should cover the farthest corner
Point center = new Point(targetBoundingBox.getLeft(), targetBoundingBox.getBottom());
double radius = center.distance(new Point(targetBoundingBox.getRight(), targetBoundingBox.getTop()));
return new RadialGradientPoint[] {
new RadialGradientPoint(center, 0d),
new RadialGradientPoint(center, radius)
};
}
/**
* {@inheritDoc}
*/
@Override
protected double getBaseVectorLength(RadialGradientPoint[] coordinates) {
return coordinates[1].getCenter().distance(coordinates[0].getCenter())
+ Math.abs(coordinates[1].getRadius() - coordinates[0].getRadius());
}
/**
* {@inheritDoc}
*/
@Override
protected PdfArray createCoordsDictEntry(RadialGradientPoint[] coordsPoints) {
assert coordsPoints != null && coordsPoints.length == 2;
return new PdfArray(new double[] {coordsPoints[0].getX(), coordsPoints[0].getY(), coordsPoints[0].getRadius(),
coordsPoints[1].getX(), coordsPoints[1].getY(), coordsPoints[1].getRadius()});
}
/**
* {@inheritDoc}
*/
@Override
protected AbstractPdfShading createPdfShading(PdfColorSpace colorSpace, PdfArray coordinates,
PdfArray coordinatesDomain, IPdfFunction stopsFunction) {
return new PdfRadialShading(colorSpace, coordinates, coordinatesDomain, stopsFunction);
}
/**
* {@inheritDoc}
*/
@Override
protected double[] computeCoveringDomain(RadialGradientPoint[] coords, Rectangle toCover) {
if (toCover == null) {
return new double[] {0d, 1d};
}
double originalCentersDistance = coords[0].distance(coords[1]);
// matching centers case
if (isZero(originalCentersDistance)) {
return getDomainForMatchingCenters(coords, toCover);
}
// For all other cases we will transform the plane to have base domain circles in points (0,0) and (1,0).
// So that covering circles will have x coordinate equals to target domain.
double scale = 1d / originalCentersDistance;
AffineTransform transform = getToIntervalTransform(coords[0].getCenter(), coords[1].getCenter(), scale);
List<Point> transformedRectVertices = new ArrayList<>(4);
for (Point v : toCover.toPointsArray()) {
transformedRectVertices.add(transform.transform(v, null));
}
// radii on transformed plane
double r0 = coords[0].getRadius() * scale;
double r1 = coords[1].getRadius() * scale;
double rDiffAbs = Math.abs(r1 - r0);
// four possible cases: rDiffAbs == 0 (lane), 0 < rDiffAbs < 1 (cone),
// rDiffAbs == 1 (half-plane), rDiffAbs > 1 (full plane)
if (isZero(rDiffAbs - 1)) {
// rDiffAbs == 1 (half-plane)
return getDomainForHalfPlaneCase(r0, r1, transformedRectVertices);
} else if (rDiffAbs > 1) {
// rDiffAbs > 1 (full plane)
return getDomainForFullPlaneCase(r0, r1, transformedRectVertices);
} else {
// rDiffAbs == 0 (lane), 0 < rDiffAbs < 1 (cone)
return getDomainForConeCase(r0, r1, transformedRectVertices);
}
}
private static double[] getDomainForHalfPlaneCase(double r0, double r1, List<Point> rectVertices) {
double rDiff = r1 - r0;
// The method assumes that radii diff is 1 (i.e. all circles has one common touch point)
assert isZero(Math.abs(rDiff) - 1);
assert !rectVertices.isEmpty();
double xZeroRad = -1d * r0 / rDiff;
boolean hasCoveredVertex = false;
boolean hasUncoveredVertex = false;
double xMin = xZeroRad;
double xMax = xZeroRad;
for (Point point : rectVertices) {
double px = point.getX();
double denominator = 2 * r0 * rDiff + 2 * px;
if (isZero(denominator)) {
// With zero denominator the point is placed on non-covered edge of the surface.
// So we need infinite max domain.
hasUncoveredVertex = true;
} else {
double py = point.getY();
double xCandidate = (-1d * r0 * r0 + px * px + py * py) / denominator;
if (getRadius(xCandidate, r0, r1) < 0) {
// uncovered half of the surface
hasUncoveredVertex = true;
} else {
hasCoveredVertex = true;
xMin = Math.min(xMin, xCandidate);
xMax = Math.max(xMax, xCandidate);
}
}
}
boolean isIncreasingRadius = rDiff > 0;
xMin = isIncreasingRadius ? xZeroRad : xMin;
xMax = isIncreasingRadius ? xMax : xZeroRad;
// Cases:
// - hasUncoveredVertex == false, hasCoveredVertex == false: unreachable
// - hasUncoveredVertex == false, hasCoveredVertex == true: all vertices are covered, we have valid xMax
// - hasUncoveredVertex == false, hasCoveredVertex == true: all vertices are uncovered, xMax = xZeroRad
// - hasUncoveredVertex == false, hasCoveredVertex == true: xMax should be equal to positive infinity
if (hasUncoveredVertex && hasCoveredVertex) {
// We should choose finite but big enough domain to cover the bbox.
double maxY = 0;
for (Point point : rectVertices) {
double py = point.getY();
maxY = Math.abs(py) > Math.abs(maxY) ? py : maxY;
}
double coveredSign = isIncreasingRadius ? 1d : -1d;
// Looking for px so that arc between (xZeroRad, 0) and (px, maxY) would correspond predefined t deg.
// Formula: px = xZeroRad +/- maxY * tan(t/2)
double px = xZeroRad + coveredSign * maxY * TAN_CONSTANT;
double denominator = 2 * r0 * rDiff + 2 * px;
double targetX = (-1d * r0 * r0 + px * px + maxY * maxY) / denominator;
if (isIncreasingRadius) {
xMax = targetX;
} else {
xMin = targetX;
}
}
return new double[] {xMin, xMax};
}
private static double[] getDomainForFullPlaneCase(double r0, double r1, List<Point> rectVertices) {
double rDiff = r1 - r0;
// The method assumes that radii diff is greater than 1 (i.e. any circles covers all smaller circles)
assert Math.abs(rDiff) > 1;
double xZeroRad = -1d * r0 / rDiff;
double xMin = xZeroRad;
double xMax = xZeroRad;
for (Point point : rectVertices) {
double px = point.getX();
double py = point.getY();
// solving ax^2 + bx + c = 0;
double a = rDiff * rDiff - 1d;
double b = 2 * (r0 * rDiff + px);
double c = r0 * r0 - px * px - py * py;
double dSqrt = Math.sqrt(b * b - 4 * a * c);
double x1 = (-1 * b - dSqrt) / (2 * a);
double x2 = (-1 * b + dSqrt) / (2 * a);
if (getRadius(x1, r0, r1) >= 0) {
xMin = Math.min(xMin, x1);
xMax = Math.max(xMax, x1);
}
if (getRadius(x2, r0, r1) >= 0) {
xMin = Math.min(xMin, x2);
xMax = Math.max(xMax, x2);
}
}
boolean isIncreasingRadius = rDiff > 0;
xMin = isIncreasingRadius ? xZeroRad : xMin;
xMax = isIncreasingRadius ? xMax : xZeroRad;
return new double[] {xMin, xMax};
}
private static double[] getDomainForConeCase(double r0, double r1, List<Point> rectVertices) {
double rDiff = r1 - r0;
// The method assumes that radii diff is smaller than 1 (i.e. cone or lane)
assert Math.abs(rDiff) < 1;
double vXMax = rectVertices.get(0).getX();
double vXMin = vXMax;
for (int i = 1; i < rectVertices.size(); ++i) {
vXMax = Math.max(vXMax, rectVertices.get(i).getX());
vXMin = Math.min(vXMin, rectVertices.get(i).getX());
}
double xMax = (vXMax + r0) / (1 - rDiff);
double xMin = (vXMin - r0) / (1 + rDiff);
if (!isZero(rDiff)) {
// for zero diff case there is no xZeroRad value,
// but both xMin and xMax should have non-negative radius (equal to r0)
double xZeroRad = -1 * r0 / rDiff;
if (getRadius(xMin, r0, r1) < 0) {
xMin = xZeroRad;
}
if (getRadius(xMax, r0, r1) < 0) {
xMax = xZeroRad;
}
}
return new double[] {xMin, xMax};
}
private static double getRadius(double x, double r0, double r1) {
return r0 + x * (r1 - r0);
}
private static double[] getDomainForMatchingCenters(RadialGradientPoint[] coords, Rectangle toCover) {
// The method assumes that circles has identical centers
assert isZero(coords[0].distance(coords[1]));
// First calculate min and max radii to cover the rectangle.
Point center = coords[0].getCenter();
double minRadius = getMinDistance(center, toCover);
double maxRadius = 0.0;
for (Point p : toCover.toPointsArray()) {
maxRadius = Math.max(maxRadius, center.distance(p));
}
// Second calculate the domain
double domainStep = coords[1].getRadius() - coords[0].getRadius();
double maxRadDomain = (maxRadius - coords[0].getRadius()) / domainStep;
double minRadDomain = (minRadius - coords[0].getRadius()) / domainStep;
double domainStart = Math.min(minRadDomain, maxRadDomain);
double domainEnd = Math.max(minRadDomain, maxRadDomain);
return new double[] {domainStart, domainEnd};
}
private static double getMinDistance(Point from, Rectangle to) {
double dx = 0.0;
if (from.getX() < to.getLeft()) {
dx = to.getLeft() - from.getX();
} else if (from.getX() > to.getRight()) {
dx = from.getX() - to.getRight();
}
double dy = 0.0;
if (from.getY() < to.getBottom()) {
dy = to.getBottom() - from.getY();
} else if (from.getY() > to.getTop()) {
dy = from.getY() - to.getTop();
}
return Math.sqrt(dx * dx + dy * dy);
}
}