Skip to content

Commit c1b2795

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 bbeab78 commit c1b2795

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
@@ -9,6 +9,9 @@
99
the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for
1010
TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and
1111
include the creation time by default; `{software: ''}` leaves the software out.
12+
- **`trim(color, options)`**: removes borders of one color, like the empty space around rendered text or a solid
13+
background around a logo. Without a color, the top left pixel's is used; `tolerance` also trims near colors (e.g.
14+
JPEG artifacts). Frames keep their position.
1215

1316
### Deprecated
1417

‎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 } from './image.ts';
1+
import { Image, ResizeMode, Color, TrimOptions } from './image.ts';
22

33
/** Names of the frame disposal modes, see {@link Frame.disposalMode} */
44
export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background';
@@ -117,6 +117,19 @@ export class Frame extends Image {
117117
return Frame.from(this, this.duration, this.xOffset, this.yOffset, this.disposalMode);
118118
}
119119

120+
/**
121+
* Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place
122+
* @param color The border color, by default the color of the top left pixel
123+
* @param options See {@link TrimOptions}
124+
*/
125+
override trim(color?: Color, options: TrimOptions = {}): this {
126+
const bounds = this.__trim_bounds__(color, options);
127+
if (!bounds) return this;
128+
this.xOffset += bounds[0];
129+
this.yOffset += bounds[1];
130+
return this.__apply__(this.__crop__(...bounds));
131+
}
132+
120133
/**
121134
* Resizes the frame, scaling its offsets along with it
122135
*/

‎src/image.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ export interface RotateOptions {
8282
resize?: boolean;
8383
}
8484

85+
/** Options of {@link Image.trim} */
86+
export interface TrimOptions {
87+
/**
88+
* How far (0-1) a pixel's channels may be from the color for it to be trimmed, e.g. for JPEG artifacts or
89+
* antialiasing. Default: `0`
90+
*/
91+
tolerance?: number;
92+
}
93+
8594
/** Options of {@link Image.colorToAlpha} */
8695
export interface ColorToAlphaOptions {
8796
/** Pixels at most this far from the color (0-1) become fully transparent. Default: 0 */
@@ -623,6 +632,56 @@ export class Image {
623632
return this.__apply__(this.__crop__(left, top, right - left, bottom - top));
624633
}
625634

635+
/**
636+
* Removes borders of one color, e.g. the empty space around rendered text or around a logo on a solid background.
637+
* Frames keep their position: their offsets move by the trimmed amount. An image that only has the color stays
638+
* unchanged
639+
* @param color The border color, by default the color of the top left pixel. Fully transparent pixels count as the
640+
* same color whatever their RGB values
641+
* @param options See {@link TrimOptions}
642+
*/
643+
trim(color?: Color, options: TrimOptions = {}): this {
644+
const bounds = this.__trim_bounds__(color, options);
645+
return bounds ? this.__apply__(this.__crop__(...bounds)) : this;
646+
}
647+
648+
/**
649+
* The area that remains after trimming as [x, y, width, height], null if nothing would remain
650+
* @internal
651+
*/
652+
protected __trim_bounds__(color: Color | undefined, { tolerance = 0 }: TrimOptions): [number, number, number, number] | null {
653+
if (!(tolerance >= 0 && tolerance <= 1)) throw new RangeError('Invalid tolerance, it has to be between 0 and 1');
654+
655+
const bitmap = this.bitmap;
656+
const { width, height } = this;
657+
const c = color === undefined ? [bitmap[0], bitmap[1], bitmap[2], bitmap[3]] : [color >>> 24, color >> 16 & 255, color >> 8 & 255, color & 255];
658+
const limit = tolerance * 255;
659+
const matches = (x: number, y: number): boolean => {
660+
const i = (y * width + x) * 4;
661+
if (Math.abs(bitmap[i + 3] - c[3]) > limit) return false;
662+
// transparent pixels have no color
663+
if (bitmap[i + 3] === 0 && c[3] === 0) return true;
664+
return Math.abs(bitmap[i] - c[0]) <= limit && Math.abs(bitmap[i + 1] - c[1]) <= limit && Math.abs(bitmap[i + 2] - c[2]) <= limit;
665+
};
666+
const rowMatches = (y: number): boolean => {
667+
for (let x = 0; x < width; x++) if (!matches(x, y)) return false;
668+
return true;
669+
};
670+
const columnMatches = (x: number, top: number, bottom: number): boolean => {
671+
for (let y = top; y < bottom; y++) if (!matches(x, y)) return false;
672+
return true;
673+
};
674+
675+
let top = 0, bottom = height;
676+
while (top < height && rowMatches(top)) top++;
677+
if (top === height) return null;
678+
while (rowMatches(bottom - 1)) bottom--;
679+
let left = 0, right = width;
680+
while (columnMatches(left, top, bottom)) left++;
681+
while (columnMatches(right - 1, top, bottom)) right--;
682+
return [left, top, right - left, bottom - top];
683+
}
684+
626685
/**
627686
* @param x
628687
* @param y

‎src/index.ts‎

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

99
export { Image } from './image.ts';
10-
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions } from './image.ts';
10+
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, TrimOptions } from './image.ts';
1111
export { Frame } from './frame.ts';
1212
export type { FrameDisposalModeName, FrameDisposalModeId } from './frame.ts';
1313
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)