Added: SVG export - Gradient bevel filter

Fixed SVG blur kernelUnitLength
This commit is contained in:
Jindra Petřík
2026-03-02 23:20:18 +01:00
parent 6b4f22f9bf
commit 85c7405a4d
13 changed files with 842 additions and 399 deletions

View File

@@ -564,6 +564,11 @@ public class SVGExporter implements RequiresNormalizedFonts {
return;
}
Element filtersElement = _svg.createElement("filter");
filtersElement.setAttribute("x", "-100%");
filtersElement.setAttribute("y", "-100%");
filtersElement.setAttribute("width", "300%");
filtersElement.setAttribute("height", "300%");
filtersElement.setAttribute("color-interpolation-filters", "sRGB");
String filterId = getUniqueId("filter");
String in = "SourceGraphic";
boolean empty = true;

View File

@@ -129,130 +129,11 @@ public class BEVELFILTER extends FILTER {
@Override
public String toSvg(Document document, Element filtersElement, SVGExporter exporter, String in) {
int type = Filtering.INNER;
if (onTop && !innerShadow) {
type = Filtering.FULL;
} else if (!innerShadow) {
type = Filtering.OUTER;
}
String shadowInner = null;
String hilightInner = null;
if (type != Filtering.OUTER) {
String hilight = dropShadowSvg(distance, angle, highlightColor, true, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
String shadow = dropShadowSvg(distance, angle + Math.PI, shadowColor, true, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
Element feComposite1 = document.createElement("feComposite");
feComposite1.setAttribute("in", hilight);
feComposite1.setAttribute("in2", shadow);
feComposite1.setAttribute("operator", "out");
hilightInner = exporter.getUniqueId("filterResult");
feComposite1.setAttribute("result", hilightInner);
filtersElement.appendChild(feComposite1);
Element feComposite2 = document.createElement("feComposite");
feComposite2.setAttribute("in", shadow);
feComposite2.setAttribute("in2", hilight);
feComposite2.setAttribute("operator", "out");
shadowInner = exporter.getUniqueId("filterResult");
feComposite2.setAttribute("result", shadowInner);
filtersElement.appendChild(feComposite2);
}
String shadowOuter = null;
String hilightOuter = null;
if (type != Filtering.INNER) {
String hilight = dropShadowSvg(distance, angle + Math.PI, highlightColor, false, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
String shadow = dropShadowSvg(distance, angle, shadowColor, false, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
Element feComposite1 = document.createElement("feComposite");
feComposite1.setAttribute("in", hilight);
feComposite1.setAttribute("in2", shadow);
feComposite1.setAttribute("operator", "out");
shadowOuter = exporter.getUniqueId("filterResult");
feComposite1.setAttribute("result", shadowOuter);
filtersElement.appendChild(feComposite1);
Element feComposite2 = document.createElement("feComposite");
feComposite2.setAttribute("in", shadow);
feComposite2.setAttribute("in2", hilight);
feComposite2.setAttribute("operator", "out");
hilightOuter = exporter.getUniqueId("filterResult");
feComposite2.setAttribute("result", hilightOuter);
filtersElement.appendChild(feComposite2);
}
String hilight = null;
String shadow = null;
switch (type) {
case Filtering.OUTER:
hilight = hilightOuter;
shadow = shadowOuter;
break;
case Filtering.INNER:
hilight = hilightInner;
shadow = shadowInner;
break;
case Filtering.FULL:
Element feComposite1 = document.createElement("feComposite");
feComposite1.setAttribute("in", hilightInner);
feComposite1.setAttribute("in2", hilightOuter);
feComposite1.setAttribute("operator", "over");
hilight = exporter.getUniqueId("filterResult");
feComposite1.setAttribute("result", hilight);
filtersElement.appendChild(feComposite1);
Element feComposite2 = document.createElement("feComposite");
feComposite2.setAttribute("in", shadowInner);
feComposite2.setAttribute("in2", shadowOuter);
feComposite2.setAttribute("operator", "over");
shadow = exporter.getUniqueId("filterResult");
feComposite2.setAttribute("result", shadow);
filtersElement.appendChild(feComposite2);
break;
}
Element feComposite3 = document.createElement("feComposite");
feComposite3.setAttribute("in", shadow);
feComposite3.setAttribute("in2", hilight);
feComposite3.setAttribute("operator", "over");
String result = exporter.getUniqueId("filterResult");
feComposite3.setAttribute("result", result);
filtersElement.appendChild(feComposite3);
result = blurSvg(blurX, blurY, passes, document, filtersElement, exporter, result);
if (type == Filtering.INNER) {
Element feComposite4 = document.createElement("feComposite");
feComposite4.setAttribute("in", result);
feComposite4.setAttribute("in2", in);
feComposite4.setAttribute("operator", "in");
result = exporter.getUniqueId("filterResult");
feComposite4.setAttribute("result", result);
filtersElement.appendChild(feComposite4);
}
if (type == Filtering.OUTER) {
Element feComposite4 = document.createElement("feComposite");
feComposite4.setAttribute("in", result);
feComposite4.setAttribute("in2", in);
feComposite4.setAttribute("operator", "out");
result = exporter.getUniqueId("filterResult");
feComposite4.setAttribute("result", result);
filtersElement.appendChild(feComposite4);
}
if (!knockout) {
Element feComposite4 = document.createElement("feComposite");
feComposite4.setAttribute("in", result);
feComposite4.setAttribute("in2", in);
feComposite4.setAttribute("operator", "over");
result = exporter.getUniqueId("filterResult");
feComposite4.setAttribute("result", result);
filtersElement.appendChild(feComposite4);
}
return result;
RGBA shadowColorTransparent = new RGBA(shadowColor);
shadowColorTransparent.alpha = 0;
RGBA highlightColorTransparent = new RGBA(highlightColor);
highlightColorTransparent.alpha = 0;
return bevelSvg(distance, angle, new RGBA[]{shadowColor, shadowColorTransparent, highlightColorTransparent, highlightColor}, new int[]{0, 127, 128, 255}, knockout, onTop, innerShadow, blurX, blurY, strength, passes, document, filtersElement, exporter, in);
}
@Override

View File

@@ -23,9 +23,12 @@ import com.jpexs.decompiler.flash.types.RGBA;
import com.jpexs.decompiler.flash.types.annotations.Internal;
import com.jpexs.decompiler.flash.types.annotations.SWFType;
import com.jpexs.helpers.ConcreteClasses;
import com.jpexs.helpers.GradientUtil;
import com.jpexs.helpers.SerializableImage;
import java.awt.Color;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@@ -132,11 +135,69 @@ public abstract class FILTER implements Serializable {
Element filtersElement,
SVGExporter exporter,
String in
) {
return dropShadowSvg(
distance,
angle,
new RGBA[]{dropShadowColor},
new int[0],
innerShadow,
knockout,
compositeSource,
blurX,
blurY,
strength,
iterations,
document,
filtersElement,
exporter,
in
);
}
/**
* Converts drop shadow to SVG.
*
* @param distance Distance
* @param angle Angle
* @param dropShadowColor Drop shadow color
* @param innerShadow Inner shadow
* @param knockout Knockout
* @param compositeSource Composite source
* @param blurX Blur X
* @param blurY Blur Y
* @param strength Strength
* @param iterations Iterations
* @param document Document
* @param filtersElement Filters element
* @param exporter SVG exporter
* @param in Input
* @return SVG id of the drop shadow
*/
protected String dropShadowSvg(
double distance,
double angle,
RGBA[] gradientColors,
int[] gradientRatio,
boolean innerShadow,
boolean knockout,
boolean compositeSource,
double blurX,
double blurY,
double strength,
int iterations,
Document document,
Element filtersElement,
SVGExporter exporter,
String in
) {
double dx = distance * Math.cos(angle);
double dy = distance * Math.sin(angle);
RGBA dropShadowColor = gradientColors.length == 1 ? gradientColors[0] : new RGBA(0, 0, 0, 255);
if (innerShadow) {
Element feFlood = document.createElement("feFlood");
feFlood.setAttribute("flood-color", dropShadowColor.toHexRGB());
feFlood.setAttribute("flood-opacity", "" + dropShadowColor.getAlphaFloat());
@@ -297,12 +358,12 @@ public abstract class FILTER implements Serializable {
int orderX = (int) Math.ceil(blurX * exporter.getZoom());
int orderY = (int) Math.ceil(blurY * exporter.getZoom());
if (orderX == 0) {
orderX = 1;
if (orderX % 2 == 0) {
orderX++;
}
if (orderY == 0) {
orderY = 1;
if (orderY % 2 == 0) {
orderY++;
}
double divisor = orderX * orderY;
@@ -315,9 +376,10 @@ public abstract class FILTER implements Serializable {
element.setAttribute("divisor", "" + divisor);
element.setAttribute("kernelMatrix", String.join(" ", parts));
element.setAttribute("in", in);
element.setAttribute("kernelUnitLength", "1");
}
element.setAttribute("in", in);
String result = exporter.getUniqueId("filterResult");
element.setAttribute("result", result);
@@ -328,6 +390,7 @@ public abstract class FILTER implements Serializable {
/**
* Converts gradient ratios to Java format float ratios.
*
* @param input 0-255 values
* @return 0f - 1f values, strictly increasing
*/
@@ -368,4 +431,226 @@ public abstract class FILTER implements Serializable {
return output;
}
protected String bevelSvg(double distance, double angle, RGBA[] gradientColors, int[] gradientRatio, boolean knockout, boolean onTop, boolean innerShadow, double blurX, double blurY, double strength, int passes, Document document, Element filtersElement, SVGExporter exporter, String in) {
RGBA highlightColor = new RGBA(255, 0, 0, 255);
RGBA shadowColor = new RGBA(0, 0, 255, 255);
int type = Filtering.INNER;
if (onTop && !innerShadow) {
type = Filtering.FULL;
} else if (!innerShadow) {
type = Filtering.OUTER;
}
String shadowInner = null;
String hilightInner = null;
if (type != Filtering.OUTER) {
String hilight = dropShadowSvg(distance, angle, highlightColor, true, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
String shadow = dropShadowSvg(distance, angle + Math.PI, shadowColor, true, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
Element feComposite1 = document.createElement("feComposite");
feComposite1.setAttribute("in", hilight);
feComposite1.setAttribute("in2", shadow);
feComposite1.setAttribute("operator", "out");
hilightInner = exporter.getUniqueId("filterResult");
feComposite1.setAttribute("result", hilightInner);
filtersElement.appendChild(feComposite1);
Element feComposite2 = document.createElement("feComposite");
feComposite2.setAttribute("in", shadow);
feComposite2.setAttribute("in2", hilight);
feComposite2.setAttribute("operator", "out");
shadowInner = exporter.getUniqueId("filterResult");
feComposite2.setAttribute("result", shadowInner);
filtersElement.appendChild(feComposite2);
}
String shadowOuter = null;
String hilightOuter = null;
if (type != Filtering.INNER) {
String hilight = dropShadowSvg(distance, angle + Math.PI, highlightColor, false, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
String shadow = dropShadowSvg(distance, angle, shadowColor, false, true, true, 0, 0, strength, passes, document, filtersElement, exporter, in);
Element feComposite1 = document.createElement("feComposite");
feComposite1.setAttribute("in", hilight);
feComposite1.setAttribute("in2", shadow);
feComposite1.setAttribute("operator", "out");
hilightOuter = exporter.getUniqueId("filterResult");
feComposite1.setAttribute("result", hilightOuter);
filtersElement.appendChild(feComposite1);
Element feComposite2 = document.createElement("feComposite");
feComposite2.setAttribute("in", shadow);
feComposite2.setAttribute("in2", hilight);
feComposite2.setAttribute("operator", "out");
shadowOuter = exporter.getUniqueId("filterResult");
feComposite2.setAttribute("result", shadowOuter);
filtersElement.appendChild(feComposite2);
}
String hilight = null;
String shadow = null;
switch (type) {
case Filtering.OUTER:
hilight = hilightOuter;
shadow = shadowOuter;
break;
case Filtering.INNER:
hilight = hilightInner;
shadow = shadowInner;
break;
case Filtering.FULL:
Element feComposite1 = document.createElement("feComposite");
feComposite1.setAttribute("in", hilightInner);
feComposite1.setAttribute("in2", hilightOuter);
feComposite1.setAttribute("operator", "over");
hilight = exporter.getUniqueId("filterResult");
feComposite1.setAttribute("result", hilight);
filtersElement.appendChild(feComposite1);
Element feComposite2 = document.createElement("feComposite");
feComposite2.setAttribute("in", shadowInner);
feComposite2.setAttribute("in2", shadowOuter);
feComposite2.setAttribute("operator", "over");
shadow = exporter.getUniqueId("filterResult");
feComposite2.setAttribute("result", shadow);
filtersElement.appendChild(feComposite2);
break;
}
Element feFlood = document.createElement("feFlood");
feFlood.setAttribute("flood-color", "black");
feFlood.setAttribute("flood-opacity", "1");
String black = exporter.getUniqueId("filterResult");
feFlood.setAttribute("result", black);
filtersElement.appendChild(feFlood);
String result;
Element feComposite4 = document.createElement("feComposite");
feComposite4.setAttribute("in", shadow);
feComposite4.setAttribute("in2", black);
feComposite4.setAttribute("operator", "over");
result = exporter.getUniqueId("filterResult");
feComposite4.setAttribute("result", result);
filtersElement.appendChild(feComposite4);
Element feComposite5 = document.createElement("feComposite");
feComposite5.setAttribute("in", hilight);
feComposite5.setAttribute("in2", result);
feComposite5.setAttribute("operator", "over");
result = exporter.getUniqueId("filterResult");
feComposite5.setAttribute("result", result);
filtersElement.appendChild(feComposite5);
result = blurSvg(blurX, blurY, passes, document, filtersElement, exporter, result);
Element feColorMatrix = document.createElement("feColorMatrix");
feColorMatrix.setAttribute("type", "matrix");
feColorMatrix.setAttribute("in", result);
double halfStrength = strength / 2;
String matrixRow = "" + halfStrength + " 0 " + (-halfStrength) + " 0 0.5";
feColorMatrix.setAttribute("values",
matrixRow + " "
+ matrixRow + " "
+ matrixRow + " "
+ matrixRow
);
result = exporter.getUniqueId("filterResult");
feColorMatrix.setAttribute("result", result);
filtersElement.appendChild(feColorMatrix);
result = prepareFeComponentTransfer(gradientColors, gradientRatio, document, filtersElement, exporter, result);
if (type == Filtering.INNER) {
Element feComposite6 = document.createElement("feComposite");
feComposite6.setAttribute("in", result);
feComposite6.setAttribute("in2", in);
feComposite6.setAttribute("operator", "in");
result = exporter.getUniqueId("filterResult");
feComposite6.setAttribute("result", result);
filtersElement.appendChild(feComposite6);
}
if (type == Filtering.OUTER) {
Element feComposite6 = document.createElement("feComposite");
feComposite6.setAttribute("in", result);
feComposite6.setAttribute("in2", in);
feComposite6.setAttribute("operator", "out");
result = exporter.getUniqueId("filterResult");
feComposite6.setAttribute("result", result);
filtersElement.appendChild(feComposite6);
}
if (!knockout) {
Element feComposite7 = document.createElement("feComposite");
feComposite7.setAttribute("in", result);
feComposite7.setAttribute("in2", in);
feComposite7.setAttribute("operator", "over");
result = exporter.getUniqueId("filterResult");
feComposite7.setAttribute("result", result);
filtersElement.appendChild(feComposite7);
}
return result;
}
private String prepareFeComponentTransfer(RGBA[] gradientColors, int[] gradientRatio, Document document, Element filtersElement, SVGExporter exporter, String in) {
Element feComponentTransfer = document.createElement("feComponentTransfer");
feComponentTransfer.setAttribute("in", in);
List<String> redValues = new ArrayList<>();
List<String> greenValues = new ArrayList<>();
List<String> blueValues = new ArrayList<>();
List<String> alphaValues = new ArrayList<>();
for (int i = 0; i < 256; i++) {
RGBA color = GradientUtil.colorAt(gradientColors, gradientRatio, i, GradientUtil.ColorInterpolation.SRGB);
redValues.add("" + (color.red / 255f));
greenValues.add("" + (color.green / 255f));
blueValues.add("" + (color.blue / 255f));
alphaValues.add("" + color.getAlphaFloat());
}
/*
//In case we want to map 128 to center
for (int i = 0; i < 126; i++) { //126 colors
RGBA color = GradientUtil.colorAt(gradientColors, gradientRatio, i * 127f / 125f, GradientUtil.ColorInterpolation.SRGB);
redValues.add("" + (color.red / 255f));
greenValues.add("" + (color.green / 255f));
blueValues.add("" + (color.blue / 255f));
alphaValues.add("" + color.getAlphaFloat());
}
for (int i = 128; i < 256; i++) { //1 center + 126 colors
RGBA color = GradientUtil.colorAt(gradientColors, gradientRatio, i, GradientUtil.ColorInterpolation.SRGB);
redValues.add("" + (color.red / 255f));
greenValues.add("" + (color.green / 255f));
blueValues.add("" + (color.blue / 255f));
alphaValues.add("" + color.getAlphaFloat());
}
*/
Element feFuncR = document.createElement("feFuncR");
feFuncR.setAttribute("type", "table");
feFuncR.setAttribute("tableValues", String.join(" ", redValues));
Element feFuncG = document.createElement("feFuncG");
feFuncG.setAttribute("type", "table");
feFuncG.setAttribute("tableValues", String.join(" ", greenValues));
Element feFuncB = document.createElement("feFuncB");
feFuncB.setAttribute("type", "table");
feFuncB.setAttribute("tableValues", String.join(" ", blueValues));
Element feFuncA = document.createElement("feFuncA");
feFuncA.setAttribute("type", "table");
feFuncA.setAttribute("tableValues", String.join(" ", alphaValues));
feComponentTransfer.appendChild(feFuncR);
feComponentTransfer.appendChild(feFuncG);
feComponentTransfer.appendChild(feFuncB);
feComponentTransfer.appendChild(feFuncA);
String result = exporter.getUniqueId("filterResult");
feComponentTransfer.setAttribute("result", result);
filtersElement.appendChild(feComponentTransfer);
return result;
}
}

View File

@@ -120,7 +120,7 @@ public class GRADIENTBEVELFILTER extends FILTER {
colorsArr[i] = gradientColors[i].toColor();
}
float[] ratiosArr = convertRatiosToJavaGradient(gradientRatio);
int type = Filtering.INNER;
if (onTop && !innerShadow) {
type = Filtering.FULL;
@@ -143,7 +143,7 @@ public class GRADIENTBEVELFILTER extends FILTER {
@Override
public String toSvg(Document document, Element filtersElement, SVGExporter exporter, String in) {
return null; //NOT SUPPORTED
return bevelSvg(distance, angle, gradientColors, gradientRatio, knockout, onTop, innerShadow, blurX, blurY, strength, passes, document, filtersElement, exporter, in);
}
@Override
@@ -211,6 +211,5 @@ public class GRADIENTBEVELFILTER extends FILTER {
}
return Arrays.equals(this.gradientRatio, other.gradientRatio);
}
}

View File

@@ -0,0 +1,288 @@
/*
* Copyright (C) 2010-2026 JPEXS, All rights reserved.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3.0 of the License, or (at your option) any later version.
*
* This library 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library.
*/
package com.jpexs.helpers;
import com.jpexs.decompiler.flash.types.RGBA;
import java.util.ArrayList;
import java.util.List;
public final class GradientUtil {
private GradientUtil() {
}
/**
* Matches SVG's color-interpolation attribute.
*/
public enum ColorInterpolation {
/**
* Interpolate directly in sRGB-encoded component values (0..255).
*/
SRGB,
/**
* Convert sRGB -> linear RGB, interpolate in linear space, convert back
* to sRGB.
*/
LINEAR_RGB
}
/**
* Returns the gradient color at position k (0..255), where "ratio" defines
* stop positions (0..255) and "colors" defines stop colors. The stop lookup
* is identical for both modes; only interpolation differs.
*
* Assumptions: - ratio[] is sorted ascending (0..255). - colors.length ==
* ratio.length.
*/
public static RGBA colorAt(RGBA[] colors, int[] ratio, float k, ColorInterpolation mode) {
if (colors == null || ratio == null || colors.length == 0 || colors.length != ratio.length) {
throw new IllegalArgumentException("colors and ratio must be non-empty and have the same length.");
}
// Clamp query position into [0, 255]
k = clamp(k, 0f, 255f);
// Handle outside range quickly
int n = ratio.length;
if (k <= ratio[0]) {
return colors[0];
}
if (k >= ratio[n - 1]) {
return colors[n - 1];
}
// Find the segment [i, i+1] such that ratio[i] <= k <= ratio[i+1]
int i = 0;
while (i < n - 1 && k > ratio[i + 1]) {
i++;
}
int p0 = ratio[i];
int p1 = ratio[i + 1];
RGBA c0 = colors[i];
RGBA c1 = colors[i + 1];
// Degenerate case: two stops at the same position
if (p1 == p0) {
return c1;
}
float t = (k - p0) / (float) (p1 - p0); // 0..1
// Interpolate alpha linearly in all modes
int a = lerp8(c0.alpha, c1.alpha, t);
if (mode == ColorInterpolation.SRGB) {
// SVG color-interpolation="sRGB": interpolate directly in sRGB component values
int r = lerp8(c0.red, c1.red, t);
int g = lerp8(c0.green, c1.green, t);
int b = lerp8(c0.blue, c1.blue, t);
return new RGBA(r, g, b, a);
} else {
// SVG color-interpolation="linearRGB": gamma-correct interpolation
float r = lerpLinearRgbChannel(c0.red, c1.red, t);
float g = lerpLinearRgbChannel(c0.green, c1.green, t);
float b = lerpLinearRgbChannel(c0.blue, c1.blue, t);
return new RGBA(
clamp255(Math.round(r * 255f)),
clamp255(Math.round(g * 255f)),
clamp255(Math.round(b * 255f)),
a
);
}
}
public static final class SplitResult {
public final RGBA[] colorsA;
public final int[] ratioA;
public final RGBA[] colorsB;
public final int[] ratioB;
public SplitResult(RGBA[] colorsA, int[] ratioA, RGBA[] colorsB, int[] ratioB) {
this.colorsA = colorsA;
this.ratioA = ratioA;
this.colorsB = colorsB;
this.ratioB = ratioB;
}
}
/**
* Splits a gradient into two halves:
* - A: original positions 0..127 mapped to 0..255
* - B: original positions 128..255 mapped to 0..255
*
* Notes:
* - Stop lookup is based on original ratio[].
* - Boundary stops at 127 (A) and 128 (B) are ensured (interpolated if missing).
* - ratio[] is assumed sorted ascending.
*/
public static SplitResult splitIntoHalves(
RGBA[] colors, int[] ratio, ColorInterpolation mode
) {
// Build left half (0..127)
Stops left = extractRange(colors, ratio, 0, 127, mode);
// Build right half (128..255)
Stops right = extractRange(colors, ratio, 128, 255, mode);
// Remap ratios to 0..255 in each half
int[] ratioA = remap(left.ratio, 0, 127);
int[] ratioB = remap(right.ratio, 128, 255);
return new SplitResult(
left.colors.toArray(new RGBA[0]), ratioA,
right.colors.toArray(new RGBA[0]), ratioB
);
}
// ----- Internal representation of stops -----
private static final class Stops {
final List<RGBA> colors = new ArrayList<>();
final List<Integer> ratio = new ArrayList<>();
}
/**
* Extracts all stops within [from..to] (inclusive), and ensures stops at both ends exist.
* If an endpoint stop is missing, it is computed via SvgGradient.colorAt(...).
*/
private static Stops extractRange(RGBA[] colors, int[] ratio, int from, int to,
ColorInterpolation mode) {
Stops out = new Stops();
// Ensure start stop
addStop(out, from, stopColorAtOrExisting(colors, ratio, from, mode));
// Add internal stops strictly inside (from, to)
for (int i = 0; i < ratio.length; i++) {
int p = ratio[i];
if (p > from && p < to) {
addStop(out, p, colors[i]);
}
}
// Ensure end stop
addStop(out, to, stopColorAtOrExisting(colors, ratio, to, mode));
return out;
}
/**
* If there is an existing stop exactly at pos, returns its color;
* otherwise computes the color by interpolation at that position.
*/
private static RGBA stopColorAtOrExisting(RGBA[] colors, int[] ratio, int pos,
ColorInterpolation mode) {
for (int i = 0; i < ratio.length; i++) {
if (ratio[i] == pos) return colors[i];
}
return colorAt(colors, ratio, pos, mode);
}
/**
* Adds a stop keeping order; if the same position already exists, it overwrites the color.
*/
private static void addStop(Stops stops, int pos, RGBA color) {
// Insert in ascending order (ratio is small, linear insert is fine)
for (int i = 0; i < stops.ratio.size(); i++) {
int existing = stops.ratio.get(i);
if (existing == pos) {
stops.colors.set(i, color);
return;
}
if (existing > pos) {
stops.ratio.add(i, pos);
stops.colors.add(i, color);
return;
}
}
stops.ratio.add(pos);
stops.colors.add(color);
}
/**
* Remaps original integer positions in [from..to] to [0..255].
* Uses rounding and clamps, guaranteeing endpoints map to 0 and 255.
*/
private static int[] remap(List<Integer> original, int from, int to) {
int span = to - from; // for 0..127 and 128..255 span is 127
int[] out = new int[original.size()];
for (int i = 0; i < original.size(); i++) {
int p = original.get(i);
float t = (p - from) / (float) span; // 0..1
int mapped = Math.round(t * 255f); // 0..255
out[i] = clamp255(mapped);
}
// Force exact endpoints (avoid any rounding surprises)
if (out.length > 0) {
out[0] = 0;
out[out.length - 1] = 255;
}
return out;
}
// ----- Interpolation helpers -----
private static int lerp8(int v0, int v1, float t) {
return clamp255(Math.round(v0 + (v1 - v0) * t));
}
/**
* Interpolates a single channel using linearRGB mode: sRGB (0..1) -> linear
* (0..1) -> lerp -> sRGB (0..1)
*/
private static float lerpLinearRgbChannel(int c0_8bit, int c1_8bit, float t) {
float s0 = c0_8bit / 255f;
float s1 = c1_8bit / 255f;
float l0 = srgbToLinear(s0);
float l1 = srgbToLinear(s1);
float l = l0 + (l1 - l0) * t;
return linearToSrgb(l);
}
// ----- sRGB transfer functions -----
private static float srgbToLinear(float c) {
// IEC 61966-2-1 sRGB
if (c <= 0.04045f) {
return c / 12.92f;
}
return (float) Math.pow((c + 0.055f) / 1.055f, 2.4);
}
private static float linearToSrgb(float c) {
// IEC 61966-2-1 sRGB
if (c <= 0.0031308f) {
return 12.92f * c;
}
return 1.055f * (float) Math.pow(c, 1.0 / 2.4) - 0.055f;
}
// ----- Clamp helpers -----
private static float clamp(float v, float lo, float hi) {
return Math.max(lo, Math.min(hi, v));
}
private static int clamp255(int v) {
return Math.max(0, Math.min(255, v));
}
}