image-edit-tools 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +90 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/ops/add-text.d.ts.map +1 -1
- package/dist/ops/add-text.js +52 -19
- package/dist/ops/add-text.js.map +1 -1
- package/dist/ops/clip-to-shape.d.ts +3 -0
- package/dist/ops/clip-to-shape.d.ts.map +1 -0
- package/dist/ops/clip-to-shape.js +58 -0
- package/dist/ops/clip-to-shape.js.map +1 -0
- package/dist/ops/draw-shape.d.ts +3 -0
- package/dist/ops/draw-shape.d.ts.map +1 -0
- package/dist/ops/draw-shape.js +54 -0
- package/dist/ops/draw-shape.js.map +1 -0
- package/dist/ops/drop-shadow.d.ts +3 -0
- package/dist/ops/drop-shadow.d.ts.map +1 -0
- package/dist/ops/drop-shadow.js +54 -0
- package/dist/ops/drop-shadow.js.map +1 -0
- package/dist/ops/gradient-overlay.d.ts +3 -0
- package/dist/ops/gradient-overlay.d.ts.map +1 -0
- package/dist/ops/gradient-overlay.js +49 -0
- package/dist/ops/gradient-overlay.js.map +1 -0
- package/dist/ops/pipeline.d.ts.map +1 -1
- package/dist/ops/pipeline.js +16 -0
- package/dist/ops/pipeline.js.map +1 -1
- package/dist/ops/rotate.d.ts +3 -0
- package/dist/ops/rotate.d.ts.map +1 -0
- package/dist/ops/rotate.js +25 -0
- package/dist/ops/rotate.js.map +1 -0
- package/dist/types.d.ts +93 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/mcp/tools.ts +86 -0
- package/src/ops/add-text.ts +55 -16
- package/src/ops/clip-to-shape.ts +63 -0
- package/src/ops/draw-shape.ts +58 -0
- package/src/ops/drop-shadow.ts +60 -0
- package/src/ops/gradient-overlay.ts +62 -0
- package/src/ops/pipeline.ts +9 -0
- package/src/ops/rotate.ts +27 -0
- package/src/types.ts +100 -1
- package/tests/unit/clip-to-shape.test.ts +36 -0
- package/tests/unit/draw-shape.test.ts +34 -0
- package/tests/unit/drop-shadow.test.ts +42 -0
- package/tests/unit/gradient-overlay.test.ts +29 -0
- package/tests/unit/rotate.test.ts +42 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { DrawShapeOptions, ImageResult, ErrorCode } from '../types.js';
|
|
3
|
+
import { err, ok } from '../utils/result.js';
|
|
4
|
+
|
|
5
|
+
export async function drawShape(options: DrawShapeOptions): Promise<ImageResult> {
|
|
6
|
+
try {
|
|
7
|
+
const { width, height, shape } = options;
|
|
8
|
+
const fill = options.fill ?? 'transparent';
|
|
9
|
+
const fillOpacity = options.fillOpacity ?? 1;
|
|
10
|
+
const stroke = options.stroke ?? 'none';
|
|
11
|
+
const strokeWidth = options.strokeWidth ?? 0;
|
|
12
|
+
const borderRadius = options.borderRadius ?? 0;
|
|
13
|
+
|
|
14
|
+
let shapeEl: string;
|
|
15
|
+
|
|
16
|
+
switch (shape) {
|
|
17
|
+
case 'rect': {
|
|
18
|
+
shapeEl = `<rect x="${strokeWidth / 2}" y="${strokeWidth / 2}" width="${width - strokeWidth}" height="${height - strokeWidth}" rx="${borderRadius}" ry="${borderRadius}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
case 'circle': {
|
|
22
|
+
const cx = options.cx ?? width / 2;
|
|
23
|
+
const cy = options.cy ?? height / 2;
|
|
24
|
+
const r = options.r ?? Math.min(width, height) / 2 - strokeWidth;
|
|
25
|
+
shapeEl = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
case 'ellipse': {
|
|
29
|
+
const cx = options.cx ?? width / 2;
|
|
30
|
+
const cy = options.cy ?? height / 2;
|
|
31
|
+
const rx = options.r ?? width / 2 - strokeWidth;
|
|
32
|
+
const ry = options.ry ?? height / 2 - strokeWidth;
|
|
33
|
+
shapeEl = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case 'line': {
|
|
37
|
+
const x1 = options.x1 ?? 0;
|
|
38
|
+
const y1 = options.y1 ?? 0;
|
|
39
|
+
const x2 = options.x2 ?? width;
|
|
40
|
+
const y2 = options.y2 ?? height;
|
|
41
|
+
shapeEl = `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke || fill}" stroke-width="${strokeWidth || 2}"/>`;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
return err(`Unknown shape: ${shape}`, ErrorCode.INVALID_INPUT);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${shapeEl}</svg>`;
|
|
49
|
+
|
|
50
|
+
const output = await sharp(Buffer.from(svg))
|
|
51
|
+
.png()
|
|
52
|
+
.toBuffer();
|
|
53
|
+
|
|
54
|
+
return ok(output);
|
|
55
|
+
} catch (e: any) {
|
|
56
|
+
return err(e.message || 'Draw shape failed', ErrorCode.PROCESSING_FAILED);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { DropShadowOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
|
|
3
|
+
import { loadImage } from '../utils/load-image.js';
|
|
4
|
+
import { err, ok } from '../utils/result.js';
|
|
5
|
+
import { getImageMetadata } from '../utils/validate.js';
|
|
6
|
+
|
|
7
|
+
export async function dropShadow(input: ImageInput, options: DropShadowOptions = {}): Promise<ImageResult> {
|
|
8
|
+
try {
|
|
9
|
+
const buffer = await loadImage(input);
|
|
10
|
+
const meta = await getImageMetadata(buffer);
|
|
11
|
+
const { width, height } = meta;
|
|
12
|
+
|
|
13
|
+
const offsetX = options.offsetX ?? 4;
|
|
14
|
+
const offsetY = options.offsetY ?? 4;
|
|
15
|
+
const blurRadius = options.blur ?? 8;
|
|
16
|
+
const color = options.color ?? 'rgba(0,0,0,0.5)';
|
|
17
|
+
const expand = options.expand ?? true;
|
|
18
|
+
|
|
19
|
+
const expandPx = expand ? Math.ceil(blurRadius * 2 + Math.max(Math.abs(offsetX), Math.abs(offsetY))) : 0;
|
|
20
|
+
const canvasW = width + expandPx * 2;
|
|
21
|
+
const canvasH = height + expandPx * 2;
|
|
22
|
+
|
|
23
|
+
// Create shadow by making a silhouette of the source image
|
|
24
|
+
const silhouette = await sharp(buffer)
|
|
25
|
+
.ensureAlpha()
|
|
26
|
+
.png()
|
|
27
|
+
.toBuffer();
|
|
28
|
+
|
|
29
|
+
// Tint to create a colored, blurred shadow
|
|
30
|
+
const shadow = await sharp({
|
|
31
|
+
create: { width, height, channels: 4, background: color }
|
|
32
|
+
})
|
|
33
|
+
.png()
|
|
34
|
+
.toBuffer();
|
|
35
|
+
|
|
36
|
+
// Use source alpha as mask for the shadow color
|
|
37
|
+
const maskedShadow = await sharp(shadow)
|
|
38
|
+
.composite([{
|
|
39
|
+
input: silhouette,
|
|
40
|
+
blend: 'dest-in'
|
|
41
|
+
}])
|
|
42
|
+
.blur(Math.max(0.3, blurRadius))
|
|
43
|
+
.toBuffer();
|
|
44
|
+
|
|
45
|
+
// Assemble on canvas
|
|
46
|
+
const canvas = await sharp({
|
|
47
|
+
create: { width: canvasW, height: canvasH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }
|
|
48
|
+
})
|
|
49
|
+
.composite([
|
|
50
|
+
{ input: maskedShadow, left: expandPx + offsetX, top: expandPx + offsetY, blend: 'over' },
|
|
51
|
+
{ input: buffer, left: expandPx, top: expandPx, blend: 'over' }
|
|
52
|
+
])
|
|
53
|
+
.png()
|
|
54
|
+
.toBuffer();
|
|
55
|
+
|
|
56
|
+
return ok(canvas);
|
|
57
|
+
} catch (e: any) {
|
|
58
|
+
return err(e.message || 'Drop shadow failed', ErrorCode.PROCESSING_FAILED);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { GradientOverlayOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
|
|
3
|
+
import { loadImage } from '../utils/load-image.js';
|
|
4
|
+
import { err, ok } from '../utils/result.js';
|
|
5
|
+
import { getImageMetadata } from '../utils/validate.js';
|
|
6
|
+
|
|
7
|
+
function buildGradientSvg(
|
|
8
|
+
width: number,
|
|
9
|
+
height: number,
|
|
10
|
+
direction: string,
|
|
11
|
+
color: string,
|
|
12
|
+
opacity: number,
|
|
13
|
+
coverage: number
|
|
14
|
+
): string {
|
|
15
|
+
// Map direction to SVG linearGradient coordinates
|
|
16
|
+
const dirMap: Record<string, { x1: string; y1: string; x2: string; y2: string }> = {
|
|
17
|
+
'top': { x1: '0%', y1: '0%', x2: '0%', y2: '100%' },
|
|
18
|
+
'bottom': { x1: '0%', y1: '100%', x2: '0%', y2: '0%' },
|
|
19
|
+
'left': { x1: '0%', y1: '0%', x2: '100%', y2: '0%' },
|
|
20
|
+
'right': { x1: '100%', y1: '0%', x2: '0%', y2: '0%' },
|
|
21
|
+
'top-left': { x1: '0%', y1: '0%', x2: '100%', y2: '100%' },
|
|
22
|
+
'top-right': { x1: '100%', y1: '0%', x2: '0%', y2: '100%' },
|
|
23
|
+
'bottom-left': { x1: '0%', y1: '100%', x2: '100%', y2: '0%' },
|
|
24
|
+
'bottom-right': { x1: '100%', y1: '100%', x2: '0%', y2: '0%' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const d = dirMap[direction] ?? dirMap['bottom'];
|
|
28
|
+
const stopOffset = Math.round(coverage * 100);
|
|
29
|
+
|
|
30
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
31
|
+
<defs>
|
|
32
|
+
<linearGradient id="g" x1="${d.x1}" y1="${d.y1}" x2="${d.x2}" y2="${d.y2}">
|
|
33
|
+
<stop offset="0%" stop-color="${color}" stop-opacity="${opacity}"/>
|
|
34
|
+
<stop offset="${stopOffset}%" stop-color="${color}" stop-opacity="0"/>
|
|
35
|
+
</linearGradient>
|
|
36
|
+
</defs>
|
|
37
|
+
<rect width="${width}" height="${height}" fill="url(#g)"/>
|
|
38
|
+
</svg>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function gradientOverlay(input: ImageInput, options: GradientOverlayOptions = {}): Promise<ImageResult> {
|
|
42
|
+
try {
|
|
43
|
+
const buffer = await loadImage(input);
|
|
44
|
+
const meta = await getImageMetadata(buffer);
|
|
45
|
+
const { width, height } = meta;
|
|
46
|
+
|
|
47
|
+
const direction = options.direction ?? 'bottom';
|
|
48
|
+
const color = options.color ?? '#000000';
|
|
49
|
+
const opacity = options.opacity ?? 0.7;
|
|
50
|
+
const coverage = options.coverage ?? 0.5;
|
|
51
|
+
|
|
52
|
+
const svg = buildGradientSvg(width, height, direction, color, opacity, coverage);
|
|
53
|
+
|
|
54
|
+
const output = await sharp(buffer)
|
|
55
|
+
.composite([{ input: Buffer.from(svg), blend: 'over' }])
|
|
56
|
+
.toBuffer();
|
|
57
|
+
|
|
58
|
+
return ok(output);
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
return err(e.message || 'Gradient overlay failed', ErrorCode.PROCESSING_FAILED);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/ops/pipeline.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { composite } from './composite.js';
|
|
|
10
10
|
import { watermark } from './watermark.js';
|
|
11
11
|
import { convert } from './convert.js';
|
|
12
12
|
import { optimize } from './optimize.js';
|
|
13
|
+
import { rotate } from './rotate.js';
|
|
14
|
+
import { gradientOverlay } from './gradient-overlay.js';
|
|
15
|
+
import { clipToShape } from './clip-to-shape.js';
|
|
16
|
+
import { dropShadow } from './drop-shadow.js';
|
|
13
17
|
|
|
14
18
|
export async function pipeline(input: ImageInput, operations: PipelineOperation[]): Promise<ImageResult & { step?: number }> {
|
|
15
19
|
let currentImage = input;
|
|
@@ -31,6 +35,10 @@ export async function pipeline(input: ImageInput, operations: PipelineOperation[
|
|
|
31
35
|
case 'watermark': result = await watermark(currentImage, op); break;
|
|
32
36
|
case 'convert': result = await convert(currentImage, op); break;
|
|
33
37
|
case 'optimize': result = await optimize(currentImage, op); break;
|
|
38
|
+
case 'rotate': result = await rotate(currentImage, op); break;
|
|
39
|
+
case 'gradientOverlay': result = await gradientOverlay(currentImage, op); break;
|
|
40
|
+
case 'clipToShape': result = await clipToShape(currentImage, op); break;
|
|
41
|
+
case 'dropShadow': result = await dropShadow(currentImage, op); break;
|
|
34
42
|
case 'removeBg': {
|
|
35
43
|
const { removeBg } = await import('./remove-bg.js');
|
|
36
44
|
result = await removeBg(currentImage, op);
|
|
@@ -62,3 +70,4 @@ export async function pipeline(input: ImageInput, operations: PipelineOperation[
|
|
|
62
70
|
|
|
63
71
|
return { ok: true, data: currentImage as Buffer };
|
|
64
72
|
}
|
|
73
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { RotateOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
|
|
3
|
+
import { loadImage } from '../utils/load-image.js';
|
|
4
|
+
import { err, ok } from '../utils/result.js';
|
|
5
|
+
|
|
6
|
+
export async function rotate(input: ImageInput, options: RotateOptions): Promise<ImageResult> {
|
|
7
|
+
try {
|
|
8
|
+
const buffer = await loadImage(input);
|
|
9
|
+
const angle = options.angle % 360;
|
|
10
|
+
|
|
11
|
+
if (angle === 0) return ok(buffer);
|
|
12
|
+
|
|
13
|
+
const bgColor = options.background ?? { r: 0, g: 0, b: 0, alpha: 0 };
|
|
14
|
+
const background = typeof bgColor === 'string'
|
|
15
|
+
? bgColor
|
|
16
|
+
: bgColor;
|
|
17
|
+
|
|
18
|
+
const output = await sharp(buffer)
|
|
19
|
+
.rotate(angle, { background })
|
|
20
|
+
.png()
|
|
21
|
+
.toBuffer();
|
|
22
|
+
|
|
23
|
+
return ok(output);
|
|
24
|
+
} catch (e: any) {
|
|
25
|
+
return err(e.message || 'Rotation failed', ErrorCode.PROCESSING_FAILED);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -128,6 +128,12 @@ export interface TextLayer {
|
|
|
128
128
|
maxWidth?: number;
|
|
129
129
|
lineHeight?: number;
|
|
130
130
|
background?: TextBackground;
|
|
131
|
+
/** Letter spacing in pixels. Default: 0 */
|
|
132
|
+
letterSpacing?: number;
|
|
133
|
+
/** Text stroke (outline) */
|
|
134
|
+
stroke?: { color: string; width: number };
|
|
135
|
+
/** Text shadow */
|
|
136
|
+
textShadow?: { color: string; offsetX: number; offsetY: number; blur?: number };
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
// ─── Composite ────────────────────────────────────────────────────────────────
|
|
@@ -245,6 +251,94 @@ export interface ImageMetadata {
|
|
|
245
251
|
exif?: Record<string, unknown>;
|
|
246
252
|
}
|
|
247
253
|
|
|
254
|
+
// ─── Rotate ───────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export interface RotateOptions {
|
|
257
|
+
/** Rotation angle in degrees (0–360). Positive = clockwise. */
|
|
258
|
+
angle: number;
|
|
259
|
+
/** Background color for exposed areas. Default: 'transparent' */
|
|
260
|
+
background?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── GradientOverlay ──────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
export type GradientDirection = 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
266
|
+
|
|
267
|
+
export interface GradientOverlayOptions {
|
|
268
|
+
/** Direction the gradient fades FROM (opaque end). Default: 'bottom' */
|
|
269
|
+
direction?: GradientDirection;
|
|
270
|
+
/** Gradient color. Default: '#000000' */
|
|
271
|
+
color?: string;
|
|
272
|
+
/** Opacity of the opaque end. 0–1. Default: 0.7 */
|
|
273
|
+
opacity?: number;
|
|
274
|
+
/** How much of the image the gradient covers. 0–1. Default: 0.5 */
|
|
275
|
+
coverage?: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── ClipToShape ──────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export type ClipShape = 'circle' | 'ellipse' | 'rounded-rect';
|
|
281
|
+
|
|
282
|
+
export interface ClipToShapeOptions {
|
|
283
|
+
shape: ClipShape;
|
|
284
|
+
/** Border radius for 'rounded-rect'. Default: 32 */
|
|
285
|
+
borderRadius?: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── DrawShape ────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
export type ShapeType = 'rect' | 'circle' | 'ellipse' | 'line';
|
|
291
|
+
|
|
292
|
+
export interface DrawShapeOptions {
|
|
293
|
+
/** Canvas width */
|
|
294
|
+
width: number;
|
|
295
|
+
/** Canvas height */
|
|
296
|
+
height: number;
|
|
297
|
+
/** Shape to draw */
|
|
298
|
+
shape: ShapeType;
|
|
299
|
+
/** Fill color. Default: 'transparent' */
|
|
300
|
+
fill?: string;
|
|
301
|
+
/** Fill opacity. 0–1. Default: 1 */
|
|
302
|
+
fillOpacity?: number;
|
|
303
|
+
/** Stroke color */
|
|
304
|
+
stroke?: string;
|
|
305
|
+
/** Stroke width. Default: 0 */
|
|
306
|
+
strokeWidth?: number;
|
|
307
|
+
/** Border radius (rect only). Default: 0 */
|
|
308
|
+
borderRadius?: number;
|
|
309
|
+
/** Circle/ellipse center X. Defaults to canvas center */
|
|
310
|
+
cx?: number;
|
|
311
|
+
/** Circle/ellipse center Y. Defaults to canvas center */
|
|
312
|
+
cy?: number;
|
|
313
|
+
/** Circle radius or ellipse rx */
|
|
314
|
+
r?: number;
|
|
315
|
+
/** Ellipse ry */
|
|
316
|
+
ry?: number;
|
|
317
|
+
/** Line start X */
|
|
318
|
+
x1?: number;
|
|
319
|
+
/** Line start Y */
|
|
320
|
+
y1?: number;
|
|
321
|
+
/** Line end X */
|
|
322
|
+
x2?: number;
|
|
323
|
+
/** Line end Y */
|
|
324
|
+
y2?: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── DropShadow ───────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
export interface DropShadowOptions {
|
|
330
|
+
/** Shadow color. Default: 'rgba(0,0,0,0.5)' */
|
|
331
|
+
color?: string;
|
|
332
|
+
/** Shadow offset X. Default: 4 */
|
|
333
|
+
offsetX?: number;
|
|
334
|
+
/** Shadow offset Y. Default: 4 */
|
|
335
|
+
offsetY?: number;
|
|
336
|
+
/** Blur radius. Default: 8 */
|
|
337
|
+
blur?: number;
|
|
338
|
+
/** Expand canvas to fit shadow. Default: true */
|
|
339
|
+
expand?: boolean;
|
|
340
|
+
}
|
|
341
|
+
|
|
248
342
|
// ─── Pipeline ─────────────────────────────────────────────────────────────────
|
|
249
343
|
|
|
250
344
|
export type PipelineOperation =
|
|
@@ -259,9 +353,14 @@ export type PipelineOperation =
|
|
|
259
353
|
| ({ op: 'watermark' } & WatermarkOptions)
|
|
260
354
|
| ({ op: 'convert' } & ConvertOptions)
|
|
261
355
|
| ({ op: 'optimize' } & OptimizeOptions)
|
|
262
|
-
| ({ op: 'removeBg' } & RemoveBgOptions)
|
|
356
|
+
| ({ op: 'removeBg' } & RemoveBgOptions)
|
|
357
|
+
| ({ op: 'rotate' } & RotateOptions)
|
|
358
|
+
| ({ op: 'gradientOverlay' } & GradientOverlayOptions)
|
|
359
|
+
| ({ op: 'clipToShape' } & ClipToShapeOptions)
|
|
360
|
+
| ({ op: 'dropShadow' } & DropShadowOptions);
|
|
263
361
|
|
|
264
362
|
export interface BatchOptions {
|
|
265
363
|
concurrency?: number;
|
|
266
364
|
onProgress?: (done: number, total: number) => void;
|
|
267
365
|
}
|
|
366
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import sharp from 'sharp'
|
|
6
|
+
import { clipToShape } from '../../src/ops/clip-to-shape.js'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('clipToShape', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('clips to circle', async () => {
|
|
20
|
+
const result = await clipToShape(sampleJpeg, { shape: 'circle' })
|
|
21
|
+
expect(result.ok).toBe(true)
|
|
22
|
+
if (!result.ok) return
|
|
23
|
+
const meta = await sharp(result.data).metadata()
|
|
24
|
+
expect(meta.hasAlpha).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('clips to rounded-rect with custom radius', async () => {
|
|
28
|
+
const result = await clipToShape(sampleJpeg, { shape: 'rounded-rect', borderRadius: 64 })
|
|
29
|
+
expect(result.ok).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('clips to ellipse', async () => {
|
|
33
|
+
const result = await clipToShape(sampleJpeg, { shape: 'ellipse' })
|
|
34
|
+
expect(result.ok).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import { drawShape } from '../../src/ops/draw-shape.js'
|
|
4
|
+
|
|
5
|
+
describe('drawShape', () => {
|
|
6
|
+
it('draws a filled rect', async () => {
|
|
7
|
+
const result = await drawShape({ width: 200, height: 100, shape: 'rect', fill: '#FF0000', borderRadius: 16 })
|
|
8
|
+
expect(result.ok).toBe(true)
|
|
9
|
+
if (!result.ok) return
|
|
10
|
+
const meta = await sharp(result.data).metadata()
|
|
11
|
+
expect(meta.width).toBe(200)
|
|
12
|
+
expect(meta.height).toBe(100)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('draws a circle with stroke', async () => {
|
|
16
|
+
const result = await drawShape({ width: 100, height: 100, shape: 'circle', fill: '#00FF00', stroke: '#000000', strokeWidth: 3 })
|
|
17
|
+
expect(result.ok).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('draws an ellipse', async () => {
|
|
21
|
+
const result = await drawShape({ width: 200, height: 100, shape: 'ellipse', fill: '#0000FF' })
|
|
22
|
+
expect(result.ok).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('draws a line', async () => {
|
|
26
|
+
const result = await drawShape({ width: 200, height: 200, shape: 'line', stroke: '#FF0000', strokeWidth: 4, x1: 10, y1: 10, x2: 190, y2: 190 })
|
|
27
|
+
expect(result.ok).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns error for unknown shape', async () => {
|
|
31
|
+
const result = await drawShape({ width: 100, height: 100, shape: 'hexagon' as any })
|
|
32
|
+
expect(result.ok).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import sharp from 'sharp'
|
|
6
|
+
import { dropShadow } from '../../src/ops/drop-shadow.js'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('dropShadow', () => {
|
|
13
|
+
let logoPng: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
logoPng = fixture('logo.png') // 100x100
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('adds drop shadow with expanded canvas', async () => {
|
|
20
|
+
const result = await dropShadow(logoPng, { blur: 8, offsetX: 4, offsetY: 4 })
|
|
21
|
+
expect(result.ok).toBe(true)
|
|
22
|
+
if (!result.ok) return
|
|
23
|
+
const meta = await sharp(result.data).metadata()
|
|
24
|
+
// Canvas should be expanded
|
|
25
|
+
expect(meta.width).toBeGreaterThan(100)
|
|
26
|
+
expect(meta.height).toBeGreaterThan(100)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('adds shadow without expanding canvas', async () => {
|
|
30
|
+
const result = await dropShadow(logoPng, { expand: false, blur: 4 })
|
|
31
|
+
expect(result.ok).toBe(true)
|
|
32
|
+
if (!result.ok) return
|
|
33
|
+
const meta = await sharp(result.data).metadata()
|
|
34
|
+
expect(meta.width).toBe(100)
|
|
35
|
+
expect(meta.height).toBe(100)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('uses default options', async () => {
|
|
39
|
+
const result = await dropShadow(logoPng)
|
|
40
|
+
expect(result.ok).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { gradientOverlay } from '../../src/ops/gradient-overlay.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
describe('gradientOverlay', () => {
|
|
12
|
+
let sampleJpeg: Buffer
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
sampleJpeg = fixture('sample.jpg')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('applies default bottom gradient', async () => {
|
|
19
|
+
const result = await gradientOverlay(sampleJpeg)
|
|
20
|
+
expect(result.ok).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('applies gradient with custom direction and color', async () => {
|
|
24
|
+
const result = await gradientOverlay(sampleJpeg, {
|
|
25
|
+
direction: 'top-right', color: '#FF0000', opacity: 0.5, coverage: 0.8
|
|
26
|
+
})
|
|
27
|
+
expect(result.ok).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import sharp from 'sharp'
|
|
6
|
+
import { rotate } from '../../src/ops/rotate.js'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('rotate', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rotates 90 degrees', async () => {
|
|
20
|
+
const result = await rotate(sampleJpeg, { angle: 90 })
|
|
21
|
+
expect(result.ok).toBe(true)
|
|
22
|
+
if (!result.ok) return
|
|
23
|
+
const meta = await sharp(result.data).metadata()
|
|
24
|
+
// 400x300 rotated 90° → 300x400
|
|
25
|
+
expect(meta.width).toBe(300)
|
|
26
|
+
expect(meta.height).toBe(400)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rotates 45 degrees with transparent bg', async () => {
|
|
30
|
+
const result = await rotate(sampleJpeg, { angle: 45 })
|
|
31
|
+
expect(result.ok).toBe(true)
|
|
32
|
+
if (!result.ok) return
|
|
33
|
+
const meta = await sharp(result.data).metadata()
|
|
34
|
+
// Canvas expands for 45° rotation
|
|
35
|
+
expect(meta.width).toBeGreaterThan(400)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns same buffer for 0 degrees', async () => {
|
|
39
|
+
const result = await rotate(sampleJpeg, { angle: 0 })
|
|
40
|
+
expect(result.ok).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|