UNPKG

imagescript

Version:

zero-dependency javascript image manipulation

1,506 lines (1,293 loc) 55.7 kB
const png = require('./png/node.js'); const mem = require('./utils/mem.js'); const {version} = require('./package.json'); const codecs = require('./codecs/node/index.js'); const { default: v2 } = require('./v2/framebuffer.js'); // old const svglib = require('./wasm/node/svg.js'); const giflib = require('./wasm/node/gif.js'); const pnglib = require('./wasm/node/png.js'); const fontlib = require('./wasm/node/font.js'); const jpeglib = require('./wasm/node/jpeg.js'); const tifflib = require('./wasm/node/tiff.js'); const MAGIC_NUMBERS = { PNG: 0x89504e47, JPEG: 0xffd8ff, TIFF: 0x49492a00, GIF: 0x474946 }; /** * Represents an image; provides utility functions */ class Image { /** * Creates a new image with the given dimensions * @param {number} width * @param {number} height * @returns {Image} */ constructor(width, height) { width = ~~width; height = ~~height; if (width < 1) throw new RangeError('Image has to be at least 1 pixel wide'); if (height < 1) throw new RangeError('Image has to be at least 1 pixel high'); /** @private */ this.__width__ = width; /** @private */ this.__height__ = height; /** @private */ this.__buffer__ = new ArrayBuffer(width * height * 4); /** @private */ this.__view__ = new DataView(this.__buffer__); /** @private */ this.__u32__ = new Uint32Array(this.__buffer__); /** * The images RGBA pixel data * @type {Uint8ClampedArray} */ this.bitmap = new Uint8ClampedArray(this.__buffer__); } /** * @private * @returns {string} */ toString() { return `Image<${this.width}x${this.height}>`; } /** * The images width * @returns {number} */ get width() { return this.__width__; } /** * The images height * @returns {number} */ get height() { return this.__height__; } /** * Yields an [x, y] array for every pixel in the image * @yields {number[]} The coordinates of the pixel ([x, y]) * @returns {void} */ *[Symbol.iterator]() { for (const x of new v2(this.width, this.height, this.bitmap)[Symbol.iterator]()) yield (x[0]++, x[1]++, x) } /** * Yields an [x, y, color] array for every pixel in the image * @yields {number[]} The coordinates and color of the pixel ([x, y, color]) */ *iterateWithColors() { for (const x of new v2(this.width, this.height, this.bitmap).pixels('int')) yield (x[0]++, x[1]++, x); } /** * Converts RGBA components to an RGBA value * @param {number} r red (0..255) * @param {number} g green (0..255) * @param {number} b blue (0..255) * @param {number} a alpha (0..255) * @returns {number} RGBA value */ static rgbaToColor(r, g, b, a) { return (((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | (a & 0xff)) >>> 0; } /** * Converts RGB components to an RGBA value (assuming alpha = 255) * @param {number} r red (0..255) * @param {number} g green (0..255) * @param {number} b blue (0..255) * @returns {number} RGBA value */ static rgbToColor(r, g, b) { return Image.rgbaToColor(r, g, b, 0xff); } /** * Converts HSLA colors to RGBA colors * @param {number} h hue (0..1) * @param {number} s saturation (0..1) * @param {number} l lightness (0..1) * @param {number} a opacity (0..1) * @returns {number} color */ static hslaToColor(h, s, l, a) { h %= 1; s = Math.min(1, Math.max(0, s)); l = Math.min(1, Math.max(0, l)); a = Math.min(1, Math.max(0, a)); let r, g, b; if (s === 0) { r = g = b = l; } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } return Image.rgbaToColor(r * 255, g * 255, b * 255, a * 255); } /** * Converts HSL colors to RGBA colors (assuming an opacity of 255) * @param {number} h hue (0..1) * @param {number} s saturation (0..1) * @param {number} l lightness (0..1) * @returns {number} color */ static hslToColor(h, s, l) { return Image.hslaToColor(h, s, l, 1); } /** * Converts an RGBA value to an array of HSLA values * @param r {number} (0..255) * @param g {number} (0..255) * @param b {number} (0..255) * @param a {number} (0..255) * @returns {number[]} The HSLA values ([H, S, L, A]) */ static rgbaToHSLA(r, g, b, a) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [h, s, l, a / 255]; } /** * Converts a color value to an array of RGBA values * @param {number} color The color value to convert * @returns {number[]} The RGBA values ([R, G, B, A]) */ static colorToRGBA(color) { return [(color >> 24) & 0xff, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]; } /** * Converts a color value to an array of RGB values (ignoring the colors alpha) * @param {number} color The color value to convert * @returns {number[]} The RGB values ([R, G, B]) */ static colorToRGB(color) { return Image.colorToRGBA(color).slice(0, 3); } /** * Gets the pixel color at the specified position * @param {number} x * @param {number} y * @returns {number} The color value */ getPixelAt(x, y) { this.__check_boundaries__(x, y); return this.__view__.getUint32(((~~y - 1) * this.width + (~~x - 1)) * 4, false); } /** * Gets the pixel color at the specified position * @param {number} x * @param {number} y * @returns {Uint8ClampedArray} The RGBA value */ getRGBAAt(x, y) { this.__check_boundaries__(x, y); const idx = ((~~y - 1) * this.width + (~~x - 1)) * 4; return this.bitmap.subarray(idx, idx + 4); } /** * Sets the pixel color for the specified position * @param {number} x * @param {number} y * @param {number} pixelColor */ setPixelAt(x, y, pixelColor) { x = ~~x; y = ~~y; this.__check_boundaries__(x, y); this.__set_pixel__(x, y, pixelColor); return this; } /** * @private * @param {number} x * @param {number} y * @param {number} pixelColor */ __set_pixel__(x, y, pixelColor) { this.__view__.setUint32(((y - 1) * this.width + (x - 1)) * 4, pixelColor, false); } /** * @private * @param {number} x * @param {number} y */ __check_boundaries__(x, y) { if (isNaN(x)) throw new TypeError(`Invalid pixel coordinates (x=${x})`); if (isNaN(y)) throw new TypeError(`Invalid pixel coordinates (y=${y})`); if (x < 1) throw new RangeError(`${Image.__out_of_bounds__} (x=${x})<1`); if (x > this.width) throw new RangeError(`${Image.__out_of_bounds__} (x=${x})>(width=${this.width})`); if (y < 1) throw new RangeError(`${Image.__out_of_bounds__} (y=${y})<1`); if (y > this.height) throw new RangeError(`${Image.__out_of_bounds__} (y=${y})>(height=${this.height})`); } /** * @private */ static get __out_of_bounds__() { return 'Tried referencing a pixel outside of the images boundaries:'; } /** * @callback colorFunction * @param {number} x * @param {number} y * @returns {number} pixel color */ /** * Fills the image data with the supplied color * @param {number|colorFunction} color * @returns {Image} */ fill(color) { new v2(this.width, this.height, this.bitmap).fill(color); return this; } /** * Clones the current image * @returns {Image} */ clone() { const image = new Image(this.width, this.height); image.bitmap.set(this.bitmap); return image; } /** * Use {@link https://en.wikipedia.org/wiki/Image_scaling#Nearest-neighbor_interpolation Nearest-neighbor} resizing. * @returns {string} */ static get RESIZE_NEAREST_NEIGHBOR() { return 'RESIZE_NEAREST_NEIGHBOR'; } /** * Used for automatically preserving an images aspect ratio when resizing. * @returns {number} */ static get RESIZE_AUTO() { return -1; } /** * Resizes the image by the given factor * @param {number} factor The factor to resize the image with * @param {string} [mode=Image.RESIZE_NEAREST_NEIGHBOR] The resizing mode to use * @returns {Image} */ scale(factor, mode = Image.RESIZE_NEAREST_NEIGHBOR) { const image = this.__scale__(factor, mode); return this.__apply__(image); } /** @private */ __scale__(factor, mode = Image.RESIZE_NEAREST_NEIGHBOR) { if (factor === 1) return this; 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 {number} width The new width * @param {number} height The new height * @param {string} [mode=Image.RESIZE_NEAREST_NEIGHBOR] The resizing mode to use * @returns {Image} The resized image */ resize(width, height, mode = Image.RESIZE_NEAREST_NEIGHBOR) { 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 {number} width The width of the bounding box * @param {number} height The height of the bounding box * @param {string} [mode=Image.RESIZE_NEAREST_NEIGHBOR] The resizing mode to use * @returns {Image} The resized image */ contain(width, height, mode = Image.RESIZE_NEAREST_NEIGHBOR) { const scaleFactor = width / height > this.width / this.height ? height / this.height : width / this.width; return this.scale(scaleFactor, 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 {number} width The width of the bounding box * @param {number} height The height of the bounding box * @param {string} [mode=Image.RESIZE_NEAREST_NEIGHBOR] The resizing mode to use * @returns {Image} The resized image */ fit(width, height, mode = Image.RESIZE_NEAREST_NEIGHBOR) { 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 {number} width The width of the bounding box * @param {number} height The height of the bounding box * @param {string} [mode=Image.RESIZE_NEAREST_NEIGHBOR] The resizing mode to use * @returns {Image} The resized image */ cover(width, height, mode = Image.RESIZE_NEAREST_NEIGHBOR) { const scaleFactor = width / height > this.width / this.height ? width / this.width : height / this.height; const result = this.scale(scaleFactor, mode); return result.crop((result.width - width) / 2, (result.height - height) / 2, width, height); } /** @private */ __resize__(width, height, mode = Image.RESIZE_NEAREST_NEIGHBOR) { 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'); else if (width === Image.RESIZE_AUTO) width = this.width / this.height * height; else if (height === Image.RESIZE_AUTO) height = this.height / this.width * width; width = Math.floor(width); height = Math.floor(height); if (width < 1) throw new RangeError('Image has to be at least 1 pixel wide'); if (height < 1) throw new RangeError('Image has to be at least 1 pixel high'); let image; if (mode === Image.RESIZE_NEAREST_NEIGHBOR) image = this.__resize_nearest_neighbor__(width, height); else throw new Error('Invalid resize mode'); return image; } /** * @private * @param {number} width The new width * @param {number} height The new height */ __resize_nearest_neighbor__(width, height) { const image = new this.constructor(width, height); const frame = new v2(this.width, this.height, this.bitmap).resize('nearest', width, height); image.bitmap.set(frame.u8); return image; } /** * Crops an image to the specified dimensions * @param {number} x The x offset * @param {number} y The y offset * @param {number} width The new images width * @param {number} height The new images height * @returns {Image} */ crop(x, y, width, height) { if (width > this.width) width = this.width; if (height > this.height) height = this.height; return this.__apply__(this.__crop__(~~x, ~~y, ~~width, ~~height)); } /** * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @returns {Image} * @private */ __crop__(x, y, width, height) { x = ~~x; y = ~~y; const image = new this.constructor(width, height); for (let tY = 0; tY < height; tY++) { const idx = (tY + y) * this.width + x; image.__u32__.set(this.__u32__.subarray(idx, idx + width), tY * width); } return image; } /** * Draws a box at the specified coordinates * @param {number} x The x offset * @param {number} y The y offset * @param {number} width The box width * @param {number} height The box height * @param {number|colorFunction} color The color to fill the box in with * @returns {Image} */ drawBox(x, y, width, height, color) { x = ~~(x - 1); y = ~~(y - 1); width = ~~width; height = ~~height; if (typeof color === 'function') { for (let tY = 1; tY <= height; tY++) { for (let tX = 1; tX <= width; tX++) { const nX = tX + x; const nY = tY + y; if (Math.min(nX, nY) < 1 || nX > this.width || nY > this.height) continue; const tC = color(tX, tY); this.__set_pixel__(nX, nY, tC); } } } else return this.__fast_box__(x, y, width, height, color); return this; } /** * @private * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @param {number} color */ __fast_box__(x, y, width, height, color) { if (x < 0) { width += x; x = 0; } if (y < 0) { height += y; y = 0; } const right = Math.max(Math.min(x + width, this.width), 1); let xPos = right; while (x <= --xPos) this.__view__.setUint32(4 * (xPos + y * this.width), color); const end = 4 * (right + y * this.width); const start = 4 * (x + y * this.width); let bottom = Math.max(Math.min(y + height, this.height), 1); while (y < --bottom) this.bitmap.copyWithin(4 * (x + bottom * this.width), start, end); return this; } /** * Draws a circle at the specified coordinates with the specified radius * @param {number} x The center x position * @param {number} y The center y position * @param {number} radius The circles radius * @param {number|colorFunction} color * @returns {Image} */ drawCircle(x, y, radius, color) { const radSquared = radius ** 2; for (let currentY = Math.max(1, y - radius); currentY <= Math.min(y + radius, this.height); currentY++) { for (let currentX = Math.max(1, x - radius); currentX <= Math.min(x + radius, this.width); currentX++) { if ((currentX - x) ** 2 + (currentY - y) ** 2 < radSquared) this.__set_pixel__(currentX, currentY, typeof color === 'function' ? color(currentX - x + radius, currentY - y + radius) : color); } } return this; } /** * Crops the image into a circle * @param {boolean} [max=false] Whether to use the larger dimension for the size * @param {number} [feathering=0] How much feathering to apply to the edges * @returns {Image} */ cropCircle(max = false, feathering = 0) { new v2(this.width, this.height, this.bitmap).crop('circle', feathering); return this; } /** * Sets the images opacity * @param {number} opacity The opacity to apply (0..1) * @param {boolean} absolute Whether to scale the current opacity (false) or just set the new opacity (true) * @returns {Image} */ opacity(opacity, absolute = false) { if (isNaN(opacity) || opacity < 0) throw new RangeError('Invalid opacity value'); this.__set_channel_value__(opacity, absolute, 3); return this; } /** * Sets the red channels saturation * @param {number} saturation The saturation to apply (0..1) * @param {boolean} absolute Whether to scale the current saturation (false) or just set the new saturation (true) * @returns {Image} */ red(saturation, absolute = false) { if (isNaN(saturation) || saturation < 0) throw new RangeError('Invalid saturation value'); this.__set_channel_value__(saturation, absolute, 0); return this; } /** * Sets the green channels saturation * @param {number} saturation The saturation to apply (0..1) * @param {boolean} absolute Whether to scale the current saturation (false) or just set the new saturation (true) * @returns {Image} */ green(saturation, absolute = false) { if (isNaN(saturation) || saturation < 0) throw new RangeError('Invalid saturation value'); this.__set_channel_value__(saturation, absolute, 1); return this; } /** * Sets the blue channels saturation * @param {number} saturation The saturation to apply (0..1) * @param {boolean} absolute Whether to scale the current saturation (false) or just set the new saturation (true) * @returns {Image} */ blue(saturation, absolute = false) { if (isNaN(saturation) || saturation < 0) throw new RangeError('Invalid saturation value'); this.__set_channel_value__(saturation, absolute, 2); return this; } /** * @private * @param {number} value * @param {boolean} absolute * @param {number} offset */ __set_channel_value__(value, absolute, offset) { for (let i = offset; i < this.bitmap.length; i += 4) this.bitmap[i] = value * (absolute ? 255 : this.bitmap[i]); } /** * Sets the brightness of the image * @param {number} value The lightness to apply (0..1) * @param {boolean} absolute Whether to scale the current lightness (false) or just set the new lightness (true) * @returns {Image} */ lightness(value, absolute = false) { if (isNaN(value) || value < 0) throw new RangeError('Invalid lightness value'); return this.fill((x, y) => { const [h, s, l, a] = Image.rgbaToHSLA(...this.getRGBAAt(x, y)); return Image.hslaToColor(h, s, value * (absolute ? 1 : l), a); }); } /** * Sets the saturation of the image * @param {number} value The saturation to apply (0..1) * @param {boolean} absolute Whether to scale the current saturation (false) or just set the new saturation (true) * @returns {Image} */ saturation(value, absolute = false) { if (isNaN(value) || value < 0) throw new RangeError('Invalid saturation value'); return this.fill((x, y) => { const [h, s, l, a] = Image.rgbaToHSLA(...this.getRGBAAt(x, y)); return Image.hslaToColor(h, value * (absolute ? 1 : s), l, a); }); } /** * Composites (overlays) the source onto this image at the specified coordinates * @param {Image} source The image to place * @param {number} [x=0] The x position to place the image at * @param {number} [y=0] The y position to place the image at * @returns {Image} */ composite(source, x = 0, y = 0) { new v2(this.width, this.height, this.bitmap).overlay(new v2(source.width, source.height, source.bitmap), x, y); return this; } /** * Inverts the images colors * @returns {Image} */ invert() { for (const [x, y, color] of this.iterateWithColors()) this.__set_pixel__(x, y, ((0xffffffff - color) & 0xffffff00) | (color & 0xff)); return this; } /** * Inverts the images value (lightness) * @returns {Image} */ invertValue() { for (const [x, y, color] of this.iterateWithColors()) { const [h, s, l, a] = Image.rgbaToHSLA(...Image.colorToRGBA(color)); this.__set_pixel__(x, y, Image.hslaToColor(h, s, 1 - l, a)); } return this; } /** * Inverts the images saturation * @returns {Image} */ invertSaturation() { for (const [x, y, color] of this.iterateWithColors()) { const [h, s, l, a] = Image.rgbaToHSLA(...Image.colorToRGBA(color)); this.__set_pixel__(x, y, Image.hslaToColor(h, 1 - s, l, a)); } return this; } /** * Inverts the images hue * @returns {Image} */ invertHue() { for (const [x, y, color] of this.iterateWithColors()) { const [h, s, l, a] = Image.rgbaToHSLA(...Image.colorToRGBA(color)); this.__set_pixel__(x, y, Image.hslaToColor(1 - h, s, l, a)); } return this; } /** * Shifts the images hue * @param {number} degrees How many degrees to shift the hue by */ hueShift(degrees) { for (const [x, y, color] of this.iterateWithColors()) { const [h, s, l, a] = Image.rgbaToHSLA(...Image.colorToRGBA(color)); this.__set_pixel__(x, y, Image.hslaToColor(h + degrees / 360, s, l, a)); } return this; } /** * Gets the average color of the image * @returns {number} */ averageColor() { let colorAvg = [0, 0, 0]; let divisor = 0; for (let idx = 0; idx < this.bitmap.length; idx += 4) { const rgba = this.bitmap.subarray(idx, idx + 4); for (let i = 0; i < 3; i++) colorAvg[i] += rgba[i]; divisor += rgba[3] / 255; } return Image.rgbaToColor(...colorAvg.map(v => v / divisor), 0xff); } /** * Gets the images dominant color * @param {boolean} [ignoreBlack=true] Whether to ignore dark colors below the threshold * @param {boolean} [ignoreWhite=true] Whether to ignore light colors above the threshold * @param {number} [bwThreshold=0xf] The black/white threshold (0-64) * @return {number} The images dominant color */ dominantColor(ignoreBlack = true, ignoreWhite = true, bwThreshold = 0xf) { const colorCounts = new Array(0x3ffff); for (let i = 0; i < this.bitmap.length; i += 4) { const color = this.__view__.getUint32(i, false); const [h, s, l] = Image.rgbaToHSLA(...Image.colorToRGBA(color)).map(v => (~~(v * 0x3f))); if (ignoreBlack && l < bwThreshold) continue; if (ignoreWhite && l > 0x3f - bwThreshold) continue; const key = h << 12 | s << 6 | l; colorCounts[key] = (colorCounts[key] || 0) + 1; } let maxColorCount = -1; let mostProminentValue = 0; colorCounts.forEach((el, i) => { if (el < maxColorCount) return; maxColorCount = el; mostProminentValue = i; }); if (mostProminentValue === -1) return this.dominantColor(ignoreBlack, ignoreWhite, bwThreshold - 1); const h = (mostProminentValue >>> 12) & 0x3f; const s = (mostProminentValue >>> 6) & 0x3f; const l = mostProminentValue & 0x3f; return Image.hslaToColor(h / 0x3f, s / 0x3f, l / 0x3f, 1); } /** * Rotates the image the given amount of degrees * @param {number} angle The angle to rotate the image for (in degrees) * @param {boolean} resize Whether to resize the image so it fits all pixels or just ignore outlying pixels */ rotate(angle, resize = true) { const frame = new v2(this.width, this.height, this.bitmap).rotate(360 - (angle % 360), resize); const out = new Image(frame.width, frame.height); out.bitmap.set(frame.u8); return this.__apply__(out); } /** * Flips / mirrors the image horizontally or vertically * @param {'horizontal' | 'vertical'} direction The direction to flip */ flip(direction) { const frame = new v2(this.width, this.height, this.bitmap).flip(direction); this.bitmap.set(frame.u8); return this; } /** * @private * @param {Image|Frame} image * @returns {Image|Frame} */ __apply__(image) { this.__width__ = image.__width__; this.__height__ = image.__height__; this.__view__ = image.__view__; this.__u32__ = image.__u32__; this.bitmap = image.bitmap; if (image instanceof Frame) return Frame.from(this, image.duration, image.xOffset, image.yOffset, image.disposalMode); return this; } /** * Creates a multi-point gradient generator * @param {Object<number, number>} colors The gradient points to use (e.g. `{0: 0xff0000ff, 1: 0x00ff00ff}`) * @return {(function(number): number)} The gradient generator. The function argument is the position in the gradient (0..1). */ static gradient(colors) { const entries = Object.entries(colors).sort((a, b) => a[0] - b[0]); const positions = entries.map(e => parseFloat(e[0])); const values = entries.map(e => e[1]); if (positions.length === 0) throw new RangeError('Invalid gradient point count'); else if (positions.length === 1) { return () => values[0]; } else if (positions.length === 2) { const gradient = this.__gradient__(values[0], values[1]); return position => { if (position <= positions[0]) return values[0]; if (position >= positions[1]) return values[1]; return gradient((position - positions[0]) / (positions[1] - positions[0])); }; } const minDef = Math.min(...positions); const maxDef = Math.max(...positions); let gradients = []; for (let i = 0; i < positions.length; i++) { let minPos = positions[i - 1]; if (minPos === undefined) continue; let maxPos = positions[i]; let minVal = values[i - 1]; if (minVal === undefined) minVal = values[i]; const maxVal = values[i]; const gradient = this.__gradient__(minVal, maxVal); gradients.push({min: minPos, max: maxPos, gradient}); } return position => { if (position <= minDef) return gradients[0].gradient(0); if (position >= maxDef) return gradients[gradients.length - 1].gradient(1); for (const gradient of gradients) if (position >= gradient.min && position <= gradient.max) return gradient.gradient((position - gradient.min) / (gradient.max - gradient.min)); throw new RangeError(`Invalid gradient position: ${position}`); }; } /** * Rounds the images corners * @param {number} [radius=min(width,height)/4] The radius of the corners * @return {Image} */ roundCorners(radius = Math.min(this.width, this.height) / 4) { const radSquared = radius ** 2; for (let x = 1; x <= radius; x++) { const xRad = (x - radius) ** 2; for (let y = 1; y <= radius; y++) { if (xRad + (y - radius) ** 2 > radSquared) this.bitmap[((y - 1) * this.width + x - 1) * 4 + 3] = 0; } } for (let x = 1; x <= radius; x++) { const xRad = (x - radius) ** 2; for (let y = this.height - radius; y <= this.height; y++) { if (xRad + ((this.height - y) - radius) ** 2 > radSquared) this.bitmap[((y - 1) * this.width + x - 1) * 4 + 3] = 0; } } for (let x = this.width - radius; x <= this.width; x++) { const xRad = ((this.width - x) - radius) ** 2; for (let y = 1; y <= radius; y++) { if (xRad + (y - radius) ** 2 > radSquared) this.bitmap[((y - 1) * this.width + x - 1) * 4 + 3] = 0; } } for (let x = this.width - radius; x <= this.width; x++) { const xRad = ((this.width - x) - radius) ** 2; for (let y = this.height - radius; y <= this.height; y++) { if (xRad + ((this.height - y) - radius) ** 2 > radSquared) this.bitmap[((y - 1) * this.width + x - 1) * 4 + 3] = 0; } } return this; } /** * @private */ static __gradient__(startColor, endColor) { const sr = startColor >>> 24; const sg = startColor >> 16 & 0xff; const sb = startColor >> 8 & 0xff; const sa = startColor & 0xff; const er = (endColor >>> 24) - sr; const eg = (endColor >> 16 & 0xff) - sg; const eb = (endColor >> 8 & 0xff) - sb; const ea = (endColor & 0xff) - sa; return position => { const r = sr + position * er; const g = sg + position * eg; const b = sb + position * eb; const a = sa + position * ea; return (((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | (a & 0xff)); }; } fisheye(radius = 2) { const r = new Image(this.width, this.height); const w = this.width; const h = this.height; const tu32 = this.__u32__; const ru32 = r.__u32__; const iw = 1 / w; const ih = 1 / h; for (const [x, y] of this) { const xco = x * iw - .5; const yco = y * ih - .5; const dfc = Math.sqrt(xco ** 2 + yco ** 2); const dis = 2 * dfc ** radius; const nx = ((dis * xco / dfc + 0.5) * w) | 0; const ny = ((dis * yco / dfc + 0.5) * h) | 0; if (nx < 1 || nx > w || ny < 1 || ny > h || isNaN(nx) || isNaN(ny)) continue; ru32[y * w + x] = tu32[w * ny + nx]; } const cO = tu32.length * .5 + w / 2; ru32[cO] = tu32[cO]; return this.__apply__(r); } /** * @typedef {object} PNGMetadata * @property {string} [title] The images title * @property {string} [author] The images author * @property {string} [description] The images description * @property {string} [copyright] The images copyright info * @property {string|number|Date} [creationTime=Date.now()] The images creation timestamp * @property {string} [software="github.com/matmen/ImageScript vX.X.X"] The software used to create this image * @property {string} [disclaimer] A disclaimer for the image * @property {string} [warning] A warning for the image * @property {string} [source] The images source * @property {string} [comment] A comment for the image */ /** * Encodes the image into a PNG * @param {number} compression The compression level to use (0-9) * @param {PNGMetadata} [meta={}] Image metadata * @return {Promise<Uint8Array>} The encoded data */ async encode(compression = 1, { title, author, description, copyright, creationTime, software, disclaimer, warning, source, comment } = {}) { return png.encode(this.bitmap, { width: this.width, height: this.height, level: compression, channels: 4, text: { Title: title, Author: author, Description: description, Copyright: copyright, 'Creation Time': new Date(creationTime === undefined ? Date.now() : creationTime).toUTCString(), Software: software === undefined ? `github.com/matmen/ImageScript v${version}` : software, Disclaimer: disclaimer, Warning: warning, Source: source, Comment: comment } }); } /** * Encodes the image into a JPEG * @param {number} [quality=90] The JPEG quality to use (1-100) * @return {Promise<Uint8Array>} */ async encodeJPEG(quality = 90) { return codecs.jpeg.encode_async(this.bitmap, { quality, width: this.width, height: this.height, }); } /** * Encodes the image into a WEBP * @param {null|number} [quality=null] The WEBP quality to use (0-100) (null is lossless) * @return {Promise<Uint8Array>} */ async encodeWEBP(quality = null) { return codecs.webp.encode_async(this.bitmap, { quality, width: this.width, height: this.height, }); } /** * Decodes an image (PNG, JPEG or TIFF) * @param {Buffer|Uint8Array} data The binary data to decode * @return {Promise<Image>} The decoded image */ static async decode(data) { let image; data = mem.view(data); const view = new DataView(data.buffer, data.byteOffset, data.byteLength); if (ImageType.isPNG(view)) { // PNG const {width, height, framebuffer} = (await pnglib.init()).decode(data); image = new Image(width, height); image.bitmap.set(framebuffer); } else if (ImageType.isJPEG(view)) { // JPEG const framebuffer = (await jpeglib.init()).decode(data); const width = framebuffer.width; const height = framebuffer.height; const pixelType = framebuffer.format; image = new Image(width, height); const buffer = framebuffer.buffer; if (pixelType === 0) { const view = new DataView(image.bitmap.buffer); for (let i = 0; i < buffer.length; i++) { const pixel = buffer[i]; view.setUint32(i * 4, pixel << 24 | pixel << 16 | pixel << 8 | 0xff, false); } } else if (pixelType === 1) { image.bitmap.fill(0xff); for (let i = 0; i < width * height; i++) image.bitmap.set(buffer.subarray(i * 3, i * 3 + 3), i * 4); } else if (pixelType === 2) { for (let i = 0; i < buffer.length; i += 4) { image.bitmap[i] = 0xff * (1 - buffer[i] / 0xff) * (1 - buffer[i + 3] / 0xff); image.bitmap[i + 1] = 0xff * (1 - buffer[i + 1] / 0xff) * (1 - buffer[i + 3] / 0xff); image.bitmap[i + 2] = 0xff * (1 - buffer[i + 2] / 0xff) * (1 - buffer[i + 3] / 0xff); image.bitmap[i + 3] = 0xff; } } } else if (ImageType.isTIFF(view)) { // TIFF const framebuffer = (await tifflib.init()).decode(data); image = new Image(framebuffer.width, framebuffer.height); image.bitmap.set(framebuffer.buffer); } else throw new Error('Unsupported image type'); return image; } /** * Scale the SVG by the given amount. For use with {@link Image.renderSVG} * @return {number} */ static get SVG_MODE_SCALE() { return 1; } /** * Scale the SVG to fit the given width. For use with {@link Image.renderSVG} * @return {number} */ static get SVG_MODE_WIDTH() { return 2; } /** * Scale the SVG to fit the given height. For use with {@link Image.renderSVG} * @return {number} */ static get SVG_MODE_HEIGHT() { return 3; } /** * Creates a new image from the given SVG * @param {string} svg The SVG content * @param {number} size The size to use * @param {number} mode The SVG resizing mode to use (one of {@link SVG_MODE_SCALE}, {@link SVG_MODE_WIDTH}, {@link SVG_MODE_HEIGHT}) * @return {Promise<Image>} The rendered SVG graphic */ static async renderSVG(svg, size = 1, mode = this.SVG_MODE_SCALE) { if (![this.SVG_MODE_WIDTH, this.SVG_MODE_HEIGHT, this.SVG_MODE_SCALE].includes(mode)) throw new Error('Invalid SVG scaling mode'); if (mode === this.SVG_MODE_SCALE && size <= 0) throw new RangeError('SVG scale must be > 0'); if (mode !== this.SVG_MODE_SCALE && size < 1) throw new RangeError('SVG size must be >= 1') if (typeof svg === 'string') svg = Buffer.from(svg); const framebuffer = (await svglib.init()).rasterize(svg, mode, size); const image = new Image(framebuffer.width, framebuffer.height); image.bitmap.set(framebuffer.buffer); return image; } /** * Creates a new image containing the rendered text. * @param {Uint8Array} font TrueType (ttf/ttc) or OpenType (otf) font buffer to use * @param {number} scale Font size to use * @param {string} text Text to render * @param {number} [color=0xffffffff] Text color to use * @param {TextLayout} [layout] The text layout to use * @return {Promise<Image>} The rendered text */ static async renderText(font, scale, text, color = 0xffffffff, layout = new TextLayout()) { const { Font, Layout } = await fontlib.init(); font = new Font(scale, font); const [r, g, b, a] = Image.colorToRGBA(color); const layoutOptions = new Layout(); layoutOptions.reset({ max_width: layout.maxWidth, max_height: layout.maxHeight, wrap_style: layout.wrapStyle, vertical_align: layout.verticalAlign, horizontal_align: layout.horizontalAlign, wrap_hard_breaks: layout.wrapHardBreaks }); layoutOptions.append(font, text, {scale}); const framebuffer = layoutOptions.rasterize(r, g, b); const image = new Image(framebuffer.width, framebuffer.height); image.bitmap.set(framebuffer.buffer); if (image.height > layout.maxHeight) image.crop(0, 0, image.width, Math.floor(layoutOptions.lines() / image.height * layout.maxHeight) * (image.height / layoutOptions.lines())); font.free(); layoutOptions.free(); return image.opacity(a / 0xff); } } /** * Represents a frame in a GIF * @extends Image */ class Frame extends Image { /** * GIF frame disposal mode KEEP. For use with {@link Frame} * @returns {string} */ static get DISPOSAL_KEEP() { return 'keep'; } /** * GIF frame disposal mode PREVIOUS. For use with {@link Frame} * @returns {string} */ static get DISPOSAL_PREVIOUS() { return 'previous'; } /** * GIF frame disposal mode BACKGROUND. For use with {@link Frame} * @returns {string} */ static get DISPOSAL_BACKGROUND() { return 'background'; } static __convert_disposal_mode__(mode) { if (typeof mode === 'string') mode = ['any', 'keep', 'previous', 'background'].indexOf(mode); if (mode < 0 || mode > 3) throw new RangeError('Invalid disposal mode'); return mode; } /** * Creates a new, blank frame * @param {number} width * @param {number} height * @param {number} [duration = 100] The frames duration (in ms) * @param {number} [xOffset=0] The frames offset on the x-axis * @param {number} [yOffset=0] The frames offset on the y-axis * @param {string|number} [disposalMode=Frame.DISPOSAL_KEEP] The frame's disposal mode ({@link Frame.DISPOSAL_KEEP}, {@link Frame.DISPOSAL_PREVIOUS} or {@link Frame.DISPOSAL_BACKGROUND}) * @return {Frame} */ constructor(width, height, duration = 100, xOffset = 0, yOffset = 0, disposalMode = Frame.DISPOSAL_KEEP) { 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; } /** * The Frame's disposal mode * @returns {number} */ get disposalMode() { return this.__disposalMode__; } /** * Sets the frame's disposal mode, converting it to the internal numeric value. * @param {string|number} disposalMode The frame's disposal mode */ set disposalMode(disposalMode) { this.__disposalMode__ = Frame.__convert_disposal_mode__(disposalMode); } toString() { 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} image The image to create the frame from * @param {number} [duration = 100] The frames duration (in ms) * @param {number} [xOffset=0] The frames offset on the x-axis * @param {number} [yOffset=0] The frames offset on the y-axis * @param {string|number} [disposalMode=Frame.DISPOSAL_KEEP] The frames disposal mode ({@link Frame.DISPOSAL_KEEP}, {@link Frame.DISPOSAL_PREVIOUS} or {@link Frame.DISPOSAL_BACKGROUND}) * @return {Frame} */ static from(image, duration, xOffset, yOffset, disposalMode = Frame.DISPOSAL_KEEP) { if (!(image instanceof Image)) throw new TypeError('Invalid image passed'); const frame = new Frame(image.width, image.height, duration, xOffset, yOffset, disposalMode); frame.bitmap.set(image.bitmap); return frame; } resize(width, height, mode = Image.RESIZE_NEAREST_NEIGHBOR) { const originalWidth = this.width; const originalHeight = this.height; const result = super.resize(width, height, mode); this.xOffset *= result.width / originalWidth; this.yOffset *= result.height / originalHeight; return result; } } /** * Represents a GIF image as an array of frames * @extends Array<Frame> */ class GIF extends Array { /** * Creates a new GIF image. * @param {Frame[]} frames The frames to create the GIF from * @param {number} [loopCount=-1] How often to loop the GIF for (-1 = unlimited) * @property {number} loopCount How often the GIF will loop for */ constructor(frames, loopCount = -1) { super(...frames); for (const frame of this) if (!(frame instanceof Frame)) throw new TypeError(`Frame ${this.indexOf(frame)} is not an instance of Frame`); if (loopCount < -1 || isNaN(loopCount)) throw new RangeError('Invalid loop count'); this.loopCount = loopCount; } /** * The GIFs width * @returns {number} */ get width() { let max = 0; for (const frame of this) { let width = frame.width + frame.xOffset; if (max < width) max = width; } return max; } /** * The GIFs height * @returns {number} */ get height() { let max = 0; for (const frame of this) { let height = frame.height + frame.yOffset; if (max < height) max = height; } return max; } toString() { return `GIF<${this.width}x${this.height}x${this.duration}ms>`; } /** * @returns {Generator<Frame, void, *>} */ * [Symbol.iterator]() { for (let i = 0; i < this.length; i++) yield this[i]; } slice(start, end) { if (end === Infinity) end = this.length; const frames = new Array(end - start); for (let i = 0; i < frames.length; i++) frames[i] = this[i + start]; return new GIF(frames, this.loopCount); } /** * The GIFs duration (in ms) * @return {number} */ get duration() { return this.reduce((acc, frame) => acc + frame.duration, 0); } /** * Encodes the image into a GIF * @param {number} [quality=95] GIF quality 0-100 * @return {Promise<Uint8Array>} The encoded data */ async encode(quality = 95) { const encoder = new codecs.gif.encoder(this.width, this.height); for (const frame of this) { if (!(frame instanceof Frame)) throw new Error('GIF contains invalid frames'); encoder.add(frame.bitmap, { quality, x: frame.xOffset, y: frame.yOffset, width: frame.width, speed: null, // 1-10 height: frame.height, colors: null, // 2-256 delay: ~~(frame.duration / 10), dispose: ['any', 'keep', 'previous', 'background'][frame.disposalMode], }); } return encoder.finish({ repeat: -1 === this.loopCount ? null : this.loopCount }); } /** * Decodes a GIF image * @param {Buffer|Uint8Array} data The binary data to decode * @param {boolean} [onlyExtractFirstFrame=false] Whether to end GIF decoding after the first frame * @return {Promise<GIF>} The decoded GIF */ static async decode(data, onlyExtractFirstFrame = false) { let image; data = mem.view(data); const view = new DataView(data.buffer, data.byteOffset, data.byteLength); if (ImageType.isGIF(view)) { // GIF const frames = []; const decoder = new (await giflib.init()).Decoder(data); const gwidth = decoder.width | 0; const gheight = decoder.height | 0; const u32 = new Uint32Array(decoder.width * decoder.height); const u8 = new Uint8Array(u32.buffer, u32.byteOffset, u32.byteLength); for (const frame of decoder.frames()) { let offset8 = 0 | 0; let offset32 = 0 | 0; const fx = frame.x | 0; const fy = frame.y | 0; const f8 = frame.buffer; const mode = frame.dispose; const width = frame.width | 0; const height = frame.height | 0; const f32 = new Uint32Array(f8.buffer, f8.byteOffset, width * height); const f = frames[frames.push(new Frame(gwidth, gheight, 10 * frame.delay, 0, 0, 3)) - 1]; const t8 = f.bitmap; const t32 = new Uint32Array(t8.buffer); t8.set(u8); if (2 === mode) { for (let y = 0 | 0; y < height; y++) { const y_offset = fx + gwidth * (y + fy) | 0; for (let x = 0 | 0; x < width; x++) { const x_offset = x + y_offset; if (0 === f8[3 + offset8]) t32[x_offset] = u32[x_offset]; else t32[x_offset] = f32[offset32]; offset32++; offset8 += 4; } } } else