Skip to content

Commit 0bec592

Browse files
committed
add outline
1 parent e27c5c5 commit 0bec592

4 files changed

Lines changed: 67 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### New
66

77
- **`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`.
8+
- **`image.outline({ width, color, expand })`**: an outline around the image's shape, the sticker effect for cut-out images or an outline for rendered text. It follows the edge's antialiasing, like the `strokeWidth` of `renderText()` does.
89
- **`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.
910
- **`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 }))`.
1011

‎src/image.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,19 @@ export interface ShadowOptions {
190190
expand?: boolean;
191191
}
192192

193+
/** Options of {@link Image.outline} */
194+
export interface OutlineOptions {
195+
/** The outline's width in pixels. Default: `1` */
196+
width?: number;
197+
/** The outline's color. Default: `0x000000ff` */
198+
color?: Color;
199+
/**
200+
* Whether to make the image larger so the outline isn't cut off. Without it, the image keeps its size and the
201+
* outline is cut where the image ends. Default: `true`
202+
*/
203+
expand?: boolean;
204+
}
205+
193206
/** The colors of a gradient, see {@link Image.linearGradient} */
194207
export interface GradientOptions {
195208
/**
@@ -2055,6 +2068,38 @@ export class Image {
20552068
} : { left: 0, top: 0, right: 0, bottom: 0 });
20562069
}
20572070

2071+
/**
2072+
* Draws an outline around the image's shape (its alpha): the sticker effect for cut-out images, or an outline for
2073+
* text. The image grows to fit it, unless `expand` is `false`. Frames stay where they are (their offsets move with
2074+
* the growth), they only grow up and left as far as their offset allows
2075+
* @param options See {@link OutlineOptions}
2076+
*/
2077+
outline(options: OutlineOptions = {}): this {
2078+
const { width = 1, color = 0x000000ff, expand = true } = options;
2079+
if (finite(width, 'outline width') < 0) throw new RangeError('Invalid outline width');
2080+
const [r, g, b, a] = Colors.toRGBA(checkColor(color));
2081+
if (width === 0) return this;
2082+
2083+
const margin = Math.ceil(width) + 1;
2084+
const layer = this.__shape_layer__(margin);
2085+
const coverage = new Uint8Array(layer.width * layer.height);
2086+
for (let i = 0; i < coverage.length; i++) coverage[i] = layer.bitmap[i * 4 + 3];
2087+
const stroke = outline(coverage, layer.width, layer.height, width);
2088+
const bitmap = layer.bitmap;
2089+
for (let i = 0; i < coverage.length; i++) {
2090+
// (around the shape only: it shows through translucent pixels neither)
2091+
bitmap[i * 4] = r;
2092+
bitmap[i * 4 + 1] = g;
2093+
bitmap[i * 4 + 2] = b;
2094+
bitmap[i * 4 + 3] = stroke[i] * (1 - coverage[i] / 255) * a;
2095+
}
2096+
2097+
const room = this.__room__(), grow = Math.ceil(width);
2098+
return this.__under__(layer, -margin, -margin, expand
2099+
? { left: Math.min(room.left, grow), top: Math.min(room.top, grow), right: grow, bottom: grow }
2100+
: { left: 0, top: 0, right: 0, bottom: 0 });
2101+
}
2102+
20582103
/**
20592104
* A transparent image with this one's alpha in the middle, with a margin all around
20602105
* @internal

‎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, ShadowOptions, 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, OutlineOptions, 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: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,7 @@ 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, recolor with an image and drop shadows / glows
616+
// gradients, recolor with an image, drop shadows / glows and outlines
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));
@@ -716,6 +716,25 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
716716
hdr.gainMap = new GainMap(new Image(5, 5).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2});
717717
const hdrShadow = hdr.clone().dropShadow({x: 5, y: 0, blur: 1});
718718
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');
719+
720+
// outlines
721+
const outlined = square().outline({width: 2, color: 0xff0000ff});
722+
assert(outlined.width === 14 && outlined.height === 14, `outline({width}) grows by the width on every side: ${outlined.width}x${outlined.height}`);
723+
assert(px(outlined, 2 + 4, 2 + 4).join() === '255,128,64,255', 'outline keeps the shape');
724+
assert(px(outlined, 2 + 2, 2 + 4).join() === '255,0,0,255' && px(outlined, 2 + 1, 2 + 4)[3] > 100 && alphaAt(outlined, 0, 2 + 4) === 0 && alphaAt(outlined, 2 + 3, 0) === 0, `outline: the stroke is next to the shape and as wide as asked: ${px(outlined, 4, 6)} ${px(outlined, 3, 6)} ${px(outlined, 0, 6)}`);
725+
assert(alphaAt(outlined, 2 + 3 - 1, 2 + 3 - 1) > 200 && alphaAt(outlined, 2 + 3 - 2, 2 + 3 - 2) < 128, 'outline rounds the corners');
726+
const clipped = square().outline({width: 2, expand: false});
727+
assert(clipped.width === 10 && px(clipped, 2, 4).join() === '0,0,0,255' && px(clipped, 4, 4).join() === '255,128,64,255', 'outline({expand: false}) keeps the size');
728+
assert(square().outline({width: 0}).width === 10 && Buffer.from(square().outline({width: 0}).bitmap).equals(Buffer.from(square().bitmap)), 'outline({width: 0}) does nothing');
729+
assert(px(square().outline({width: 1, color: 0x00ff0080}), 2 + 2, 2 + 4).slice(3).join() === '128' || Math.abs(alphaAt(square().outline({width: 1, color: 0x00ff0080}), 3 - 1 + 1, 3 + 2 + 1) - 128) <= 1, 'outline with a translucent color');
730+
const holey = new Image(9, 9).fill((x, y) => x >= 2 && x < 7 && y >= 2 && y < 7 && !(x === 4 && y === 4) ? 0xffffffff : 0);
731+
assert(alphaAt(holey.clone().outline({width: 1, color: 0x000000ff}), 1 + 4, 1 + 4) === 255, 'outline fills small holes of the shape');
732+
throws(() => square().outline({width: -1}), RangeError, 'negative outline width');
733+
throws(() => square().outline({width: Infinity}), RangeError, 'infinite outline width');
734+
const outlinedFrame = Frame.from(square(), {xOffset: 1, yOffset: 4}).outline({width: 3});
735+
assert(outlinedFrame.xOffset === 0 && outlinedFrame.yOffset === 1 && outlinedFrame.width === 10 + 1 + 3 && outlinedFrame.height === 10 + 3 + 3, `Frame.outline: ${outlinedFrame.width}x${outlinedFrame.height} at ${outlinedFrame.xOffset},${outlinedFrame.yOffset}`);
736+
const glowOnText = new Image(12, 12).fill((x, y) => (x - 5.5) ** 2 + (y - 5.5) ** 2 < 16 ? 0xffffffff : 0).outline({width: 2, color: 0xfacc15ff}).dropShadow({blur: 1, y: 2});
737+
assert(glowOnText.width > 16 && glowOnText.height > 18, 'outline and dropShadow chain');
719738
}
720739

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

0 commit comments

Comments
 (0)