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.
Files changed (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/mcp/tools.d.ts.map +1 -1
  7. package/dist/mcp/tools.js +90 -0
  8. package/dist/mcp/tools.js.map +1 -1
  9. package/dist/ops/add-text.d.ts +28 -0
  10. package/dist/ops/add-text.d.ts.map +1 -1
  11. package/dist/ops/add-text.js +255 -40
  12. package/dist/ops/add-text.js.map +1 -1
  13. package/dist/ops/clip-to-shape.d.ts +3 -0
  14. package/dist/ops/clip-to-shape.d.ts.map +1 -0
  15. package/dist/ops/clip-to-shape.js +58 -0
  16. package/dist/ops/clip-to-shape.js.map +1 -0
  17. package/dist/ops/draw-shape.d.ts +3 -0
  18. package/dist/ops/draw-shape.d.ts.map +1 -0
  19. package/dist/ops/draw-shape.js +54 -0
  20. package/dist/ops/draw-shape.js.map +1 -0
  21. package/dist/ops/drop-shadow.d.ts +3 -0
  22. package/dist/ops/drop-shadow.d.ts.map +1 -0
  23. package/dist/ops/drop-shadow.js +54 -0
  24. package/dist/ops/drop-shadow.js.map +1 -0
  25. package/dist/ops/gradient-overlay.d.ts +3 -0
  26. package/dist/ops/gradient-overlay.d.ts.map +1 -0
  27. package/dist/ops/gradient-overlay.js +49 -0
  28. package/dist/ops/gradient-overlay.js.map +1 -0
  29. package/dist/ops/pipeline.d.ts.map +1 -1
  30. package/dist/ops/pipeline.js +16 -0
  31. package/dist/ops/pipeline.js.map +1 -1
  32. package/dist/ops/rotate.d.ts +3 -0
  33. package/dist/ops/rotate.d.ts.map +1 -0
  34. package/dist/ops/rotate.js +25 -0
  35. package/dist/ops/rotate.js.map +1 -0
  36. package/dist/types.d.ts +123 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/utils/font-loader.d.ts +26 -0
  39. package/dist/utils/font-loader.d.ts.map +1 -0
  40. package/dist/utils/font-loader.js +103 -0
  41. package/dist/utils/font-loader.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/index.ts +5 -0
  44. package/src/mcp/tools.ts +86 -0
  45. package/src/ops/add-text.ts +283 -45
  46. package/src/ops/clip-to-shape.ts +63 -0
  47. package/src/ops/draw-shape.ts +58 -0
  48. package/src/ops/drop-shadow.ts +60 -0
  49. package/src/ops/gradient-overlay.ts +62 -0
  50. package/src/ops/pipeline.ts +9 -0
  51. package/src/ops/rotate.ts +27 -0
  52. package/src/types.ts +131 -1
  53. package/src/utils/font-loader.ts +119 -0
  54. package/tests/integration/font-url.test.ts +62 -0
  55. package/tests/unit/add-text.test.ts +110 -0
  56. package/tests/unit/clip-to-shape.test.ts +36 -0
  57. package/tests/unit/draw-shape.test.ts +34 -0
  58. package/tests/unit/drop-shadow.test.ts +42 -0
  59. package/tests/unit/font-loader.test.ts +39 -0
  60. package/tests/unit/gradient-overlay.test.ts +29 -0
  61. 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
+ })