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
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Resolves a fontUrl to a local `file://` path usable by librsvg.
3
+ *
4
+ * Handles three cases:
5
+ * - `file://` or absolute path → returned as `file://` URI
6
+ * - `https://fonts.googleapis.com/css*` → fetches CSS, extracts font URL, downloads binary
7
+ * - Direct binary URL (`.woff`, `.woff2`, `.ttf`, `.otf`) → downloads and caches
8
+ *
9
+ * Cache is stored in `os.tmpdir()` with prefix `iet-font-`. No TTL (font files are immutable).
10
+ *
11
+ * @param fontUrl - The font URL to resolve
12
+ * @returns A `file://` URI pointing to a local font file
13
+ * @throws {Error} If network fetch fails or CSS contains no font URLs
14
+ *
15
+ * @example
16
+ * // Google Fonts CSS URL
17
+ * const path = await resolveFontUrl('https://fonts.googleapis.com/css2?family=Jua');
18
+ * // → 'file:///tmp/iet-font-abcdef1234567890.woff2'
19
+ *
20
+ * @example
21
+ * // Direct font binary URL
22
+ * const path = await resolveFontUrl('https://example.com/font.woff2');
23
+ * // → 'file:///tmp/iet-font-0123456789abcdef.woff2'
24
+ */
25
+ export declare function resolveFontUrl(fontUrl: string): Promise<string>;
26
+ //# sourceMappingURL=font-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"font-loader.d.ts","sourceRoot":"","sources":["../../src/utils/font-loader.ts"],"names":[],"mappings":"AAoCA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA0DrE"}
@@ -0,0 +1,103 @@
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
+ const CACHE_DIR = tmpdir();
7
+ const CACHE_PREFIX = 'iet-font-';
8
+ /**
9
+ * Generates a deterministic cache file path for a given URL.
10
+ * Uses SHA-256 hash truncated to 16 hex chars to avoid filename collisions.
11
+ *
12
+ * @param url - The font binary URL to hash
13
+ * @returns Absolute path without extension in the OS temp directory
14
+ */
15
+ function cacheKey(url) {
16
+ return join(CACHE_DIR, CACHE_PREFIX + createHash('sha256').update(url).digest('hex').slice(0, 16));
17
+ }
18
+ /**
19
+ * Extracts the file extension from a URL, stripping query parameters.
20
+ *
21
+ * @param url - URL to extract extension from
22
+ * @returns File extension (e.g. 'woff2', 'ttf') or 'woff2' as default
23
+ */
24
+ function extractExtension(url) {
25
+ const lastSegment = url.split('/').pop() ?? '';
26
+ const withoutQuery = lastSegment.split('?')[0];
27
+ const ext = withoutQuery.split('.').pop() ?? 'woff2';
28
+ return ext;
29
+ }
30
+ /**
31
+ * Resolves a fontUrl to a local `file://` path usable by librsvg.
32
+ *
33
+ * Handles three cases:
34
+ * - `file://` or absolute path → returned as `file://` URI
35
+ * - `https://fonts.googleapis.com/css*` → fetches CSS, extracts font URL, downloads binary
36
+ * - Direct binary URL (`.woff`, `.woff2`, `.ttf`, `.otf`) → downloads and caches
37
+ *
38
+ * Cache is stored in `os.tmpdir()` with prefix `iet-font-`. No TTL (font files are immutable).
39
+ *
40
+ * @param fontUrl - The font URL to resolve
41
+ * @returns A `file://` URI pointing to a local font file
42
+ * @throws {Error} If network fetch fails or CSS contains no font URLs
43
+ *
44
+ * @example
45
+ * // Google Fonts CSS URL
46
+ * const path = await resolveFontUrl('https://fonts.googleapis.com/css2?family=Jua');
47
+ * // → 'file:///tmp/iet-font-abcdef1234567890.woff2'
48
+ *
49
+ * @example
50
+ * // Direct font binary URL
51
+ * const path = await resolveFontUrl('https://example.com/font.woff2');
52
+ * // → 'file:///tmp/iet-font-0123456789abcdef.woff2'
53
+ */
54
+ export async function resolveFontUrl(fontUrl) {
55
+ // Already a local path — pass through
56
+ if (fontUrl.startsWith('file://')) {
57
+ return fontUrl;
58
+ }
59
+ if (fontUrl.startsWith('/')) {
60
+ return `file://${fontUrl}`;
61
+ }
62
+ // Determine the actual binary URL to download
63
+ let binaryUrl = fontUrl;
64
+ if (fontUrl.includes('fonts.googleapis.com/css')) {
65
+ // Google Fonts CSS endpoint — fetch CSS and extract the font binary URL
66
+ const cssRes = await fetch(fontUrl, {
67
+ headers: {
68
+ // Desktop UA ensures we get woff2 (most compact modern format)
69
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
70
+ },
71
+ });
72
+ if (!cssRes.ok) {
73
+ throw new Error(`Google Fonts CSS fetch failed: ${cssRes.status}`);
74
+ }
75
+ const css = await cssRes.text();
76
+ // Extract all url() references pointing to font binary files
77
+ const urls = [...css.matchAll(/url\((https[^)]+\.(?:woff2?|ttf|otf)[^)]*)\)/g)].map((m) => m[1]);
78
+ if (urls.length === 0) {
79
+ throw new Error('No font URL found in Google Fonts CSS');
80
+ }
81
+ // Prefer woff2 for smaller file size, fallback to first match
82
+ binaryUrl = urls.find((u) => u.endsWith('.woff2')) ?? urls[0];
83
+ }
84
+ // Check cache before downloading
85
+ const ext = extractExtension(binaryUrl);
86
+ const cacheFile = `${cacheKey(binaryUrl)}.${ext}`;
87
+ try {
88
+ await access(cacheFile);
89
+ // Cache hit
90
+ return `file://${cacheFile}`;
91
+ }
92
+ catch {
93
+ // Cache miss — download the binary
94
+ const res = await fetch(binaryUrl);
95
+ if (!res.ok) {
96
+ throw new Error(`Font download failed: ${res.status} ${binaryUrl}`);
97
+ }
98
+ const buf = Buffer.from(await res.arrayBuffer());
99
+ await writeFile(cacheFile, buf);
100
+ return `file://${cacheFile}`;
101
+ }
102
+ }
103
+ //# sourceMappingURL=font-loader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"font-loader.js","sourceRoot":"","sources":["../../src/utils/font-loader.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "image-edit-tools",
3
- "version": "1.0.5",
3
+ "version": "1.0.8",
4
4
  "description": "Deterministic image editing SDK for AI agents. Ships with MCP tools.",
5
5
  "author": "swimmingkiim",
6
6
  "homepage": "https://github.com/swimmingkiim/image-edit-tools#readme",
package/src/index.ts CHANGED
@@ -22,3 +22,8 @@ export { detectFaces } from './ops/detect-faces.js';
22
22
  export { extractText } from './ops/extract-text.js';
23
23
  export { pipeline } from './ops/pipeline.js';
24
24
  export { batch } from './ops/batch.js';
25
+ export { rotate } from './ops/rotate.js';
26
+ export { gradientOverlay } from './ops/gradient-overlay.js';
27
+ export { clipToShape } from './ops/clip-to-shape.js';
28
+ export { drawShape } from './ops/draw-shape.js';
29
+ export { dropShadow } from './ops/drop-shadow.js';
package/src/mcp/tools.ts CHANGED
@@ -219,6 +219,80 @@ export const allTools: Tool[] = [
219
219
  },
220
220
  required: ['images', 'operation', 'options']
221
221
  }
222
+ },
223
+ {
224
+ name: 'image_rotate',
225
+ description: 'Rotates an image by an arbitrary angle. Exposed areas are transparent by default.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ image: { type: 'string' },
230
+ angle: { type: 'number', description: 'Rotation angle in degrees (0-360), clockwise' },
231
+ background: { type: 'string', description: 'Background color for exposed areas. Default: transparent' }
232
+ },
233
+ required: ['image', 'angle']
234
+ }
235
+ },
236
+ {
237
+ name: 'image_gradient_overlay',
238
+ description: 'Applies a gradient overlay for text readability. Great for placing text over photos.',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ image: { type: 'string' },
243
+ direction: { type: 'string', enum: ['top','bottom','left','right','top-left','top-right','bottom-left','bottom-right'] },
244
+ color: { type: 'string', description: 'Gradient color. Default: #000000' },
245
+ opacity: { type: 'number', description: '0-1. Default: 0.7' },
246
+ coverage: { type: 'number', description: '0-1 how much of image is covered. Default: 0.5' }
247
+ },
248
+ required: ['image']
249
+ }
250
+ },
251
+ {
252
+ name: 'image_clip_to_shape',
253
+ description: 'Clips an image to a shape: circle, ellipse, or rounded-rect. Perfect for profile photos.',
254
+ inputSchema: {
255
+ type: 'object',
256
+ properties: {
257
+ image: { type: 'string' },
258
+ shape: { type: 'string', enum: ['circle', 'ellipse', 'rounded-rect'] },
259
+ borderRadius: { type: 'number', description: 'For rounded-rect. Default: 32' }
260
+ },
261
+ required: ['image', 'shape']
262
+ }
263
+ },
264
+ {
265
+ name: 'image_draw_shape',
266
+ description: 'Creates a new image containing a shape (rect, circle, ellipse, line). Use with composite to layer.',
267
+ inputSchema: {
268
+ type: 'object',
269
+ properties: {
270
+ width: { type: 'number' }, height: { type: 'number' },
271
+ shape: { type: 'string', enum: ['rect', 'circle', 'ellipse', 'line'] },
272
+ fill: { type: 'string' }, fillOpacity: { type: 'number' },
273
+ stroke: { type: 'string' }, strokeWidth: { type: 'number' },
274
+ borderRadius: { type: 'number' },
275
+ cx: { type: 'number' }, cy: { type: 'number' }, r: { type: 'number' }, ry: { type: 'number' },
276
+ x1: { type: 'number' }, y1: { type: 'number' }, x2: { type: 'number' }, y2: { type: 'number' }
277
+ },
278
+ required: ['width', 'height', 'shape']
279
+ }
280
+ },
281
+ {
282
+ name: 'image_drop_shadow',
283
+ description: 'Adds a drop shadow behind the image. Expands canvas to fit shadow.',
284
+ inputSchema: {
285
+ type: 'object',
286
+ properties: {
287
+ image: { type: 'string' },
288
+ color: { type: 'string', description: 'Shadow color. Default: rgba(0,0,0,0.5)' },
289
+ offsetX: { type: 'number', description: 'Default: 4' },
290
+ offsetY: { type: 'number', description: 'Default: 4' },
291
+ blur: { type: 'number', description: 'Blur radius. Default: 8' },
292
+ expand: { type: 'boolean', description: 'Expand canvas. Default: true' }
293
+ },
294
+ required: ['image']
295
+ }
222
296
  }
223
297
  ];
224
298
 
@@ -260,6 +334,18 @@ export async function handleTool(name: string, args: Record<string, any>): Promi
260
334
  const txt = await api.extractText(image, args);
261
335
  return JSON.stringify(txt);
262
336
  }
337
+ else if (name === 'image_rotate') result = await api.rotate(image, args as any);
338
+ else if (name === 'image_gradient_overlay') result = await api.gradientOverlay(image, args as any);
339
+ else if (name === 'image_clip_to_shape') result = await api.clipToShape(image, args as any);
340
+ else if (name === 'image_draw_shape') {
341
+ result = await api.drawShape(args as any);
342
+ if (result && result.ok && Buffer.isBuffer(result.data)) {
343
+ const b64 = result.data.toString('base64');
344
+ return JSON.stringify({ ok: true, data: `data:image/png;base64,${b64}` });
345
+ }
346
+ return JSON.stringify(result);
347
+ }
348
+ else if (name === 'image_drop_shadow') result = await api.dropShadow(image, args as any);
263
349
  else {
264
350
  return JSON.stringify({ error: `Tool ${name} not implemented`, code: 'INVALID_INPUT' });
265
351
  }
@@ -1,9 +1,19 @@
1
1
  import sharp from 'sharp';
2
- import { TextLayer, ImageInput, ImageResult, ErrorCode, TextAnchor } from '../types.js';
2
+ import { TextLayer, TextSpan, ImageInput, ImageResult, ErrorCode, TextAnchor } from '../types.js';
3
3
  import { loadImage } from '../utils/load-image.js';
4
4
  import { err, ok } from '../utils/result.js';
5
5
  import { getImageMetadata } from '../utils/validate.js';
6
+ import { resolveFontUrl } from '../utils/font-loader.js';
6
7
 
8
+ /**
9
+ * Wraps text into lines that fit within a maximum pixel width.
10
+ * Uses a character-width approximation of `fontSize * 0.6`.
11
+ *
12
+ * @param text - The text to wrap
13
+ * @param fontSize - Font size in pixels
14
+ * @param maxWidth - Maximum line width in pixels (optional)
15
+ * @returns Array of text lines
16
+ */
7
17
  function wrapText(text: string, fontSize: number, maxWidth?: number): string[] {
8
18
  if (!maxWidth) return [text];
9
19
  const charWidth = fontSize * 0.6; // Approximation
@@ -24,6 +34,12 @@ function wrapText(text: string, fontSize: number, maxWidth?: number): string[] {
24
34
  return lines;
25
35
  }
26
36
 
37
+ /**
38
+ * Escapes special XML characters to prevent SVG injection.
39
+ *
40
+ * @param text - Raw text to escape
41
+ * @returns XML-safe string
42
+ */
27
43
  function escapeXml(text: string): string {
28
44
  return text
29
45
  .replace(/&/g, '&amp;')
@@ -33,23 +49,26 @@ function escapeXml(text: string): string {
33
49
  .replace(/'/g, '&apos;');
34
50
  }
35
51
 
52
+ /**
53
+ * Computes SVG text-anchor and y-offset based on the anchor setting.
54
+ * librsvg only reliably supports `dominant-baseline: auto` (alphabetic),
55
+ * so vertical alignment is achieved via manual y-offset.
56
+ *
57
+ * @param anchor - The text anchor position
58
+ * @param fontSize - Font size for offset calculation
59
+ * @returns Object with `textAnchor` SVG attribute and `yOffset` pixel shift
60
+ */
36
61
  function getAnchorProps(anchor: TextAnchor = 'top-left', fontSize: number = 24): { textAnchor: string, yOffset: number } {
37
62
  const parts = anchor.split('-');
38
63
  const yAlign = parts.length === 2 ? parts[0] : parts[0] === 'center' ? 'middle' : parts[0];
39
64
  const xAlign = parts.length === 2 ? parts[1] : parts[0] === 'center' ? 'center' : 'left';
40
65
 
41
- // librsvg does NOT reliably support dominant-baseline values other than 'auto' (alphabetic).
42
- // Instead of relying on dominant-baseline, we compute a y-offset to position text correctly.
43
- // With 'auto' (alphabetic baseline), y = text baseline (bottom of caps).
44
- // To make y = text top, we shift down by ~0.8 * fontSize.
45
- // To make y = text middle, we shift down by ~0.35 * fontSize.
46
66
  let yOffset = 0;
47
67
  if (yAlign === 'top') {
48
68
  yOffset = Math.round(fontSize * 0.8);
49
69
  } else if (yAlign === 'middle' || yAlign === 'center') {
50
70
  yOffset = Math.round(fontSize * 0.35);
51
71
  }
52
- // 'bottom' / 'auto' → yOffset = 0 (alphabetic baseline is already at y)
53
72
 
54
73
  let textAnchor = 'start';
55
74
  if (xAlign === 'center') textAnchor = 'middle';
@@ -58,6 +77,127 @@ function getAnchorProps(anchor: TextAnchor = 'top-left', fontSize: number = 24):
58
77
  return { textAnchor, yOffset };
59
78
  }
60
79
 
80
+ /**
81
+ * Builds SVG `<tspan>` elements from an array of inline spans.
82
+ * Handles style inheritance, `\n` line breaks, and highlight rects.
83
+ *
84
+ * @param spans - Array of TextSpan objects
85
+ * @param layer - Parent TextLayer for default values
86
+ * @param renderY - The computed y position for the text element
87
+ * @returns Object containing `tspanSvg`, `highlightSvg`, and `approxMaxWidth`
88
+ */
89
+ function buildSpansSvg(
90
+ spans: TextSpan[],
91
+ layer: TextLayer,
92
+ renderY: number,
93
+ ): { tspanSvg: string; highlightSvg: string; approxMaxWidth: number } {
94
+ const baseFontSize = layer.fontSize ?? 24;
95
+ const baseColor = layer.color ?? '#000000';
96
+ const lineHeight = layer.lineHeight ?? 1.2;
97
+
98
+ let tspanSvg = '';
99
+ let highlightSvg = '';
100
+
101
+ // Track cursor position for highlight rects and line breaks
102
+ let cursorX = layer.x;
103
+ let currentLineY = renderY;
104
+ let isFirstOnLine = true;
105
+ let maxLineWidth = 0;
106
+ let currentLineWidth = 0;
107
+
108
+ for (const span of spans) {
109
+ const spanFontSize = span.fontSize ?? baseFontSize;
110
+ const spanColor = span.color ?? baseColor;
111
+
112
+ // Split on \n to handle line breaks within a single span
113
+ const segments = span.text.split('\n');
114
+
115
+ for (let segIdx = 0; segIdx < segments.length; segIdx++) {
116
+ // Handle line break (every segment after the first means a \n was found)
117
+ if (segIdx > 0) {
118
+ // Flush current line width
119
+ if (currentLineWidth > maxLineWidth) maxLineWidth = currentLineWidth;
120
+ currentLineWidth = 0;
121
+ cursorX = layer.x;
122
+ currentLineY += baseFontSize * lineHeight;
123
+ isFirstOnLine = true;
124
+ }
125
+
126
+ const segText = segments[segIdx];
127
+ if (segText.length === 0) continue;
128
+
129
+ const segWidth = segText.length * spanFontSize * 0.6;
130
+
131
+ // Highlight rect (rendered BEFORE text so it appears behind)
132
+ if (span.highlight) {
133
+ highlightSvg += `<rect x="${cursorX}" y="${currentLineY - spanFontSize * 0.8}" width="${segWidth}" height="${spanFontSize}" fill="${span.highlight}" />`;
134
+ }
135
+
136
+ // Build inline style overrides
137
+ const styleAttrs: string[] = [];
138
+ if (span.bold) styleAttrs.push('font-weight: bold');
139
+ if (span.italic) styleAttrs.push('font-style: italic');
140
+ if (span.color) styleAttrs.push(`fill: ${spanColor}`);
141
+ if (span.fontSize) styleAttrs.push(`font-size: ${spanFontSize}px`);
142
+
143
+ const styleAttr = styleAttrs.length > 0 ? ` style="${styleAttrs.join('; ')};"` : '';
144
+
145
+ if (isFirstOnLine) {
146
+ // First tspan on a line: reset x and apply dy for line break
147
+ const dy = currentLineY === renderY ? 0 : baseFontSize * lineHeight;
148
+ if (dy > 0) {
149
+ tspanSvg += `<tspan x="${layer.x}" dy="${dy}"${styleAttr}>${escapeXml(segText)}</tspan>`;
150
+ } else {
151
+ tspanSvg += `<tspan${styleAttr}>${escapeXml(segText)}</tspan>`;
152
+ }
153
+ isFirstOnLine = false;
154
+ } else {
155
+ tspanSvg += `<tspan${styleAttr}>${escapeXml(segText)}</tspan>`;
156
+ }
157
+
158
+ cursorX += segWidth;
159
+ currentLineWidth += segWidth;
160
+ }
161
+ }
162
+
163
+ // Final line width check
164
+ if (currentLineWidth > maxLineWidth) maxLineWidth = currentLineWidth;
165
+
166
+ return {
167
+ tspanSvg,
168
+ highlightSvg,
169
+ approxMaxWidth: maxLineWidth,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Adds text layers onto an image using SVG overlay compositing.
175
+ *
176
+ * Supports two rendering modes:
177
+ * 1. **Plain text** — uses `layer.text` with optional `maxWidth` wrapping
178
+ * 2. **Inline spans** — uses `layer.spans[]` for mixed-style rendering
179
+ *
180
+ * Font loading: if `layer.fontUrl` starts with `https://`, the font is
181
+ * automatically downloaded and cached locally for librsvg compatibility.
182
+ *
183
+ * @param input - Source image (Buffer, URL, data-URI, or file path)
184
+ * @param options - Object containing `layers` array of `TextLayer`
185
+ * @returns ImageResult with the composited image buffer
186
+ *
187
+ * @example
188
+ * // Plain text
189
+ * await addText(buffer, { layers: [{ text: 'Hello', x: 10, y: 50 }] });
190
+ *
191
+ * @example
192
+ * // Inline spans
193
+ * await addText(buffer, { layers: [{
194
+ * x: 10, y: 50, fontSize: 28, color: '#333',
195
+ * spans: [
196
+ * { text: 'normal ' },
197
+ * { text: 'bold', bold: true, color: '#000' },
198
+ * ]
199
+ * }] });
200
+ */
61
201
  export async function addText(input: ImageInput, options: { layers: TextLayer[] }): Promise<ImageResult> {
62
202
  try {
63
203
  const buffer = await loadImage(input);
@@ -81,13 +221,14 @@ export async function addText(input: ImageInput, options: { layers: TextLayer[]
81
221
  const color = layer.color ?? '#000000';
82
222
  const opacity = layer.opacity ?? 1.0;
83
223
  const fontFamily = layer.fontFamily ?? 'sans-serif';
84
- if (layer.fontUrl) fontImports.add(`@import url('${layer.fontUrl}');`);
85
224
 
86
- const lines = wrapText(layer.text, fontSize, layer.maxWidth);
87
- const lineHeight = layer.lineHeight ?? 1.2;
88
- const totalHeight = lines.length * fontSize * lineHeight;
89
- const approxMaxWidth = Math.max(...lines.map(l => l.length * fontSize * 0.6));
225
+ // ── Font loading ──────────────────────────────────────────────
226
+ if (layer.fontUrl) {
227
+ const localUrl = await resolveFontUrl(layer.fontUrl);
228
+ fontImports.add(`@font-face { font-family: '${fontFamily}'; src: url('${localUrl}'); }`);
229
+ }
90
230
 
231
+ const lineHeight = layer.lineHeight ?? 1.2;
91
232
  const { textAnchor, yOffset } = getAnchorProps(layer.anchor, fontSize);
92
233
  const renderY = layer.y + yOffset;
93
234
 
@@ -97,48 +238,142 @@ export async function addText(input: ImageInput, options: { layers: TextLayer[]
97
238
  }
98
239
 
99
240
  // Always use dominant-baseline: auto (alphabetic) — the only value librsvg reliably supports
100
- const style = `font-family: ${fontFamily}; font-size: ${fontSize}px; fill: ${color}; opacity: ${opacity}; text-anchor: ${align}; dominant-baseline: auto;`;
101
-
241
+ let style = `font-family: ${fontFamily}; font-size: ${fontSize}px; fill: ${color}; opacity: ${opacity}; text-anchor: ${align}; dominant-baseline: auto;`;
242
+
243
+ // Letter spacing
244
+ if (layer.letterSpacing) {
245
+ style += ` letter-spacing: ${layer.letterSpacing}px;`;
246
+ }
247
+
248
+ // Stroke (outline) — paint-order renders stroke behind fill
249
+ if (layer.stroke) {
250
+ style += ` stroke: ${layer.stroke.color}; stroke-width: ${layer.stroke.width}px; paint-order: stroke;`;
251
+ }
252
+
102
253
  let layerSvg = '';
254
+ let totalHeight: number;
255
+ let approxMaxWidth: number;
256
+ let layerTextPreview: string;
103
257
 
104
- if (layer.background) {
105
- const bg = layer.background;
106
- const pad = bg.padding ?? 0;
107
- const bgOpacity = bg.opacity ?? 1.0;
108
- const radius = bg.borderRadius ?? 0;
109
-
110
- // Background rect is positioned relative to the *intended* y (layer.y), not renderY
111
- let rectX = layer.x - pad;
112
- let rectY = layer.y - pad;
113
-
114
- if (textAnchor === 'middle') {
115
- rectX = layer.x - (approxMaxWidth / 2) - pad;
116
- } else if (textAnchor === 'end') {
117
- rectX = layer.x - approxMaxWidth - pad;
258
+ // ── Spans mode vs plain text mode ─────────────────────────────
259
+ if (layer.spans && layer.spans.length > 0) {
260
+ // Emit warnings for spans mode edge cases
261
+ if (layer.text) {
262
+ warnings.push('text field ignored when spans is provided');
263
+ }
264
+ if (layer.maxWidth) {
265
+ warnings.push('maxWidth is not supported with spans');
118
266
  }
119
267
 
120
- // Adjust for anchor vertical alignment
121
- const parts = (layer.anchor ?? 'top-left').split('-');
122
- const vAlign = parts.length === 2 ? parts[0] : parts[0] === 'center' ? 'middle' : parts[0];
123
- if (vAlign === 'middle' || vAlign === 'center') {
124
- rectY = layer.y - (totalHeight / 2) - pad;
125
- } else if (vAlign === 'bottom') {
126
- rectY = layer.y - totalHeight - pad + fontSize;
268
+ const spansResult = buildSpansSvg(layer.spans, layer, renderY);
269
+ approxMaxWidth = spansResult.approxMaxWidth;
270
+
271
+ // Count line breaks to compute totalHeight
272
+ const fullText = layer.spans.map((s) => s.text).join('');
273
+ const lineCount = (fullText.match(/\n/g) ?? []).length + 1;
274
+ totalHeight = lineCount * fontSize * lineHeight;
275
+
276
+ layerTextPreview = fullText.slice(0, 20);
277
+
278
+ // Background rect
279
+ if (layer.background) {
280
+ const bg = layer.background;
281
+ const pad = bg.padding ?? 0;
282
+ const bgOpacity = bg.opacity ?? 1.0;
283
+ const radius = bg.borderRadius ?? 0;
284
+
285
+ let rectX = layer.x - pad;
286
+ let rectY = layer.y - pad;
287
+
288
+ if (textAnchor === 'middle') {
289
+ rectX = layer.x - (approxMaxWidth / 2) - pad;
290
+ } else if (textAnchor === 'end') {
291
+ rectX = layer.x - approxMaxWidth - pad;
292
+ }
293
+
294
+ const parts = (layer.anchor ?? 'top-left').split('-');
295
+ const vAlign = parts.length === 2 ? parts[0] : parts[0] === 'center' ? 'middle' : parts[0];
296
+ if (vAlign === 'middle' || vAlign === 'center') {
297
+ rectY = layer.y - (totalHeight / 2) - pad;
298
+ } else if (vAlign === 'bottom') {
299
+ rectY = layer.y - totalHeight - pad + fontSize;
300
+ }
301
+
302
+ layerSvg += `<rect x="${rectX}" y="${rectY}" width="${approxMaxWidth + pad * 2}" height="${totalHeight + pad * 2}" fill="${bg.color}" opacity="${bgOpacity}" rx="${radius}" ry="${radius}" />`;
127
303
  }
128
304
 
129
- layerSvg += `<rect x="${rectX}" y="${rectY}" width="${approxMaxWidth + pad * 2}" height="${totalHeight + pad * 2}" fill="${bg.color}" opacity="${bgOpacity}" rx="${radius}" ry="${radius}" />`;
130
- }
305
+ // Highlight rects (behind text)
306
+ layerSvg += spansResult.highlightSvg;
131
307
 
132
- layerSvg += `<text x="${layer.x}" y="${renderY}" style="${style}">`;
133
- lines.forEach((line, idx) => {
134
- let dy = idx === 0 ? 0 : fontSize * lineHeight;
135
- layerSvg += `<tspan x="${layer.x}" dy="${dy}">${escapeXml(line)}</tspan>`;
136
- });
137
- layerSvg += `</text>`;
308
+ // Shadow for spans mode
309
+ if (layer.textShadow) {
310
+ const ts = layer.textShadow;
311
+ const shadowStyle = `font-family: ${fontFamily}; font-size: ${fontSize}px; fill: ${ts.color}; opacity: ${opacity}; text-anchor: ${align}; dominant-baseline: auto;${layer.letterSpacing ? ` letter-spacing: ${layer.letterSpacing}px;` : ''}`;
312
+ const sx = layer.x + ts.offsetX;
313
+ const sy = renderY + ts.offsetY;
314
+ layerSvg += `<text x="${sx}" y="${sy}" style="${shadowStyle}">${spansResult.tspanSvg}</text>`;
315
+ }
316
+
317
+ // Main text element with spans
318
+ layerSvg += `<text x="${layer.x}" y="${renderY}" style="${style}">${spansResult.tspanSvg}</text>`;
319
+ } else {
320
+ // ── Plain text mode (existing logic) ─────────────────────────
321
+ const lines = wrapText(layer.text, fontSize, layer.maxWidth);
322
+ totalHeight = lines.length * fontSize * lineHeight;
323
+ approxMaxWidth = Math.max(...lines.map(l => l.length * fontSize * 0.6));
324
+ layerTextPreview = layer.text.slice(0, 20);
325
+
326
+ if (layer.background) {
327
+ const bg = layer.background;
328
+ const pad = bg.padding ?? 0;
329
+ const bgOpacity = bg.opacity ?? 1.0;
330
+ const radius = bg.borderRadius ?? 0;
331
+
332
+ let rectX = layer.x - pad;
333
+ let rectY = layer.y - pad;
334
+
335
+ if (textAnchor === 'middle') {
336
+ rectX = layer.x - (approxMaxWidth / 2) - pad;
337
+ } else if (textAnchor === 'end') {
338
+ rectX = layer.x - approxMaxWidth - pad;
339
+ }
340
+
341
+ const parts = (layer.anchor ?? 'top-left').split('-');
342
+ const vAlign = parts.length === 2 ? parts[0] : parts[0] === 'center' ? 'middle' : parts[0];
343
+ if (vAlign === 'middle' || vAlign === 'center') {
344
+ rectY = layer.y - (totalHeight / 2) - pad;
345
+ } else if (vAlign === 'bottom') {
346
+ rectY = layer.y - totalHeight - pad + fontSize;
347
+ }
348
+
349
+ layerSvg += `<rect x="${rectX}" y="${rectY}" width="${approxMaxWidth + pad * 2}" height="${totalHeight + pad * 2}" fill="${bg.color}" opacity="${bgOpacity}" rx="${radius}" ry="${radius}" />`;
350
+ }
351
+
352
+ // Text shadow: render a duplicate text behind the main text
353
+ if (layer.textShadow) {
354
+ const ts = layer.textShadow;
355
+ const shadowStyle = `font-family: ${fontFamily}; font-size: ${fontSize}px; fill: ${ts.color}; opacity: ${opacity}; text-anchor: ${align}; dominant-baseline: auto;${layer.letterSpacing ? ` letter-spacing: ${layer.letterSpacing}px;` : ''}`;
356
+ const sx = layer.x + ts.offsetX;
357
+ const sy = renderY + ts.offsetY;
358
+ layerSvg += `<text x="${sx}" y="${sy}" style="${shadowStyle}">`;
359
+ lines.forEach((line, idx) => {
360
+ let dy = idx === 0 ? 0 : fontSize * lineHeight;
361
+ layerSvg += `<tspan x="${sx}" dy="${dy}">${escapeXml(line)}</tspan>`;
362
+ });
363
+ layerSvg += `</text>`;
364
+ }
365
+
366
+ layerSvg += `<text x="${layer.x}" y="${renderY}" style="${style}">`;
367
+ lines.forEach((line, idx) => {
368
+ let dy = idx === 0 ? 0 : fontSize * lineHeight;
369
+ layerSvg += `<tspan x="${layer.x}" dy="${dy}">${escapeXml(line)}</tspan>`;
370
+ });
371
+ layerSvg += `</text>`;
372
+ }
138
373
 
139
374
  svgBody += `<g style="isolation: isolate">${layerSvg}</g>`;
140
375
 
141
- // Compute bounding box for overflow detection (using intended y, not renderY)
376
+ // ── Bounding box / overflow detection ──────────────────────────
142
377
  let boxX = layer.x;
143
378
  let boxY = layer.y;
144
379
  if (textAnchor === 'middle') boxX -= approxMaxWidth / 2;
@@ -155,7 +390,7 @@ export async function addText(input: ImageInput, options: { layers: TextLayer[]
155
390
 
156
391
  if (boxX < 0 || boxY < 0 || boxRight > width || boxBottom > height) {
157
392
  warnings.push(
158
- `Text layer ${i} ("${layer.text.slice(0, 20)}...") extends beyond canvas bounds.`
393
+ `Text layer ${i} ("${layerTextPreview}...") extends beyond canvas bounds.`
159
394
  );
160
395
  }
161
396
  }
@@ -180,6 +415,9 @@ export async function addText(input: ImageInput, options: { layers: TextLayer[]
180
415
  if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
181
416
  if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
182
417
  if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
418
+ if (msg.includes('Font download failed') || msg.includes('Google Fonts CSS fetch failed')) {
419
+ return err(msg, ErrorCode.FETCH_FAILED);
420
+ }
183
421
  return err(msg, ErrorCode.PROCESSING_FAILED);
184
422
  }
185
423
  }