Skip to content

Commit 875d1a3

Browse files
committed
add Image.linearGradient and Image.radialGradient
1 parent c58feea commit 875d1a3

4 files changed

Lines changed: 138 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 3.19.0
4+
5+
### New
6+
7+
- **`Image.linearGradient(width, height, { stops, angle })`** and **`Image.radialGradient(width, height, { stops, x, y, radius })`**: images with a gradient, with the angle like CSS' `linear-gradient()`. `stops` are positions and colors like `Colors.gradient()` takes them, or just the colors, evenly spaced.
8+
39
## 3.18.0
410

511
### Changes

‎src/image.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,36 @@ export interface SharpenOptions {
171171
radius?: number;
172172
}
173173

174+
/** The colors of a gradient, see {@link Image.linearGradient} */
175+
export interface GradientOptions {
176+
/**
177+
* The colors along the gradient: their positions (0..1) and colors, like {@link Colors.gradient} takes them
178+
* (`{ 0: 0xff0000ff, 0.5: 0xffff00ff, 1: 0x00ff00ff }`), or just the colors, evenly spaced
179+
* (`[0xff0000ff, 0x00ff00ff]`)
180+
*/
181+
stops: Record<number, Color> | Color[];
182+
}
183+
184+
/** Options of {@link Image.linearGradient} */
185+
export interface LinearGradientOptions extends GradientOptions {
186+
/**
187+
* The direction of the gradient in degrees, like CSS' `linear-gradient()`: `0` goes up, `90` to the right, `180`
188+
* down, `270` to the left. The first stop is at the start of the line through the center that touches the
189+
* corners. Default: `180`
190+
*/
191+
angle?: number;
192+
}
193+
194+
/** Options of {@link Image.radialGradient} */
195+
export interface RadialGradientOptions extends GradientOptions {
196+
/** The x position of the center in pixels. Default: the middle of the image */
197+
x?: number;
198+
/** The y position of the center in pixels. Default: the middle of the image */
199+
y?: number;
200+
/** The radius of the last stop in pixels. Default: the distance to the farthest corner */
201+
radius?: number;
202+
}
203+
174204
/** Options of {@link Image.encode} */
175205
export interface PNGEncodeOptions {
176206
/** The compression level (0-9), higher is smaller but slower. Default: `4` */
@@ -769,6 +799,50 @@ export class Image {
769799
return Colors.toRGB(color);
770800
}
771801

802+
/**
803+
* Creates an image with a linear gradient, like CSS' `linear-gradient()`
804+
* @param width The image's width
805+
* @param height The image's height
806+
* @param options The colors and the direction, see {@link LinearGradientOptions}
807+
* @returns The new image
808+
*/
809+
static linearGradient(width: number, height: number, options: LinearGradientOptions): Image {
810+
const { stops, angle = 180 } = options;
811+
const color = gradientFunction(stops);
812+
finite(angle, 'angle');
813+
const image = new Image(width, height);
814+
const radians = angle * Math.PI / 180;
815+
// the direction (y points down, 0 degrees up), and the length of the line through the center to the corners
816+
const dx = Math.sin(radians), dy = -Math.cos(radians);
817+
const length = Math.abs(width * dx) + Math.abs(height * dy);
818+
const cx = width / 2, cy = height / 2, bitmap = new DataView(image.bitmap.buffer, image.bitmap.byteOffset, image.bitmap.byteLength);
819+
for (let y = 0, i = 0; y < height; y++)
820+
for (let x = 0; x < width; x++, i += 4)
821+
bitmap.setUint32(i, color(((x + 0.5 - cx) * dx + (y + 0.5 - cy) * dy) / length + 0.5), false);
822+
return image;
823+
}
824+
825+
/**
826+
* Creates an image with a radial (circular) gradient, like CSS' `radial-gradient(circle)`
827+
* @param width The image's width
828+
* @param height The image's height
829+
* @param options The colors and the circle, see {@link RadialGradientOptions}
830+
* @returns The new image
831+
*/
832+
static radialGradient(width: number, height: number, options: RadialGradientOptions): Image {
833+
const { stops, x: cx = width / 2, y: cy = height / 2, radius = Math.hypot(Math.max(cx, width - cx), Math.max(cy, height - cy)) } = options;
834+
const color = gradientFunction(stops);
835+
finite(cx, 'x');
836+
finite(cy, 'y');
837+
if (!(finite(radius, 'radius') > 0)) throw new RangeError('Invalid radius');
838+
const image = new Image(width, height);
839+
const bitmap = new DataView(image.bitmap.buffer, image.bitmap.byteOffset, image.bitmap.byteLength);
840+
for (let y = 0, i = 0; y < height; y++)
841+
for (let x = 0; x < width; x++, i += 4)
842+
bitmap.setUint32(i, color(Math.hypot(x + 0.5 - cx, y + 0.5 - cy) / radius), false);
843+
return image;
844+
}
845+
772846
/**
773847
* Creates a multi-point gradient generator
774848
* @param colors The gradient points to use (e.g. `{0: 0xff0000ff, 1: 0x00ff00ff}`)
@@ -2992,6 +3066,15 @@ const BLEND_MODES: Record<BlendMode, (backdrop: number, source: number) => numbe
29923066
'add': (b, s) => Math.min(1, b + s)
29933067
};
29943068

3069+
// the color at every position of a gradient, from its stops
3070+
function gradientFunction(stops: Record<number, Color> | Color[]): (position: number) => Color {
3071+
if (!Array.isArray(stops)) return Colors.gradient(stops);
3072+
if (!stops.length) throw new RangeError('Invalid gradient point count');
3073+
// (one color is a gradient of two equal ones)
3074+
const colors = stops.length === 1 ? [stops[0], stops[0]] : stops;
3075+
return Colors.gradient(Object.fromEntries(colors.map((color, i) => [i / (colors.length - 1), color])));
3076+
}
3077+
29953078
/**
29963079
* Grows a coverage mask by a radius, using the distance of every pixel to the glyphs (an exact euclidean distance
29973080
* transform, so the cost doesn't depend on the radius). Near the outline's edge, the distance goes to the glyph edge

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ImageType } from './image-type.ts';
99

1010
export { Image, ResizeModes, SVGScaleModes } from './image.ts';
1111
export { Colors } from './colors.ts';
12-
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts';
12+
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts';
1313
export { Frame, FrameDisposalModes } from './frame.ts';
1414
export type { FrameDisposalModeName, FrameDisposalModeId, FrameOptions } from './frame.ts';
1515
export { GainMap } from './gainmap.ts';

‎tests/regressions.js‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,54 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
613613
assert(Buffer.from(await map.encode()).equals(Buffer.from(await map.encode({}))), 'GainMap.encode() without arguments');
614614
}
615615

616+
// gradients
617+
{
618+
const px = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4));
619+
const throws = (fn, type, message) => {
620+
let error = null;
621+
try { fn(); } catch (e) { error = e; }
622+
assert(error instanceof type, `${message}: should throw a ${type.name}, got ${error}`);
623+
};
624+
const stops = {0: 0xff0000ff, 0.5: 0x00ff00ff, 1: 0x0000ffff};
625+
const gradient = ColorHelpers.gradient(stops);
626+
const rgba = color => ColorHelpers.toRGBA(color);
627+
628+
// linear gradients follow Colors.gradient along CSS' directions (0 up, 90 right, 180 down, 270 left)
629+
for (const [angle, at] of [[90, x => [x, 0, (x + 0.5) / 4]], [270, x => [x, 0, 1 - (x + 0.5) / 4]]]) {
630+
const horizontal = Image.linearGradient(4, 1, {stops, angle});
631+
for (let x = 0; x < 4; x++) {
632+
const [X, Y, t] = at(x);
633+
assert(px(horizontal, X, Y).join() === rgba(gradient(t)).join(), `linearGradient ${angle}deg, x ${x}: ${px(horizontal, X, Y)} vs ${rgba(gradient(t))}`);
634+
}
635+
}
636+
for (const [angle, t] of [[180, y => (y + 0.5) / 4], [0, y => 1 - (y + 0.5) / 4]]) {
637+
const vertical = Image.linearGradient(1, 4, {stops, angle});
638+
for (let y = 0; y < 4; y++)
639+
assert(px(vertical, 0, y).join() === rgba(gradient(t(y))).join(), `linearGradient ${angle}deg, y ${y}`);
640+
}
641+
const defaultDirection = Image.linearGradient(2, 3, {stops: [0xff0000ff, 0x0000ffff]});
642+
assert(px(defaultDirection, 0, 0).join() === px(defaultDirection, 1, 0).join() && px(defaultDirection, 0, 0)[0] > px(defaultDirection, 0, 2)[0], 'linearGradient goes down by default');
643+
const diagonal = Image.linearGradient(8, 8, {stops: [0x000000ff, 0xffffffff], angle: 135});
644+
assert(px(diagonal, 0, 0)[0] < 20 && px(diagonal, 7, 7)[0] > 235 && px(diagonal, 7, 0).join() === px(diagonal, 0, 7).join(), 'linearGradient at 135deg runs from corner to corner');
645+
// (an array of colors is evenly spaced, one color is a plain fill, alpha is part of the color)
646+
assert(px(Image.linearGradient(3, 1, {stops: [0xff0000ff, 0x00ff00ff, 0x0000ffff], angle: 90}), 1, 0).join() === rgba(ColorHelpers.gradient({0: 0xff0000ff, 0.5: 0x00ff00ff, 1: 0x0000ffff})(0.5)).join(), 'linearGradient with an array of colors');
647+
assert(px(Image.linearGradient(3, 3, {stops: [0x336699ff]}), 2, 2).join() === '51,102,153,255', 'linearGradient with one color');
648+
assert(px(Image.linearGradient(2, 1, {stops: [0xff000000, 0xff0000ff], angle: 90}), 0, 0)[3] < 100, 'linearGradient keeps alpha');
649+
650+
// radial gradients: the center is the first stop, the radius the last
651+
const radial = Image.radialGradient(9, 9, {stops: [0xffffffff, 0x000000ff]});
652+
assert(px(radial, 4, 4)[0] > 240 && px(radial, 0, 0)[0] < 40 && px(radial, 0, 8).join() === px(radial, 8, 0).join(), 'radialGradient from the center to the farthest corner');
653+
const circle = Image.radialGradient(20, 20, {stops: [0xff0000ff, 0x0000ffff], x: 5.5, y: 5.5, radius: 4});
654+
assert(px(circle, 5, 5)[0] > 240 && px(circle, 15, 15).join() === '0,0,255,255' && px(circle, 9, 5)[2] > 200, 'radialGradient with a center and a radius');
655+
for (const [name, call] of Object.entries({
656+
'no stops': () => Image.linearGradient(2, 2, {stops: []}),
657+
'no stops (object)': () => Image.radialGradient(2, 2, {stops: {}}),
658+
'NaN angle': () => Image.linearGradient(2, 2, {stops: [1, 2], angle: NaN}),
659+
'zero radius': () => Image.radialGradient(2, 2, {stops: [1, 2], radius: 0}),
660+
'NaN center': () => Image.radialGradient(2, 2, {stops: [1, 2], x: NaN})
661+
})) throws(call, RangeError, name);
662+
}
663+
616664
// Colors: the color helpers, and the deprecated Image ones they replaced, give the same values
617665
{
618666
const { Colors } = require('..');

0 commit comments

Comments
 (0)