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
|
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs'
|
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
import { addText } from '../../src/ops/add-text.js'
|
|
6
|
+
import sharp from 'sharp'
|
|
6
7
|
|
|
7
8
|
const __filename = fileURLToPath(import.meta.url)
|
|
8
9
|
const __dirname = dirname(__filename)
|
|
@@ -74,4 +75,113 @@ describe('addText', () => {
|
|
|
74
75
|
expect((result as any).bounds).toBeDefined()
|
|
75
76
|
expect((result as any).bounds.contentBottom).toBeGreaterThan(50)
|
|
76
77
|
})
|
|
78
|
+
|
|
79
|
+
// ── Spans tests ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('spans', () => {
|
|
82
|
+
it('renders mixed bold+normal spans in one line', async () => {
|
|
83
|
+
const result = await addText(sampleJpeg, {
|
|
84
|
+
layers: [{
|
|
85
|
+
text: '',
|
|
86
|
+
x: 20, y: 50, fontSize: 28, color: '#333',
|
|
87
|
+
spans: [
|
|
88
|
+
{ text: 'normal ' },
|
|
89
|
+
{ text: 'bold part', bold: true, color: '#000' },
|
|
90
|
+
]
|
|
91
|
+
}]
|
|
92
|
+
})
|
|
93
|
+
expect(result.ok).toBe(true)
|
|
94
|
+
if (!result.ok) return
|
|
95
|
+
const meta = await sharp(result.data).metadata()
|
|
96
|
+
expect(meta.width).toBe(400)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('renders italic and custom fontSize spans', async () => {
|
|
100
|
+
const result = await addText(sampleJpeg, {
|
|
101
|
+
layers: [{
|
|
102
|
+
text: '',
|
|
103
|
+
x: 20, y: 50, fontSize: 24, color: '#333',
|
|
104
|
+
spans: [
|
|
105
|
+
{ text: 'normal ' },
|
|
106
|
+
{ text: 'italic small', italic: true, fontSize: 16, color: '#666' },
|
|
107
|
+
]
|
|
108
|
+
}]
|
|
109
|
+
})
|
|
110
|
+
expect(result.ok).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('handles newline in span text', async () => {
|
|
114
|
+
const result = await addText(sampleJpeg, {
|
|
115
|
+
layers: [{
|
|
116
|
+
text: '',
|
|
117
|
+
x: 20, y: 50, fontSize: 24, color: '#333',
|
|
118
|
+
spans: [
|
|
119
|
+
{ text: 'line one\n' },
|
|
120
|
+
{ text: 'line two', bold: true },
|
|
121
|
+
]
|
|
122
|
+
}]
|
|
123
|
+
})
|
|
124
|
+
expect(result.ok).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('renders highlight background behind span', async () => {
|
|
128
|
+
const result = await addText(sampleJpeg, {
|
|
129
|
+
layers: [{
|
|
130
|
+
text: '',
|
|
131
|
+
x: 20, y: 50, fontSize: 24, color: '#333',
|
|
132
|
+
spans: [
|
|
133
|
+
{ text: 'highlighted', highlight: '#FFFF00' },
|
|
134
|
+
{ text: ' normal' },
|
|
135
|
+
]
|
|
136
|
+
}]
|
|
137
|
+
})
|
|
138
|
+
expect(result.ok).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('warns when maxWidth used with spans', async () => {
|
|
142
|
+
const result = await addText(sampleJpeg, {
|
|
143
|
+
layers: [{
|
|
144
|
+
text: '',
|
|
145
|
+
x: 20, y: 50, fontSize: 24, color: '#333',
|
|
146
|
+
maxWidth: 200,
|
|
147
|
+
spans: [{ text: 'hello' }]
|
|
148
|
+
}]
|
|
149
|
+
})
|
|
150
|
+
expect(result.ok).toBe(true)
|
|
151
|
+
if (!result.ok) return
|
|
152
|
+
expect(result.warnings).toContain(
|
|
153
|
+
'maxWidth is not supported with spans'
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('warns when both text and spans are provided', async () => {
|
|
158
|
+
const result = await addText(sampleJpeg, {
|
|
159
|
+
layers: [{
|
|
160
|
+
text: 'this should be ignored',
|
|
161
|
+
x: 20, y: 50, fontSize: 24, color: '#333',
|
|
162
|
+
spans: [{ text: 'spans win' }]
|
|
163
|
+
}]
|
|
164
|
+
})
|
|
165
|
+
expect(result.ok).toBe(true)
|
|
166
|
+
if (!result.ok) return
|
|
167
|
+
expect(result.warnings).toContain(
|
|
168
|
+
'text field ignored when spans is provided'
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('renders spans with background box', async () => {
|
|
173
|
+
const result = await addText(sampleJpeg, {
|
|
174
|
+
layers: [{
|
|
175
|
+
text: '',
|
|
176
|
+
x: 20, y: 50, fontSize: 24, color: '#333',
|
|
177
|
+
background: { color: '#EEE', padding: 8 },
|
|
178
|
+
spans: [
|
|
179
|
+
{ text: 'with ' },
|
|
180
|
+
{ text: 'background', bold: true },
|
|
181
|
+
]
|
|
182
|
+
}]
|
|
183
|
+
})
|
|
184
|
+
expect(result.ok).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
77
187
|
})
|
|
@@ -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,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveFontUrl } from '../../src/utils/font-loader.js';
|
|
3
|
+
|
|
4
|
+
describe('resolveFontUrl', () => {
|
|
5
|
+
it('passes through file:// URLs unchanged', async () => {
|
|
6
|
+
const result = await resolveFontUrl('file:///usr/share/fonts/truetype/noto/NotoSans.ttf');
|
|
7
|
+
expect(result).toBe('file:///usr/share/fonts/truetype/noto/NotoSans.ttf');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('converts absolute paths to file:// URLs', async () => {
|
|
11
|
+
const result = await resolveFontUrl('/home/user/fonts/MyFont.ttf');
|
|
12
|
+
expect(result).toMatch(/^file:\/\//);
|
|
13
|
+
expect(result).toContain('MyFont.ttf');
|
|
14
|
+
expect(result).toBe('file:///home/user/fonts/MyFont.ttf');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('downloads and caches a direct woff2 URL', async () => {
|
|
18
|
+
// Use a real small font file from Google's CDN
|
|
19
|
+
const url = 'https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2';
|
|
20
|
+
const result = await resolveFontUrl(url);
|
|
21
|
+
expect(result).toMatch(/^file:\/\//);
|
|
22
|
+
expect(result).toMatch(/\.woff2$/);
|
|
23
|
+
}, { timeout: 15000 });
|
|
24
|
+
|
|
25
|
+
it('returns cached file on second call (no re-download)', async () => {
|
|
26
|
+
const url = 'https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2';
|
|
27
|
+
const first = await resolveFontUrl(url);
|
|
28
|
+
const second = await resolveFontUrl(url);
|
|
29
|
+
// Both should return identical paths (cache hit)
|
|
30
|
+
expect(second).toBe(first);
|
|
31
|
+
}, { timeout: 15000 });
|
|
32
|
+
|
|
33
|
+
it('resolves Google Fonts CSS URL to local file', async () => {
|
|
34
|
+
const url = 'https://fonts.googleapis.com/css2?family=Inter&display=swap';
|
|
35
|
+
const result = await resolveFontUrl(url);
|
|
36
|
+
expect(result).toMatch(/^file:\/\//);
|
|
37
|
+
expect(result).toMatch(/\.woff2$/);
|
|
38
|
+
}, { timeout: 15000 });
|
|
39
|
+
});
|
|
@@ -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
|
+
})
|