Skip to content
Merged

trim #67

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion src/frame.ts
Original file line number Diff line number Diff line change
@@ -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} */
Expand Down Expand Up @@ -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
*/
Expand Down
59 changes: 59 additions & 0 deletions src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions tests/trim.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
})();
Loading