mirror of
https://github.com/penpot/penpot-mcp.git
synced 2026-04-25 11:18:37 +00:00
Add ImportImageTool
Add PenpotUtils.atob to support base64 conversion (regular atob not available in plugin context) Resolves #10
This commit is contained in:
parent
cec2290f5d
commit
f8f440c7dd
@ -9,6 +9,7 @@ import { Tool } from "./Tool";
|
||||
import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool";
|
||||
import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool";
|
||||
import { ExportShapeTool } from "./tools/ExportShapeTool";
|
||||
import { ImportImageTool } from "./tools/ImportImageTool";
|
||||
import { ReplServer } from "./ReplServer";
|
||||
import { ApiDocs } from "./ApiDocs";
|
||||
|
||||
@ -64,6 +65,7 @@ export class PenpotMcpServer {
|
||||
new HighLevelOverviewTool(this),
|
||||
new PenpotApiInfoTool(this, this.apiDocs),
|
||||
new ExportShapeTool(this),
|
||||
new ImportImageTool(this),
|
||||
];
|
||||
|
||||
for (const tool of toolInstances) {
|
||||
|
||||
182
mcp-server/src/tools/ImportImageTool.ts
Normal file
182
mcp-server/src/tools/ImportImageTool.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "../Tool";
|
||||
import { TextResponse, ToolResponse } from "../ToolResponse";
|
||||
import "reflect-metadata";
|
||||
import { PenpotMcpServer } from "../PenpotMcpServer";
|
||||
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
|
||||
import { FileUtils } from "../utils/FileUtils";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Arguments class for ImportImageTool
|
||||
*/
|
||||
export class ImportImageArgs {
|
||||
static schema = {
|
||||
filePath: z.string().min(1, "filePath cannot be empty").describe("Absolute path to the image file to import."),
|
||||
x: z.number().optional().describe("Optional X coordinate for the rectangle's position."),
|
||||
y: z.number().optional().describe("Optional Y coordinate for the rectangle's position."),
|
||||
width: z
|
||||
.number()
|
||||
.positive("width must be positive")
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional width for the rectangle. If only width is provided, height is calculated to maintain aspect ratio."
|
||||
),
|
||||
height: z
|
||||
.number()
|
||||
.positive("height must be positive")
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional height for the rectangle. If only height is provided, width is calculated to maintain aspect ratio."
|
||||
),
|
||||
};
|
||||
|
||||
filePath!: string;
|
||||
|
||||
x?: number;
|
||||
|
||||
y?: number;
|
||||
|
||||
width?: number;
|
||||
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for importing an image from the local file system into Penpot
|
||||
*/
|
||||
export class ImportImageTool extends Tool<ImportImageArgs> {
|
||||
/**
|
||||
* Maps file extensions to MIME types.
|
||||
*/
|
||||
protected static readonly MIME_TYPES: { [key: string]: string } = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new ImportImage tool instance.
|
||||
*
|
||||
* @param mcpServer - The MCP server instance
|
||||
*/
|
||||
constructor(mcpServer: PenpotMcpServer) {
|
||||
super(mcpServer, ImportImageArgs.schema);
|
||||
}
|
||||
|
||||
public getToolName(): string {
|
||||
return "import_image";
|
||||
}
|
||||
|
||||
public getToolDescription(): string {
|
||||
return (
|
||||
"Imports a pixel image from the local file system into Penpot by creating a Rectangle instance " +
|
||||
"that uses the image as a fill. The rectangle has the image's original proportions by default. " +
|
||||
"Optionally accepts position (x, y) and dimensions (width, height) parameters. " +
|
||||
"If only one dimension is provided, the other is calculated to maintain the image's aspect ratio. " +
|
||||
"Supported image formats: JPG, PNG, GIF, WEBP, and BMP."
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeCore(args: ImportImageArgs): Promise<ToolResponse> {
|
||||
// check that file path is absolute
|
||||
FileUtils.checkPathIsAbsolute(args.filePath);
|
||||
|
||||
// check that file exists
|
||||
if (!fs.existsSync(args.filePath)) {
|
||||
throw new Error(`File not found: ${args.filePath}`);
|
||||
}
|
||||
|
||||
// read the file as binary data
|
||||
const fileData = fs.readFileSync(args.filePath);
|
||||
const base64Data = fileData.toString("base64");
|
||||
|
||||
// determine mime type from file extension
|
||||
const ext = path.extname(args.filePath).toLowerCase();
|
||||
const mimeType = ImportImageTool.MIME_TYPES[ext];
|
||||
if (!mimeType) {
|
||||
const supportedExtensions = Object.keys(ImportImageTool.MIME_TYPES).join(", ");
|
||||
throw new Error(
|
||||
`Unsupported image format: ${ext}. Supported formats (file extensions): ${supportedExtensions}`
|
||||
);
|
||||
}
|
||||
|
||||
// generate and execute JavaScript code to import the image
|
||||
const fileName = path.basename(args.filePath);
|
||||
const code = this.generateImportCode(fileName, base64Data, mimeType, args);
|
||||
const task = new ExecuteCodePluginTask({ code: code });
|
||||
await this.mcpServer.pluginBridge.executePluginTask(task);
|
||||
|
||||
return new TextResponse(`Image imported successfully from ${args.filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the JavaScript code to import the image in the Penpot plugin context.
|
||||
*
|
||||
* @param fileName - The name of the file
|
||||
* @param base64Data - The base64-encoded image data
|
||||
* @param mimeType - The MIME type of the image
|
||||
* @param args - The tool arguments containing position and dimension parameters
|
||||
*/
|
||||
protected generateImportCode(
|
||||
fileName: string,
|
||||
base64Data: string,
|
||||
mimeType: string,
|
||||
args: ImportImageArgs
|
||||
): string {
|
||||
// escape the base64 data for use in a JavaScript string
|
||||
const escapedBase64 = base64Data.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const escapedFileName = fileName.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
|
||||
return `
|
||||
// convert base64 to Uint8Array
|
||||
const base64 = '${escapedBase64}';
|
||||
const bytes = penpotUtils.atob(base64);
|
||||
|
||||
// upload the image data to Penpot
|
||||
const imageData = await penpot.uploadMediaData('${escapedFileName}', bytes, '${mimeType}');
|
||||
|
||||
// create a rectangle shape
|
||||
const rect = penpot.createRectangle();
|
||||
rect.name = '${escapedFileName}';
|
||||
|
||||
// calculate dimensions
|
||||
let rectWidth, rectHeight;
|
||||
const hasWidth = ${args.width !== undefined};
|
||||
const hasHeight = ${args.height !== undefined};
|
||||
|
||||
if (hasWidth && hasHeight) {
|
||||
// both width and height provided - use them directly
|
||||
rectWidth = ${args.width ?? 0};
|
||||
rectHeight = ${args.height ?? 0};
|
||||
} else if (hasWidth) {
|
||||
// only width provided - maintain aspect ratio
|
||||
rectWidth = ${args.width ?? 0};
|
||||
rectHeight = rectWidth * (imageData.height / imageData.width);
|
||||
} else if (hasHeight) {
|
||||
// only height provided - maintain aspect ratio
|
||||
rectHeight = ${args.height ?? 0};
|
||||
rectWidth = rectHeight * (imageData.width / imageData.height);
|
||||
} else {
|
||||
// neither provided - use original dimensions
|
||||
rectWidth = imageData.width;
|
||||
rectHeight = imageData.height;
|
||||
}
|
||||
|
||||
// set rectangle dimensions
|
||||
rect.resize(rectWidth, rectHeight);
|
||||
|
||||
// set position if provided
|
||||
${args.x !== undefined ? `rect.x = ${args.x};` : ""}
|
||||
${args.y !== undefined ? `rect.y = ${args.y};` : ""}
|
||||
|
||||
// apply the image as a fill
|
||||
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];
|
||||
|
||||
return { shapeId: rect.id };`;
|
||||
}
|
||||
}
|
||||
@ -139,4 +139,42 @@ export class PenpotUtils {
|
||||
penpot.openPage(page);
|
||||
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string to a Uint8Array.
|
||||
* This is required because the Penpot plugin environment does not provide the atob function.
|
||||
*
|
||||
* @param base64 - The base64-encoded string to decode
|
||||
* @returns The decoded data as a Uint8Array
|
||||
*/
|
||||
public static atob(base64: string): Uint8Array {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const lookup = new Uint8Array(256);
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
let bufferLength = base64.length * 0.75;
|
||||
if (base64[base64.length - 1] === "=") {
|
||||
bufferLength--;
|
||||
if (base64[base64.length - 2] === "=") {
|
||||
bufferLength--;
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(bufferLength);
|
||||
let p = 0;
|
||||
for (let i = 0; i < base64.length; i += 4) {
|
||||
const encoded1 = lookup[base64.charCodeAt(i)];
|
||||
const encoded2 = lookup[base64.charCodeAt(i + 1)];
|
||||
const encoded3 = lookup[base64.charCodeAt(i + 2)];
|
||||
const encoded4 = lookup[base64.charCodeAt(i + 3)];
|
||||
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user