AbstractLinearGradientBuilder.java

/*
    This file is part of the iText (R) project.
    Copyright (c) 1998-2025 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.io.logs.IoLogMessageConstant;
import com.itextpdf.kernel.colors.Color;
import com.itextpdf.kernel.colors.PatternColor;
import com.itextpdf.kernel.colors.gradients.GradientColorStop.HintOffsetType;
import com.itextpdf.kernel.colors.gradients.GradientColorStop.OffsetType;
import com.itextpdf.kernel.geom.AffineTransform;
import com.itextpdf.kernel.geom.NoninvertibleTransformException;
import com.itextpdf.kernel.geom.Point;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfArray;
import com.itextpdf.kernel.pdf.PdfDictionary;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.colorspace.PdfDeviceCs;
import com.itextpdf.kernel.pdf.colorspace.PdfPattern;
import com.itextpdf.kernel.pdf.colorspace.shading.PdfAxialShading;
import com.itextpdf.kernel.pdf.function.AbstractPdfFunction;
import com.itextpdf.kernel.pdf.function.IPdfFunction;
import com.itextpdf.kernel.pdf.function.PdfType2Function;
import com.itextpdf.kernel.pdf.function.PdfType3Function;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.slf4j.LoggerFactory;

/**
 * Base class for linear gradient builders implementations.
 * <p>
 * Color transitions for linear gradients are defined by a series of color stops along a gradient
 * vector. A gradient normal defines how the colors in a vector are painted to the surface. For
 * a linear gradient, a normal is a line perpendicular to the vector.
 * <p>
 * Contains the main logic that works with stop colors and creation of the resulted pdf color object.
 */
public abstract class AbstractLinearGradientBuilder {

    /**
     * The epsilon value used for data creation
     */
    protected static final double ZERO_EPSILON = 1E-10;

    private final List<GradientColorStop> stops = new ArrayList<>();
    private GradientSpreadMethod spreadMethod = GradientSpreadMethod.NONE;

    /**
     * Adds the new color stop to the end ({@link AbstractLinearGradientBuilder more info}).
     *
     * Note: if the previously added color stop's offset would have grater offset than the added
     * one, then the new offset would be normalized to be equal to the previous one. (Comparison
     * made between relative on coordinates vector offsets. If any of them has
     * the absolute offset, then the absolute value would converted to relative first.)
     *
     * @param gradientColorStop the gradient stop color to add
     * @return the current builder instance
     */
    public AbstractLinearGradientBuilder addColorStop(GradientColorStop gradientColorStop) {
        if (gradientColorStop != null) {
            this.stops.add(gradientColorStop);
        }
        return this;
    }

    /**
     * Set the spread method to use for the gradient
     *
     * @param gradientSpreadMethod the gradient spread method to set
     * @return the current builder instance
     */
    public AbstractLinearGradientBuilder setSpreadMethod(GradientSpreadMethod gradientSpreadMethod) {
        if (spreadMethod != null) {
            this.spreadMethod = gradientSpreadMethod;
        } else {
            this.spreadMethod = GradientSpreadMethod.NONE;
        }
        return this;
    }

    /**
     * Get the copy of current color stops list. Note that the stop colors are not copied here
     *
     * @return the copy of current stop colors list
     */
    public List<GradientColorStop> getColorStops() {
        return new ArrayList<>(this.stops);
    }

    /**
     * Get the current spread method
     *
     * @return the current spread method
     */
    public GradientSpreadMethod getSpreadMethod() {
        return this.spreadMethod;
    }

    /**
     * Builds the {@link Color} object representing the linear gradient with specified configuration
     * that fills the target bounding box.
     *
     * @param targetBoundingBox the bounding box to be filled in current space
     * @param contextTransform  the transformation from the base coordinates space into
     *                          the current space. The {@code null} value is valid and can be used
     *                          if there is no transformation from base coordinates to current space
     *                          specified, or it is equal to identity transformation.
     * @param document          the {@link PdfDocument} for which the linear gradient would be built.
     * @return the constructed {@link Color} or {@code null} if no color to be applied
     * or base gradient vector has been specified
     */
    // TODO: DEVSIX-4136 the document argument would be required for opaque gradients (as we would need to create a mask form xObject)
    public Color buildColor(Rectangle targetBoundingBox, AffineTransform contextTransform, PdfDocument document) {
        Point[] baseCoordinatesVector = getGradientVector(targetBoundingBox, contextTransform);
        if (baseCoordinatesVector == null || this.stops.isEmpty()) {
            // Can not create gradient color with 0 stops or null coordinates vector
            return null;
        }

        // evaluate actual coordinates and transformation
        AffineTransform shadingTransform = new AffineTransform();
        if (contextTransform != null) {
            shadingTransform.concatenate(contextTransform);
        }

        AffineTransform gradientTransformation = getCurrentSpaceToGradientVectorSpaceTransformation(targetBoundingBox,
                contextTransform);
        if (gradientTransformation != null) {
            try {
                if (targetBoundingBox != null) {
                    targetBoundingBox = Rectangle.calculateBBox(Arrays.asList(
                            gradientTransformation.inverseTransform(
                                    new Point(targetBoundingBox.getLeft(), targetBoundingBox.getBottom()), null),
                            gradientTransformation.inverseTransform(
                                    new Point(targetBoundingBox.getLeft(), targetBoundingBox.getTop()), null),
                            gradientTransformation.inverseTransform(
                                    new Point(targetBoundingBox.getRight(), targetBoundingBox.getBottom()), null),
                            gradientTransformation.inverseTransform(
                                    new Point(targetBoundingBox.getRight(), targetBoundingBox.getTop()), null)
                    ));
                }
                shadingTransform.concatenate(gradientTransformation);
            } catch (NoninvertibleTransformException e) {
                LoggerFactory.getLogger(getClass()).error(IoLogMessageConstant.UNABLE_TO_INVERT_GRADIENT_TRANSFORMATION);
            }
        }

        PdfAxialShading axial = createAxialShading(baseCoordinatesVector, this.stops, this.spreadMethod,
                targetBoundingBox);
        if (axial == null) {
            return null;
        }

        PdfPattern.Shading shading = new PdfPattern.Shading(axial);
        if (!shadingTransform.isIdentity()) {
            double[] matrix = new double[6];
            shadingTransform.getMatrix(matrix);
            shading.setMatrix(new PdfArray(matrix));
        }
        return new PatternColor(shading);
    }

    /**
     * Returns the base gradient vector in gradient vector space. This vector would be set
     * as shading coordinates vector and its length would be used to translate all color stops
     * absolute offsets into the relatives.
     *
     * @param targetBoundingBox the rectangle to be covered by constructed color in current space
     * @param contextTransform  the current canvas transformation
     * @return the array of exactly two elements specifying the gradient coordinates vector
     */
    protected abstract Point[] getGradientVector(Rectangle targetBoundingBox,
            AffineTransform contextTransform);

    /**
     * Returns the current space to gradient vector space transformations that should be applied
     * to the shading color. The transformation should be invertible as the current target
     * bounding box coordinates should be transformed into the resulted shading space coordinates.
     *
     * @param targetBoundingBox the rectangle to be covered by constructed color in current space
     * @param contextTransform  the current canvas transformation
     * @return the additional transformation to be concatenated to the current for resulted shading
     * or {@code null} if no additional transformation is specified
     */
    protected AffineTransform getCurrentSpaceToGradientVectorSpaceTransformation(
            Rectangle targetBoundingBox, AffineTransform contextTransform) {
        return null;
    }

    /**
     * Evaluates the minimal domain that covers the box with vector normals.
     * The domain corresponding to the initial vector is [0, 1].
     *
     * @param coords  the array of exactly two elements that describe
     *                the base vector (corresponding to [0,1] domain, that need to be adjusted
     *                to cover the box
     * @param toCover the box that needs to be covered
     * @return the array of two elements in ascending order specifying the calculated covering
     * domain
     */
    protected static double[] evaluateCoveringDomain(Point[] coords, Rectangle toCover) {
        if (toCover == null) {
            return new double[] {0d, 1d};
        }
        AffineTransform transform = new AffineTransform();
        double scale = 1d / (coords[0].distance(coords[1]));
        double sin = -(coords[1].getY() - coords[0].getY()) * scale;
        double cos = (coords[1].getX() - coords[0].getX()) * scale;
        if (Math.abs(cos) < ZERO_EPSILON) {
            cos = 0d;
            sin = sin > 0d ? 1d : -1d;
        } else if (Math.abs(sin) < ZERO_EPSILON) {
            sin = 0d;
            cos = cos > 0d ? 1d : -1d;
        }
        transform.concatenate(new AffineTransform(cos, sin, -sin, cos, 0, 0));

        transform.scale(scale, scale);
        transform.translate(-coords[0].getX(), -coords[0].getY());

        Point[] rectanglePoints = toCover.toPointsArray();
        double minX = transform.transform(rectanglePoints[0], null).getX();
        double maxX = minX;
        for (int i = 1; i < rectanglePoints.length; ++i) {
            double currentX = transform.transform(rectanglePoints[i], null).getX();
            minX = Math.min(minX, currentX);
            maxX = Math.max(maxX, currentX);
        }

        return new double[] {minX, maxX};
    }

    /**
     * Expand the base vector to cover the new domain
     *
     * @param newDomain  the array of exactly two elements that specifies the domain
     *                   that should be covered by the created vector
     * @param baseVector the array of exactly two elements that specifies the base vector
     *                   which corresponds to [0, 1] domain
     * @return the array of two
     */
    protected static Point[] createCoordinatesForNewDomain(double[] newDomain, Point[] baseVector) {
        double xDiff = baseVector[1].getX() - baseVector[0].getX();
        double yDiff = baseVector[1].getY() - baseVector[0].getY();

        Point[] targetCoords = new Point[] {
                baseVector[0].getLocation(),
                baseVector[1].getLocation()
        };
        targetCoords[0].move(xDiff * newDomain[0], yDiff * newDomain[0]);
        targetCoords[1].move(xDiff * (newDomain[1] - 1), yDiff * (newDomain[1] - 1));
        return targetCoords;
    }

    private static PdfAxialShading createAxialShading(Point[] baseCoordinatesVector,
            List<GradientColorStop> stops, GradientSpreadMethod spreadMethod, Rectangle targetBoundingBox) {
        double baseVectorLength = baseCoordinatesVector[1].distance(baseCoordinatesVector[0]);

        List<GradientColorStop> stopsToConstruct = normalizeStops(stops, baseVectorLength);
        double[] coordinatesDomain = new double[] {0, 1};
        Point[] actualCoordinates;
        if (baseVectorLength < ZERO_EPSILON || stopsToConstruct.size() == 1) {
            // single color case
            if (spreadMethod == GradientSpreadMethod.NONE) {
                return null;
            }
            actualCoordinates = new Point[]{new Point(targetBoundingBox.getLeft(), targetBoundingBox.getBottom()),
                    new Point(targetBoundingBox.getRight(), targetBoundingBox.getBottom())};

            GradientColorStop lastColorStop = stopsToConstruct.get(stopsToConstruct.size() - 1);
            stopsToConstruct = Arrays.asList(new GradientColorStop(lastColorStop, 0d, OffsetType.RELATIVE),
                    new GradientColorStop(lastColorStop, 1d, OffsetType.RELATIVE));
        } else {
            coordinatesDomain = evaluateCoveringDomain(baseCoordinatesVector, targetBoundingBox);
            if (spreadMethod == GradientSpreadMethod.REPEAT || spreadMethod == GradientSpreadMethod.REFLECT) {
                stopsToConstruct = adjustNormalizedStopsToCoverDomain(stopsToConstruct, coordinatesDomain,
                        spreadMethod);
            } else if (spreadMethod == GradientSpreadMethod.PAD) {
                adjustStopsForPadIfNeeded(stopsToConstruct, coordinatesDomain);
            } else {
                // none case
                double firstStopOffset = stopsToConstruct.get(0).getOffset();
                double lastStopOffset = stopsToConstruct.get(stopsToConstruct.size() - 1).getOffset();
                if ((lastStopOffset - firstStopOffset < ZERO_EPSILON)
                        || coordinatesDomain[1] <= firstStopOffset
                        || coordinatesDomain[0] >= lastStopOffset) {
                    return null;
                }
                coordinatesDomain[0] = Math.max(coordinatesDomain[0], firstStopOffset);
                coordinatesDomain[1] = Math.min(coordinatesDomain[1], lastStopOffset);
            }
            assert coordinatesDomain[0] <= coordinatesDomain[1];

            actualCoordinates = createCoordinatesForNewDomain(coordinatesDomain, baseCoordinatesVector);
        }

        return new PdfAxialShading(
                new PdfDeviceCs.Rgb(),
                createCoordsPdfArray(actualCoordinates),
                new PdfArray(coordinatesDomain),
                constructFunction(stopsToConstruct)
        );
    }

    // the result list would have the same list of stop colors as the original one
    // with all offsets on coordinates domain dimension and adjusted for ascending values
    private static List<GradientColorStop> normalizeStops(List<GradientColorStop> toNormalize, double baseVectorLength) {
        if (baseVectorLength < ZERO_EPSILON) {
            return Collections.singletonList(new GradientColorStop(toNormalize.get(toNormalize.size() - 1),
                    0d, OffsetType.RELATIVE));
        }
        // get rid of all absolute on vector offsets and hint offsets
        List<GradientColorStop> result = copyStopsAndNormalizeAbsoluteOffsets(toNormalize, baseVectorLength);
        // normalize 1st stop as it may be a special case
        normalizeFirstStopOffset(result);
        // now we have 1st stop with relative offset, all other stops are either auto or relative
        normalizeAutoStops(result);
        // normalize hints to left only none or relative to colors hint offset types
        normalizeHintsOffsets(result);

        return result;
    }

    private static void normalizeHintsOffsets(List<GradientColorStop> result) {
        // normalize all except last
        for (int i = 0; i < result.size() - 1; ++i) {
            GradientColorStop stopColor = result.get(i);
            if (stopColor.getHintOffsetType() == HintOffsetType.RELATIVE_ON_GRADIENT) {
                double currentStopOffset = stopColor.getOffset();
                double nextStopOffset = result.get(i + 1).getOffset();
                if (currentStopOffset != nextStopOffset) {
                    double hintOffset = (stopColor.getHintOffset() - currentStopOffset)
                            / (nextStopOffset - currentStopOffset);
                    stopColor.setHint(hintOffset, HintOffsetType.RELATIVE_BETWEEN_COLORS);
                } else {
                    // if stops has the same offset, then no hint needed
                    stopColor.setHint(0, HintOffsetType.NONE);
                }
            }
        }
        // the last color hint is not needed as even with pad and reflect it won't be used
        result.get(result.size() - 1).setHint(0, HintOffsetType.NONE);
    }

    private static void normalizeAutoStops(List<GradientColorStop> toNormalize) {
        assert toNormalize.get(0).getOffsetType() == OffsetType.RELATIVE;

        int firstAutoStopIndex = 1;
        GradientColorStop firstStopColor = toNormalize.get(0);
        double prevOffset = firstStopColor.getHintOffsetType() == HintOffsetType.RELATIVE_ON_GRADIENT
                ? firstStopColor.getHintOffset() : firstStopColor.getOffset();
        for (int i = 1; i < toNormalize.size(); ++i) {
            GradientColorStop currentStop = toNormalize.get(i);
            if (currentStop.getOffsetType() == OffsetType.AUTO) {
                if (currentStop.getHintOffsetType() == HintOffsetType.RELATIVE_ON_GRADIENT) {
                    double hintOffset = currentStop.getHintOffset();
                    normalizeAutoStops(toNormalize, firstAutoStopIndex, i + 1, prevOffset, hintOffset);
                    prevOffset = hintOffset;
                    firstAutoStopIndex = i + 1;
                }
            } else {
                if (firstAutoStopIndex < i) {
                    // current stop offset is relative
                    double offset = currentStop.getOffset();
                    normalizeAutoStops(toNormalize, firstAutoStopIndex, i, prevOffset, offset);
                }
                firstAutoStopIndex = i + 1;
                prevOffset = currentStop.getHintOffsetType() == HintOffsetType.RELATIVE_ON_GRADIENT
                        ? currentStop.getHintOffset() : currentStop.getOffset();
            }
        }

        // check whether the last interval has auto
        if (firstAutoStopIndex < toNormalize.size()) {
            double lastStopOffset = Math.max(1, prevOffset);
            normalizeAutoStops(toNormalize, firstAutoStopIndex, toNormalize.size(), prevOffset, lastStopOffset);
        }
    }

    private static void normalizeAutoStops(List<GradientColorStop> toNormalizeList,
            int fromIndex, int toIndex, double prevOffset, double nextOffset) {
        assert toIndex >= fromIndex;

        int intervalsCount = Math.min(toIndex, toNormalizeList.size() - 1) - fromIndex + 1;
        double offsetShift = (nextOffset - prevOffset) / intervalsCount;
        double currentOffset = prevOffset;
        for (int i = fromIndex; i < toIndex; ++i) {
            currentOffset += offsetShift;
            GradientColorStop currentAutoStop = toNormalizeList.get(i);

            assert currentAutoStop.getOffsetType() == OffsetType.AUTO;

            currentAutoStop.setOffset(currentOffset, OffsetType.RELATIVE);
        }
    }

    private static void normalizeFirstStopOffset(List<GradientColorStop> result) {
        // assert that all stops has no absolute on vector offsets and hints
        GradientColorStop firstStop = result.get(0);
        if (firstStop.getOffsetType() != OffsetType.AUTO) {
            return;
        }
        double firstStopOffset = 0;
        for (GradientColorStop stopColor : result) {
            if (stopColor.getOffsetType() == OffsetType.RELATIVE) {
                firstStopOffset = stopColor.getOffset();
                break;
            } else if (stopColor.getHintOffsetType() == HintOffsetType.RELATIVE_ON_GRADIENT) {
                firstStopOffset = stopColor.getHintOffset();
                break;
            }
        }
        firstStopOffset = Math.min(0, firstStopOffset);
        firstStop.setOffset(firstStopOffset, OffsetType.RELATIVE);
    }

    private static List<GradientColorStop> copyStopsAndNormalizeAbsoluteOffsets(List<GradientColorStop> toNormalize,
            double baseVectorLength) {
        double lastUsedOffset = Double.NEGATIVE_INFINITY;
        List<GradientColorStop> copy = new ArrayList<>(toNormalize.size());
        for (GradientColorStop stop : toNormalize) {
            double offset = stop.getOffset();
            OffsetType offsetType = stop.getOffsetType();
            if (offsetType == OffsetType.ABSOLUTE) {
                offsetType = OffsetType.RELATIVE;
                offset /= baseVectorLength;
            }

            if (offsetType == OffsetType.RELATIVE) {
                if (offset < lastUsedOffset) {
                    offset = lastUsedOffset;
                }
                lastUsedOffset = offset;
            }

            GradientColorStop result = new GradientColorStop(stop, offset, offsetType);

            double hintOffset = stop.getHintOffset();
            HintOffsetType hintOffsetType = stop.getHintOffsetType();
            if (hintOffsetType == HintOffsetType.ABSOLUTE_ON_GRADIENT) {
                hintOffsetType = HintOffsetType.RELATIVE_ON_GRADIENT;
                hintOffset /= baseVectorLength;
            }

            if (hintOffsetType == HintOffsetType.RELATIVE_ON_GRADIENT) {
                if (hintOffset < lastUsedOffset) {
                    hintOffset = lastUsedOffset;
                }
                lastUsedOffset = hintOffset;
            }

            result.setHint(hintOffset, hintOffsetType);
            copy.add(result);
        }
        return copy;
    }

    private static void adjustStopsForPadIfNeeded(List<GradientColorStop> stopsToConstruct,
            double[] coordinatesDomain) {
        GradientColorStop firstStop = stopsToConstruct.get(0);
        if (coordinatesDomain[0] < firstStop.getOffset()) {
            stopsToConstruct.add(0, new GradientColorStop(firstStop, coordinatesDomain[0], OffsetType.RELATIVE));
        }
        GradientColorStop lastStop = stopsToConstruct.get(stopsToConstruct.size() - 1);
        if (coordinatesDomain[1] > lastStop.getOffset()) {
            stopsToConstruct.add(new GradientColorStop(lastStop, coordinatesDomain[1], OffsetType.RELATIVE));
        }
    }

    private static List<GradientColorStop> adjustNormalizedStopsToCoverDomain(List<GradientColorStop> normalizedStops,
            double[] targetDomain, GradientSpreadMethod spreadMethod) {
        List<GradientColorStop> adjustedStops = new ArrayList<>();

        GradientColorStop lastColorStop = normalizedStops.get(normalizedStops.size() - 1);
        double originalIntervalEnd = lastColorStop.getOffset();
        double originalIntervalStart = normalizedStops.get(0).getOffset();
        double originalIntervalLength = originalIntervalEnd - originalIntervalStart;

        if (originalIntervalLength <= ZERO_EPSILON) {
            return Arrays.asList(new GradientColorStop(lastColorStop, targetDomain[0], OffsetType.RELATIVE),
                    new GradientColorStop(lastColorStop, targetDomain[1], OffsetType.RELATIVE));
        }

        double startIntervalsShift = Math.floor((targetDomain[0] - originalIntervalStart) / originalIntervalLength);
        double iterationOffset = originalIntervalStart + (originalIntervalLength * startIntervalsShift);
        boolean isIterationInverse =
                spreadMethod == GradientSpreadMethod.REFLECT && Math.abs(startIntervalsShift) % 2 != 0;

        int currentIterationIndex = isIterationInverse ? normalizedStops.size() - 1 : 0;

        double lastComputedOffset = iterationOffset;
        while (lastComputedOffset <= targetDomain[1]) {
            GradientColorStop currentStop = normalizedStops.get(currentIterationIndex);
            lastComputedOffset = isIterationInverse ?
                    iterationOffset + originalIntervalEnd - currentStop.getOffset()
                    : iterationOffset + currentStop.getOffset() - originalIntervalStart;
            GradientColorStop computedStop = new GradientColorStop(currentStop, lastComputedOffset,
                    OffsetType.RELATIVE);

            if (lastComputedOffset < targetDomain[0] && !adjustedStops.isEmpty()) {
                adjustedStops.set(0, computedStop);
            } else {
                adjustedStops.add(computedStop);
            }

            if (isIterationInverse) {
                --currentIterationIndex;
                if (currentIterationIndex < 0) {
                    iterationOffset += originalIntervalLength;
                    isIterationInverse = false;
                    currentIterationIndex = 1;
                }
            } else {
                ++currentIterationIndex;
                if (currentIterationIndex == normalizedStops.size()) {
                    iterationOffset += originalIntervalLength;
                    isIterationInverse = spreadMethod == GradientSpreadMethod.REFLECT;
                    currentIterationIndex = isIterationInverse ? normalizedStops.size() - 2 : 0;
                }
            }
            // check the next iteration type to set the correct stop color hint for just added stop
            if (isIterationInverse) {
                GradientColorStop nextColor = normalizedStops.get(currentIterationIndex);
                // this method should be invoked only after the normalization. it means that
                // the hint offset type for each stop is either relative to colors interval
                // (i.e. for inverse iteration we need to inverse the hint offset), or is none
                // (i.e. the hint offset value should be ignored)
                computedStop.setHint(1 - nextColor.getHintOffset(), nextColor.getHintOffsetType());
            } else {
                computedStop.setHint(currentStop.getHintOffset(), currentStop.getHintOffsetType());
            }
        }

        return adjustedStops;
    }

    private static IPdfFunction constructFunction(List<GradientColorStop> toConstruct) {
        int functionsAmount = toConstruct.size() - 1;

        double[] bounds = new double[functionsAmount - 1];
        List<AbstractPdfFunction<? extends PdfDictionary>> type2Functions = new ArrayList<>(functionsAmount);

        GradientColorStop currentStop;
        GradientColorStop nextStop = toConstruct.get(0);
        double domainStart = nextStop.getOffset();
        for (int i = 1; i < functionsAmount; ++i) {
            currentStop = nextStop;
            nextStop = toConstruct.get(i);
            bounds[i - 1] = nextStop.getOffset();
            type2Functions.add(constructSingleGradientSegmentFunction(currentStop, nextStop));
        }

        currentStop = nextStop;
        nextStop = toConstruct.get(toConstruct.size() - 1);
        type2Functions.add(constructSingleGradientSegmentFunction(currentStop, nextStop));
        double domainEnd = nextStop.getOffset();

        double[] encode = new double[functionsAmount * 2];
        for (int i = 0; i < encode.length; i += 2) {
            encode[i] = 0d;
            encode[i + 1] = 1d;
        }

        return new PdfType3Function(new double[] {domainStart, domainEnd}, null, type2Functions, bounds, encode);
    }

    private static AbstractPdfFunction<? extends PdfDictionary> constructSingleGradientSegmentFunction(
            GradientColorStop from, GradientColorStop to) {

        double exponent = 1d;
        float[] fromColor = from.getRgbArray();
        float[] toColor = to.getRgbArray();
        if (from.getHintOffsetType() == HintOffsetType.RELATIVE_BETWEEN_COLORS) {
            double hintOffset = from.getHintOffset();
            if (hintOffset <= 0d + ZERO_EPSILON) {
                fromColor = toColor;
            } else if (hintOffset >= 1d - ZERO_EPSILON) {
                toColor = fromColor;
            } else {
                // similar to css color hint logic
                exponent = Math.log(0.5) / Math.log(hintOffset);
            }
        }
        return new PdfType2Function(new float[] {0f, 1f}, null, fromColor, toColor, exponent);
    }

    private static PdfArray createCoordsPdfArray(Point[] coordsPoints) {
        assert coordsPoints != null && coordsPoints.length == 2;

        return new PdfArray(new double[] {coordsPoints[0].getX(), coordsPoints[0].getY(),
                coordsPoints[1].getX(), coordsPoints[1].getY()});
    }

}