Merge pull request #13 from penpot/import-image-tool

Add ImportImageTool (enabling import of pixel images from the file system)
This commit is contained in:
Dominik Jain 2025-11-14 13:19:25 +01:00 committed by GitHub
commit bc0932fe5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 239 additions and 1 deletions

View File

@ -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) {

View File

@ -0,0 +1,123 @@
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 a raster 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",
};
/**
* 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 formats: JPEG, PNG, GIF, WEBP."
);
}
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 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 new TextResponse(JSON.stringify(executionResult.data?.result, null, 2));
}
}

View File

@ -1,4 +1,4 @@
import { Page, Shape } from "@penpot/plugin-types";
import { Page, Rectangle, Shape } from "@penpot/plugin-types";
export class PenpotUtils {
/**
@ -139,4 +139,117 @@ 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;
}
/**
* 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<Rectangle> {
// 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;
}
}