image-edit-tools 1.0.5 → 1.0.8
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/CHANGELOG.md +23 -0
- 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 +28 -0
- package/dist/ops/add-text.d.ts.map +1 -1
- package/dist/ops/add-text.js +255 -40
- 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 +123 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/font-loader.d.ts +26 -0
- package/dist/utils/font-loader.d.ts.map +1 -0
- package/dist/utils/font-loader.js +103 -0
- package/dist/utils/font-loader.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/mcp/tools.ts +86 -0
- package/src/ops/add-text.ts +283 -45
- 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 +131 -1
- package/src/utils/font-loader.ts +119 -0
- package/tests/integration/font-url.test.ts +62 -0
- package/tests/unit/add-text.test.ts +110 -0
- 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/font-loader.test.ts +39 -0
- package/tests/unit/gradient-overlay.test.ts +29 -0
- package/tests/unit/rotate.test.ts +42 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { ClipToShapeOptions, 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 buildClipSvg(width: number, height: number, options: ClipToShapeOptions): string {
|
|
8
|
+
const { shape } = options;
|
|
9
|
+
let clipContent: string;
|
|
10
|
+
|
|
11
|
+
switch (shape) {
|
|
12
|
+
case 'circle': {
|
|
13
|
+
const r = Math.min(width, height) / 2;
|
|
14
|
+
const cx = width / 2;
|
|
15
|
+
const cy = height / 2;
|
|
16
|
+
clipContent = `<circle cx="${cx}" cy="${cy}" r="${r}"/>`;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
case 'ellipse': {
|
|
20
|
+
clipContent = `<ellipse cx="${width / 2}" cy="${height / 2}" rx="${width / 2}" ry="${height / 2}"/>`;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
case 'rounded-rect': {
|
|
24
|
+
const radius = options.borderRadius ?? 32;
|
|
25
|
+
clipContent = `<rect width="${width}" height="${height}" rx="${radius}" ry="${radius}"/>`;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
default:
|
|
29
|
+
clipContent = `<rect width="${width}" height="${height}"/>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Create an SVG mask: white shape on black = keep shape area
|
|
33
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
34
|
+
${clipContent.replace('/>', ' fill="white"/>')}
|
|
35
|
+
</svg>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function clipToShape(input: ImageInput, options: ClipToShapeOptions): Promise<ImageResult> {
|
|
39
|
+
try {
|
|
40
|
+
const buffer = await loadImage(input);
|
|
41
|
+
const meta = await getImageMetadata(buffer);
|
|
42
|
+
const { width, height } = meta;
|
|
43
|
+
|
|
44
|
+
const maskSvg = buildClipSvg(width, height, options);
|
|
45
|
+
const mask = await sharp(Buffer.from(maskSvg))
|
|
46
|
+
.resize(width, height)
|
|
47
|
+
.grayscale()
|
|
48
|
+
.toBuffer();
|
|
49
|
+
|
|
50
|
+
const output = await sharp(buffer)
|
|
51
|
+
.ensureAlpha()
|
|
52
|
+
.composite([{
|
|
53
|
+
input: mask,
|
|
54
|
+
blend: 'dest-in'
|
|
55
|
+
}])
|
|
56
|
+
.png()
|
|
57
|
+
.toBuffer();
|
|
58
|
+
|
|
59
|
+
return ok(output);
|
|
60
|
+
} catch (e: any) {
|
|
61
|
+
return err(e.message || 'Clip to shape failed', ErrorCode.PROCESSING_FAILED);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -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
|
@@ -112,6 +112,26 @@ export interface TextBackground {
|
|
|
112
112
|
borderRadius?: number;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A styled inline span within a text layer.
|
|
117
|
+
* Each span maps to a single SVG `<tspan>` element.
|
|
118
|
+
* Unset fields inherit from the parent `TextLayer`.
|
|
119
|
+
*/
|
|
120
|
+
export interface TextSpan {
|
|
121
|
+
/** The text content for this span */
|
|
122
|
+
text: string;
|
|
123
|
+
/** Override color for this span. Inherits `layer.color` if omitted. */
|
|
124
|
+
color?: string;
|
|
125
|
+
/** If true, renders with `font-weight: bold` */
|
|
126
|
+
bold?: boolean;
|
|
127
|
+
/** If true, renders with `font-style: italic` */
|
|
128
|
+
italic?: boolean;
|
|
129
|
+
/** Override fontSize for this span. Inherits `layer.fontSize` if omitted. */
|
|
130
|
+
fontSize?: number;
|
|
131
|
+
/** Background highlight color for this span */
|
|
132
|
+
highlight?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
export interface TextLayer {
|
|
116
136
|
text: string;
|
|
117
137
|
x: number;
|
|
@@ -128,6 +148,23 @@ export interface TextLayer {
|
|
|
128
148
|
maxWidth?: number;
|
|
129
149
|
lineHeight?: number;
|
|
130
150
|
background?: TextBackground;
|
|
151
|
+
/** Letter spacing in pixels. Default: 0 */
|
|
152
|
+
letterSpacing?: number;
|
|
153
|
+
/** Text stroke (outline) */
|
|
154
|
+
stroke?: { color: string; width: number };
|
|
155
|
+
/** Text shadow */
|
|
156
|
+
textShadow?: { color: string; offsetX: number; offsetY: number; blur?: number };
|
|
157
|
+
/**
|
|
158
|
+
* Inline mixed-style spans.
|
|
159
|
+
* When provided, `text` field is ignored and this array is rendered instead.
|
|
160
|
+
* Use `\n` within span text or a separate `{ text: '\n' }` span for line breaks.
|
|
161
|
+
* @example
|
|
162
|
+
* spans: [
|
|
163
|
+
* { text: '캠핑장, 북스테이 등 ' },
|
|
164
|
+
* { text: '다양한 주제별 숙소 추천', bold: true, color: '#1A1A1A' },
|
|
165
|
+
* ]
|
|
166
|
+
*/
|
|
167
|
+
spans?: TextSpan[];
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
// ─── Composite ────────────────────────────────────────────────────────────────
|
|
@@ -245,6 +282,94 @@ export interface ImageMetadata {
|
|
|
245
282
|
exif?: Record<string, unknown>;
|
|
246
283
|
}
|
|
247
284
|
|
|
285
|
+
// ─── Rotate ───────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
export interface RotateOptions {
|
|
288
|
+
/** Rotation angle in degrees (0–360). Positive = clockwise. */
|
|
289
|
+
angle: number;
|
|
290
|
+
/** Background color for exposed areas. Default: 'transparent' */
|
|
291
|
+
background?: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── GradientOverlay ──────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export type GradientDirection = 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
297
|
+
|
|
298
|
+
export interface GradientOverlayOptions {
|
|
299
|
+
/** Direction the gradient fades FROM (opaque end). Default: 'bottom' */
|
|
300
|
+
direction?: GradientDirection;
|
|
301
|
+
/** Gradient color. Default: '#000000' */
|
|
302
|
+
color?: string;
|
|
303
|
+
/** Opacity of the opaque end. 0–1. Default: 0.7 */
|
|
304
|
+
opacity?: number;
|
|
305
|
+
/** How much of the image the gradient covers. 0–1. Default: 0.5 */
|
|
306
|
+
coverage?: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── ClipToShape ──────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
export type ClipShape = 'circle' | 'ellipse' | 'rounded-rect';
|
|
312
|
+
|
|
313
|
+
export interface ClipToShapeOptions {
|
|
314
|
+
shape: ClipShape;
|
|
315
|
+
/** Border radius for 'rounded-rect'. Default: 32 */
|
|
316
|
+
borderRadius?: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── DrawShape ────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
export type ShapeType = 'rect' | 'circle' | 'ellipse' | 'line';
|
|
322
|
+
|
|
323
|
+
export interface DrawShapeOptions {
|
|
324
|
+
/** Canvas width */
|
|
325
|
+
width: number;
|
|
326
|
+
/** Canvas height */
|
|
327
|
+
height: number;
|
|
328
|
+
/** Shape to draw */
|
|
329
|
+
shape: ShapeType;
|
|
330
|
+
/** Fill color. Default: 'transparent' */
|
|
331
|
+
fill?: string;
|
|
332
|
+
/** Fill opacity. 0–1. Default: 1 */
|
|
333
|
+
fillOpacity?: number;
|
|
334
|
+
/** Stroke color */
|
|
335
|
+
stroke?: string;
|
|
336
|
+
/** Stroke width. Default: 0 */
|
|
337
|
+
strokeWidth?: number;
|
|
338
|
+
/** Border radius (rect only). Default: 0 */
|
|
339
|
+
borderRadius?: number;
|
|
340
|
+
/** Circle/ellipse center X. Defaults to canvas center */
|
|
341
|
+
cx?: number;
|
|
342
|
+
/** Circle/ellipse center Y. Defaults to canvas center */
|
|
343
|
+
cy?: number;
|
|
344
|
+
/** Circle radius or ellipse rx */
|
|
345
|
+
r?: number;
|
|
346
|
+
/** Ellipse ry */
|
|
347
|
+
ry?: number;
|
|
348
|
+
/** Line start X */
|
|
349
|
+
x1?: number;
|
|
350
|
+
/** Line start Y */
|
|
351
|
+
y1?: number;
|
|
352
|
+
/** Line end X */
|
|
353
|
+
x2?: number;
|
|
354
|
+
/** Line end Y */
|
|
355
|
+
y2?: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── DropShadow ───────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
export interface DropShadowOptions {
|
|
361
|
+
/** Shadow color. Default: 'rgba(0,0,0,0.5)' */
|
|
362
|
+
color?: string;
|
|
363
|
+
/** Shadow offset X. Default: 4 */
|
|
364
|
+
offsetX?: number;
|
|
365
|
+
/** Shadow offset Y. Default: 4 */
|
|
366
|
+
offsetY?: number;
|
|
367
|
+
/** Blur radius. Default: 8 */
|
|
368
|
+
blur?: number;
|
|
369
|
+
/** Expand canvas to fit shadow. Default: true */
|
|
370
|
+
expand?: boolean;
|
|
371
|
+
}
|
|
372
|
+
|
|
248
373
|
// ─── Pipeline ─────────────────────────────────────────────────────────────────
|
|
249
374
|
|
|
250
375
|
export type PipelineOperation =
|
|
@@ -259,9 +384,14 @@ export type PipelineOperation =
|
|
|
259
384
|
| ({ op: 'watermark' } & WatermarkOptions)
|
|
260
385
|
| ({ op: 'convert' } & ConvertOptions)
|
|
261
386
|
| ({ op: 'optimize' } & OptimizeOptions)
|
|
262
|
-
| ({ op: 'removeBg' } & RemoveBgOptions)
|
|
387
|
+
| ({ op: 'removeBg' } & RemoveBgOptions)
|
|
388
|
+
| ({ op: 'rotate' } & RotateOptions)
|
|
389
|
+
| ({ op: 'gradientOverlay' } & GradientOverlayOptions)
|
|
390
|
+
| ({ op: 'clipToShape' } & ClipToShapeOptions)
|
|
391
|
+
| ({ op: 'dropShadow' } & DropShadowOptions);
|
|
263
392
|
|
|
264
393
|
export interface BatchOptions {
|
|
265
394
|
concurrency?: number;
|
|
266
395
|
onProgress?: (done: number, total: number) => void;
|
|
267
396
|
}
|
|
397
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { writeFile, access } from 'fs/promises';
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
|
|
7
|
+
const CACHE_DIR = tmpdir();
|
|
8
|
+
const CACHE_PREFIX = 'iet-font-';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generates a deterministic cache file path for a given URL.
|
|
12
|
+
* Uses SHA-256 hash truncated to 16 hex chars to avoid filename collisions.
|
|
13
|
+
*
|
|
14
|
+
* @param url - The font binary URL to hash
|
|
15
|
+
* @returns Absolute path without extension in the OS temp directory
|
|
16
|
+
*/
|
|
17
|
+
function cacheKey(url: string): string {
|
|
18
|
+
return join(
|
|
19
|
+
CACHE_DIR,
|
|
20
|
+
CACHE_PREFIX + createHash('sha256').update(url).digest('hex').slice(0, 16),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the file extension from a URL, stripping query parameters.
|
|
26
|
+
*
|
|
27
|
+
* @param url - URL to extract extension from
|
|
28
|
+
* @returns File extension (e.g. 'woff2', 'ttf') or 'woff2' as default
|
|
29
|
+
*/
|
|
30
|
+
function extractExtension(url: string): string {
|
|
31
|
+
const lastSegment = url.split('/').pop() ?? '';
|
|
32
|
+
const withoutQuery = lastSegment.split('?')[0];
|
|
33
|
+
const ext = withoutQuery.split('.').pop() ?? 'woff2';
|
|
34
|
+
return ext;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a fontUrl to a local `file://` path usable by librsvg.
|
|
39
|
+
*
|
|
40
|
+
* Handles three cases:
|
|
41
|
+
* - `file://` or absolute path → returned as `file://` URI
|
|
42
|
+
* - `https://fonts.googleapis.com/css*` → fetches CSS, extracts font URL, downloads binary
|
|
43
|
+
* - Direct binary URL (`.woff`, `.woff2`, `.ttf`, `.otf`) → downloads and caches
|
|
44
|
+
*
|
|
45
|
+
* Cache is stored in `os.tmpdir()` with prefix `iet-font-`. No TTL (font files are immutable).
|
|
46
|
+
*
|
|
47
|
+
* @param fontUrl - The font URL to resolve
|
|
48
|
+
* @returns A `file://` URI pointing to a local font file
|
|
49
|
+
* @throws {Error} If network fetch fails or CSS contains no font URLs
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // Google Fonts CSS URL
|
|
53
|
+
* const path = await resolveFontUrl('https://fonts.googleapis.com/css2?family=Jua');
|
|
54
|
+
* // → 'file:///tmp/iet-font-abcdef1234567890.woff2'
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* // Direct font binary URL
|
|
58
|
+
* const path = await resolveFontUrl('https://example.com/font.woff2');
|
|
59
|
+
* // → 'file:///tmp/iet-font-0123456789abcdef.woff2'
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveFontUrl(fontUrl: string): Promise<string> {
|
|
62
|
+
// Already a local path — pass through
|
|
63
|
+
if (fontUrl.startsWith('file://')) {
|
|
64
|
+
return fontUrl;
|
|
65
|
+
}
|
|
66
|
+
if (fontUrl.startsWith('/')) {
|
|
67
|
+
return `file://${fontUrl}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Determine the actual binary URL to download
|
|
71
|
+
let binaryUrl = fontUrl;
|
|
72
|
+
|
|
73
|
+
if (fontUrl.includes('fonts.googleapis.com/css')) {
|
|
74
|
+
// Google Fonts CSS endpoint — fetch CSS and extract the font binary URL
|
|
75
|
+
const cssRes = await fetch(fontUrl, {
|
|
76
|
+
headers: {
|
|
77
|
+
// Desktop UA ensures we get woff2 (most compact modern format)
|
|
78
|
+
'User-Agent':
|
|
79
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
if (!cssRes.ok) {
|
|
83
|
+
throw new Error(`Google Fonts CSS fetch failed: ${cssRes.status}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const css = await cssRes.text();
|
|
87
|
+
|
|
88
|
+
// Extract all url() references pointing to font binary files
|
|
89
|
+
const urls = [...css.matchAll(/url\((https[^)]+\.(?:woff2?|ttf|otf)[^)]*)\)/g)].map(
|
|
90
|
+
(m) => m[1],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (urls.length === 0) {
|
|
94
|
+
throw new Error('No font URL found in Google Fonts CSS');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Prefer woff2 for smaller file size, fallback to first match
|
|
98
|
+
binaryUrl = urls.find((u) => u.endsWith('.woff2')) ?? urls[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check cache before downloading
|
|
102
|
+
const ext = extractExtension(binaryUrl);
|
|
103
|
+
const cacheFile = `${cacheKey(binaryUrl)}.${ext}`;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await access(cacheFile);
|
|
107
|
+
// Cache hit
|
|
108
|
+
return `file://${cacheFile}`;
|
|
109
|
+
} catch {
|
|
110
|
+
// Cache miss — download the binary
|
|
111
|
+
const res = await fetch(binaryUrl);
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
throw new Error(`Font download failed: ${res.status} ${binaryUrl}`);
|
|
114
|
+
}
|
|
115
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
116
|
+
await writeFile(cacheFile, buf);
|
|
117
|
+
return `file://${cacheFile}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { addText } from '../../src/ops/add-text.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('fontUrl integration', () => {
|
|
12
|
+
let samplePng: Buffer;
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
samplePng = fixture('sample.png');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders text with Google Fonts URL (Korean)', async () => {
|
|
19
|
+
const result = await addText(samplePng, {
|
|
20
|
+
layers: [{
|
|
21
|
+
text: '한국어 테스트',
|
|
22
|
+
x: 20, y: 50,
|
|
23
|
+
fontSize: 32, color: '#000',
|
|
24
|
+
fontFamily: 'Jua',
|
|
25
|
+
fontUrl: 'https://fonts.googleapis.com/css2?family=Jua&display=swap',
|
|
26
|
+
anchor: 'top-left',
|
|
27
|
+
}]
|
|
28
|
+
});
|
|
29
|
+
expect(result.ok).toBe(true);
|
|
30
|
+
}, { timeout: 30000 });
|
|
31
|
+
|
|
32
|
+
it('renders text with direct woff2 font URL', async () => {
|
|
33
|
+
const result = await addText(samplePng, {
|
|
34
|
+
layers: [{
|
|
35
|
+
text: 'Direct Font Test',
|
|
36
|
+
x: 20, y: 50,
|
|
37
|
+
fontSize: 28, color: '#333',
|
|
38
|
+
fontFamily: 'Inter',
|
|
39
|
+
fontUrl: 'https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2',
|
|
40
|
+
anchor: 'top-left',
|
|
41
|
+
}]
|
|
42
|
+
});
|
|
43
|
+
expect(result.ok).toBe(true);
|
|
44
|
+
}, { timeout: 15000 });
|
|
45
|
+
|
|
46
|
+
it('renders spans with Google Fonts URL', async () => {
|
|
47
|
+
const result = await addText(samplePng, {
|
|
48
|
+
layers: [{
|
|
49
|
+
text: '',
|
|
50
|
+
x: 20, y: 50,
|
|
51
|
+
fontSize: 28, color: '#333',
|
|
52
|
+
fontFamily: 'Jua',
|
|
53
|
+
fontUrl: 'https://fonts.googleapis.com/css2?family=Jua&display=swap',
|
|
54
|
+
spans: [
|
|
55
|
+
{ text: '캠핑장, 북스테이 등 ' },
|
|
56
|
+
{ text: '다양한 주제별 숙소 추천', bold: true, color: '#1A1A1A' },
|
|
57
|
+
]
|
|
58
|
+
}]
|
|
59
|
+
});
|
|
60
|
+
expect(result.ok).toBe(true);
|
|
61
|
+
}, { timeout: 30000 });
|
|
62
|
+
});
|