From f8f440c7dd8379641d2991ea89cffae4bdabe2ea Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 13 Nov 2025 18:31:03 +0100 Subject: [PATCH 1/2] Add ImportImageTool Add PenpotUtils.atob to support base64 conversion (regular atob not available in plugin context) Resolves #10 --- mcp-server/src/PenpotMcpServer.ts | 2 + mcp-server/src/tools/ImportImageTool.ts | 182 ++++++++++++++++++++++++ penpot-plugin/src/PenpotUtils.ts | 38 +++++ 3 files changed, 222 insertions(+) create mode 100644 mcp-server/src/tools/ImportImageTool.ts 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; + } } From 24ffeac2bfa50ec2c7a6a69f220dc95e5008184f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 14 Nov 2025 12:36:07 +0100 Subject: [PATCH 2/2] Move image import code to PenpotUtils --- mcp-server/src/tools/ImportImageTool.ts | 83 ++++--------------------- penpot-plugin/src/PenpotUtils.ts | 77 ++++++++++++++++++++++- 2 files changed, 88 insertions(+), 72 deletions(-) diff --git a/mcp-server/src/tools/ImportImageTool.ts b/mcp-server/src/tools/ImportImageTool.ts index 834692e..7581aaf 100644 --- a/mcp-server/src/tools/ImportImageTool.ts +++ b/mcp-server/src/tools/ImportImageTool.ts @@ -44,7 +44,7 @@ export class ImportImageArgs { } /** - * Tool for importing an image from the local file system into Penpot + * Tool for importing a raster image from the local file system into Penpot */ export class ImportImageTool extends Tool { /** @@ -56,7 +56,6 @@ export class ImportImageTool extends Tool { ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", - ".bmp": "image/bmp", }; /** @@ -78,7 +77,7 @@ export class ImportImageTool extends Tool { "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." + "Supported formats: JPEG, PNG, GIF, WEBP." ); } @@ -107,76 +106,18 @@ export class ImportImageTool extends Tool { // 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, "\\'"); + const code = ` + const rectangle = await penpotUtils.importImage( + '${escapedBase64}', '${mimeType}', '${escapedFileName}', + ${args.x ?? "undefined"}, ${args.y ?? "undefined"}, + ${args.width ?? "undefined"}, ${args.height ?? "undefined"}); + return { shapeId: rectangle.id }; + `; + const task = new ExecuteCodePluginTask({ code: code }); + const executionResult = await this.mcpServer.pluginBridge.executePluginTask(task); - 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 };`; + return new TextResponse(JSON.stringify(executionResult.data?.result, null, 2)); } } diff --git a/penpot-plugin/src/PenpotUtils.ts b/penpot-plugin/src/PenpotUtils.ts index f737b20..f5627ee 100644 --- a/penpot-plugin/src/PenpotUtils.ts +++ b/penpot-plugin/src/PenpotUtils.ts @@ -1,4 +1,4 @@ -import { Page, Shape } from "@penpot/plugin-types"; +import { Page, Rectangle, Shape } from "@penpot/plugin-types"; export class PenpotUtils { /** @@ -177,4 +177,79 @@ export class PenpotUtils { return bytes; } + + /** + * Imports an image from base64 data into the Penpot design as a Rectangle shape filled with the image. + * 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. + * + * This function is used internally by the ImportImageTool in the MCP server. + * + * @param base64 - The base64-encoded image data + * @param mimeType - The MIME type of the image (e.g., "image/png") + * @param name - The name to assign to the newly created rectangle shape + * @param x - The x-coordinate for positioning the rectangle (optional) + * @param y - The y-coordinate for positioning the rectangle (optional) + * @param width - The desired width of the rectangle (optional) + * @param height - The desired height of the rectangle (optional) + */ + public static async importImage( + base64: string, + mimeType: string, + name: string, + x: number | undefined, + y: number | undefined, + width: number | undefined, + height: number | undefined + ): Promise { + // convert base64 to Uint8Array + const bytes = PenpotUtils.atob(base64); + + // upload the image data to Penpot + const imageData = await penpot.uploadMediaData(name, bytes, mimeType); + + // create a rectangle shape + const rect = penpot.createRectangle(); + rect.name = "${escapedFileName}"; + + // calculate dimensions + let rectWidth, rectHeight; + const hasWidth = width !== undefined; + const hasHeight = height !== undefined; + + if (hasWidth && hasHeight) { + // both width and height provided - use them directly + rectWidth = width; + rectHeight = height; + } else if (hasWidth) { + // only width provided - maintain aspect ratio + rectWidth = width; + rectHeight = rectWidth * (imageData.height / imageData.width); + } else if (hasHeight) { + // only height provided - maintain aspect ratio + rectHeight = height; + 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 + if (x !== undefined) { + rect.x = x; + } + if (y !== undefined) { + rect.y = y; + } + + // apply the image as a fill + rect.fills = [{ fillOpacity: 1, fillImage: imageData }]; + + return rect; + } }