Skip to content

Commit d641dbe

Browse files
matmenclaude
andcommitted
trim
trim(color, options) removes borders of one color: by default the top left pixel's, fully transparent pixels count as one color whatever their RGB values. A tolerance (0-1 per channel) also trims near colors, e.g. JPEG artifacts. Frames move their offsets by the trimmed amount, so the remaining pixels stay in place. An image of only the color is left as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
1 parent 8b8da51 commit d641dbe

5 files changed

Lines changed: 127 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for
2424
TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and
2525
include the creation time by default; `{software: ''}` leaves the software out.
26+
- **`trim(color, options)`**: removes borders of one color, like the empty space around rendered text or a solid
27+
background around a logo. Without a color, the top left pixel's is used; `tolerance` also trims near colors (e.g.
28+
JPEG artifacts). Frames keep their position.
2629

2730
### Performance
2831

‎src/frame.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Image, ResizeMode, ResizeOptions } from './image.ts';
1+
import { Image, ResizeMode, ResizeOptions, Color, TrimOptions } from './image.ts';
22
import { isOptions } from './util/options.ts';
33

44
/** Names of the frame disposal modes, see {@link Frame.disposalMode} */
@@ -146,6 +146,19 @@ export class Frame extends Image {
146146
return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode });
147147
}
148148

149+
/**
150+
* Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place
151+
* @param color The border color, by default the color of the top left pixel
152+
* @param options See {@link TrimOptions}
153+
*/
154+
override trim(color?: Color, options: TrimOptions = {}): this {
155+
const bounds = this.__trim_bounds__(color, options);
156+
if (!bounds) return this;
157+
this.xOffset += bounds[0];
158+
this.yOffset += bounds[1];
159+
return this.__apply__(this.__crop__(...bounds));
160+
}
161+
149162
/**
150163
* Resizes the frame, scaling its offsets along with it
151164
*/

‎src/image.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ export interface RotateOptions {
163163
resize?: boolean;
164164
}
165165

166+
/** Options of {@link Image.trim} */
167+
export interface TrimOptions {
168+
/**
169+
* How far (0-1) a pixel's channels may be from the color for it to be trimmed, e.g. for JPEG artifacts or
170+
* antialiasing. Default: `0`
171+
*/
172+
tolerance?: number;
173+
}
174+
166175
/** Options of {@link Image.colorToAlpha} */
167176
export interface ColorToAlphaOptions {
168177
/** Pixels at most this far from the color (0-1) become fully transparent. Default: 0 */
@@ -755,6 +764,56 @@ export class Image {
755764
return this.__apply__(this.__crop__(left, top, right - left, bottom - top));
756765
}
757766

767+
/**
768+
* Removes borders of one color, e.g. the empty space around rendered text or around a logo on a solid background.
769+
* Frames keep their position: their offsets move by the trimmed amount. An image that only has the color stays
770+
* unchanged
771+
* @param color The border color, by default the color of the top left pixel. Fully transparent pixels count as the
772+
* same color whatever their RGB values
773+
* @param options See {@link TrimOptions}
774+
*/
775+
trim(color?: Color, options: TrimOptions = {}): this {
776+
const bounds = this.__trim_bounds__(color, options);
777+
return bounds ? this.__apply__(this.__crop__(...bounds)) : this;
778+
}
779+
780+
/**
781+
* The area that remains after trimming as [x, y, width, height], null if nothing would remain
782+
* @internal
783+
*/
784+
protected __trim_bounds__(color: Color | undefined, { tolerance = 0 }: TrimOptions): [number, number, number, number] | null {
785+
if (!(tolerance >= 0 && tolerance <= 1)) throw new RangeError('Invalid tolerance, it has to be between 0 and 1');
786+
787+
const bitmap = this.bitmap;
788+
const { width, height } = this;
789+
const c = color === undefined ? [bitmap[0], bitmap[1], bitmap[2], bitmap[3]] : [color >>> 24, color >> 16 & 255, color >> 8 & 255, color & 255];
790+
const limit = tolerance * 255;
791+
const matches = (x: number, y: number): boolean => {
792+
const i = (y * width + x) * 4;
793+
if (Math.abs(bitmap[i + 3] - c[3]) > limit) return false;
794+
// transparent pixels have no color
795+
if (bitmap[i + 3] === 0 && c[3] === 0) return true;
796+
return Math.abs(bitmap[i] - c[0]) <= limit && Math.abs(bitmap[i + 1] - c[1]) <= limit && Math.abs(bitmap[i + 2] - c[2]) <= limit;
797+
};
798+
const rowMatches = (y: number): boolean => {
799+
for (let x = 0; x < width; x++) if (!matches(x, y)) return false;
800+
return true;
801+
};
802+
const columnMatches = (x: number, top: number, bottom: number): boolean => {
803+
for (let y = top; y < bottom; y++) if (!matches(x, y)) return false;
804+
return true;
805+
};
806+
807+
let top = 0, bottom = height;
808+
while (top < height && rowMatches(top)) top++;
809+
if (top === height) return null;
810+
while (rowMatches(bottom - 1)) bottom--;
811+
let left = 0, right = width;
812+
while (columnMatches(left, top, bottom)) left++;
813+
while (columnMatches(right - 1, top, bottom)) right--;
814+
return [left, top, right - left, bottom - top];
815+
}
816+
758817
/**
759818
* @param x
760819
* @param y

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { view } from './util/mem.ts';
88
import { ImageType } from './image-type.ts';
99

1010
export { Image } from './image.ts';
11-
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';
11+
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';
1212
export { Frame } from './frame.ts';
1313
export type { FrameDisposalModeName, FrameDisposalModeId, FrameOptions } from './frame.ts';
1414
export { GIF } from './gif.ts';

‎tests/trim.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const {Image, Frame} = require('..');
2+
3+
const panic = message => {
4+
console.error(message);
5+
process.exit(1);
6+
};
7+
8+
const size = image => `${image.width}x${image.height}`;
9+
10+
(async () => {
11+
// transparent borders (like around rendered text), whatever the transparent pixels' RGB values
12+
{
13+
const image = new Image(10, 8);
14+
image.setPixelAt(1, 1, 0xffffff00); // transparent white
15+
image.drawBox(3, 4, 4, 2, 0xff0000ff);
16+
image.setPixelAt(9, 2, 0x00ff00ff);
17+
image.trim();
18+
if (size(image) !== '7x4') panic(`trim: ${size(image)} instead of 7x4`);
19+
if (image.getPixelAt(0, 2) !== 0xff0000ff || image.getPixelAt(6, 0) !== 0x00ff00ff) panic('trim: pixels moved wrongly');
20+
}
21+
22+
// the top left pixel's color by default, or a given one
23+
{
24+
const make = () => new Image(6, 6).fill(0xffffffff).drawBox(3, 2, 2, 3, 0x000000ff);
25+
if (size(make().trim()) !== '2x3') panic('trim: solid background not trimmed');
26+
if (size(make().trim(0x000000ff)) !== '6x6') panic('trim: explicit color should only trim that color');
27+
const marked = make().setPixelAt(1, 1, 0xff0000ff);
28+
if (size(marked.trim(0xffffffff)) !== '4x4') panic(`trim: explicit color: ${size(marked)}`);
29+
}
30+
31+
// tolerance, e.g. for JPEG artifacts
32+
{
33+
const make = () => new Image(5, 5).fill(0xffffffff).setPixelAt(1, 0, 0xf8f8f8ff).setPixelAt(3, 3, 0x000000ff);
34+
if (size(make().trim()) !== '3x4') panic('trim: without tolerance, near colors stay');
35+
if (size(make().trim(undefined, {tolerance: 0.05})) !== '1x1') panic('trim: tolerance has no effect');
36+
for (const tolerance of [-0.1, 1.5, NaN])
37+
if (!(() => { try { make().trim(undefined, {tolerance}); } catch { return true; } })()) panic(`trim: tolerance ${tolerance} accepted`);
38+
}
39+
40+
// an image of only the color stays unchanged
41+
if (size(new Image(4, 3).fill(0x123456ff).trim()) !== '4x3') panic('trim: uniform image changed');
42+
43+
// frames keep their position
44+
{
45+
const frame = new Frame(10, 10, 100, 5, 7);
46+
frame.drawBox(2, 3, 4, 4, 0xffffffff);
47+
frame.trim();
48+
if (size(frame) !== '4x4' || frame.xOffset !== 7 || frame.yOffset !== 10) panic(`trim: frame ${size(frame)} at ${frame.xOffset},${frame.yOffset}`);
49+
}
50+
})();

0 commit comments

Comments
 (0)