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/README.md +145 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2098 -0
- package/package.json +54 -0
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);
|