diff --git a/mcp-server/src/PenpotMcpServer.ts b/mcp-server/src/PenpotMcpServer.ts index 1d75cf6..b726960 100644 --- a/mcp-server/src/PenpotMcpServer.ts +++ b/mcp-server/src/PenpotMcpServer.ts @@ -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) { diff --git a/mcp-server/src/tools/ImportImageTool.ts b/mcp-server/src/tools/ImportImageTool.ts new file mode 100644 index 0000000..834692e --- /dev/null +++ b/mcp-server/src/tools/ImportImageTool.ts @@ -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 { + /** + * 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 { + // 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 };`; + } +} diff --git a/penpot-plugin/src/PenpotUtils.ts b/penpot-plugin/src/PenpotUtils.ts index f0c585e..f737b20 100644 --- a/penpot-plugin/src/PenpotUtils.ts +++ b/penpot-plugin/src/PenpotUtils.ts @@ -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; + } }