Skip to content

Commit e27c5c5

Browse files
committed
add dropShadow
1 parent 67efc0a commit e27c5c5

5 files changed

Lines changed: 186 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### New
66

7+
- **`image.dropShadow({ x, y, blur, spread, color, expand })`**: a shadow behind the image, shaped by its alpha. Without an offset it is a glow: `image.dropShadow({ blur: 8, spread: 2, color: 0xff2fb9ff })`. The image grows to fit it, unless `expand: false`.
78
- **`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.
89
- **`image.recolor(otherImage)`**: takes the colors from another image of the same size and keeps the alpha. With a gradient it fills a shape, e.g. gradient text: `text.recolor(Image.linearGradient(text.width, text.height, { stops: [0x22d3eeff, 0xfb923cff], angle: 90 }))`.
910

‎src/frame.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,23 @@ export class Frame extends Image {
197197
return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode });
198198
}
199199

200+
/**
201+
* @internal
202+
* @ignore
203+
*/
204+
protected override __room__(): { left: number, top: number } {
205+
return { left: this.xOffset, top: this.yOffset };
206+
}
207+
208+
/**
209+
* @internal
210+
* @ignore
211+
*/
212+
protected override __grown__(left: number, top: number): void {
213+
this.xOffset -= left;
214+
this.yOffset -= top;
215+
}
216+
200217
/**
201218
* Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place
202219
* @param options See {@link TrimOptions}

‎src/image.ts‎

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

174+
/** Options of {@link Image.dropShadow} */
175+
export interface ShadowOptions {
176+
/** How far to move the shadow to the right (negative values: to the left), in pixels. Default: `0` */
177+
x?: number;
178+
/** How far to move the shadow down (negative values: up), in pixels. Default: `0` */
179+
y?: number;
180+
/** How soft the shadow's edge is: the standard deviation of a gaussian blur in pixels, like {@link Image.blur}. Default: `0` */
181+
blur?: number;
182+
/** How far to grow the shadow's shape before blurring it, in pixels. Default: `0` */
183+
spread?: number;
184+
/** The shadow's color, its alpha is the shadow's opacity. Default: `0x00000080` (50% black) */
185+
color?: Color;
186+
/**
187+
* Whether to make the image larger so the shadow isn't cut off. Without it, the image keeps its size and the part
188+
* of the shadow outside is lost. Default: `true`
189+
*/
190+
expand?: boolean;
191+
}
192+
174193
/** The colors of a gradient, see {@link Image.linearGradient} */
175194
export interface GradientOptions {
176195
/**
@@ -1987,6 +2006,111 @@ export class Image {
19872006
return this;
19882007
}
19892008

2009+
/**
2010+
* Adds a shadow behind the image, shaped by its alpha: a drop shadow with an offset, or a glow without one
2011+
* (`image.dropShadow({ blur: 8, color: 0xff2fb9ff })`, with `spread` to make it stronger). The image grows to fit
2012+
* the shadow, unless `expand` is `false`. Frames stay where they are (their offsets move with the growth), they only
2013+
* grow up and left as far as their offset allows
2014+
* @param options See {@link ShadowOptions}
2015+
*/
2016+
dropShadow(options: ShadowOptions = {}): this {
2017+
const { x = 0, y = 0, blur = 0, spread = 0, color = 0x00000080, expand = true } = options;
2018+
finite(x, 'x');
2019+
finite(y, 'y');
2020+
if (finite(blur, 'blur') < 0) throw new RangeError('Invalid blur radius');
2021+
if (finite(spread, 'spread') < 0) throw new RangeError('Invalid spread');
2022+
const [r, g, b, a] = Colors.toRGBA(checkColor(color));
2023+
2024+
// the shadow's room: the spread and what the blur reaches (3 standard deviations), and a border for the outline
2025+
const reach = spread + 3 * blur, margin = Math.ceil(reach) + 1;
2026+
const layer = this.__shape_layer__(margin);
2027+
const width = layer.width;
2028+
const coverage = new Uint8Array(width * layer.height);
2029+
for (let i = 0; i < coverage.length; i++) coverage[i] = layer.bitmap[i * 4 + 3];
2030+
const grown = spread > 0 ? outline(coverage, width, layer.height, spread) : null;
2031+
const bitmap = layer.bitmap;
2032+
for (let i = 0; i < coverage.length; i++) {
2033+
bitmap[i * 4] = r;
2034+
bitmap[i * 4 + 1] = g;
2035+
bitmap[i * 4 + 2] = b;
2036+
bitmap[i * 4 + 3] = (grown ? grown[i] * 255 : coverage[i]) * a / 255;
2037+
}
2038+
if (blur > 0) {
2039+
layer.blur(blur);
2040+
// (a flat color: blurring the alpha only, the exact color goes back, 8 bit premultiplied colors drift where it's faint)
2041+
for (let i = 0; i < coverage.length; i++) {
2042+
if (!bitmap[i * 4 + 3]) continue;
2043+
bitmap[i * 4] = r;
2044+
bitmap[i * 4 + 1] = g;
2045+
bitmap[i * 4 + 2] = b;
2046+
}
2047+
}
2048+
2049+
const room = this.__room__();
2050+
return this.__under__(layer, x - margin, y - margin, expand ? {
2051+
left: Math.min(room.left, Math.max(0, Math.ceil(reach - x))),
2052+
top: Math.min(room.top, Math.max(0, Math.ceil(reach - y))),
2053+
right: Math.max(0, Math.ceil(reach + x)),
2054+
bottom: Math.max(0, Math.ceil(reach + y))
2055+
} : { left: 0, top: 0, right: 0, bottom: 0 });
2056+
}
2057+
2058+
/**
2059+
* A transparent image with this one's alpha in the middle, with a margin all around
2060+
* @internal
2061+
* @ignore
2062+
*/
2063+
protected __shape_layer__(margin: number): Image {
2064+
const layer = new Image(this.width + 2 * margin, this.height + 2 * margin);
2065+
for (let y = 0; y < this.height; y++)
2066+
for (let x = 0; x < this.width; x++)
2067+
layer.bitmap[((y + margin) * layer.width + x + margin) * 4 + 3] = this.bitmap[(y * this.width + x) * 4 + 3];
2068+
return layer;
2069+
}
2070+
2071+
/**
2072+
* Puts a layer behind the image on a larger canvas (the layer's position is relative to the image), keeping the
2073+
* gain map in place
2074+
* @internal
2075+
* @ignore
2076+
*/
2077+
protected __under__(layer: Image, layerX: number, layerY: number, grow: { left: number, top: number, right: number, bottom: number }): this {
2078+
const { left, top, right, bottom } = grow;
2079+
const width = this.width + left + right, height = this.height + top + bottom;
2080+
const result = new Image(width, height);
2081+
result.composite(layer, { x: layerX + left, y: layerY + top });
2082+
const map = this.__gain_map__;
2083+
this.__gain_map__ = null;
2084+
try {
2085+
result.composite(this, { x: left, y: top });
2086+
} finally {
2087+
this.__gain_map__ = map;
2088+
}
2089+
map?.__transform__(this.width, this.height, width, height, (X, Y) => [X - left, Y - top]);
2090+
this.__apply__(result);
2091+
if (left || top) this.__grown__(left, top);
2092+
return this;
2093+
}
2094+
2095+
/**
2096+
* How far the image may grow up and left
2097+
* @internal
2098+
* @ignore
2099+
*/
2100+
protected __room__(): { left: number, top: number } {
2101+
return { left: Infinity, top: Infinity };
2102+
}
2103+
2104+
/**
2105+
* Called when the image has grown up and left
2106+
* @internal
2107+
* @ignore
2108+
*/
2109+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2110+
protected __grown__(_left: number, _top: number): void {
2111+
// (frames move their offsets)
2112+
}
2113+
19902114
/**
19912115
* Sharpens the image with an unsharp mask: the difference to a blurred copy is amplified
19922116
* @param options See {@link SharpenOptions}

‎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, GradientOptions, LinearGradientOptions, RadialGradientOptions, 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, ShadowOptions, 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: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,10 +613,11 @@ 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 and recolor with an image
616+
// gradients, recolor with an image and drop shadows / glows
617617
{
618618
const {GainMap} = require('..');
619619
const px = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4));
620+
const alphaAt = (image, x, y) => image.bitmap[(y * image.width + x) * 4 + 3];
620621
const throws = (fn, type, message) => {
621622
let error = null;
622623
try { fn(); } catch (e) { error = e; }
@@ -674,6 +675,47 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
674675
mapped.gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2});
675676
mapped.recolor(Image.linearGradient(8, 8, {stops: [0xff0000ff, 0x0000ffff], angle: 90}));
676677
assert(px(mapped, 0, 0)[0] > 200 && px(mapped, 7, 0)[2] > 200 && mapped.gainMap && mapped.gainMap.width === 4, 'recolor(image) keeps the gain map');
678+
679+
// drop shadows and glows
680+
const square = () => new Image(10, 10).fill((x, y) => x >= 3 && x < 7 && y >= 3 && y < 7 ? 0xff8040ff : 0);
681+
let image = square().dropShadow();
682+
assert(image.width === 10 && image.height === 10 && px(image, 4, 4).join() === '255,128,64,255' && px(image, 2, 4)[3] === 0, 'dropShadow() without offset or blur changes nothing outside of the shape');
683+
image = square().dropShadow({x: 2, y: 3});
684+
assert(image.width === 12 && image.height === 13, `dropShadow({x, y}) grows right and down: ${image.width}x${image.height}`);
685+
assert(px(image, 8, 9).join() === '0,0,0,128' && px(image, 4, 4).join() === '255,128,64,255' && px(image, 9, 9)[3] === 0, `dropShadow: the shadow is 50% black behind the shape, shifted: ${px(image, 8, 9)}`);
686+
image = square().dropShadow({x: -2, y: -1, color: 0xff0000ff});
687+
assert(image.width === 12 && image.height === 11 && px(image, 3, 3).join() === '255,0,0,255' && px(image, 5, 5).join() === '255,128,64,255', `dropShadow({x: -2, y: -1}) grows left and up, the image moves: ${image.width}x${image.height} ${px(image, 3, 3)}`);
688+
image = square().dropShadow({x: 2, y: 3, expand: false});
689+
assert(image.width === 10 && image.height === 10 && px(image, 8, 9).join() === '0,0,0,128', 'dropShadow({expand: false}) keeps the size');
690+
image = square().dropShadow({blur: 2});
691+
assert(image.width === 22 && image.height === 22, `dropShadow({blur}) leaves room for the blur (3 deviations): ${image.width}x${image.height}`);
692+
assert(alphaAt(image, 3 + 6 - 1, 3 + 6 + 2) > 0 && alphaAt(image, 0, 0) <= 1 && alphaAt(image, 3 + 6 + 1, 3 + 6 + 1) === 255 && px(image, 11, 11).join() === '255,128,64,255', 'dropShadow({blur}): a soft shadow around the shape, the shape on top');
693+
image = square().dropShadow({spread: 1.5, color: 0x00ff00ff});
694+
assert(image.width === 14 && px(image, 4, 7).join() === '0,255,0,255' && alphaAt(image, 3, 7) > 64 && alphaAt(image, 3, 7) < 192 && alphaAt(image, 2, 7) === 0, `dropShadow({spread}) grows the shape by the spread: ${[2, 3, 4].map(x => alphaAt(image, x, 7))}`);
695+
const glowing = square().dropShadow({blur: 3, spread: 1, color: 0xff2fb9ff});
696+
let pinks = 0;
697+
for (let i = 0; i < glowing.bitmap.length; i += 4)
698+
if (glowing.bitmap[i + 3] > 0 && glowing.bitmap[i + 3] < 255) pinks += glowing.bitmap[i] === 255 && glowing.bitmap[i + 1] === 47 && glowing.bitmap[i + 2] === 185 ? 1 : 0;
699+
assert(pinks > 200 && pinks === (() => { let n = 0; for (let i = 0; i < glowing.bitmap.length; i += 4) if (glowing.bitmap[i + 3] > 0 && glowing.bitmap[i + 3] < 255) n++; return n; })(), `a glow is exactly the color all around, also where it's faint: ${pinks} pixels`);
700+
throws(() => square().dropShadow({blur: -1}), RangeError, 'negative blur');
701+
throws(() => square().dropShadow({spread: -1}), RangeError, 'negative spread');
702+
throws(() => square().dropShadow({x: NaN}), RangeError, 'NaN offset');
703+
const translucent = new Image(6, 6).fill(0xffffff80);
704+
const shadowed = translucent.clone().dropShadow({color: 0x000000ff});
705+
assert(px(shadowed, 2, 2).join() === px(translucent, 2, 2).join() || px(shadowed, 2, 2)[3] >= 128, 'dropShadow keeps translucent pixels over their shadow');
706+
707+
// frames stay in place: the offsets move with the growth, up and left only as far as they allow
708+
const frame = Frame.from(square(), {duration: 40, xOffset: 2, yOffset: 5}).dropShadow({blur: 2});
709+
assert(frame.xOffset === 0 && frame.yOffset === 0 && frame.width === 10 + 2 + 6 && frame.height === 10 + 5 + 6 && frame.duration === 40, `Frame.dropShadow: ${frame.width}x${frame.height} at ${frame.xOffset},${frame.yOffset}`);
710+
assert(px(frame, 2 + 4, 5 + 4).join() === '255,128,64,255', 'Frame.dropShadow: the pixels stay where they were on the animation canvas');
711+
const farFrame = Frame.from(square(), {xOffset: 20, yOffset: 20}).dropShadow({blur: 1});
712+
assert(farFrame.xOffset === 17 && farFrame.yOffset === 17, 'Frame.dropShadow with room moves the offsets by the growth');
713+
714+
// gain maps grow with the image
715+
const hdr = square();
716+
hdr.gainMap = new GainMap(new Image(5, 5).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2});
717+
const hdrShadow = hdr.clone().dropShadow({x: 5, y: 0, blur: 1});
718+
assert(hdrShadow.gainMap && Math.abs(hdrShadow.gainMap.width / hdrShadow.width - 0.5) < 0.1 && Math.abs(hdrShadow.gainMap.height / hdrShadow.height - 0.5) < 0.1, 'dropShadow keeps the gain map at its scale');
677719
}
678720

679721
// Colors: the color helpers, and the deprecated Image ones they replaced, give the same values

0 commit comments

Comments
 (0)