image-edit-tools 1.0.6 → 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/ops/add-text.d.ts +28 -0
- package/dist/ops/add-text.d.ts.map +1 -1
- package/dist/ops/add-text.js +241 -47
- package/dist/ops/add-text.js.map +1 -1
- package/dist/types.d.ts +30 -0
- 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/ops/add-text.ts +267 -53
- package/src/types.ts +31 -0
- 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/font-loader.test.ts +39 -0
|
@@ -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
|
+
});
|
|
@@ -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,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
|
+
});
|