diff --git a/CHANGELOG.md b/CHANGELOG.md index 844f5d76..208c081b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ 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. +- **`trim(color, options)`**: removes borders of one color, like the empty space around rendered text or a solid + background around a logo. Without a color, the top left pixel's is used; `tolerance` also trims near colors (e.g. + JPEG artifacts). Frames keep their position. ### Performance diff --git a/src/frame.ts b/src/frame.ts index 4a658b83..1de29b53 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,4 +1,4 @@ -import { Image, ResizeMode, ResizeOptions } from './image.ts'; +import { Image, ResizeMode, ResizeOptions, Color, TrimOptions } from './image.ts'; import { isOptions } from './util/options.ts'; /** Names of the frame disposal modes, see {@link Frame.disposalMode} */ @@ -146,6 +146,19 @@ export class Frame extends Image { return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode }); } + /** + * Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place + * @param color The border color, by default the color of the top left pixel + * @param options See {@link TrimOptions} + */ + override trim(color?: Color, options: TrimOptions = {}): this { + const bounds = this.__trim_bounds__(color, options); + if (!bounds) return this; + this.xOffset += bounds[0]; + this.yOffset += bounds[1]; + return this.__apply__(this.__crop__(...bounds)); + } + /** * Resizes the frame, scaling its offsets along with it */ diff --git a/src/image.ts b/src/image.ts index 4f9a4a98..3dc2ff89 100644 --- a/src/image.ts +++ b/src/image.ts @@ -163,6 +163,15 @@ export interface RotateOptions { resize?: boolean; } +/** Options of {@link Image.trim} */ +export interface TrimOptions { + /** + * How far (0-1) a pixel's channels may be from the color for it to be trimmed, e.g. for JPEG artifacts or + * antialiasing. Default: `0` + */ + tolerance?: number; +} + /** Options of {@link Image.colorToAlpha} */ export interface ColorToAlphaOptions { /** Pixels at most this far from the color (0-1) become fully transparent. Default: 0 */ @@ -755,6 +764,56 @@ export class Image { return this.__apply__(this.__crop__(left, top, right - left, bottom - top)); } + /** + * Removes borders of one color, e.g. the empty space around rendered text or around a logo on a solid background. + * Frames keep their position: their offsets move by the trimmed amount. An image that only has the color stays + * unchanged + * @param color The border color, by default the color of the top left pixel. Fully transparent pixels count as the + * same color whatever their RGB values + * @param options See {@link TrimOptions} + */ + trim(color?: Color, options: TrimOptions = {}): this { + const bounds = this.__trim_bounds__(color, options); + return bounds ? this.__apply__(this.__crop__(...bounds)) : this; + } + + /** + * The area that remains after trimming as [x, y, width, height], null if nothing would remain + * @internal + */ + protected __trim_bounds__(color: Color | undefined, { tolerance = 0 }: TrimOptions): [number, number, number, number] | null { + if (!(tolerance >= 0 && tolerance <= 1)) throw new RangeError('Invalid tolerance, it has to be between 0 and 1'); + + const bitmap = this.bitmap; + const { width, height } = this; + const c = color === undefined ? [bitmap[0], bitmap[1], bitmap[2], bitmap[3]] : [color >>> 24, color >> 16 & 255, color >> 8 & 255, color & 255]; + const limit = tolerance * 255; + const matches = (x: number, y: number): boolean => { + const i = (y * width + x) * 4; + if (Math.abs(bitmap[i + 3] - c[3]) > limit) return false; + // transparent pixels have no color + if (bitmap[i + 3] === 0 && c[3] === 0) return true; + return Math.abs(bitmap[i] - c[0]) <= limit && Math.abs(bitmap[i + 1] - c[1]) <= limit && Math.abs(bitmap[i + 2] - c[2]) <= limit; + }; + const rowMatches = (y: number): boolean => { + for (let x = 0; x < width; x++) if (!matches(x, y)) return false; + return true; + }; + const columnMatches = (x: number, top: number, bottom: number): boolean => { + for (let y = top; y < bottom; y++) if (!matches(x, y)) return false; + return true; + }; + + let top = 0, bottom = height; + while (top < height && rowMatches(top)) top++; + if (top === height) return null; + while (rowMatches(bottom - 1)) bottom--; + let left = 0, right = width; + while (columnMatches(left, top, bottom)) left++; + while (columnMatches(right - 1, top, bottom)) right--; + return [left, top, right - left, bottom - top]; + } + /** * @param x * @param y diff --git a/src/index.ts b/src/index.ts index a9cfe4f5..9d070f0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ 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, ResizeOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RoundCornersOptions, FisheyeOptions } 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, TrimOptions } from './image.ts'; export { Frame } from './frame.ts'; export type { FrameDisposalModeName, FrameDisposalModeId, FrameOptions } from './frame.ts'; export { GIF } from './gif.ts'; diff --git a/tests/trim.js b/tests/trim.js new file mode 100644 index 00000000..a6783b74 --- /dev/null +++ b/tests/trim.js @@ -0,0 +1,50 @@ +const {Image, Frame} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; + +const size = image => `${image.width}x${image.height}`; + +(async () => { + // transparent borders (like around rendered text), whatever the transparent pixels' RGB values + { + const image = new Image(10, 8); + image.setPixelAt(1, 1, 0xffffff00); // transparent white + image.drawBox(3, 4, 4, 2, 0xff0000ff); + image.setPixelAt(9, 2, 0x00ff00ff); + image.trim(); + if (size(image) !== '7x4') panic(`trim: ${size(image)} instead of 7x4`); + if (image.getPixelAt(0, 2) !== 0xff0000ff || image.getPixelAt(6, 0) !== 0x00ff00ff) panic('trim: pixels moved wrongly'); + } + + // the top left pixel's color by default, or a given one + { + const make = () => new Image(6, 6).fill(0xffffffff).drawBox(3, 2, 2, 3, 0x000000ff); + if (size(make().trim()) !== '2x3') panic('trim: solid background not trimmed'); + if (size(make().trim(0x000000ff)) !== '6x6') panic('trim: explicit color should only trim that color'); + const marked = make().setPixelAt(1, 1, 0xff0000ff); + if (size(marked.trim(0xffffffff)) !== '4x4') panic(`trim: explicit color: ${size(marked)}`); + } + + // tolerance, e.g. for JPEG artifacts + { + const make = () => new Image(5, 5).fill(0xffffffff).setPixelAt(1, 0, 0xf8f8f8ff).setPixelAt(3, 3, 0x000000ff); + if (size(make().trim()) !== '3x4') panic('trim: without tolerance, near colors stay'); + if (size(make().trim(undefined, {tolerance: 0.05})) !== '1x1') panic('trim: tolerance has no effect'); + for (const tolerance of [-0.1, 1.5, NaN]) + if (!(() => { try { make().trim(undefined, {tolerance}); } catch { return true; } })()) panic(`trim: tolerance ${tolerance} accepted`); + } + + // an image of only the color stays unchanged + if (size(new Image(4, 3).fill(0x123456ff).trim()) !== '4x3') panic('trim: uniform image changed'); + + // frames keep their position + { + const frame = new Frame(10, 10, 100, 5, 7); + frame.drawBox(2, 3, 4, 4, 0xffffffff); + frame.trim(); + if (size(frame) !== '4x4' || frame.xOffset !== 7 || frame.yOffset !== 10) panic(`trim: frame ${size(frame)} at ${frame.xOffset},${frame.yOffset}`); + } +})();