psd-mcp-server 1.0.0

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/dist/index.js ADDED
@@ -0,0 +1,2098 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import "ag-psd/initialize-canvas.js";
6
+ import { readPsd } from "ag-psd";
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { createCanvas } from "canvas";
10
+ // Helper: Color to hex (ag-psd uses different color formats)
11
+ function colorToHex(color) {
12
+ if (!color)
13
+ return undefined;
14
+ // Handle FRGB format (0-1 range)
15
+ if (typeof color.fr === "number") {
16
+ const r = Math.round(color.fr * 255)
17
+ .toString(16)
18
+ .padStart(2, "0");
19
+ const g = Math.round(color.fg * 255)
20
+ .toString(16)
21
+ .padStart(2, "0");
22
+ const b = Math.round(color.fb * 255)
23
+ .toString(16)
24
+ .padStart(2, "0");
25
+ return `#${r}${g}${b}`;
26
+ }
27
+ // Handle RGB format (0-255 range)
28
+ if (typeof color.r === "number") {
29
+ const r = Math.round(color.r).toString(16).padStart(2, "0");
30
+ const g = Math.round(color.g).toString(16).padStart(2, "0");
31
+ const b = Math.round(color.b).toString(16).padStart(2, "0");
32
+ return `#${r}${g}${b}`;
33
+ }
34
+ return undefined;
35
+ }
36
+ // Extract layer info recursively
37
+ function extractLayerInfo(layer) {
38
+ const bounds = {
39
+ left: layer.left ?? 0,
40
+ top: layer.top ?? 0,
41
+ width: (layer.right ?? 0) - (layer.left ?? 0),
42
+ height: (layer.bottom ?? 0) - (layer.top ?? 0),
43
+ };
44
+ let type = "unknown";
45
+ let textInfo;
46
+ // Determine layer type
47
+ if (layer.text) {
48
+ type = "text";
49
+ const style = layer.text.style;
50
+ textInfo = {
51
+ content: layer.text.text || "",
52
+ font: style?.font?.name,
53
+ fontSize: style?.fontSize,
54
+ color: colorToHex(style?.fillColor),
55
+ lineHeight: style?.leading,
56
+ letterSpacing: style?.tracking,
57
+ };
58
+ }
59
+ else if (layer.children && layer.children.length > 0) {
60
+ type = "group";
61
+ }
62
+ else if (layer.canvas) {
63
+ type = "image";
64
+ }
65
+ else if (layer.vectorMask || layer.vectorStroke) {
66
+ type = "shape";
67
+ }
68
+ const info = {
69
+ name: layer.name || "Unnamed",
70
+ type,
71
+ visible: !layer.hidden,
72
+ opacity: layer.opacity !== undefined ? layer.opacity / 255 : 1,
73
+ bounds,
74
+ };
75
+ if (textInfo) {
76
+ info.text = textInfo;
77
+ }
78
+ if (layer.children && layer.children.length > 0) {
79
+ info.children = layer.children.map(extractLayerInfo);
80
+ }
81
+ return info;
82
+ }
83
+ // Parse PSD file
84
+ function parsePsdFile(filePath) {
85
+ const absolutePath = path.resolve(filePath);
86
+ if (!fs.existsSync(absolutePath)) {
87
+ throw new Error(`File not found: ${absolutePath}`);
88
+ }
89
+ const buffer = fs.readFileSync(absolutePath);
90
+ const psd = readPsd(buffer, {
91
+ skipCompositeImageData: true,
92
+ skipLayerImageData: true,
93
+ skipThumbnail: true,
94
+ });
95
+ const colorModes = {
96
+ 0: "Bitmap",
97
+ 1: "Grayscale",
98
+ 2: "Indexed",
99
+ 3: "RGB",
100
+ 4: "CMYK",
101
+ 7: "Multichannel",
102
+ 8: "Duotone",
103
+ 9: "Lab",
104
+ };
105
+ return {
106
+ width: psd.width,
107
+ height: psd.height,
108
+ colorMode: colorModes[psd.colorMode ?? 3] || "Unknown",
109
+ bitsPerChannel: psd.bitsPerChannel ?? 8,
110
+ layers: psd.children?.map(extractLayerInfo) || [],
111
+ };
112
+ }
113
+ // Convert VectorContent color to CSS color string
114
+ function vectorContentToColor(content) {
115
+ if (!content)
116
+ return undefined;
117
+ if (content.type === "color") {
118
+ const color = content.color;
119
+ if ("r" in color) {
120
+ return `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`;
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+ // Convert Bezier paths to SVG path data
126
+ function bezierPathsToSvgPath(paths, width, height) {
127
+ const pathData = [];
128
+ for (const bezierPath of paths) {
129
+ const knots = bezierPath.knots;
130
+ if (knots.length === 0)
131
+ continue;
132
+ // PSD bezier points are normalized (0-1), scale to document size
133
+ // points array: [prevAnchorX, prevAnchorY, anchorX, anchorY, nextAnchorX, nextAnchorY]
134
+ const scaleX = (v) => (v * width).toFixed(2);
135
+ const scaleY = (v) => (v * height).toFixed(2);
136
+ // Start with first knot
137
+ const firstKnot = knots[0];
138
+ const startX = scaleX(firstKnot.points[2]);
139
+ const startY = scaleY(firstKnot.points[3]);
140
+ pathData.push(`M ${startX} ${startY}`);
141
+ // Draw curves between knots
142
+ for (let i = 0; i < knots.length; i++) {
143
+ const currentKnot = knots[i];
144
+ const nextKnot = knots[(i + 1) % knots.length];
145
+ // Skip last segment if path is open
146
+ if (bezierPath.open && i === knots.length - 1)
147
+ break;
148
+ // Control point 1: current knot's "next" anchor
149
+ const cp1x = scaleX(currentKnot.points[4]);
150
+ const cp1y = scaleY(currentKnot.points[5]);
151
+ // Control point 2: next knot's "prev" anchor
152
+ const cp2x = scaleX(nextKnot.points[0]);
153
+ const cp2y = scaleY(nextKnot.points[1]);
154
+ // End point: next knot's anchor
155
+ const endX = scaleX(nextKnot.points[2]);
156
+ const endY = scaleY(nextKnot.points[3]);
157
+ pathData.push(`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`);
158
+ }
159
+ // Close path if not open
160
+ if (!bezierPath.open) {
161
+ pathData.push("Z");
162
+ }
163
+ }
164
+ return pathData.join(" ");
165
+ }
166
+ // Find vector layer by name
167
+ function findVectorLayer(layers, name) {
168
+ for (const layer of layers) {
169
+ if (layer.name?.toLowerCase().includes(name.toLowerCase()) &&
170
+ layer.vectorMask) {
171
+ return layer;
172
+ }
173
+ if (layer.children) {
174
+ const found = findVectorLayer(layer.children, name);
175
+ if (found)
176
+ return found;
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+ // Get all vector layers
182
+ function getAllVectorLayers(layers) {
183
+ const result = [];
184
+ function traverse(items) {
185
+ for (const layer of items) {
186
+ if (layer.vectorMask) {
187
+ result.push(layer);
188
+ }
189
+ if (layer.children) {
190
+ traverse(layer.children);
191
+ }
192
+ }
193
+ }
194
+ traverse(layers);
195
+ return result;
196
+ }
197
+ // Convert a vector layer to SVG string
198
+ function vectorLayerToSvg(layer, width, height) {
199
+ if (!layer.vectorMask) {
200
+ throw new Error("Layer does not have vector data");
201
+ }
202
+ const paths = layer.vectorMask.paths;
203
+ const svgPath = bezierPathsToSvgPath(paths, width, height);
204
+ // Get fill color
205
+ const fillColor = vectorContentToColor(layer.vectorFill) || "#000000";
206
+ // Get stroke info
207
+ const stroke = layer.vectorStroke;
208
+ const strokeColor = stroke?.content
209
+ ? vectorContentToColor(stroke.content)
210
+ : undefined;
211
+ const strokeWidth = stroke?.lineWidth?.value || 0;
212
+ const strokeEnabled = stroke?.strokeEnabled !== false && strokeWidth > 0;
213
+ // Build SVG
214
+ const svgParts = [
215
+ `<?xml version="1.0" encoding="UTF-8"?>`,
216
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
217
+ ` <path d="${svgPath}"`,
218
+ ` fill="${layer.vectorFill ? fillColor : "none"}"`,
219
+ ];
220
+ if (strokeEnabled && strokeColor) {
221
+ svgParts.push(` stroke="${strokeColor}"`);
222
+ svgParts.push(` stroke-width="${strokeWidth}"`);
223
+ if (stroke?.lineCapType) {
224
+ svgParts.push(` stroke-linecap="${stroke.lineCapType}"`);
225
+ }
226
+ if (stroke?.lineJoinType) {
227
+ svgParts.push(` stroke-linejoin="${stroke.lineJoinType}"`);
228
+ }
229
+ }
230
+ svgParts.push(` />`);
231
+ svgParts.push(`</svg>`);
232
+ return svgParts.join("\n");
233
+ }
234
+ // Get all image layers (layers with canvas data)
235
+ function getAllImageLayers(layers) {
236
+ const result = [];
237
+ function traverse(items) {
238
+ for (const layer of items) {
239
+ // Has canvas and is not a group
240
+ if (layer.canvas && !layer.children) {
241
+ result.push(layer);
242
+ }
243
+ if (layer.children) {
244
+ traverse(layer.children);
245
+ }
246
+ }
247
+ }
248
+ traverse(layers);
249
+ return result;
250
+ }
251
+ // Get image layers from a specific group
252
+ function getImageLayersFromGroup(layers, groupName) {
253
+ // Find the group first
254
+ function findGroup(items) {
255
+ for (const layer of items) {
256
+ if (layer.name?.toLowerCase().includes(groupName.toLowerCase()) &&
257
+ layer.children) {
258
+ return layer;
259
+ }
260
+ if (layer.children) {
261
+ const found = findGroup(layer.children);
262
+ if (found)
263
+ return found;
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+ const group = findGroup(layers);
269
+ if (!group || !group.children)
270
+ return [];
271
+ return getAllImageLayers(group.children);
272
+ }
273
+ // Export layer canvas to image buffer (PNG or JPG)
274
+ function layerToImageBuffer(layer, scale = 2, format = "png", quality = 90) {
275
+ if (!layer.canvas)
276
+ return null;
277
+ const srcCanvas = layer.canvas;
278
+ const width = srcCanvas.width * scale;
279
+ const height = srcCanvas.height * scale;
280
+ // Create scaled canvas
281
+ const canvas = createCanvas(width, height);
282
+ const ctx = canvas.getContext("2d");
283
+ // For JPG, fill with white background (no transparency support)
284
+ if (format === "jpg") {
285
+ ctx.fillStyle = "#FFFFFF";
286
+ ctx.fillRect(0, 0, width, height);
287
+ }
288
+ // Scale and draw
289
+ ctx.scale(scale, scale);
290
+ ctx.drawImage(srcCanvas, 0, 0);
291
+ if (format === "jpg") {
292
+ return canvas.toBuffer("image/jpeg", { quality: quality / 100 });
293
+ }
294
+ return canvas.toBuffer("image/png");
295
+ }
296
+ // Sanitize filename
297
+ function sanitizeFilename(name) {
298
+ return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
299
+ }
300
+ // Convert any Color type to hex
301
+ function anyColorToHex(color) {
302
+ if (!color)
303
+ return null;
304
+ let r, g, b;
305
+ // FRGB format (0-1 range)
306
+ if (typeof color.fr === "number") {
307
+ r = Math.round(color.fr * 255);
308
+ g = Math.round(color.fg * 255);
309
+ b = Math.round(color.fb * 255);
310
+ }
311
+ // RGB/RGBA format (0-255 range)
312
+ else if (typeof color.r === "number") {
313
+ r = Math.round(color.r);
314
+ g = Math.round(color.g);
315
+ b = Math.round(color.b);
316
+ }
317
+ // Grayscale
318
+ else if (typeof color.k === "number" && !("c" in color)) {
319
+ const gray = Math.round((1 - color.k) * 255);
320
+ r = g = b = gray;
321
+ }
322
+ // HSB - convert to RGB
323
+ else if (typeof color.h === "number" &&
324
+ typeof color.s === "number" &&
325
+ typeof color.b === "number") {
326
+ const h = color.h / 360;
327
+ const s = color.s;
328
+ const v = color.b;
329
+ const i = Math.floor(h * 6);
330
+ const f = h * 6 - i;
331
+ const p = v * (1 - s);
332
+ const q = v * (1 - f * s);
333
+ const t = v * (1 - (1 - f) * s);
334
+ switch (i % 6) {
335
+ case 0:
336
+ r = v * 255;
337
+ g = t * 255;
338
+ b = p * 255;
339
+ break;
340
+ case 1:
341
+ r = q * 255;
342
+ g = v * 255;
343
+ b = p * 255;
344
+ break;
345
+ case 2:
346
+ r = p * 255;
347
+ g = v * 255;
348
+ b = t * 255;
349
+ break;
350
+ case 3:
351
+ r = p * 255;
352
+ g = q * 255;
353
+ b = v * 255;
354
+ break;
355
+ case 4:
356
+ r = t * 255;
357
+ g = p * 255;
358
+ b = v * 255;
359
+ break;
360
+ case 5:
361
+ r = v * 255;
362
+ g = p * 255;
363
+ b = q * 255;
364
+ break;
365
+ default:
366
+ r = g = b = 0;
367
+ }
368
+ r = Math.round(r);
369
+ g = Math.round(g);
370
+ b = Math.round(b);
371
+ }
372
+ // LAB - simplified conversion
373
+ else if (typeof color.l === "number" && typeof color.a === "number") {
374
+ // Simplified LAB to RGB
375
+ const y = (color.l + 16) / 116;
376
+ const x = color.a / 500 + y;
377
+ const z = y - color.b / 200;
378
+ const x3 = x * x * x;
379
+ const y3 = y * y * y;
380
+ const z3 = z * z * z;
381
+ const xn = x3 > 0.008856 ? x3 : (x - 16 / 116) / 7.787;
382
+ const yn = y3 > 0.008856 ? y3 : (y - 16 / 116) / 7.787;
383
+ const zn = z3 > 0.008856 ? z3 : (z - 16 / 116) / 7.787;
384
+ // XYZ to RGB
385
+ const xr = xn * 0.95047;
386
+ const yr = yn * 1.0;
387
+ const zr = zn * 1.08883;
388
+ r = Math.round(Math.max(0, Math.min(255, (xr * 3.2406 + yr * -1.5372 + zr * -0.4986) * 255)));
389
+ g = Math.round(Math.max(0, Math.min(255, (xr * -0.9689 + yr * 1.8758 + zr * 0.0415) * 255)));
390
+ b = Math.round(Math.max(0, Math.min(255, (xr * 0.0557 + yr * -0.204 + zr * 1.057) * 255)));
391
+ }
392
+ // CMYK
393
+ else if (typeof color.c === "number" &&
394
+ typeof color.m === "number" &&
395
+ typeof color.y === "number" &&
396
+ typeof color.k === "number") {
397
+ r = Math.round(255 * (1 - color.c) * (1 - color.k));
398
+ g = Math.round(255 * (1 - color.m) * (1 - color.k));
399
+ b = Math.round(255 * (1 - color.y) * (1 - color.k));
400
+ }
401
+ else {
402
+ return null;
403
+ }
404
+ // Clamp values
405
+ r = Math.max(0, Math.min(255, r));
406
+ g = Math.max(0, Math.min(255, g));
407
+ b = Math.max(0, Math.min(255, b));
408
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
409
+ }
410
+ // Extract all colors from a layer
411
+ function extractColorsFromLayer(layer, layerName) {
412
+ const colors = [];
413
+ const gradients = [];
414
+ const addColor = (color, source) => {
415
+ const hex = anyColorToHex(color);
416
+ if (hex) {
417
+ const r = parseInt(hex.slice(1, 3), 16);
418
+ const g = parseInt(hex.slice(3, 5), 16);
419
+ const b = parseInt(hex.slice(5, 7), 16);
420
+ colors.push({ hex, rgb: { r, g, b }, source, layerName });
421
+ }
422
+ };
423
+ const addGradient = (gradient, source) => {
424
+ if (gradient?.colorStops) {
425
+ const gradientColors = gradient.colorStops
426
+ .map((stop) => anyColorToHex(stop.color))
427
+ .filter((c) => c !== null);
428
+ if (gradientColors.length > 0) {
429
+ gradients.push({
430
+ name: gradient.name,
431
+ colors: gradientColors,
432
+ source,
433
+ layerName,
434
+ });
435
+ }
436
+ }
437
+ };
438
+ // Text color
439
+ if (layer.text?.style?.fillColor) {
440
+ addColor(layer.text.style.fillColor, "text");
441
+ }
442
+ // Vector fill
443
+ if (layer.vectorFill) {
444
+ if (layer.vectorFill.type === "color") {
445
+ addColor(layer.vectorFill.color, "vector-fill");
446
+ }
447
+ else if ("colorStops" in layer.vectorFill) {
448
+ addGradient(layer.vectorFill, "vector-fill-gradient");
449
+ }
450
+ }
451
+ // Vector stroke
452
+ if (layer.vectorStroke?.content) {
453
+ if (layer.vectorStroke.content.type === "color") {
454
+ addColor(layer.vectorStroke.content.color, "vector-stroke");
455
+ }
456
+ else if ("colorStops" in layer.vectorStroke.content) {
457
+ addGradient(layer.vectorStroke.content, "vector-stroke-gradient");
458
+ }
459
+ }
460
+ // Layer effects
461
+ const effects = layer.effects;
462
+ if (effects) {
463
+ // Drop shadow
464
+ effects.dropShadow?.forEach((shadow, i) => {
465
+ if (shadow.enabled !== false && shadow.color) {
466
+ addColor(shadow.color, `drop-shadow${i > 0 ? `-${i + 1}` : ""}`);
467
+ }
468
+ });
469
+ // Inner shadow
470
+ effects.innerShadow?.forEach((shadow, i) => {
471
+ if (shadow.enabled !== false && shadow.color) {
472
+ addColor(shadow.color, `inner-shadow${i > 0 ? `-${i + 1}` : ""}`);
473
+ }
474
+ });
475
+ // Outer glow
476
+ if (effects.outerGlow?.enabled !== false && effects.outerGlow?.color) {
477
+ addColor(effects.outerGlow.color, "outer-glow");
478
+ }
479
+ // Inner glow
480
+ if (effects.innerGlow?.enabled !== false && effects.innerGlow?.color) {
481
+ addColor(effects.innerGlow.color, "inner-glow");
482
+ }
483
+ // Color overlay (solid fill)
484
+ effects.solidFill?.forEach((fill, i) => {
485
+ if (fill.enabled !== false && fill.color) {
486
+ addColor(fill.color, `color-overlay${i > 0 ? `-${i + 1}` : ""}`);
487
+ }
488
+ });
489
+ // Stroke effect
490
+ effects.stroke?.forEach((stroke, i) => {
491
+ if (stroke.enabled !== false) {
492
+ if (stroke.color) {
493
+ addColor(stroke.color, `stroke-effect${i > 0 ? `-${i + 1}` : ""}`);
494
+ }
495
+ if (stroke.gradient) {
496
+ addGradient(stroke.gradient, `stroke-gradient${i > 0 ? `-${i + 1}` : ""}`);
497
+ }
498
+ }
499
+ });
500
+ // Satin
501
+ if (effects.satin?.enabled !== false && effects.satin?.color) {
502
+ addColor(effects.satin.color, "satin");
503
+ }
504
+ // Gradient overlay
505
+ effects.gradientOverlay?.forEach((overlay, i) => {
506
+ if (overlay.enabled !== false && overlay.gradient) {
507
+ addGradient(overlay.gradient, `gradient-overlay${i > 0 ? `-${i + 1}` : ""}`);
508
+ }
509
+ });
510
+ }
511
+ return { colors, gradients };
512
+ }
513
+ // Extract all colors from PSD
514
+ function extractAllColors(layers) {
515
+ const allColors = [];
516
+ const allGradients = [];
517
+ function traverse(items) {
518
+ for (const layer of items) {
519
+ const { colors, gradients } = extractColorsFromLayer(layer, layer.name || "Unnamed");
520
+ allColors.push(...colors);
521
+ allGradients.push(...gradients);
522
+ if (layer.children) {
523
+ traverse(layer.children);
524
+ }
525
+ }
526
+ }
527
+ traverse(layers);
528
+ // Get unique colors
529
+ const uniqueColors = [...new Set(allColors.map((c) => c.hex))].sort();
530
+ return {
531
+ solidColors: allColors,
532
+ gradients: allGradients,
533
+ uniqueColors,
534
+ };
535
+ }
536
+ // Format layers as tree structure
537
+ function formatLayerTree(layers, prefix = "") {
538
+ const lines = [];
539
+ layers.forEach((layer, index) => {
540
+ const isLast = index === layers.length - 1;
541
+ const connector = isLast ? "└── " : "├── ";
542
+ const childPrefix = isLast ? " " : "│ ";
543
+ const typeLabel = layer.type === "group"
544
+ ? "(group)"
545
+ : layer.type === "text"
546
+ ? "(text)"
547
+ : layer.type === "image"
548
+ ? "(image)"
549
+ : layer.type === "shape"
550
+ ? "(shape)"
551
+ : "";
552
+ const visibilityMark = layer.visible ? "" : " [hidden]";
553
+ lines.push(`${prefix}${connector}${layer.name} ${typeLabel}${visibilityMark}`);
554
+ if (layer.children && layer.children.length > 0) {
555
+ lines.push(formatLayerTree(layer.children, prefix + childPrefix));
556
+ }
557
+ });
558
+ return lines.join("\n");
559
+ }
560
+ // Find layer by name (recursive search)
561
+ function findLayerByName(layers, name, exact = false) {
562
+ for (const layer of layers) {
563
+ const match = exact
564
+ ? layer.name === name
565
+ : layer.name.toLowerCase().includes(name.toLowerCase());
566
+ if (match) {
567
+ return layer;
568
+ }
569
+ if (layer.children) {
570
+ const found = findLayerByName(layer.children, name, exact);
571
+ if (found)
572
+ return found;
573
+ }
574
+ }
575
+ return null;
576
+ }
577
+ // Search layers by name (returns all matches)
578
+ function searchLayersByName(layers, name) {
579
+ const results = [];
580
+ function traverse(items) {
581
+ for (const layer of items) {
582
+ if (layer.name.toLowerCase().includes(name.toLowerCase())) {
583
+ results.push(layer);
584
+ }
585
+ if (layer.children) {
586
+ traverse(layer.children);
587
+ }
588
+ }
589
+ }
590
+ traverse(layers);
591
+ return results;
592
+ }
593
+ // Get text layers only (flattened)
594
+ function getTextLayers(layers) {
595
+ const result = [];
596
+ function traverse(items) {
597
+ for (const layer of items) {
598
+ if (layer.type === "text") {
599
+ result.push(layer);
600
+ }
601
+ if (layer.children) {
602
+ traverse(layer.children);
603
+ }
604
+ }
605
+ }
606
+ traverse(layers);
607
+ return result;
608
+ }
609
+ // Extract all fonts from text layers
610
+ function extractAllFonts(layers) {
611
+ const fontMap = new Map();
612
+ function addFont(fontName, fontSize, style, layerName) {
613
+ if (!fontName)
614
+ return;
615
+ if (!fontMap.has(fontName)) {
616
+ fontMap.set(fontName, {
617
+ fontName,
618
+ sizes: [],
619
+ styles: {
620
+ regular: false,
621
+ bold: false,
622
+ italic: false,
623
+ fauxBold: false,
624
+ fauxItalic: false,
625
+ },
626
+ layers: [],
627
+ colors: [],
628
+ });
629
+ }
630
+ const usage = fontMap.get(fontName);
631
+ // Add size
632
+ if (fontSize && !usage.sizes.includes(fontSize)) {
633
+ usage.sizes.push(fontSize);
634
+ }
635
+ // Track styles
636
+ if (style?.fauxBold) {
637
+ usage.styles.fauxBold = true;
638
+ }
639
+ else if (style?.fauxItalic) {
640
+ usage.styles.fauxItalic = true;
641
+ }
642
+ else {
643
+ // Check font name for style hints
644
+ const nameLower = fontName.toLowerCase();
645
+ if (nameLower.includes("bold")) {
646
+ usage.styles.bold = true;
647
+ }
648
+ else if (nameLower.includes("italic") ||
649
+ nameLower.includes("oblique")) {
650
+ usage.styles.italic = true;
651
+ }
652
+ else {
653
+ usage.styles.regular = true;
654
+ }
655
+ }
656
+ // Add layer name
657
+ if (!usage.layers.includes(layerName)) {
658
+ usage.layers.push(layerName);
659
+ }
660
+ // Add color
661
+ if (style?.fillColor) {
662
+ const hex = anyColorToHex(style.fillColor);
663
+ if (hex && !usage.colors.includes(hex)) {
664
+ usage.colors.push(hex);
665
+ }
666
+ }
667
+ }
668
+ function traverse(items) {
669
+ for (const layer of items) {
670
+ if (layer.text) {
671
+ const layerName = layer.name || "Unnamed";
672
+ // Check default style
673
+ if (layer.text.style?.font?.name) {
674
+ addFont(layer.text.style.font.name, layer.text.style.fontSize, layer.text.style, layerName);
675
+ }
676
+ // Check style runs (for text with multiple fonts)
677
+ if (layer.text.styleRuns) {
678
+ for (const run of layer.text.styleRuns) {
679
+ if (run.style?.font?.name) {
680
+ addFont(run.style.font.name, run.style.fontSize, run.style, layerName);
681
+ }
682
+ }
683
+ }
684
+ }
685
+ if (layer.children) {
686
+ traverse(layer.children);
687
+ }
688
+ }
689
+ }
690
+ traverse(layers);
691
+ // Sort sizes
692
+ for (const usage of fontMap.values()) {
693
+ usage.sizes.sort((a, b) => a - b);
694
+ }
695
+ return fontMap;
696
+ }
697
+ // Get all smart object layers from PSD
698
+ function getAllSmartObjectLayers(layers) {
699
+ const result = [];
700
+ function traverse(items, currentPath) {
701
+ for (const layer of items) {
702
+ if (layer.placedLayer) {
703
+ result.push({ layer, path: [...currentPath, layer.name || "Unnamed"] });
704
+ }
705
+ if (layer.children) {
706
+ traverse(layer.children, [...currentPath, layer.name || "Unnamed"]);
707
+ }
708
+ }
709
+ }
710
+ traverse(layers, []);
711
+ return result;
712
+ }
713
+ // Extract smart object information
714
+ function extractSmartObjectInfo(layer, linkedFiles, psdFilePath) {
715
+ const placed = layer.placedLayer;
716
+ // Determine type (ag-psd uses "image stack" not "imageStack")
717
+ let soType = "unknown";
718
+ if (placed.type === "vector") {
719
+ soType = "vector";
720
+ }
721
+ else if (placed.type === "raster") {
722
+ soType = "raster";
723
+ }
724
+ else if (placed.type === "image stack") {
725
+ soType = "imageStack";
726
+ }
727
+ // Find linked file
728
+ let linkedFile = undefined;
729
+ if (placed.id && linkedFiles) {
730
+ linkedFile = linkedFiles.find((f) => f.id === placed.id);
731
+ }
732
+ // Transform is an array [xx, xy, yx, yy, tx, ty] in ag-psd
733
+ const transform = placed.transform;
734
+ // Check for external file if no embedded data
735
+ let externalFilePath;
736
+ const hasEmbeddedData = !!(linkedFile?.data && linkedFile.data.length > 0);
737
+ if (!hasEmbeddedData && linkedFile?.name && psdFilePath) {
738
+ const psdDir = path.dirname(psdFilePath);
739
+ const linkedFileName = linkedFile.name;
740
+ const possiblePaths = [
741
+ path.join(psdDir, linkedFileName),
742
+ path.join(psdDir, "Links", linkedFileName),
743
+ path.join(psdDir, "links", linkedFileName),
744
+ path.join(psdDir, "..", linkedFileName),
745
+ ];
746
+ for (const tryPath of possiblePaths) {
747
+ if (fs.existsSync(tryPath)) {
748
+ externalFilePath = tryPath;
749
+ break;
750
+ }
751
+ }
752
+ }
753
+ return {
754
+ layerName: layer.name || "Unnamed",
755
+ type: soType,
756
+ transform: transform && transform.length >= 6
757
+ ? {
758
+ xx: transform[0],
759
+ xy: transform[1],
760
+ yx: transform[2],
761
+ yy: transform[3],
762
+ tx: transform[4],
763
+ ty: transform[5],
764
+ }
765
+ : undefined,
766
+ width: placed.width,
767
+ height: placed.height,
768
+ linkedFileId: placed.id,
769
+ linkedFileName: linkedFile?.name,
770
+ linkedFileType: linkedFile?.type,
771
+ hasEmbeddedData,
772
+ externalFilePath,
773
+ };
774
+ }
775
+ // Create MCP Server
776
+ const server = new Server({
777
+ name: "psd-parser",
778
+ version: "1.0.0",
779
+ }, {
780
+ capabilities: {
781
+ tools: {},
782
+ },
783
+ });
784
+ // List available tools
785
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
786
+ return {
787
+ tools: [
788
+ {
789
+ name: "extract_colors",
790
+ description: "Extract all colors used in the PSD file including text colors, fills, strokes, layer effects (shadows, glows, overlays), and gradients",
791
+ inputSchema: {
792
+ type: "object",
793
+ properties: {
794
+ path: {
795
+ type: "string",
796
+ description: "Absolute path to the PSD file",
797
+ },
798
+ format: {
799
+ type: "string",
800
+ enum: ["summary", "detailed", "css"],
801
+ description: "Output format: 'summary' for unique colors only, 'detailed' for all colors with sources, 'css' for CSS custom properties (default: summary)",
802
+ },
803
+ },
804
+ required: ["path"],
805
+ },
806
+ },
807
+ {
808
+ name: "export_layer_image",
809
+ description: "Export a single image layer by name. Use layerIndex when multiple layers have the same name.",
810
+ inputSchema: {
811
+ type: "object",
812
+ properties: {
813
+ path: {
814
+ type: "string",
815
+ description: "Absolute path to the PSD file",
816
+ },
817
+ layerName: {
818
+ type: "string",
819
+ description: "Name of the layer to export (partial match, case-insensitive)",
820
+ },
821
+ layerIndex: {
822
+ type: "number",
823
+ description: "When multiple layers match the name, specify which one (0-based index). Use list_layers first to see the order.",
824
+ },
825
+ groupName: {
826
+ type: "string",
827
+ description: "Optional: Search only within this group (useful for narrowing down same-named layers)",
828
+ },
829
+ outputPath: {
830
+ type: "string",
831
+ description: "Full output path including filename (e.g., /path/to/output/hero.png)",
832
+ },
833
+ scale: {
834
+ type: "number",
835
+ description: "Scale factor (default: 2 for @2x)",
836
+ },
837
+ format: {
838
+ type: "string",
839
+ enum: ["png", "jpg"],
840
+ description: "Image format (default: png)",
841
+ },
842
+ quality: {
843
+ type: "number",
844
+ description: "JPG quality 1-100 (default: 90)",
845
+ },
846
+ },
847
+ required: ["path", "layerName", "outputPath"],
848
+ },
849
+ },
850
+ {
851
+ name: "export_all_vectors_as_svg",
852
+ description: "Export all vector/shape layers as SVG files to a specified directory",
853
+ inputSchema: {
854
+ type: "object",
855
+ properties: {
856
+ path: {
857
+ type: "string",
858
+ description: "Absolute path to the PSD file",
859
+ },
860
+ outputDir: {
861
+ type: "string",
862
+ description: "Directory to save SVG files",
863
+ },
864
+ groupName: {
865
+ type: "string",
866
+ description: "Optional: Only export vectors from this group",
867
+ },
868
+ },
869
+ required: ["path", "outputDir"],
870
+ },
871
+ },
872
+ {
873
+ name: "export_images",
874
+ description: "Export image layers as PNG or JPG files (@2x scale for Retina). Can export from a specific group or all images.",
875
+ inputSchema: {
876
+ type: "object",
877
+ properties: {
878
+ path: {
879
+ type: "string",
880
+ description: "Absolute path to the PSD file",
881
+ },
882
+ outputDir: {
883
+ type: "string",
884
+ description: "Directory to save image files",
885
+ },
886
+ groupName: {
887
+ type: "string",
888
+ description: "Optional: Only export images from this group",
889
+ },
890
+ scale: {
891
+ type: "number",
892
+ description: "Scale factor (default: 2 for @2x)",
893
+ },
894
+ format: {
895
+ type: "string",
896
+ enum: ["png", "jpg"],
897
+ description: "Image format: 'png' (default, supports transparency) or 'jpg' (smaller file size)",
898
+ },
899
+ quality: {
900
+ type: "number",
901
+ description: "JPG quality 1-100 (default: 90). Only applies to JPG format.",
902
+ },
903
+ },
904
+ required: ["path", "outputDir"],
905
+ },
906
+ },
907
+ {
908
+ name: "list_vector_layers",
909
+ description: "List all vector/shape layers in a PSD file that can be exported as SVG",
910
+ inputSchema: {
911
+ type: "object",
912
+ properties: {
913
+ path: {
914
+ type: "string",
915
+ description: "Absolute path to the PSD file",
916
+ },
917
+ },
918
+ required: ["path"],
919
+ },
920
+ },
921
+ {
922
+ name: "export_vector_as_svg",
923
+ description: "Export a vector/shape layer as SVG. Returns the SVG string or saves to a file.",
924
+ inputSchema: {
925
+ type: "object",
926
+ properties: {
927
+ path: {
928
+ type: "string",
929
+ description: "Absolute path to the PSD file",
930
+ },
931
+ layerName: {
932
+ type: "string",
933
+ description: "Name of the vector layer to export",
934
+ },
935
+ outputPath: {
936
+ type: "string",
937
+ description: "Optional: Path to save the SVG file. If not provided, returns the SVG string.",
938
+ },
939
+ },
940
+ required: ["path", "layerName"],
941
+ },
942
+ },
943
+ {
944
+ name: "list_layers",
945
+ description: "List all layers in a PSD file as a tree structure. Great for getting an overview of the document structure.",
946
+ inputSchema: {
947
+ type: "object",
948
+ properties: {
949
+ path: {
950
+ type: "string",
951
+ description: "Absolute path to the PSD file",
952
+ },
953
+ depth: {
954
+ type: "number",
955
+ description: "Maximum depth to display (optional, default: unlimited)",
956
+ },
957
+ },
958
+ required: ["path"],
959
+ },
960
+ },
961
+ {
962
+ name: "get_layer_by_name",
963
+ description: "Find a layer by name and return its detailed information including position, size, and text content if applicable",
964
+ inputSchema: {
965
+ type: "object",
966
+ properties: {
967
+ path: {
968
+ type: "string",
969
+ description: "Absolute path to the PSD file",
970
+ },
971
+ name: {
972
+ type: "string",
973
+ description: "Layer name to search for (partial match, case-insensitive)",
974
+ },
975
+ exact: {
976
+ type: "boolean",
977
+ description: "If true, require exact name match (default: false)",
978
+ },
979
+ },
980
+ required: ["path", "name"],
981
+ },
982
+ },
983
+ {
984
+ name: "get_layer_children",
985
+ description: "Get the children of a specific group layer. Use this to drill down into nested groups.",
986
+ inputSchema: {
987
+ type: "object",
988
+ properties: {
989
+ path: {
990
+ type: "string",
991
+ description: "Absolute path to the PSD file",
992
+ },
993
+ groupName: {
994
+ type: "string",
995
+ description: "Name of the group layer to get children from",
996
+ },
997
+ format: {
998
+ type: "string",
999
+ enum: ["tree", "detailed"],
1000
+ description: "Output format: 'tree' for simple tree view, 'detailed' for full info (default: tree)",
1001
+ },
1002
+ },
1003
+ required: ["path", "groupName"],
1004
+ },
1005
+ },
1006
+ {
1007
+ name: "parse_psd",
1008
+ description: "Parse a PSD file and return document info with all layers including text content, sizes, positions, and hierarchy",
1009
+ inputSchema: {
1010
+ type: "object",
1011
+ properties: {
1012
+ path: {
1013
+ type: "string",
1014
+ description: "Absolute path to the PSD file",
1015
+ },
1016
+ },
1017
+ required: ["path"],
1018
+ },
1019
+ },
1020
+ {
1021
+ name: "get_text_layers",
1022
+ description: "Get only text layers from a PSD file with their content, font info, and positions",
1023
+ inputSchema: {
1024
+ type: "object",
1025
+ properties: {
1026
+ path: {
1027
+ type: "string",
1028
+ description: "Absolute path to the PSD file",
1029
+ },
1030
+ },
1031
+ required: ["path"],
1032
+ },
1033
+ },
1034
+ {
1035
+ name: "list_fonts",
1036
+ description: "List all fonts used in a PSD file with sizes, styles, colors, and which layers use them",
1037
+ inputSchema: {
1038
+ type: "object",
1039
+ properties: {
1040
+ path: {
1041
+ type: "string",
1042
+ description: "Absolute path to the PSD file",
1043
+ },
1044
+ format: {
1045
+ type: "string",
1046
+ enum: ["summary", "detailed", "css"],
1047
+ description: "Output format: 'summary' for font list, 'detailed' for full info, 'css' for @font-face template (default: summary)",
1048
+ },
1049
+ },
1050
+ required: ["path"],
1051
+ },
1052
+ },
1053
+ {
1054
+ name: "list_smart_objects",
1055
+ description: "List all Smart Object layers in a PSD file. Shows their type (vector/raster/imageStack), linked file info, and whether embedded data is available.",
1056
+ inputSchema: {
1057
+ type: "object",
1058
+ properties: {
1059
+ path: {
1060
+ type: "string",
1061
+ description: "Absolute path to the PSD file",
1062
+ },
1063
+ },
1064
+ required: ["path"],
1065
+ },
1066
+ },
1067
+ {
1068
+ name: "get_smart_object_content",
1069
+ description: "Get the content of an embedded Smart Object. If the embedded file is a PSD, it will be parsed recursively to show its layers. Supports PSD, PSB, AI, and other embedded formats.",
1070
+ inputSchema: {
1071
+ type: "object",
1072
+ properties: {
1073
+ path: {
1074
+ type: "string",
1075
+ description: "Absolute path to the PSD file",
1076
+ },
1077
+ layerName: {
1078
+ type: "string",
1079
+ description: "Name of the Smart Object layer to read (partial match, case-insensitive)",
1080
+ },
1081
+ layerIndex: {
1082
+ type: "number",
1083
+ description: "When multiple Smart Objects match the name, specify which one (0-based index)",
1084
+ },
1085
+ outputPath: {
1086
+ type: "string",
1087
+ description: "Optional: Save the embedded file data to this path. If not provided, embedded PSD files are parsed and layers are returned.",
1088
+ },
1089
+ },
1090
+ required: ["path", "layerName"],
1091
+ },
1092
+ },
1093
+ ],
1094
+ };
1095
+ });
1096
+ // Handle tool calls
1097
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1098
+ const { name, arguments: args } = request.params;
1099
+ try {
1100
+ switch (name) {
1101
+ case "extract_colors": {
1102
+ const { path: filePath, format = "summary" } = args;
1103
+ const absolutePath = path.resolve(filePath);
1104
+ if (!fs.existsSync(absolutePath)) {
1105
+ throw new Error(`File not found: ${absolutePath}`);
1106
+ }
1107
+ const buffer = fs.readFileSync(absolutePath);
1108
+ const psd = readPsd(buffer, {
1109
+ skipCompositeImageData: true,
1110
+ skipLayerImageData: true,
1111
+ skipThumbnail: true,
1112
+ });
1113
+ const palette = extractAllColors(psd.children || []);
1114
+ if (palette.uniqueColors.length === 0 &&
1115
+ palette.gradients.length === 0) {
1116
+ return {
1117
+ content: [
1118
+ {
1119
+ type: "text",
1120
+ text: "No colors found in this PSD file.",
1121
+ },
1122
+ ],
1123
+ };
1124
+ }
1125
+ let output;
1126
+ if (format === "css") {
1127
+ // Generate CSS custom properties
1128
+ const cssLines = [":root {"];
1129
+ palette.uniqueColors.forEach((hex, i) => {
1130
+ cssLines.push(` --color-${i + 1}: ${hex};`);
1131
+ });
1132
+ cssLines.push("");
1133
+ palette.gradients.forEach((grad, i) => {
1134
+ const gradientCss = `linear-gradient(90deg, ${grad.colors.join(", ")})`;
1135
+ cssLines.push(` --gradient-${i + 1}: ${gradientCss};`);
1136
+ });
1137
+ cssLines.push("}");
1138
+ output = cssLines.join("\n");
1139
+ }
1140
+ else if (format === "detailed") {
1141
+ // Detailed output with sources
1142
+ const lines = ["## Solid Colors\n"];
1143
+ // Group by color
1144
+ const colorMap = new Map();
1145
+ for (const color of palette.solidColors) {
1146
+ if (!colorMap.has(color.hex)) {
1147
+ colorMap.set(color.hex, { sources: [], layers: [] });
1148
+ }
1149
+ const entry = colorMap.get(color.hex);
1150
+ if (!entry.sources.includes(color.source)) {
1151
+ entry.sources.push(color.source);
1152
+ }
1153
+ if (!entry.layers.includes(color.layerName)) {
1154
+ entry.layers.push(color.layerName);
1155
+ }
1156
+ }
1157
+ for (const [hex, info] of colorMap) {
1158
+ lines.push(`**${hex}**`);
1159
+ lines.push(` Sources: ${info.sources.join(", ")}`);
1160
+ lines.push(` Layers: ${info.layers.slice(0, 3).join(", ")}${info.layers.length > 3 ? ` (+${info.layers.length - 3} more)` : ""}`);
1161
+ lines.push("");
1162
+ }
1163
+ if (palette.gradients.length > 0) {
1164
+ lines.push("\n## Gradients\n");
1165
+ for (const grad of palette.gradients) {
1166
+ lines.push(`**${grad.name || "Unnamed"}** (${grad.source})`);
1167
+ lines.push(` Colors: ${grad.colors.join(" → ")}`);
1168
+ lines.push(` Layer: ${grad.layerName}`);
1169
+ lines.push("");
1170
+ }
1171
+ }
1172
+ output = lines.join("\n");
1173
+ }
1174
+ else {
1175
+ // Summary format
1176
+ const lines = [
1177
+ `Found ${palette.uniqueColors.length} unique color(s) and ${palette.gradients.length} gradient(s)`,
1178
+ "",
1179
+ "## Colors",
1180
+ ...palette.uniqueColors.map((hex) => `- ${hex}`),
1181
+ ];
1182
+ if (palette.gradients.length > 0) {
1183
+ lines.push("");
1184
+ lines.push("## Gradients");
1185
+ for (const grad of palette.gradients) {
1186
+ lines.push(`- ${grad.name || "Unnamed"}: ${grad.colors.join(" → ")}`);
1187
+ }
1188
+ }
1189
+ output = lines.join("\n");
1190
+ }
1191
+ return {
1192
+ content: [
1193
+ {
1194
+ type: "text",
1195
+ text: output,
1196
+ },
1197
+ ],
1198
+ };
1199
+ }
1200
+ case "export_layer_image": {
1201
+ const { path: filePath, layerName, layerIndex, groupName, outputPath: outPath, scale = 2, format = "png", quality = 90, } = args;
1202
+ const absolutePath = path.resolve(filePath);
1203
+ const absoluteOutputPath = path.resolve(outPath);
1204
+ if (!fs.existsSync(absolutePath)) {
1205
+ throw new Error(`File not found: ${absolutePath}`);
1206
+ }
1207
+ const buffer = fs.readFileSync(absolutePath);
1208
+ const psd = readPsd(buffer, {
1209
+ skipCompositeImageData: true,
1210
+ skipLayerImageData: false,
1211
+ skipThumbnail: true,
1212
+ });
1213
+ // Get image layers (optionally from specific group)
1214
+ let allImages;
1215
+ if (groupName) {
1216
+ allImages = getImageLayersFromGroup(psd.children || [], groupName);
1217
+ }
1218
+ else {
1219
+ allImages = getAllImageLayers(psd.children || []);
1220
+ }
1221
+ // Find matching layers
1222
+ const matchingLayers = allImages.filter((l) => l.name?.toLowerCase().includes(layerName.toLowerCase()));
1223
+ if (matchingLayers.length === 0) {
1224
+ const suggestions = allImages
1225
+ .slice(0, 5)
1226
+ .map((l) => l.name)
1227
+ .join(", ");
1228
+ return {
1229
+ content: [
1230
+ {
1231
+ type: "text",
1232
+ text: `Layer "${layerName}" not found${groupName ? ` in group "${groupName}"` : ""}.\n\nAvailable image layers: ${suggestions || "none"}`,
1233
+ },
1234
+ ],
1235
+ };
1236
+ }
1237
+ // If multiple matches and no index specified, show options
1238
+ if (matchingLayers.length > 1 && layerIndex === undefined) {
1239
+ const options = matchingLayers
1240
+ .map((l, i) => ` ${i}: "${l.name}"`)
1241
+ .join("\n");
1242
+ return {
1243
+ content: [
1244
+ {
1245
+ type: "text",
1246
+ text: `Found ${matchingLayers.length} layers matching "${layerName}". Please specify layerIndex:\n\n${options}\n\nExample: layerIndex: 0 for the first one`,
1247
+ },
1248
+ ],
1249
+ };
1250
+ }
1251
+ // Select the target layer
1252
+ const targetIndex = layerIndex ?? 0;
1253
+ if (targetIndex >= matchingLayers.length) {
1254
+ return {
1255
+ content: [
1256
+ {
1257
+ type: "text",
1258
+ text: `layerIndex ${targetIndex} is out of range. Only ${matchingLayers.length} layer(s) found.`,
1259
+ },
1260
+ ],
1261
+ };
1262
+ }
1263
+ const targetLayer = matchingLayers[targetIndex];
1264
+ // Create output directory if needed
1265
+ const outputDir = path.dirname(absoluteOutputPath);
1266
+ if (!fs.existsSync(outputDir)) {
1267
+ fs.mkdirSync(outputDir, { recursive: true });
1268
+ }
1269
+ const imageBuffer = layerToImageBuffer(targetLayer, scale, format, quality);
1270
+ if (!imageBuffer) {
1271
+ return {
1272
+ content: [
1273
+ {
1274
+ type: "text",
1275
+ text: `Layer "${targetLayer.name}" has no image data.`,
1276
+ },
1277
+ ],
1278
+ };
1279
+ }
1280
+ fs.writeFileSync(absoluteOutputPath, imageBuffer);
1281
+ return {
1282
+ content: [
1283
+ {
1284
+ type: "text",
1285
+ text: `Exported "${targetLayer.name}" to ${absoluteOutputPath} (${format.toUpperCase()}, ${scale}x)`,
1286
+ },
1287
+ ],
1288
+ };
1289
+ }
1290
+ case "export_all_vectors_as_svg": {
1291
+ const { path: filePath, outputDir, groupName, } = args;
1292
+ const absolutePath = path.resolve(filePath);
1293
+ const absoluteOutputDir = path.resolve(outputDir);
1294
+ if (!fs.existsSync(absolutePath)) {
1295
+ throw new Error(`File not found: ${absolutePath}`);
1296
+ }
1297
+ // Create output directory if it doesn't exist
1298
+ if (!fs.existsSync(absoluteOutputDir)) {
1299
+ fs.mkdirSync(absoluteOutputDir, { recursive: true });
1300
+ }
1301
+ const buffer = fs.readFileSync(absolutePath);
1302
+ const psd = readPsd(buffer, {
1303
+ skipCompositeImageData: true,
1304
+ skipLayerImageData: true,
1305
+ skipThumbnail: true,
1306
+ });
1307
+ let vectorLayers;
1308
+ if (groupName) {
1309
+ // Find group and get vectors from it
1310
+ const findGroup = (layers) => {
1311
+ for (const layer of layers) {
1312
+ if (layer.name?.toLowerCase().includes(groupName.toLowerCase()) &&
1313
+ layer.children) {
1314
+ return layer;
1315
+ }
1316
+ if (layer.children) {
1317
+ const found = findGroup(layer.children);
1318
+ if (found)
1319
+ return found;
1320
+ }
1321
+ }
1322
+ return null;
1323
+ };
1324
+ const group = findGroup(psd.children || []);
1325
+ vectorLayers = group ? getAllVectorLayers(group.children || []) : [];
1326
+ }
1327
+ else {
1328
+ vectorLayers = getAllVectorLayers(psd.children || []);
1329
+ }
1330
+ if (vectorLayers.length === 0) {
1331
+ return {
1332
+ content: [
1333
+ {
1334
+ type: "text",
1335
+ text: groupName
1336
+ ? `No vector layers found in group "${groupName}".`
1337
+ : "No vector layers found in this PSD file.",
1338
+ },
1339
+ ],
1340
+ };
1341
+ }
1342
+ const exported = [];
1343
+ // Track filenames to avoid duplicates
1344
+ const usedFilenames = new Map();
1345
+ for (const layer of vectorLayers) {
1346
+ try {
1347
+ const svg = vectorLayerToSvg(layer, psd.width, psd.height);
1348
+ const baseName = sanitizeFilename(layer.name || "unnamed");
1349
+ // Check for duplicate and add number suffix if needed
1350
+ let finalName;
1351
+ const count = usedFilenames.get(baseName) || 0;
1352
+ if (count === 0) {
1353
+ finalName = baseName;
1354
+ }
1355
+ else {
1356
+ finalName = `${baseName}_${String(count).padStart(2, "0")}`;
1357
+ }
1358
+ usedFilenames.set(baseName, count + 1);
1359
+ const filename = finalName + ".svg";
1360
+ const outputPath = path.join(absoluteOutputDir, filename);
1361
+ fs.writeFileSync(outputPath, svg, "utf-8");
1362
+ exported.push(filename);
1363
+ }
1364
+ catch (e) {
1365
+ // Skip layers that fail to export
1366
+ }
1367
+ }
1368
+ return {
1369
+ content: [
1370
+ {
1371
+ type: "text",
1372
+ text: `Exported ${exported.length} SVG file(s) to ${absoluteOutputDir}:\n\n${exported.map((f) => `- ${f}`).join("\n")}`,
1373
+ },
1374
+ ],
1375
+ };
1376
+ }
1377
+ case "export_images": {
1378
+ const { path: filePath, outputDir, groupName, scale = 2, format = "png", quality = 90, } = args;
1379
+ const absolutePath = path.resolve(filePath);
1380
+ const absoluteOutputDir = path.resolve(outputDir);
1381
+ if (!fs.existsSync(absolutePath)) {
1382
+ throw new Error(`File not found: ${absolutePath}`);
1383
+ }
1384
+ // Create output directory if it doesn't exist
1385
+ if (!fs.existsSync(absoluteOutputDir)) {
1386
+ fs.mkdirSync(absoluteOutputDir, { recursive: true });
1387
+ }
1388
+ const buffer = fs.readFileSync(absolutePath);
1389
+ const psd = readPsd(buffer, {
1390
+ skipCompositeImageData: true,
1391
+ skipLayerImageData: false, // Need image data for export
1392
+ skipThumbnail: true,
1393
+ });
1394
+ let imageLayers;
1395
+ if (groupName) {
1396
+ imageLayers = getImageLayersFromGroup(psd.children || [], groupName);
1397
+ }
1398
+ else {
1399
+ imageLayers = getAllImageLayers(psd.children || []);
1400
+ }
1401
+ if (imageLayers.length === 0) {
1402
+ return {
1403
+ content: [
1404
+ {
1405
+ type: "text",
1406
+ text: groupName
1407
+ ? `No image layers found in group "${groupName}".`
1408
+ : "No image layers found in this PSD file.",
1409
+ },
1410
+ ],
1411
+ };
1412
+ }
1413
+ const exported = [];
1414
+ const suffix = scale !== 1 ? `@${scale}x` : "";
1415
+ const ext = format === "jpg" ? ".jpg" : ".png";
1416
+ // Track filenames to avoid duplicates
1417
+ const usedFilenames = new Map();
1418
+ for (const layer of imageLayers) {
1419
+ try {
1420
+ const imageBuffer = layerToImageBuffer(layer, scale, format, quality);
1421
+ if (imageBuffer) {
1422
+ const baseName = sanitizeFilename(layer.name || "unnamed");
1423
+ // Check for duplicate and add number suffix if needed
1424
+ let finalName;
1425
+ const count = usedFilenames.get(baseName) || 0;
1426
+ if (count === 0) {
1427
+ finalName = baseName;
1428
+ }
1429
+ else {
1430
+ finalName = `${baseName}_${String(count).padStart(2, "0")}`;
1431
+ }
1432
+ usedFilenames.set(baseName, count + 1);
1433
+ const filename = finalName + suffix + ext;
1434
+ const outputPath = path.join(absoluteOutputDir, filename);
1435
+ fs.writeFileSync(outputPath, imageBuffer);
1436
+ exported.push(filename);
1437
+ }
1438
+ }
1439
+ catch (e) {
1440
+ // Skip layers that fail to export
1441
+ }
1442
+ }
1443
+ const formatInfo = format === "jpg" ? `JPG (quality: ${quality})` : "PNG";
1444
+ return {
1445
+ content: [
1446
+ {
1447
+ type: "text",
1448
+ text: `Exported ${exported.length} ${formatInfo} file(s) at ${scale}x to ${absoluteOutputDir}:\n\n${exported.map((f) => `- ${f}`).join("\n")}`,
1449
+ },
1450
+ ],
1451
+ };
1452
+ }
1453
+ case "list_vector_layers": {
1454
+ const filePath = args.path;
1455
+ const absolutePath = path.resolve(filePath);
1456
+ if (!fs.existsSync(absolutePath)) {
1457
+ throw new Error(`File not found: ${absolutePath}`);
1458
+ }
1459
+ const buffer = fs.readFileSync(absolutePath);
1460
+ const psd = readPsd(buffer, {
1461
+ skipCompositeImageData: true,
1462
+ skipLayerImageData: true,
1463
+ skipThumbnail: true,
1464
+ });
1465
+ const vectorLayers = getAllVectorLayers(psd.children || []);
1466
+ if (vectorLayers.length === 0) {
1467
+ return {
1468
+ content: [
1469
+ {
1470
+ type: "text",
1471
+ text: "No vector layers found in this PSD file.",
1472
+ },
1473
+ ],
1474
+ };
1475
+ }
1476
+ const layerList = vectorLayers
1477
+ .map((layer) => {
1478
+ const hasFill = !!layer.vectorFill;
1479
+ const hasStroke = !!layer.vectorStroke?.strokeEnabled;
1480
+ return `- ${layer.name} (fill: ${hasFill ? "yes" : "no"}, stroke: ${hasStroke ? "yes" : "no"})`;
1481
+ })
1482
+ .join("\n");
1483
+ return {
1484
+ content: [
1485
+ {
1486
+ type: "text",
1487
+ text: `Found ${vectorLayers.length} vector layer(s):\n\n${layerList}`,
1488
+ },
1489
+ ],
1490
+ };
1491
+ }
1492
+ case "export_vector_as_svg": {
1493
+ const { path: filePath, layerName, outputPath, } = args;
1494
+ const absolutePath = path.resolve(filePath);
1495
+ if (!fs.existsSync(absolutePath)) {
1496
+ throw new Error(`File not found: ${absolutePath}`);
1497
+ }
1498
+ const buffer = fs.readFileSync(absolutePath);
1499
+ const psd = readPsd(buffer, {
1500
+ skipCompositeImageData: true,
1501
+ skipLayerImageData: true,
1502
+ skipThumbnail: true,
1503
+ });
1504
+ const vectorLayer = findVectorLayer(psd.children || [], layerName);
1505
+ if (!vectorLayer) {
1506
+ const allVectors = getAllVectorLayers(psd.children || []);
1507
+ const suggestions = allVectors
1508
+ .slice(0, 5)
1509
+ .map((l) => l.name)
1510
+ .join(", ");
1511
+ return {
1512
+ content: [
1513
+ {
1514
+ type: "text",
1515
+ text: `Vector layer "${layerName}" not found.\n\nAvailable vector layers: ${suggestions || "none"}`,
1516
+ },
1517
+ ],
1518
+ };
1519
+ }
1520
+ const svg = vectorLayerToSvg(vectorLayer, psd.width, psd.height);
1521
+ if (outputPath) {
1522
+ const absoluteOutputPath = path.resolve(outputPath);
1523
+ fs.writeFileSync(absoluteOutputPath, svg, "utf-8");
1524
+ return {
1525
+ content: [
1526
+ {
1527
+ type: "text",
1528
+ text: `SVG saved to: ${absoluteOutputPath}`,
1529
+ },
1530
+ ],
1531
+ };
1532
+ }
1533
+ return {
1534
+ content: [
1535
+ {
1536
+ type: "text",
1537
+ text: svg,
1538
+ },
1539
+ ],
1540
+ };
1541
+ }
1542
+ case "list_layers": {
1543
+ const { path: filePath, depth } = args;
1544
+ const psdInfo = parsePsdFile(filePath);
1545
+ // Apply depth limit if specified
1546
+ function limitDepth(layers, currentDepth, maxDepth) {
1547
+ if (currentDepth >= maxDepth) {
1548
+ return layers.map((l) => ({
1549
+ ...l,
1550
+ children: l.children?.length
1551
+ ? [
1552
+ {
1553
+ name: `... (${l.children.length} children)`,
1554
+ type: "unknown",
1555
+ visible: true,
1556
+ opacity: 1,
1557
+ bounds: { left: 0, top: 0, width: 0, height: 0 },
1558
+ },
1559
+ ]
1560
+ : undefined,
1561
+ }));
1562
+ }
1563
+ return layers.map((l) => ({
1564
+ ...l,
1565
+ children: l.children
1566
+ ? limitDepth(l.children, currentDepth + 1, maxDepth)
1567
+ : undefined,
1568
+ }));
1569
+ }
1570
+ const layersToShow = depth
1571
+ ? limitDepth(psdInfo.layers, 0, depth)
1572
+ : psdInfo.layers;
1573
+ const tree = formatLayerTree(layersToShow);
1574
+ return {
1575
+ content: [
1576
+ {
1577
+ type: "text",
1578
+ text: `PSD: ${psdInfo.width}x${psdInfo.height}\n\n${tree}`,
1579
+ },
1580
+ ],
1581
+ };
1582
+ }
1583
+ case "get_layer_by_name": {
1584
+ const { path: filePath, name: layerName, exact, } = args;
1585
+ const psdInfo = parsePsdFile(filePath);
1586
+ const layer = findLayerByName(psdInfo.layers, layerName, exact ?? false);
1587
+ if (!layer) {
1588
+ // Show suggestions
1589
+ const similar = searchLayersByName(psdInfo.layers, layerName.slice(0, 3));
1590
+ const suggestions = similar
1591
+ .slice(0, 5)
1592
+ .map((l) => l.name)
1593
+ .join(", ");
1594
+ return {
1595
+ content: [
1596
+ {
1597
+ type: "text",
1598
+ text: `Layer "${layerName}" not found.\n\nSimilar layers: ${suggestions || "none"}`,
1599
+ },
1600
+ ],
1601
+ };
1602
+ }
1603
+ return {
1604
+ content: [
1605
+ {
1606
+ type: "text",
1607
+ text: JSON.stringify(layer, null, 2),
1608
+ },
1609
+ ],
1610
+ };
1611
+ }
1612
+ case "get_layer_children": {
1613
+ const { path: filePath, groupName, format, } = args;
1614
+ const psdInfo = parsePsdFile(filePath);
1615
+ const group = findLayerByName(psdInfo.layers, groupName, false);
1616
+ if (!group) {
1617
+ return {
1618
+ content: [
1619
+ {
1620
+ type: "text",
1621
+ text: `Group "${groupName}" not found.`,
1622
+ },
1623
+ ],
1624
+ };
1625
+ }
1626
+ if (!group.children || group.children.length === 0) {
1627
+ return {
1628
+ content: [
1629
+ {
1630
+ type: "text",
1631
+ text: `"${group.name}" has no children (type: ${group.type})`,
1632
+ },
1633
+ ],
1634
+ };
1635
+ }
1636
+ const outputFormat = format ?? "tree";
1637
+ const output = outputFormat === "tree"
1638
+ ? formatLayerTree(group.children)
1639
+ : JSON.stringify(group.children, null, 2);
1640
+ return {
1641
+ content: [
1642
+ {
1643
+ type: "text",
1644
+ text: `Children of "${group.name}" (${group.children.length} items):\n\n${output}`,
1645
+ },
1646
+ ],
1647
+ };
1648
+ }
1649
+ case "parse_psd": {
1650
+ const filePath = args.path;
1651
+ const psdInfo = parsePsdFile(filePath);
1652
+ return {
1653
+ content: [
1654
+ {
1655
+ type: "text",
1656
+ text: JSON.stringify(psdInfo, null, 2),
1657
+ },
1658
+ ],
1659
+ };
1660
+ }
1661
+ case "get_text_layers": {
1662
+ const filePath = args.path;
1663
+ const psdInfo = parsePsdFile(filePath);
1664
+ const textLayers = getTextLayers(psdInfo.layers);
1665
+ return {
1666
+ content: [
1667
+ {
1668
+ type: "text",
1669
+ text: JSON.stringify({
1670
+ documentSize: {
1671
+ width: psdInfo.width,
1672
+ height: psdInfo.height,
1673
+ },
1674
+ textLayers,
1675
+ }, null, 2),
1676
+ },
1677
+ ],
1678
+ };
1679
+ }
1680
+ case "list_fonts": {
1681
+ const { path: filePath, format = "summary" } = args;
1682
+ const absolutePath = path.resolve(filePath);
1683
+ if (!fs.existsSync(absolutePath)) {
1684
+ throw new Error(`File not found: ${absolutePath}`);
1685
+ }
1686
+ const buffer = fs.readFileSync(absolutePath);
1687
+ const psd = readPsd(buffer, {
1688
+ skipCompositeImageData: true,
1689
+ skipLayerImageData: true,
1690
+ skipThumbnail: true,
1691
+ });
1692
+ const fontMap = extractAllFonts(psd.children || []);
1693
+ if (fontMap.size === 0) {
1694
+ return {
1695
+ content: [
1696
+ {
1697
+ type: "text",
1698
+ text: "No fonts found in this PSD file (no text layers).",
1699
+ },
1700
+ ],
1701
+ };
1702
+ }
1703
+ const fonts = Array.from(fontMap.values()).sort((a, b) => a.fontName.localeCompare(b.fontName));
1704
+ let output;
1705
+ if (format === "css") {
1706
+ // Generate CSS @font-face template
1707
+ const cssLines = [
1708
+ "/* Font faces used in this PSD */",
1709
+ "/* Replace src with actual font file paths */",
1710
+ "",
1711
+ ];
1712
+ for (const font of fonts) {
1713
+ const safeName = font.fontName.replace(/[^a-zA-Z0-9-]/g, "-");
1714
+ cssLines.push(`@font-face {`);
1715
+ cssLines.push(` font-family: '${font.fontName}';`);
1716
+ cssLines.push(` src: url('./fonts/${safeName}.woff2') format('woff2'),`);
1717
+ cssLines.push(` url('./fonts/${safeName}.woff') format('woff');`);
1718
+ cssLines.push(` font-weight: normal;`);
1719
+ cssLines.push(` font-style: normal;`);
1720
+ cssLines.push(` font-display: swap;`);
1721
+ cssLines.push(`}`);
1722
+ cssLines.push(``);
1723
+ }
1724
+ cssLines.push(`/* CSS variables for font sizes */`);
1725
+ cssLines.push(`:root {`);
1726
+ const allSizes = [...new Set(fonts.flatMap((f) => f.sizes))].sort((a, b) => a - b);
1727
+ allSizes.forEach((size, i) => {
1728
+ cssLines.push(` --font-size-${i + 1}: ${size}px;`);
1729
+ });
1730
+ cssLines.push(`}`);
1731
+ output = cssLines.join("\n");
1732
+ }
1733
+ else if (format === "detailed") {
1734
+ // Detailed output
1735
+ const lines = [`Found ${fonts.length} font(s):\n`];
1736
+ for (const font of fonts) {
1737
+ const styleList = [];
1738
+ if (font.styles.regular)
1739
+ styleList.push("Regular");
1740
+ if (font.styles.bold)
1741
+ styleList.push("Bold");
1742
+ if (font.styles.italic)
1743
+ styleList.push("Italic");
1744
+ if (font.styles.fauxBold)
1745
+ styleList.push("Faux Bold");
1746
+ if (font.styles.fauxItalic)
1747
+ styleList.push("Faux Italic");
1748
+ lines.push(`## ${font.fontName}`);
1749
+ lines.push(` Sizes: ${font.sizes.join("px, ")}px`);
1750
+ lines.push(` Styles: ${styleList.join(", ") || "Unknown"}`);
1751
+ if (font.colors.length > 0) {
1752
+ lines.push(` Colors: ${font.colors.join(", ")}`);
1753
+ }
1754
+ lines.push(` Used in: ${font.layers.slice(0, 5).join(", ")}${font.layers.length > 5 ? ` (+${font.layers.length - 5} more)` : ""}`);
1755
+ lines.push(``);
1756
+ }
1757
+ output = lines.join("\n");
1758
+ }
1759
+ else {
1760
+ // Summary format
1761
+ const lines = [`Found ${fonts.length} font(s):\n`];
1762
+ for (const font of fonts) {
1763
+ const sizeRange = font.sizes.length > 0
1764
+ ? font.sizes.length === 1
1765
+ ? `${font.sizes[0]}px`
1766
+ : `${font.sizes[0]}-${font.sizes[font.sizes.length - 1]}px`
1767
+ : "";
1768
+ lines.push(`- **${font.fontName}** ${sizeRange}`);
1769
+ }
1770
+ output = lines.join("\n");
1771
+ }
1772
+ return {
1773
+ content: [
1774
+ {
1775
+ type: "text",
1776
+ text: output,
1777
+ },
1778
+ ],
1779
+ };
1780
+ }
1781
+ case "list_smart_objects": {
1782
+ const filePath = args.path;
1783
+ const absolutePath = path.resolve(filePath);
1784
+ if (!fs.existsSync(absolutePath)) {
1785
+ throw new Error(`File not found: ${absolutePath}`);
1786
+ }
1787
+ const buffer = fs.readFileSync(absolutePath);
1788
+ const psd = readPsd(buffer, {
1789
+ skipCompositeImageData: true,
1790
+ skipLayerImageData: true,
1791
+ skipThumbnail: true,
1792
+ skipLinkedFilesData: false, // We need linked file info
1793
+ });
1794
+ const smartObjects = getAllSmartObjectLayers(psd.children || []);
1795
+ if (smartObjects.length === 0) {
1796
+ return {
1797
+ content: [
1798
+ {
1799
+ type: "text",
1800
+ text: "No Smart Object layers found in this PSD file.",
1801
+ },
1802
+ ],
1803
+ };
1804
+ }
1805
+ const results = smartObjects.map(({ layer, path: layerPath }) => {
1806
+ const info = extractSmartObjectInfo(layer, psd.linkedFiles, absolutePath);
1807
+ return {
1808
+ ...info,
1809
+ layerPath: layerPath.join(" > "),
1810
+ };
1811
+ });
1812
+ const lines = [
1813
+ `Found ${smartObjects.length} Smart Object(s):\n`,
1814
+ ...results.map((so, i) => {
1815
+ let statusLine;
1816
+ if (so.hasEmbeddedData) {
1817
+ statusLine = "✓ embedded data available";
1818
+ }
1819
+ else if (so.externalFilePath) {
1820
+ statusLine = `✓ external file found: ${so.externalFilePath}`;
1821
+ }
1822
+ else if (so.linkedFileName) {
1823
+ statusLine = `✗ external link (file not found: ${so.linkedFileName})`;
1824
+ }
1825
+ else {
1826
+ statusLine = "✗ no data available";
1827
+ }
1828
+ return [
1829
+ `${i + 1}. **${so.layerName}**`,
1830
+ ` Path: ${so.layerPath}`,
1831
+ ` Type: ${so.type}`,
1832
+ so.linkedFileName ? ` File: ${so.linkedFileName}` : null,
1833
+ so.linkedFileType ? ` Format: ${so.linkedFileType}` : null,
1834
+ so.width && so.height
1835
+ ? ` Size: ${so.width}x${so.height}`
1836
+ : null,
1837
+ ` Status: ${statusLine}`,
1838
+ "",
1839
+ ]
1840
+ .filter((l) => l !== null)
1841
+ .join("\n");
1842
+ }),
1843
+ ];
1844
+ return {
1845
+ content: [
1846
+ {
1847
+ type: "text",
1848
+ text: lines.join("\n"),
1849
+ },
1850
+ ],
1851
+ };
1852
+ }
1853
+ case "get_smart_object_content": {
1854
+ const { path: filePath, layerName, layerIndex, outputPath, } = args;
1855
+ const absolutePath = path.resolve(filePath);
1856
+ if (!fs.existsSync(absolutePath)) {
1857
+ throw new Error(`File not found: ${absolutePath}`);
1858
+ }
1859
+ const buffer = fs.readFileSync(absolutePath);
1860
+ const psd = readPsd(buffer, {
1861
+ skipCompositeImageData: true,
1862
+ skipLayerImageData: true,
1863
+ skipThumbnail: true,
1864
+ skipLinkedFilesData: false, // Need the embedded data
1865
+ });
1866
+ const smartObjects = getAllSmartObjectLayers(psd.children || []);
1867
+ // Find matching smart objects
1868
+ const matchingSOs = smartObjects.filter(({ layer }) => layer.name?.toLowerCase().includes(layerName.toLowerCase()));
1869
+ if (matchingSOs.length === 0) {
1870
+ const suggestions = smartObjects
1871
+ .slice(0, 5)
1872
+ .map(({ layer }) => layer.name)
1873
+ .join(", ");
1874
+ return {
1875
+ content: [
1876
+ {
1877
+ type: "text",
1878
+ text: `Smart Object "${layerName}" not found.\n\nAvailable Smart Objects: ${suggestions || "none"}`,
1879
+ },
1880
+ ],
1881
+ };
1882
+ }
1883
+ // If multiple matches and no index specified, show options
1884
+ if (matchingSOs.length > 1 && layerIndex === undefined) {
1885
+ const options = matchingSOs
1886
+ .map(({ layer, path: p }, i) => ` ${i}: "${layer.name}" (${p.join(" > ")})`)
1887
+ .join("\n");
1888
+ return {
1889
+ content: [
1890
+ {
1891
+ type: "text",
1892
+ text: `Found ${matchingSOs.length} Smart Objects matching "${layerName}". Please specify layerIndex:\n\n${options}`,
1893
+ },
1894
+ ],
1895
+ };
1896
+ }
1897
+ // Select target
1898
+ const targetIndex = layerIndex ?? 0;
1899
+ if (targetIndex >= matchingSOs.length) {
1900
+ return {
1901
+ content: [
1902
+ {
1903
+ type: "text",
1904
+ text: `layerIndex ${targetIndex} is out of range. Only ${matchingSOs.length} Smart Object(s) found.`,
1905
+ },
1906
+ ],
1907
+ };
1908
+ }
1909
+ const targetSO = matchingSOs[targetIndex];
1910
+ const placed = targetSO.layer.placedLayer;
1911
+ // Find linked file data
1912
+ let linkedFile = undefined;
1913
+ if (placed.id && psd.linkedFiles) {
1914
+ linkedFile = psd.linkedFiles.find((f) => f.id === placed.id);
1915
+ }
1916
+ // If no embedded data, try to find the external linked file
1917
+ let fileData = null;
1918
+ let fileType = linkedFile?.type || "unknown";
1919
+ let externalFilePath = null;
1920
+ if (linkedFile?.data && linkedFile.data.length > 0) {
1921
+ fileData = linkedFile.data;
1922
+ }
1923
+ else if (linkedFile?.name) {
1924
+ // Try to find external file relative to the PSD
1925
+ const psdDir = path.dirname(absolutePath);
1926
+ const linkedFileName = linkedFile.name;
1927
+ // Possible locations to search for the linked file
1928
+ const possiblePaths = [
1929
+ path.join(psdDir, linkedFileName), // Same directory
1930
+ path.join(psdDir, "Links", linkedFileName), // Links subfolder (common in Adobe)
1931
+ path.join(psdDir, "links", linkedFileName), // links subfolder (lowercase)
1932
+ path.join(psdDir, "..", linkedFileName), // Parent directory
1933
+ ];
1934
+ for (const tryPath of possiblePaths) {
1935
+ if (fs.existsSync(tryPath)) {
1936
+ externalFilePath = tryPath;
1937
+ fileData = new Uint8Array(fs.readFileSync(tryPath));
1938
+ break;
1939
+ }
1940
+ }
1941
+ if (!fileData) {
1942
+ const info = extractSmartObjectInfo(targetSO.layer, psd.linkedFiles);
1943
+ return {
1944
+ content: [
1945
+ {
1946
+ type: "text",
1947
+ text: [
1948
+ `Smart Object "${targetSO.layer.name}" is an external link.`,
1949
+ "",
1950
+ `Linked file: ${linkedFileName}`,
1951
+ linkedFile?.type ? `File type: ${linkedFile.type}` : "",
1952
+ info.type !== "unknown"
1953
+ ? `Smart Object type: ${info.type}`
1954
+ : "",
1955
+ "",
1956
+ "Could not find the linked file in these locations:",
1957
+ ...possiblePaths.map((p) => ` - ${p}`),
1958
+ "",
1959
+ "Please ensure the linked file exists in one of these locations.",
1960
+ ]
1961
+ .filter((l) => l)
1962
+ .join("\n"),
1963
+ },
1964
+ ],
1965
+ };
1966
+ }
1967
+ }
1968
+ else {
1969
+ const info = extractSmartObjectInfo(targetSO.layer, psd.linkedFiles);
1970
+ return {
1971
+ content: [
1972
+ {
1973
+ type: "text",
1974
+ text: [
1975
+ `Smart Object "${targetSO.layer.name}" has no embedded data and no linked file info.`,
1976
+ "",
1977
+ info.type !== "unknown"
1978
+ ? `Smart Object type: ${info.type}`
1979
+ : "",
1980
+ ]
1981
+ .filter((l) => l)
1982
+ .join("\n"),
1983
+ },
1984
+ ],
1985
+ };
1986
+ }
1987
+ const embeddedData = fileData;
1988
+ const dataSource = externalFilePath
1989
+ ? `external file: ${externalFilePath}`
1990
+ : "embedded data";
1991
+ // If output path specified, save the raw embedded file
1992
+ if (outputPath) {
1993
+ const absoluteOutputPath = path.resolve(outputPath);
1994
+ const outputDir = path.dirname(absoluteOutputPath);
1995
+ if (!fs.existsSync(outputDir)) {
1996
+ fs.mkdirSync(outputDir, { recursive: true });
1997
+ }
1998
+ fs.writeFileSync(absoluteOutputPath, Buffer.from(embeddedData));
1999
+ return {
2000
+ content: [
2001
+ {
2002
+ type: "text",
2003
+ text: `Saved file (${fileType}, ${embeddedData.length} bytes) from ${dataSource} to: ${absoluteOutputPath}`,
2004
+ },
2005
+ ],
2006
+ };
2007
+ }
2008
+ // Try to parse as PSD if it looks like one
2009
+ const isPsd = fileType === "psd" ||
2010
+ fileType === "psb" ||
2011
+ linkedFile.name?.toLowerCase().endsWith(".psd") ||
2012
+ linkedFile.name?.toLowerCase().endsWith(".psb");
2013
+ if (isPsd) {
2014
+ try {
2015
+ const embeddedPsd = readPsd(Buffer.from(embeddedData), {
2016
+ skipCompositeImageData: true,
2017
+ skipLayerImageData: true,
2018
+ skipThumbnail: true,
2019
+ });
2020
+ const embeddedLayers = embeddedPsd.children?.map(extractLayerInfo) || [];
2021
+ const tree = formatLayerTree(embeddedLayers);
2022
+ return {
2023
+ content: [
2024
+ {
2025
+ type: "text",
2026
+ text: [
2027
+ `**Smart Object: ${targetSO.layer.name}**`,
2028
+ `Source: ${dataSource}`,
2029
+ `PSD Size: ${embeddedPsd.width}x${embeddedPsd.height}`,
2030
+ "",
2031
+ "## Layers inside Smart Object:",
2032
+ "",
2033
+ tree,
2034
+ ].join("\n"),
2035
+ },
2036
+ ],
2037
+ };
2038
+ }
2039
+ catch (parseError) {
2040
+ return {
2041
+ content: [
2042
+ {
2043
+ type: "text",
2044
+ text: [
2045
+ `Smart Object "${targetSO.layer.name}" contains embedded data (${embeddedData.length} bytes).`,
2046
+ `File type: ${fileType}`,
2047
+ "",
2048
+ "Could not parse as PSD. Use outputPath to extract the raw file.",
2049
+ ].join("\n"),
2050
+ },
2051
+ ],
2052
+ };
2053
+ }
2054
+ }
2055
+ // For non-PSD files, return info about the data
2056
+ return {
2057
+ content: [
2058
+ {
2059
+ type: "text",
2060
+ text: [
2061
+ `**Smart Object: ${targetSO.layer.name}**`,
2062
+ `Source: ${dataSource}`,
2063
+ `File type: ${fileType}`,
2064
+ `Data size: ${embeddedData.length} bytes`,
2065
+ linkedFile?.name ? `Original name: ${linkedFile.name}` : "",
2066
+ "",
2067
+ "This is not a PSD file. Use outputPath to extract the file.",
2068
+ ]
2069
+ .filter((l) => l)
2070
+ .join("\n"),
2071
+ },
2072
+ ],
2073
+ };
2074
+ }
2075
+ default:
2076
+ throw new Error(`Unknown tool: ${name}`);
2077
+ }
2078
+ }
2079
+ catch (error) {
2080
+ const message = error instanceof Error ? error.message : String(error);
2081
+ return {
2082
+ content: [
2083
+ {
2084
+ type: "text",
2085
+ text: `Error: ${message}`,
2086
+ },
2087
+ ],
2088
+ isError: true,
2089
+ };
2090
+ }
2091
+ });
2092
+ // Start server
2093
+ async function main() {
2094
+ const transport = new StdioServerTransport();
2095
+ await server.connect(transport);
2096
+ console.error("PSD Parser MCP Server running on stdio");
2097
+ }
2098
+ main().catch(console.error);