diff --git a/CHANGELOG.md b/CHANGELOG.md index 70bb0084..6c2f5101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,14 @@ ### New +- **Options objects**: every method with optional arguments takes them as an object too, e.g. + `image.resize(100, 100, {mode: Image.RESIZE_BICUBIC})`, `image.composite(logo, {x: 10, y: 10, opacity: 0.5})`, + `Image.renderText(font, 32, 'Hi', {color: 0xff0000ff, strokeWidth: 2})`, `new Frame(10, 10, {duration: 50})`, + `animation.encodeGIF({quality: 80})`. Options can be left out or added without counting positions; the positional + arguments keep working until 4.0. - **Metadata in every format**: `encodeJPEG()`, `encodeWebP()`, `encodeTGA()` and, for animations, `encodeGIF()`, - `encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`). Each format stores + `encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`), as the `metadata` + option: `image.encodeJPEG({quality: 80, metadata: {author: 'me'}})`. Each format stores the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and include the creation time by default; `{software: ''}` leaves the software out. diff --git a/src/animation.ts b/src/animation.ts index 3759152e..3f6a4ecd 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -2,10 +2,11 @@ import * as gifCodec from './codecs/gif.ts'; import * as png from './codecs/png.ts'; import * as webp from './codecs/webp.ts'; import { BinaryData, view } from './util/mem.ts'; -import { Image, ResizeMode } from './image.ts'; +import { Image, ResizeMode, ResizeOptions } from './image.ts'; import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; +import { isOptions } from './util/options.ts'; // loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG and WebP count plays (0 forever) const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1; @@ -20,6 +21,36 @@ function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uin }); } +/** Options of {@link Animation} */ +export interface AnimationOptions { + /** How many times to repeat the animation (-1 = forever, 0 = play once). Default: `-1` */ + loopCount?: number; +} + +/** Options of {@link Animation.decode} */ +export interface AnimationDecodeOptions { + /** Whether to stop decoding after the first frame. Default: `false` */ + onlyFirstFrame?: boolean; +} + +/** Options of {@link Animation.encodeWebP}, and those all animation encoders have */ +export interface AnimationEncodeOptions { + /** Metadata to include, see {@link Metadata} */ + metadata?: Metadata; +} + +/** Options of {@link Animation.encodeGIF} */ +export interface GIFEncodeOptions extends AnimationEncodeOptions { + /** 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above). Default: `95` */ + quality?: number; +} + +/** Options of {@link Animation.encodeAPNG} */ +export interface APNGEncodeOptions extends AnimationEncodeOptions { + /** The zlib compression level (0-9), higher is smaller but slower. Default: `1` */ + compression?: number; +} + /** * An animation: an array of frames that can be decoded from and encoded to GIF, APNG and WebP. * Array methods like map and filter return plain arrays. @@ -28,13 +59,21 @@ export class Animation extends Array { /** How many times to repeat the animation (-1 = forever, 0 = play once) */ loopCount: number; + /** + * Creates a new animation + * @param frames The frames to create the animation from + * @param options See {@link AnimationOptions} + */ + constructor(frames: Frame[], options?: AnimationOptions); /** * Creates a new animation * @param frames The frames to create the animation from * @param loopCount How many times to repeat the animation (-1 = forever, 0 = play once) */ - constructor(frames: Frame[], loopCount = -1) { + constructor(frames: Frame[], loopCount?: number); + constructor(frames: Frame[], loopCountOrOptions: number | AnimationOptions = -1) { super(); + const { loopCount = -1 } = isOptions(loopCountOrOptions) ? loopCountOrOptions : { loopCount: loopCountOrOptions }; // push instead of super(...frames), so a single frame isn't taken as a length for (const frame of frames) this.push(frame); @@ -91,7 +130,14 @@ export class Animation extends Array { * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally. * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio. */ - resize(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { + resize(width: number, height: number, options?: ResizeOptions): this; + /** + * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally. + * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio. + */ + resize(width: number, height: number, mode?: ResizeMode): this; + resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { + const mode = (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR; const originalWidth = this.width, originalHeight = this.height; if (width === Image.RESIZE_AUTO && height === Image.RESIZE_AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both'); if (width === Image.RESIZE_AUTO) width = originalWidth / originalHeight * height; @@ -116,12 +162,18 @@ export class Animation extends Array { if (!(frame instanceof Frame)) throw new Error('Animation contains invalid frames'); } + /** + * Encodes the animation as GIF + * @param options See {@link GIFEncodeOptions} + */ + encodeGIF(options?: GIFEncodeOptions): Promise; /** * Encodes the animation as GIF * @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above) - * @param metadata Metadata to include, see {@link Metadata} */ - async encodeGIF(quality = 95, metadata: Metadata = {}): Promise { + encodeGIF(quality?: number): Promise; + async encodeGIF(qualityOrOptions: number | GIFEncodeOptions = 95): Promise { + const { quality = 95, metadata = {} } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions }; this.__validate__(); const frames: gifCodec.GIFFrame[] = []; @@ -140,13 +192,20 @@ export class Animation extends Array { return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality, gifComments(resolveMetadata(metadata))); } + /** + * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show + * the first frame + * @param options See {@link APNGEncodeOptions} + */ + encodeAPNG(options?: APNGEncodeOptions): Promise; /** * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show * the first frame * @param compression The zlib compression level (0-9), higher is smaller but slower - * @param metadata Metadata to include, see {@link Metadata} */ - async encodeAPNG(compression = 1, metadata: Metadata = {}): Promise { + encodeAPNG(compression?: number): Promise; + async encodeAPNG(compressionOrOptions: number | APNGEncodeOptions = 1): Promise { + const { compression = 1, metadata = {} } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions }; this.__validate__(); if (!this.length) throw new Error('An animation needs at least one frame'); @@ -155,9 +214,9 @@ export class Animation extends Array { /** * Encodes the animation as lossless animated WebP - * @param metadata Metadata to include, see {@link Metadata} + * @param options See {@link AnimationEncodeOptions} */ - async encodeWebP(metadata: Metadata = {}): Promise { + async encodeWebP({ metadata = {} }: AnimationEncodeOptions = {}): Promise { this.__validate__(); if (!this.length) throw new Error('An animation needs at least one frame'); const width = this.width, height = this.height; @@ -201,12 +260,20 @@ export class Animation extends Array { })); } + /** + * Decodes an animated GIF, APNG or WebP into fully composited frames (still images become a single frame) + * @param input The binary data to decode + * @param options See {@link AnimationDecodeOptions} + */ + static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise; /** * Decodes an animated GIF, APNG or WebP into fully composited frames (still images become a single frame) * @param input The binary data to decode * @param onlyExtractFirstFrame Whether to stop decoding after the first frame */ - static async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise { + static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise; + static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise { + const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions; const data = view(input); const type = ImageType.getType(data); diff --git a/src/frame.ts b/src/frame.ts index 60cabf29..4a658b83 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,4 +1,5 @@ -import { Image, ResizeMode } from './image.ts'; +import { Image, ResizeMode, ResizeOptions } from './image.ts'; +import { isOptions } from './util/options.ts'; /** Names of the frame disposal modes, see {@link Frame.disposalMode} */ export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background'; @@ -7,6 +8,18 @@ export type FrameDisposalModeId = 0 | 1 | 2 | 3; const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background']; +/** Options of {@link Frame} and {@link Frame.from} */ +export interface FrameOptions { + /** The frame's duration in milliseconds. Default: `100` */ + duration?: number; + /** The frame's offset on the x-axis. Default: `0` */ + xOffset?: number; + /** The frame's offset on the y-axis. Default: `0` */ + yOffset?: number; + /** The frame's disposal mode, see {@link Frame.disposalMode}. Default: `'keep'` */ + disposalMode?: FrameDisposalModeName | FrameDisposalModeId; +} + /** * Represents a frame of an {@link Animation} */ @@ -52,6 +65,11 @@ export class Frame extends Image { return id as FrameDisposalModeId; } + /** + * Creates a new, blank frame + * @param options See {@link FrameOptions} + */ + constructor(width: number, height: number, options?: FrameOptions); /** * Creates a new, blank frame * @param duration The frame's duration (in ms) @@ -59,15 +77,18 @@ export class Frame extends Image { * @param yOffset The frame's offset on the y-axis * @param disposalMode The frame's disposal mode */ - constructor(width: number, height: number, duration = 100, xOffset = 0, yOffset = 0, disposalMode: FrameDisposalModeName | FrameDisposalModeId = Frame.DISPOSAL_KEEP) { + constructor(width: number, height: number, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId); + constructor(width: number, height: number, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId) { + const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = Frame.DISPOSAL_KEEP } = + isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode }; if (isNaN(duration) || duration < 0) throw new RangeError('Invalid frame duration'); super(width, height); this.duration = duration; - this.xOffset = xOffset; - this.yOffset = yOffset; - this.disposalMode = disposalMode; + this.xOffset = x; + this.yOffset = y; + this.disposalMode = disposal; } /** The frame's disposal mode (`'any'`, `'keep'`, `'previous'` or `'background'`); also settable by id (0-3) */ @@ -92,6 +113,12 @@ export class Frame extends Image { return `Frame<${this.width}x${this.height}x${this.duration}ms>`; } + /** + * Converts an Image instance to a Frame, cloning it in the process + * @param image The image to create the frame from + * @param options See {@link FrameOptions} + */ + static from(image: Image, options?: FrameOptions): Frame; /** * Converts an Image instance to a Frame, cloning it in the process * @param image The image to create the frame from @@ -100,11 +127,13 @@ export class Frame extends Image { * @param yOffset The frame's offset on the y-axis * @param disposalMode The frame's disposal mode */ - static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode: FrameDisposalModeName | FrameDisposalModeId = Frame.DISPOSAL_KEEP): Frame { + static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame; + static from(image: Image, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame { if (!(image instanceof Image)) throw new TypeError('Invalid image passed'); - const frame = new Frame(image.width, image.height, duration, xOffset, yOffset, disposalMode); + const options = isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode }; + const frame = new Frame(image.width, image.height, options); frame.bitmap.set(image.bitmap); return frame; @@ -114,17 +143,17 @@ export class Frame extends Image { * Clones the frame, including its duration, offsets and disposal mode */ override clone(): Frame { - return Frame.from(this, this.duration, this.xOffset, this.yOffset, this.disposalMode); + return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode }); } /** * Resizes the frame, scaling its offsets along with it */ - override resize(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { + override resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { const originalWidth = this.width; const originalHeight = this.height; - super.resize(width, height, mode); + super.resize(width, height, modeOrOptions as ResizeMode); this.xOffset = Math.round(this.xOffset * this.width / originalWidth); this.yOffset = Math.round(this.yOffset * this.height / originalHeight); diff --git a/src/gif.ts b/src/gif.ts index f75b08a4..b1c12ff4 100644 --- a/src/gif.ts +++ b/src/gif.ts @@ -1,8 +1,8 @@ import { BinaryData, view } from './util/mem.ts'; import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; -import { Animation } from './animation.ts'; -import { Metadata } from './metadata.ts'; +import { Animation, AnimationDecodeOptions, AnimationOptions, GIFEncodeOptions } from './animation.ts'; +import { isOptions } from './util/options.ts'; /** * Represents a GIF image as an array of frames, an {@link Animation} whose `encode()` and `decode()` work with GIFs. @@ -14,10 +14,11 @@ export class GIF extends Animation { /** * Creates a new GIF image * @param frames The frames to create the GIF from - * @param loopCount How many times to repeat the GIF (-1 = forever, 0 = play once) + * @param loopCountOrOptions How many times to repeat the GIF (-1 = forever, 0 = play once), or see + * {@link AnimationOptions} */ - constructor(frames: Frame[], loopCount = -1) { - super(frames, loopCount); + constructor(frames: Frame[], loopCountOrOptions?: number | AnimationOptions) { + super(frames, loopCountOrOptions as number); } override toString(): string { @@ -26,21 +27,23 @@ export class GIF extends Animation { /** * Encodes the GIF - * @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above) - * @param metadata Metadata to include, see {@link Metadata} + * @param qualityOrOptions 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above), or + * see {@link GIFEncodeOptions} * @deprecated Use {@link Animation.encodeGIF} */ - async encode(quality = 95, metadata: Metadata = {}): Promise { - return this.encodeGIF(quality, metadata); + async encode(qualityOrOptions?: number | GIFEncodeOptions): Promise { + return this.encodeGIF(qualityOrOptions as number); } /** * Decodes a GIF into fully composited frames * @param input The binary data to decode - * @param onlyExtractFirstFrame Whether to end GIF decoding after the first frame + * @param onlyFirstFrameOrOptions Whether to end GIF decoding after the first frame, or see + * {@link AnimationDecodeOptions} * @deprecated Use {@link Animation.decode}, which decodes APNGs too */ - static override async decode(input: BinaryData, onlyExtractFirstFrame = false): Promise { + static override async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise { + const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions; const data = view(input); if (!ImageType.isGIF(new DataView(data.buffer, data.byteOffset, data.byteLength))) throw new Error('Unsupported image type'); return Animation.__fromGIF__(data, onlyExtractFirstFrame, GIF); diff --git a/src/image.ts b/src/image.ts index ab76d9ab..4f9a4a98 100644 --- a/src/image.ts +++ b/src/image.ts @@ -13,6 +13,7 @@ import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts'; import { encode as utf8 } from './util/utf8.ts'; import { resample, BOX, TRIANGLE, CUBIC } from './util/resample.ts'; import { gaussianBlur, premultiply, unpremultiply } from './util/blur.ts'; +import { isOptions } from './util/options.ts'; /** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */ export type Color = number; @@ -32,12 +33,92 @@ export type BlendMode = 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' /** Options of {@link Image.composite} */ export interface CompositeOptions { + /** The x position to place the source at, if the options are the second argument. Default: `0` */ + x?: number; + /** The y position to place the source at, if the options are the second argument. Default: `0` */ + y?: number; /** Opacity of the source (0..1). Default: `1` */ opacity?: number; /** How the colors are mixed. Default: `'normal'` */ mode?: BlendMode; } +/** Options of {@link Image.scale}, {@link Image.resize}, {@link Image.contain}, {@link Image.fit} and {@link Image.cover} */ +export interface ResizeOptions { + /** The resizing mode. Default: {@link Image.RESIZE_BILINEAR} */ + mode?: ResizeMode; +} + +/** + * Options of {@link Image.opacity}, {@link Image.red}, {@link Image.green}, {@link Image.blue}, {@link Image.lightness} + * and {@link Image.saturation} + */ +export interface AdjustOptions { + /** Whether to set the value (true) or scale the current one (false). Default: `false` */ + absolute?: boolean; +} + +/** Options of {@link Image.sharpen} */ +export interface SharpenOptions { + /** How strongly to sharpen (0 does nothing). Default: `1` */ + amount?: number; + /** The radius of the blur the image is compared with (larger values sharpen coarser details). Default: `1` */ + radius?: number; +} + +/** Options of {@link Image.encode} */ +export interface PNGEncodeOptions { + /** The compression level (0-9). Default: `1` */ + compression?: number; + /** Metadata to include, see {@link Metadata} */ + metadata?: Metadata; +} + +/** Options of {@link Image.encodeJPEG} */ +export interface JPEGEncodeOptions { + /** The JPEG quality (1-100), chroma subsampling is disabled above 90. Default: `90` */ + quality?: number; + /** Metadata to include, see {@link Metadata} */ + metadata?: Metadata; +} + +/** Options of {@link Image.encodeWebP} and {@link Image.encodeTGA} */ +export interface EncodeOptions { + /** Metadata to include, see {@link Metadata} */ + metadata?: Metadata; +} + +/** Options of {@link Image.roundCorners} */ +export interface RoundCornersOptions { + /** The radius of the corners. Default: a quarter of the smaller side */ + radius?: number; +} + +/** Options of {@link Image.fisheye} */ +export interface FisheyeOptions { + /** The distortion strength (exponent of the radial mapping), higher values bulge the center more. Default: `2` */ + strength?: number; +} + +/** Options of {@link Image.renderSVG} */ +export interface SVGOptions { + /** The scale, or the width or height in pixels, see `mode`. Default: `1` */ + size?: number; + /** + * What `size` is: one of {@link Image.SVG_MODE_SCALE}, {@link Image.SVG_MODE_WIDTH}, {@link Image.SVG_MODE_HEIGHT}. + * Default: {@link Image.SVG_MODE_SCALE} + */ + mode?: SVGScaleMode; +} + +/** Options of {@link Image.renderText}: the text color and layout, and its style (see {@link TextStyle}) */ +export interface TextOptions extends TextStyle { + /** The text color. Default: `0xffffffff` */ + color?: Color; + /** The text layout. Default: `new TextLayout()` */ + layout?: TextLayout; +} + /** Options of {@link Image.decode} */ export interface DecodeOptions { /** Whether to rotate/mirror JPEGs, TIFFs, PNGs and WebPs as their orientation tag (Exif or XMP) says. Default: `true` */ @@ -479,13 +560,20 @@ export class Image { return -1; } + /** + * Resizes the image by the given factor + * @param factor The factor to resize the image with + * @param options See {@link ResizeOptions} + */ + scale(factor: number, options?: ResizeOptions): this; /** * Resizes the image by the given factor * @param factor The factor to resize the image with * @param [mode=Image.RESIZE_BILINEAR] The resizing mode to use */ - scale(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { - const image = this.__scale__(factor, mode); + scale(factor: number, mode?: ResizeMode): this; + scale(factor: number, modeOrOptions?: ResizeMode | ResizeOptions): this { + const image = this.__scale__(factor, resizeMode(modeOrOptions)); return this.__apply__(image); } @@ -496,6 +584,15 @@ export class Image { return this.__resize__(this.width * factor, this.height * factor, mode); } + /** + * Resizes the image to the given dimensions. + * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio. + * @param width The new width + * @param height The new height + * @param options See {@link ResizeOptions} + * @returns The resized image + */ + resize(width: number, height: number, options?: ResizeOptions): this; /** * Resizes the image to the given dimensions. * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio. @@ -504,11 +601,22 @@ export class Image { * @param [mode=Image.RESIZE_BILINEAR] The resizing mode to use * @returns The resized image */ - resize(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { + resize(width: number, height: number, mode?: ResizeMode): this; + resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { + const mode = resizeMode(modeOrOptions); const image = this.__resize__(width, height, mode); return this.__apply__(image); } + /** + * Resizes the image so it is contained in the given bounding box. + * Can return an image with one axis smaller than the given bounding box. + * @param width The width of the bounding box + * @param height The height of the bounding box + * @param options See {@link ResizeOptions} + * @returns The resized image + */ + contain(width: number, height: number, options?: ResizeOptions): this; /** * Resizes the image so it is contained in the given bounding box. * Can return an image with one axis smaller than the given bounding box. @@ -517,7 +625,9 @@ export class Image { * @param [mode=Image.RESIZE_BILINEAR] The resizing mode to use * @returns The resized image */ - contain(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { + contain(width: number, height: number, mode?: ResizeMode): this; + contain(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { + const mode = resizeMode(modeOrOptions); width = Math.floor(width); height = Math.floor(height); // round instead of flooring the scaled size, so the limiting axis matches the box exactly @@ -525,6 +635,15 @@ export class Image { return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), mode); } + /** + * Resizes the image so it is contained in the given bounding box, placing it in the center of the given bounding box. + * Always returns the exact dimensions of the bounding box. + * @param width The width of the bounding box + * @param height The height of the bounding box + * @param options See {@link ResizeOptions} + * @returns The resized image + */ + fit(width: number, height: number, options?: ResizeOptions): this; /** * Resizes the image so it is contained in the given bounding box, placing it in the center of the given bounding box. * Always returns the exact dimensions of the bounding box. @@ -533,13 +652,24 @@ export class Image { * @param [mode=Image.RESIZE_BILINEAR] The resizing mode to use * @returns The resized image */ - fit(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { + fit(width: number, height: number, mode?: ResizeMode): this; + fit(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { + const mode = resizeMode(modeOrOptions); const result = new Image(width, height); this.contain(width, height, mode); result.composite(this, (width - this.width) / 2, (height - this.height) / 2); return this.__apply__(result); } + /** + * Resizes the image so it covers the given bounding box, cropping the overflowing edges. + * Always returns the exact dimensions of the bounding box. + * @param width The width of the bounding box + * @param height The height of the bounding box + * @param options See {@link ResizeOptions} + * @returns The resized image + */ + cover(width: number, height: number, options?: ResizeOptions): this; /** * Resizes the image so it covers the given bounding box, cropping the overflowing edges. * Always returns the exact dimensions of the bounding box. @@ -548,7 +678,9 @@ export class Image { * @param [mode=Image.RESIZE_BILINEAR] The resizing mode to use * @returns The resized image */ - cover(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): this { + cover(width: number, height: number, mode?: ResizeMode): this; + cover(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { + const mode = resizeMode(modeOrOptions); width = Math.floor(width); height = Math.floor(height); const scale = Math.max(width / this.width, height / this.height); @@ -776,12 +908,20 @@ export class Image { return this; } + /** + * Sets the images opacity + * @param opacity The opacity to apply (0..1) + * @param options See {@link AdjustOptions} + */ + opacity(opacity: number, options?: AdjustOptions): this; /** * Sets the images opacity * @param opacity The opacity to apply (0..1) * @param absolute Whether to scale the current opacity (false) or just set the new opacity (true) */ - opacity(opacity: number, absolute = false): this { + opacity(opacity: number, absolute?: boolean): this; + opacity(opacity: number, absoluteOrOptions: boolean | AdjustOptions = false): this { + const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; if (isNaN(opacity) || opacity < 0) throw new RangeError('Invalid opacity value'); @@ -790,12 +930,20 @@ export class Image { return this; } + /** + * Scales or sets the red channel + * @param factor The factor to multiply the channel with (>= 0, results are clamped), or the value (0..1) to set it to + * @param options See {@link AdjustOptions} + */ + red(factor: number, options?: AdjustOptions): this; /** * Scales or sets the red channel * @param factor The factor to multiply the channel with (>= 0, results are clamped), or the value (0..1) to set it to * @param absolute Whether to scale the current value (false) or set every pixel's red channel to `factor` * 255 (true) */ - red(factor: number, absolute = false): this { + red(factor: number, absolute?: boolean): this; + red(factor: number, absoluteOrOptions: boolean | AdjustOptions = false): this { + const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; if (isNaN(factor) || factor < 0) throw new RangeError('Invalid red factor'); @@ -804,12 +952,20 @@ export class Image { return this; } + /** + * Scales or sets the green channel + * @param factor The factor to multiply the channel with (>= 0, results are clamped), or the value (0..1) to set it to + * @param options See {@link AdjustOptions} + */ + green(factor: number, options?: AdjustOptions): this; /** * Scales or sets the green channel * @param factor The factor to multiply the channel with (>= 0, results are clamped), or the value (0..1) to set it to * @param absolute Whether to scale the current value (false) or set every pixel's green channel to `factor` * 255 (true) */ - green(factor: number, absolute = false): this { + green(factor: number, absolute?: boolean): this; + green(factor: number, absoluteOrOptions: boolean | AdjustOptions = false): this { + const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; if (isNaN(factor) || factor < 0) throw new RangeError('Invalid green factor'); @@ -818,12 +974,20 @@ export class Image { return this; } + /** + * Scales or sets the blue channel + * @param factor The factor to multiply the channel with (>= 0, results are clamped), or the value (0..1) to set it to + * @param options See {@link AdjustOptions} + */ + blue(factor: number, options?: AdjustOptions): this; /** * Scales or sets the blue channel * @param factor The factor to multiply the channel with (>= 0, results are clamped), or the value (0..1) to set it to * @param absolute Whether to scale the current value (false) or set every pixel's blue channel to `factor` * 255 (true) */ - blue(factor: number, absolute = false): this { + blue(factor: number, absolute?: boolean): this; + blue(factor: number, absoluteOrOptions: boolean | AdjustOptions = false): this { + const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; if (isNaN(factor) || factor < 0) throw new RangeError('Invalid blue factor'); @@ -844,30 +1008,52 @@ export class Image { this.bitmap[i] = value * (absolute ? 255 : this.bitmap[i]); } + /** + * Sets the brightness of the image + * @param value The lightness to apply (0..1) + * @param options See {@link AdjustOptions} + */ + lightness(value: number, options?: AdjustOptions): this; /** * Sets the brightness of the image * @param value The lightness to apply (0..1) * @param absolute Whether to scale the current lightness (false) or just set the new lightness (true) */ - lightness(value: number, absolute = false): this { + lightness(value: number, absolute?: boolean): this; + lightness(value: number, absoluteOrOptions: boolean | AdjustOptions = false): this { + const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; if (isNaN(value) || value < 0) throw new RangeError('Invalid lightness value'); return this.__map_hsla__(c => { c.l = value * (absolute ? 1 : c.l); }); } + /** + * Sets the saturation of the image + * @param value The saturation to apply (0..1) + * @param options See {@link AdjustOptions} + */ + saturation(value: number, options?: AdjustOptions): this; /** * Sets the saturation of the image * @param value The saturation to apply (0..1) * @param absolute Whether to scale the current saturation (false) or just set the new saturation (true) */ - saturation(value: number, absolute = false): this { + saturation(value: number, absolute?: boolean): this; + saturation(value: number, absoluteOrOptions: boolean | AdjustOptions = false): this { + const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; if (isNaN(value) || value < 0) throw new RangeError('Invalid saturation value'); return this.__map_hsla__(c => { c.s = value * (absolute ? 1 : c.s); }); } + /** + * Composites (overlays) the source onto this image + * @param source The image to place + * @param options Position, opacity and blend mode, see {@link CompositeOptions} + */ + composite(source: Image, options?: CompositeOptions): this; /** * Composites (overlays) the source onto this image at the specified coordinates * @param source The image to place @@ -875,8 +1061,11 @@ export class Image { * @param [y=0] The y position to place the image at * @param options Opacity and blend mode, see {@link CompositeOptions} */ - composite(source: Image, x = 0, y = 0, options: CompositeOptions = {}): this { + composite(source: Image, x?: number, y?: number, options?: CompositeOptions): this; + composite(source: Image, xOrOptions: number | CompositeOptions = 0, yArgument = 0, optionsArgument: CompositeOptions = {}): this { + const options = isOptions(xOrOptions) ? xOrOptions : optionsArgument; const { opacity = 1, mode = 'normal' } = options; + let x = isOptions(xOrOptions) ? xOrOptions.x ?? 0 : xOrOptions, y = isOptions(xOrOptions) ? xOrOptions.y ?? 0 : yArgument; x |= 0; y |= 0; @@ -1084,12 +1273,19 @@ export class Image { return this; } + /** + * Sharpens the image with an unsharp mask: the difference to a blurred copy is amplified + * @param options See {@link SharpenOptions} + */ + sharpen(options?: SharpenOptions): this; /** * Sharpens the image with an unsharp mask: the difference to a blurred copy is amplified * @param [amount=1] How strongly to sharpen (0 does nothing) * @param [radius=1] The radius of the blur the image is compared with (larger values sharpen coarser details) */ - sharpen(amount = 1, radius = 1): this { + sharpen(amount?: number, radius?: number): this; + sharpen(amountOrOptions: number | SharpenOptions = 1, radiusArgument = 1): this { + const { amount = 1, radius = 1 } = isOptions(amountOrOptions) ? amountOrOptions : { amount: amountOrOptions, radius: radiusArgument }; if (isNaN(amount) || amount < 0) throw new RangeError('Invalid sharpen amount'); if (isNaN(radius) || radius <= 0) @@ -1464,11 +1660,18 @@ export class Image { }; } + /** + * Rounds the images corners, with antialiased edges + * @param options See {@link RoundCornersOptions} + */ + roundCorners(options?: RoundCornersOptions): this; /** * Rounds the images corners, with antialiased edges * @param [radius=min(width,height)/4] The radius of the corners */ - roundCorners(radius: number = Math.min(this.width, this.height) / 4): this { + roundCorners(radius?: number): this; + roundCorners(radiusOrOptions?: number | RoundCornersOptions): this { + let radius = (isOptions(radiusOrOptions) ? radiusOrOptions.radius : radiusOrOptions) ?? Math.min(this.width, this.height) / 4; radius = Math.max(0, Math.min(radius, this.width / 2, this.height / 2)); const size = Math.ceil(radius); @@ -1510,11 +1713,18 @@ export class Image { }; } + /** + * Applies a fisheye (barrel distortion) effect + * @param options See {@link FisheyeOptions} + */ + fisheye(options?: FisheyeOptions): this; /** * Applies a fisheye (barrel distortion) effect * @param [strength=2] The distortion strength (exponent of the radial mapping), higher values bulge the center more */ - fisheye(strength = 2): this { + fisheye(strength?: number): this; + fisheye(strengthOrOptions?: number | FisheyeOptions): this { + const strength = (isOptions(strengthOrOptions) ? strengthOrOptions.strength : strengthOrOptions) ?? 2; const r = new Image(this.width, this.height); const w = this.width; @@ -1549,6 +1759,12 @@ export class Image { /** * Encodes the image into a PNG, with the smallest lossless color type (a palette for up to 256 colors, * greyscale or RGB, alpha only if needed). Uses the runtime's native zlib when available + * @param options See {@link PNGEncodeOptions} + * @return The encoded data + */ + encode(options?: PNGEncodeOptions): Promise; + /** + * Encodes the image into a PNG * @param compression The compression level (0-9). Default: `1` * @param metadata Metadata to include, see {@link Metadata} * @return The encoded data @@ -1560,11 +1776,13 @@ export class Image { * @return The encoded data */ encode(metadata?: Metadata): Promise; - async encode(compression: number | Metadata = 1, metadata: Metadata = {}): Promise { - if (typeof compression === 'object' && compression !== null) { - metadata = compression; - compression = 1; - } + async encode(compressionOrOptions: number | PNGEncodeOptions | Metadata = 1, metadataArgument: Metadata = {}): Promise { + let compression = 1, metadata = metadataArgument; + if (!isOptions(compressionOrOptions)) compression = compressionOrOptions; + // the options and the metadata have no key in common: an object with neither is empty, the same either way + else if ('compression' in compressionOrOptions || 'metadata' in compressionOrOptions) + ({ compression = 1, metadata = {} } = compressionOrOptions as PNGEncodeOptions); + else metadata = compressionOrOptions as Metadata; const level = Math.min(9, Math.max(0, ~~compression)); return png.encodeRGBA(this.__bytes__(), this.width, this.height, level, pngText(resolveMetadata(metadata))); @@ -1573,35 +1791,42 @@ export class Image { /** * Encodes the image into a PNG, an alias of {@link Image.encode} */ + encodePNG(options?: PNGEncodeOptions): Promise; encodePNG(compression?: number, metadata?: Metadata): Promise; encodePNG(metadata?: Metadata): Promise; - encodePNG(compression?: number | Metadata, metadata?: Metadata): Promise { - return this.encode(compression as number, metadata); + encodePNG(compressionOrOptions?: number | PNGEncodeOptions | Metadata, metadata?: Metadata): Promise { + return this.encode(compressionOrOptions as number, metadata); } + /** + * Encodes the image into a JPEG + * @param options See {@link JPEGEncodeOptions} + */ + encodeJPEG(options?: JPEGEncodeOptions): Promise; /** * Encodes the image into a JPEG * @param [quality=90] The JPEG quality to use (1-100), chroma subsampling is disabled above 90 - * @param metadata Metadata to include, see {@link Metadata} */ - async encodeJPEG(quality = 90, metadata: Metadata = {}): Promise { + encodeJPEG(quality?: number): Promise; + async encodeJPEG(qualityOrOptions: number | JPEGEncodeOptions = 90): Promise { + const { quality = 90, metadata = {} } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions }; const m = resolveMetadata(metadata); return jpeg.encode(this.__bytes__(), this.width, this.height, quality, { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined }); } /** * Encodes the image as lossless WebP (usually 20-30% smaller than PNG, but slower to encode) - * @param metadata Metadata to include, see {@link Metadata} + * @param options See {@link EncodeOptions} */ - async encodeWebP(metadata: Metadata = {}): Promise { + async encodeWebP({ metadata = {} }: EncodeOptions = {}): Promise { return webp.encode(this.__bytes__(), this.width, this.height, exifData(resolveMetadata(metadata))); } /** * Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque) - * @param metadata Metadata to include, see {@link Metadata} + * @param options See {@link EncodeOptions} */ - async encodeTGA(metadata: Metadata = {}): Promise { + async encodeTGA({ metadata = {} }: EncodeOptions = {}): Promise { const { author, comment, creationTime: time, software } = resolveMetadata(metadata); return tga.encode(this.__bytes__(), this.width, this.height, { author, comment, time, software }); } @@ -1693,11 +1918,20 @@ export class Image { * Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, strokes, clip paths, masks and opacity; * text, images, filters and patterns are not rendered * @param svg The SVG content + * @param options See {@link SVGOptions} + * @return The rendered SVG graphic + */ + static renderSVG(svg: string | BinaryData, options?: SVGOptions): Promise; + /** + * Creates a new image from the given SVG + * @param svg The SVG content * @param size The size to use * @param mode The SVG resizing mode to use (one of {@link SVG_MODE_SCALE}, {@link SVG_MODE_WIDTH}, {@link SVG_MODE_HEIGHT}) * @return The rendered SVG graphic */ - static async renderSVG(svg: string | BinaryData, size = 1, mode: SVGScaleMode = Image.SVG_MODE_SCALE): Promise { + static renderSVG(svg: string | BinaryData, size?: number, mode?: SVGScaleMode): Promise; + static async renderSVG(svg: string | BinaryData, sizeOrOptions: number | SVGOptions = 1, modeArgument: SVGScaleMode = Image.SVG_MODE_SCALE): Promise { + const { size = 1, mode = Image.SVG_MODE_SCALE } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument }; if (![1, 2, 3].includes(mode)) throw new Error('Invalid SVG scaling mode'); @@ -1713,6 +1947,16 @@ export class Image { return image; } + /** + * Creates a new image containing the rendered text. + * @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use + * @param size Font size in pixels (per em) + * @param text Text to render + * @param options Color, layout and style, see {@link TextOptions}; the image grows by the outline width on every + * side + * @return The rendered text + */ + static renderText(font: BinaryData, size: number, text: string, options?: TextOptions): Promise; /** * Creates a new image containing the rendered text. * @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use @@ -1723,8 +1967,10 @@ export class Image { * @param style Outline, see {@link TextStyle}; the image grows by the outline width on every side * @return The rendered text */ - static async renderText(font: BinaryData, size: number, text: string, color: Color = 0xffffffff, layout: TextLayout = new TextLayout(), style: TextStyle = {}): Promise { - const { strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = style; + static renderText(font: BinaryData, size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise; + static async renderText(font: BinaryData, size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise { + const options: TextOptions = isOptions(colorOrOptions) ? colorOrOptions : { ...styleArgument, color: colorOrOptions, layout: layoutArgument }; + const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = options; if (isNaN(strokeWidth) || strokeWidth < 0) throw new RangeError('Invalid stroke width'); @@ -1819,6 +2065,11 @@ function hue2rgb(p: number, q: number, t: number): number { return p; } +// the resize mode of a positional argument or options object +function resizeMode(modeOrOptions?: ResizeMode | ResizeOptions): ResizeMode { + return (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR; +} + // separable blend functions of normalized channel values (W3C Compositing and Blending Level 1) const hardLight = (b: number, s: number): number => s <= 0.5 ? b * 2 * s : 1 - (1 - b) * (1 - (2 * s - 1)); diff --git a/src/index.ts b/src/index.ts index e9bdcf83..a9cfe4f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,19 @@ import { BinaryData } from './util/mem.ts'; -import { Image } from './image.ts'; +import { Image, DecodeOptions } from './image.ts'; import { GIF } from './gif.ts'; -import { Animation } from './animation.ts'; +import { Animation, AnimationDecodeOptions } from './animation.ts'; +import { isOptions } from './util/options.ts'; import * as webp from './codecs/webp.ts'; import { view } from './util/mem.ts'; import { ImageType } from './image-type.ts'; export { Image } from './image.ts'; -export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RoundCornersOptions, FisheyeOptions } from './image.ts'; export { Frame } from './frame.ts'; -export type { FrameDisposalModeName, FrameDisposalModeId } from './frame.ts'; +export type { FrameDisposalModeName, FrameDisposalModeId, FrameOptions } from './frame.ts'; export { GIF } from './gif.ts'; export { Animation } from './animation.ts'; +export type { AnimationOptions, AnimationDecodeOptions, AnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { TextLayout } from './text-layout.ts'; export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } from './text-layout.ts'; export { ImageType } from './image-type.ts'; @@ -19,6 +21,14 @@ export type { ImageTypeName } from './image-type.ts'; export type { BinaryData } from './util/mem.ts'; export type { Metadata } from './metadata.ts'; +/** + * Decodes any supported image: GIFs are returned as {@link GIF} (an {@link Animation}), animated WebPs as + * {@link Animation}, everything else (including APNGs, as their default image) as {@link Image}. + * {@link Animation.decode} decodes the frames of GIFs, APNGs and WebPs + * @param data The image data + * @param options See {@link AnimationDecodeOptions} (for animations) and {@link DecodeOptions} (for images) + */ +export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise; /** * Decodes any supported image: GIFs are returned as {@link GIF} (an {@link Animation}), animated WebPs as * {@link Animation}, everything else (including APNGs, as their default image) as {@link Image}. @@ -26,9 +36,11 @@ export type { Metadata } from './metadata.ts'; * @param data The image data * @param onlyExtractFirstFrame Whether to end animation decoding after the first frame */ -export function decode(data: BinaryData, onlyExtractFirstFrame = false): Promise { +export function decode(data: BinaryData, onlyExtractFirstFrame?: boolean): Promise; +export function decode(data: BinaryData, onlyFirstFrameOrOptions: boolean | (AnimationDecodeOptions & DecodeOptions) = false): Promise { + const { onlyFirstFrame = false, applyOrientation } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; const type = ImageType.getType(data); - if (type === 'gif') return GIF.decode(data, onlyExtractFirstFrame); - if (type === 'webp' && webp.isAnimated(view(data))) return Animation.decode(data, onlyExtractFirstFrame); - return Image.decode(data); + if (type === 'gif') return GIF.decode(data, { onlyFirstFrame }); + if (type === 'webp' && webp.isAnimated(view(data))) return Animation.decode(data, { onlyFirstFrame }); + return Image.decode(data, { applyOrientation }); } diff --git a/src/util/options.ts b/src/util/options.ts new file mode 100644 index 00000000..4e673591 --- /dev/null +++ b/src/util/options.ts @@ -0,0 +1,4 @@ +/** Whether an argument is an options object rather than a positional value */ +export function isOptions(value: T): value is Extract { + return typeof value === 'object' && value !== null && !ArrayBuffer.isView(value); +} diff --git a/tests/metadata.js b/tests/metadata.js index 6dbd063f..093dd664 100644 --- a/tests/metadata.js +++ b/tests/metadata.js @@ -16,12 +16,12 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat const encoders = { png: m => image.encode(m), - jpeg: m => image.encodeJPEG(90, m), - webp: m => image.encodeWebP(m), - tga: m => image.encodeTGA(m), - gif: m => animation.encodeGIF(95, m), - apng: m => animation.encodeAPNG(1, m), - 'animated webp': m => animation.encodeWebP(m) + jpeg: m => image.encodeJPEG({quality: 90, metadata: m}), + webp: m => image.encodeWebP({metadata: m}), + tga: m => image.encodeTGA({metadata: m}), + gif: m => animation.encodeGIF({metadata: m}), + apng: m => animation.encodeAPNG({metadata: m}), + 'animated webp': m => animation.encodeWebP({metadata: m}) }; // every format names the software by default, '' leaves it out, and the files still decode @@ -55,11 +55,11 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat } // GIF: comment extensions - if (!has(await animation.encodeGIF(95, metadata), Buffer.from([0x21, 0xfe, 9, ...Buffer.from('a comment'), 0]))) panic('gif: comment extension missing'); + if (!has(await animation.encodeGIF({metadata}), Buffer.from([0x21, 0xfe, 9, ...Buffer.from('a comment'), 0]))) panic('gif: comment extension missing'); // TGA: extension area, referenced by the footer { - const tga = Buffer.from(await image.encodeTGA({...metadata, comment: 'line 1\n' + 'x'.repeat(100)})); + const tga = Buffer.from(await image.encodeTGA({metadata: {...metadata, comment: 'line 1\n' + 'x'.repeat(100)}})); const area = tga.readUInt32LE(tga.length - 26); if (tga.readUInt16LE(area) !== 495) panic('tga: extension area missing'); const field = (offset, size) => tga.subarray(area + offset, area + offset + size).toString().split('\0')[0]; diff --git a/tests/options.js b/tests/options.js new file mode 100644 index 00000000..15f7fdaf --- /dev/null +++ b/tests/options.js @@ -0,0 +1,91 @@ +const fs = require('fs').promises; +const {Image, Frame, Animation, GIF, decode} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; + +const same = (name, a, b) => { + const x = a.bitmap ?? a, y = b.bitmap ?? b; + if (x.length !== y.length || x.some((v, i) => v !== y[i])) panic(`${name}: options object and positional arguments differ`); +}; + +// every method with optional arguments takes them as an options object too, with the same result +(async () => { + const photo = await Image.decode(await fs.readFile('./tests/targets/external.jpg')); + const tile = () => photo.clone().resize(64, 48); + const logo = new Image(16, 16).fill(0xff000080); + + same('scale', tile().scale(0.5, Image.RESIZE_NEAREST_NEIGHBOR), tile().scale(0.5, {mode: Image.RESIZE_NEAREST_NEIGHBOR})); + for (const method of ['resize', 'contain', 'fit', 'cover']) + same(method, tile()[method](30, 40, Image.RESIZE_BICUBIC), tile()[method](30, 40, {mode: Image.RESIZE_BICUBIC})); + // the default mode + same('resize default', tile().resize(30, 40), tile().resize(30, 40, {})); + + for (const method of ['opacity', 'red', 'green', 'blue', 'lightness', 'saturation']) { + same(method, tile()[method](0.5, true), tile()[method](0.5, {absolute: true})); + same(`${method} default`, tile()[method](0.5), tile()[method](0.5, {})); + } + + same('composite', tile().composite(logo, 5, 7, {opacity: 0.5, mode: 'multiply'}), tile().composite(logo, {x: 5, y: 7, opacity: 0.5, mode: 'multiply'})); + same('composite default', tile().composite(logo), tile().composite(logo, {})); + same('sharpen', tile().sharpen(2, 3), tile().sharpen({amount: 2, radius: 3})); + same('roundCorners', tile().roundCorners(9), tile().roundCorners({radius: 9})); + same('roundCorners default', tile().roundCorners(), tile().roundCorners({})); + same('fisheye', tile().fisheye(3), tile().fisheye({strength: 3})); + same('fisheye default', tile().fisheye(), tile().fisheye({})); + + // encoders: decoded, since the metadata has a creation time + const pixelsOf = async data => (await Image.decode(data)).bitmap; + same('encode', await pixelsOf(await tile().encode(9)), await pixelsOf(await tile().encode({compression: 9}))); + same('encodeJPEG', await pixelsOf(await tile().encodeJPEG(40)), await pixelsOf(await tile().encodeJPEG({quality: 40}))); + // metadata as the first argument (3.x), and in the options + const author = 'options test'; + for (const data of [await tile().encode({author}), await tile().encode({metadata: {author}}), await tile().encode(1, {author})]) + if (!Buffer.from(data).includes(author)) panic('encode: metadata missing'); + if (!Buffer.from(await tile().encodeJPEG({metadata: {author}})).includes(author)) panic('encodeJPEG: metadata missing'); + for (const method of ['encodeWebP', 'encodeTGA']) + if (!Buffer.from(await tile()[method]({metadata: {author}})).includes(author)) panic(`${method}: metadata missing`); + + const svg = await fs.readFile('./tests/svgs/crown.svg'); + same('renderSVG', await Image.renderSVG(svg, 40, Image.SVG_MODE_WIDTH), await Image.renderSVG(svg, {size: 40, mode: Image.SVG_MODE_WIDTH})); + + const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); + same('renderText', await Image.renderText(font, 24, 'Options', 0x3366ffff, undefined, {strokeWidth: 2, strokeColor: 0xffffffff}), + await Image.renderText(font, 24, 'Options', {color: 0x3366ffff, strokeWidth: 2, strokeColor: 0xffffffff})); + + // frames and animations + const a = new Frame(8, 6, 40, 2, 3, 'background'), b = new Frame(8, 6, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + const c = Frame.from(logo, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + for (const frame of [b, c]) + if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('Frame: options not applied'); + if (new Frame(1, 1, {}).duration !== 100 || new Frame(1, 1, {}).disposalMode !== 'keep') panic('Frame: wrong defaults'); + same('Frame', a, b); + + const frames = () => [Frame.from(tile(), {duration: 50}), Frame.from(tile().invert(), {duration: 50})]; + if (new Animation(frames(), {loopCount: 2}).loopCount !== 2 || new GIF(frames(), {loopCount: 2}).loopCount !== 2) panic('Animation: loopCount not applied'); + const animation = new Animation(frames()); + const sameFrames = async (name, x, y) => { + const fx = await Animation.decode(x), fy = await Animation.decode(y); + if (fx.length !== fy.length) panic(`${name}: options object and positional arguments differ`); + fx.forEach((f, i) => same(`${name} frame ${i}`, f, fy[i])); + }; + await sameFrames('encodeGIF', await animation.encodeGIF(10), await animation.encodeGIF({quality: 10})); + await sameFrames('encodeAPNG', await animation.encodeAPNG(9), await animation.encodeAPNG({compression: 9})); + // (GIFs only store comments) + for (const method of ['encodeGIF', 'encodeAPNG', 'encodeWebP']) + if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); + + same('Animation.resize', (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, Image.RESIZE_NEAREST_NEIGHBOR)[1], + (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, {mode: Image.RESIZE_NEAREST_NEIGHBOR})[1]); + + const gif = await animation.encodeGIF(); + for (const decoded of [await Animation.decode(gif, {onlyFirstFrame: true}), await GIF.decode(gif, {onlyFirstFrame: true}), await decode(gif, {onlyFirstFrame: true})]) + if (decoded.length !== 1) panic('decode: onlyFirstFrame not applied'); + if ((await decode(gif, {})).length !== 2) panic('decode: wrong default'); + + // decode() passes applyOrientation on to Image.decode() + const rotated = await fs.readFile('./tests/targets/external.jpg'); + same('decode', await decode(rotated, {applyOrientation: false}), await Image.decode(rotated, {applyOrientation: false})); +})();