@imenam/mcp-design 1.0.11 → 1.0.12

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.
Files changed (36) hide show
  1. package/README.md +23 -2
  2. package/dist/shared/node-defaults.js +6 -1
  3. package/dist/shared/node-fields.d.ts +2 -0
  4. package/dist/shared/node-fields.js +5 -1
  5. package/dist/shared/render.d.ts +24 -5
  6. package/dist/shared/render.js +34 -13
  7. package/dist/shared/schemas/design.schema.d.ts +36 -1
  8. package/dist/shared/schemas/design.schema.js +10 -1
  9. package/dist/shared/schemas/feedback.schema.d.ts +6 -0
  10. package/dist/src/core/design-store.d.ts +3 -0
  11. package/dist/src/core/design-store.js +4 -0
  12. package/dist/src/core/export.d.ts +2 -1
  13. package/dist/src/core/export.js +35 -7
  14. package/dist/src/core/feedback-store.js +2 -2
  15. package/dist/src/core/image-store.d.ts +45 -0
  16. package/dist/src/core/image-store.js +175 -0
  17. package/dist/src/core/node-factory.js +16 -0
  18. package/dist/src/core/screenshot.d.ts +3 -0
  19. package/dist/src/core/screenshot.js +8 -4
  20. package/dist/src/gui-worker.js +10 -0
  21. package/dist/src/index.js +56 -2
  22. package/dist/src/tools/add-image.d.ts +3 -0
  23. package/dist/src/tools/add-image.js +65 -0
  24. package/dist/src/tools/add-nodes.js +2 -0
  25. package/dist/src/tools/export-design.js +1 -1
  26. package/dist/src/tools/screenshot-design.js +1 -0
  27. package/dist/src/tools/update-nodes.js +4 -1
  28. package/dist/src/version.d.ts +1 -1
  29. package/dist/src/version.js +16 -2
  30. package/gui/dist/assets/{index-BXpZGU8f.css → index-DNUHDrGi.css} +1 -1
  31. package/gui/dist/assets/index-uk5sDkVA.js +15 -0
  32. package/gui/dist/assets/{search-DnrsSC4m.js → search-jSqY2fPS.js} +1 -1
  33. package/gui/dist/index.html +2 -2
  34. package/package.json +2 -3
  35. package/gui/dist/assets/index-CgZSm3aX.js +0 -15
  36. package/scripts/generate-version.js +0 -20
@@ -127,10 +127,10 @@ export class FeedbackStore {
127
127
  renderCapture(designId, target) {
128
128
  const design = this.designs.get(designId);
129
129
  if (target.kind === "node") {
130
- return screenshotDesign(design, { nodeId: target.element.id, padding: NODE_CAPTURE_PADDING }).png;
130
+ return screenshotDesign(design, { nodeId: target.element.id, padding: NODE_CAPTURE_PADDING, images: this.designs.images }).png;
131
131
  }
132
132
  if (target.kind === "area")
133
- return screenshotDesign(design, { region: target.box }).png;
133
+ return screenshotDesign(design, { region: target.box, images: this.designs.images }).png;
134
134
  throw new DesignError("A page remark has no capture.");
135
135
  }
136
136
  add(input) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Médiathèque des designs : `<dataDir>/images/<nom>`.
3
+ *
4
+ * Un fichier est nommé d'après l'empreinte de son contenu : importer deux fois
5
+ * la même image ne la stocke qu'une fois, et un nom ne désigne jamais deux
6
+ * contenus différents — ce qui permet de le servir avec un cache permanent.
7
+ * Les images sont partagées par toutes les planches du dossier de données.
8
+ */
9
+ /** Taille maximale d'une image importée. */
10
+ export declare const MAX_IMAGE_BYTES: number;
11
+ export declare const IMAGE_FORMATS: {
12
+ readonly png: "image/png";
13
+ readonly jpg: "image/jpeg";
14
+ readonly gif: "image/gif";
15
+ readonly webp: "image/webp";
16
+ readonly svg: "image/svg+xml";
17
+ };
18
+ export type ImageFormat = keyof typeof IMAGE_FORMATS;
19
+ export interface StoredImage {
20
+ /** Nom du fichier dans la médiathèque, à porter dans le champ `image` d'un noeud. */
21
+ name: string;
22
+ format: ImageFormat;
23
+ mimeType: string;
24
+ bytes: number;
25
+ /** Dimensions naturelles, en pixels. */
26
+ width: number;
27
+ height: number;
28
+ }
29
+ export declare class ImageStore {
30
+ private readonly dir;
31
+ constructor(dataDir: string);
32
+ getDir(): string;
33
+ /** Enregistre une image et renvoie sa fiche ; une image déjà présente n'est pas réécrite. */
34
+ save(data: Buffer): StoredImage;
35
+ private fileOf;
36
+ has(name: string): boolean;
37
+ /** Échoue si un noeud désigne une image absente de la médiathèque. */
38
+ requireStored(name: string): void;
39
+ read(name: string): {
40
+ data: Buffer;
41
+ mimeType: string;
42
+ };
43
+ /** L'image en data URI, pour un SVG ou un HTML qui doit se suffire à lui-même. */
44
+ dataUri(name: string): string;
45
+ }
@@ -0,0 +1,175 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { DesignError } from "./node-factory.js";
5
+ /**
6
+ * Médiathèque des designs : `<dataDir>/images/<nom>`.
7
+ *
8
+ * Un fichier est nommé d'après l'empreinte de son contenu : importer deux fois
9
+ * la même image ne la stocke qu'une fois, et un nom ne désigne jamais deux
10
+ * contenus différents — ce qui permet de le servir avec un cache permanent.
11
+ * Les images sont partagées par toutes les planches du dossier de données.
12
+ */
13
+ /** Taille maximale d'une image importée. */
14
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
15
+ export const IMAGE_FORMATS = {
16
+ png: "image/png",
17
+ jpg: "image/jpeg",
18
+ gif: "image/gif",
19
+ webp: "image/webp",
20
+ svg: "image/svg+xml",
21
+ };
22
+ const IMAGE_NAME = /^img_[0-9a-f]{16}\.(png|jpg|gif|webp|svg)$/;
23
+ /** Format reconnu d'après les premiers octets, jamais d'après l'extension annoncée. */
24
+ function detectFormat(data) {
25
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
26
+ return "png";
27
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
28
+ return "jpg";
29
+ if (data.length >= 6 && /^GIF8[79]a$/.test(data.subarray(0, 6).toString("latin1")))
30
+ return "gif";
31
+ if (data.length >= 12 && data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") {
32
+ return "webp";
33
+ }
34
+ const head = data.subarray(0, 4096).toString("utf8").replace(/^/, "").trimStart();
35
+ if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i.test(head))
36
+ return "svg";
37
+ return null;
38
+ }
39
+ function jpegSize(data) {
40
+ let offset = 2;
41
+ while (offset + 9 < data.length) {
42
+ if (data[offset] !== 0xff)
43
+ return null;
44
+ const marker = data[offset + 1];
45
+ // Marqueurs sans longueur : remplissage, RST, SOI.
46
+ if (marker === 0xff) {
47
+ offset += 1;
48
+ continue;
49
+ }
50
+ if ((marker >= 0xd0 && marker <= 0xd9) || marker === 0x01) {
51
+ offset += 2;
52
+ continue;
53
+ }
54
+ const length = data.readUInt16BE(offset + 2);
55
+ // SOF0..SOF15, hors DHT (C4), JPG (C8) et DAC (CC).
56
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
57
+ return { height: data.readUInt16BE(offset + 5), width: data.readUInt16BE(offset + 7) };
58
+ }
59
+ offset += 2 + length;
60
+ }
61
+ return null;
62
+ }
63
+ function webpSize(data) {
64
+ const chunk = data.subarray(12, 16).toString("latin1");
65
+ if (chunk === "VP8X" && data.length >= 30) {
66
+ return { width: 1 + data.readUIntLE(24, 3), height: 1 + data.readUIntLE(27, 3) };
67
+ }
68
+ if (chunk === "VP8 " && data.length >= 30) {
69
+ return { width: data.readUInt16LE(26) & 0x3fff, height: data.readUInt16LE(28) & 0x3fff };
70
+ }
71
+ if (chunk === "VP8L" && data.length >= 25) {
72
+ const bits = data.readUInt32LE(21);
73
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
74
+ }
75
+ return null;
76
+ }
77
+ /** Longueur SVG en pixels ; null pour une valeur relative (%) ou absente. */
78
+ function svgLength(value) {
79
+ if (value === undefined)
80
+ return null;
81
+ const match = /^\s*([\d.]+)\s*(px)?\s*$/.exec(value);
82
+ return match ? Number(match[1]) : null;
83
+ }
84
+ function svgSize(data) {
85
+ const tag = /<svg\b[^>]*>/i.exec(data.toString("utf8"))?.[0];
86
+ if (!tag)
87
+ return null;
88
+ const attribute = (name) => new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(tag)?.[1];
89
+ const width = svgLength(attribute("width"));
90
+ const height = svgLength(attribute("height"));
91
+ const viewBox = attribute("viewBox")?.trim().split(/[\s,]+/).map(Number);
92
+ const box = viewBox && viewBox.length === 4 && viewBox[2] > 0 && viewBox[3] > 0 ? viewBox : null;
93
+ if (width && height)
94
+ return { width, height };
95
+ if (box && width)
96
+ return { width, height: (width * box[3]) / box[2] };
97
+ if (box && height)
98
+ return { width: (height * box[2]) / box[3], height };
99
+ if (box)
100
+ return { width: box[2], height: box[3] };
101
+ return null;
102
+ }
103
+ function naturalSize(data, format) {
104
+ switch (format) {
105
+ case "png":
106
+ return data.length >= 24 ? { width: data.readUInt32BE(16), height: data.readUInt32BE(20) } : null;
107
+ case "gif":
108
+ return data.length >= 10 ? { width: data.readUInt16LE(6), height: data.readUInt16LE(8) } : null;
109
+ case "jpg":
110
+ return jpegSize(data);
111
+ case "webp":
112
+ return webpSize(data);
113
+ case "svg":
114
+ return svgSize(data);
115
+ }
116
+ }
117
+ export class ImageStore {
118
+ dir;
119
+ constructor(dataDir) {
120
+ this.dir = path.join(dataDir, "images");
121
+ }
122
+ getDir() {
123
+ return this.dir;
124
+ }
125
+ /** Enregistre une image et renvoie sa fiche ; une image déjà présente n'est pas réécrite. */
126
+ save(data) {
127
+ if (data.length === 0)
128
+ throw new DesignError("The image file is empty.");
129
+ if (data.length > MAX_IMAGE_BYTES) {
130
+ throw new DesignError(`Image too large: ${data.length} bytes, the limit is ${MAX_IMAGE_BYTES}.`);
131
+ }
132
+ const format = detectFormat(data);
133
+ if (!format) {
134
+ throw new DesignError(`Unsupported image format. Accepted: ${Object.keys(IMAGE_FORMATS).join(", ")}.`);
135
+ }
136
+ const size = naturalSize(data, format);
137
+ if (!size || !(size.width > 0) || !(size.height > 0)) {
138
+ throw new DesignError(`Could not read the dimensions of this ${format} image: the file looks damaged.`);
139
+ }
140
+ const hash = crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
141
+ const name = `img_${hash}.${format}`;
142
+ const file = path.join(this.dir, name);
143
+ if (!fs.existsSync(file)) {
144
+ fs.mkdirSync(this.dir, { recursive: true });
145
+ fs.writeFileSync(file, data);
146
+ }
147
+ return { name, format, mimeType: IMAGE_FORMATS[format], bytes: data.length, ...size };
148
+ }
149
+ fileOf(name) {
150
+ if (!IMAGE_NAME.test(name))
151
+ throw new DesignError(`"${name}" is not an image name of the media library.`);
152
+ return path.join(this.dir, name);
153
+ }
154
+ has(name) {
155
+ return IMAGE_NAME.test(name) && fs.existsSync(path.join(this.dir, name));
156
+ }
157
+ /** Échoue si un noeud désigne une image absente de la médiathèque. */
158
+ requireStored(name) {
159
+ if (!this.has(name)) {
160
+ throw new DesignError(`Image "${name}" is not in the media library. Import the picture with add_image, which returns the name to use.`);
161
+ }
162
+ }
163
+ read(name) {
164
+ const file = this.fileOf(name);
165
+ if (!fs.existsSync(file))
166
+ throw new DesignError(`Image "${name}" is missing from ${this.dir}.`);
167
+ const format = path.extname(name).slice(1);
168
+ return { data: fs.readFileSync(file), mimeType: IMAGE_FORMATS[format] };
169
+ }
170
+ /** L'image en data URI, pour un SVG ou un HTML qui doit se suffire à lui-même. */
171
+ dataUri(name) {
172
+ const { data, mimeType } = this.read(name);
173
+ return `data:${mimeType};base64,${data.toString("base64")}`;
174
+ }
175
+ }
@@ -30,6 +30,18 @@ function requireIconName(value) {
30
30
  (suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : "") +
31
31
  ` Call search_icons to search the ${iconCount()} available icons by description.`);
32
32
  }
33
+ /**
34
+ * Valide le champ `image` d'un noeud `image`. L'existence du fichier dans la
35
+ * mediatheque, elle, est verifiee par les outils, qui ont acces au stockage.
36
+ */
37
+ function requireImageName(value) {
38
+ const name = typeof value === "string" ? value.trim() : "";
39
+ if (name === "") {
40
+ throw new DesignError(`An "image" node needs an "image" field naming a stored image, e.g. "img_3f9a1c2b7d4e5f60.png". ` +
41
+ `Call add_image to import a picture file: it stores the file and places the node in one step.`);
42
+ }
43
+ return name;
44
+ }
33
45
  /** Champs geometriques réellement modifiables pour un type donné. */
34
46
  function geometryFieldsFor(type) {
35
47
  return SIZE_DERIVED_KINDS.includes(type) ? GEOMETRY.filter((field) => !DERIVED.includes(field)) : GEOMETRY;
@@ -97,6 +109,8 @@ export function buildNode(input, existingIds, index) {
97
109
  }
98
110
  if (type === "instance")
99
111
  draft.componentId = requireComponentId(draft.componentId);
112
+ if (type === "image")
113
+ draft.image = requireImageName(draft.image);
100
114
  if (type === "icon") {
101
115
  draft.icon = requireIconName(draft.icon);
102
116
  // Un calque nomme "Icône 7" ne dit rien au relecteur ; le nom de l'icone,
@@ -148,6 +162,8 @@ export function applyPatch(node, patch) {
148
162
  }
149
163
  if (node.type === "icon" && patch.icon !== undefined)
150
164
  next.icon = requireIconName(patch.icon);
165
+ if (node.type === "image" && patch.image !== undefined)
166
+ next.image = requireImageName(patch.image);
151
167
  // Changer le lien echange l'instance de composant, comme un "swap instance".
152
168
  if (node.type === "instance" && patch.componentId !== undefined)
153
169
  next.componentId = requireComponentId(patch.componentId);
@@ -1,4 +1,5 @@
1
1
  import type { Design } from "../../shared/schemas/design.schema.js";
2
+ import type { ImageStore } from "./image-store.js";
2
3
  import type { Box } from "../../shared/tree.js";
3
4
  /**
4
5
  * Capture d'écran d'un design.
@@ -20,6 +21,8 @@ export interface ScreenshotOptions {
20
21
  /** Marge, en pixels du design, ajoutée autour du noeud capturé. */
21
22
  padding?: number;
22
23
  maxDimension?: number;
24
+ /** Médiathèque où lire les images de la planche. Obligatoire dès qu'elle en contient. */
25
+ images?: ImageStore;
23
26
  }
24
27
  export interface Screenshot {
25
28
  png: Buffer;
@@ -52,8 +52,8 @@ function artboardOf(doc) {
52
52
  * boîte. Dans la planche entière, tout ce qui est visible est dans le cadre ; la
53
53
  * zone en est ensuite extraite par une image, qui ne porte aucune opacité.
54
54
  */
55
- function rasterizeRegion(doc, region, scale) {
56
- const artboard = rasterize(regionToSvg(doc, artboardOf(doc)), scale);
55
+ function rasterizeRegion(doc, region, scale, render) {
56
+ const artboard = rasterize(regionToSvg(doc, artboardOf(doc), render), scale);
57
57
  const crop = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
58
58
  `width="${region.width}" height="${region.height}" ` +
59
59
  `viewBox="${region.x} ${region.y} ${region.width} ${region.height}">` +
@@ -66,9 +66,13 @@ export function screenshotDesign(design, options = {}) {
66
66
  const doc = toDocument(design);
67
67
  const padding = Math.max(0, options.padding ?? 0);
68
68
  const region = options.region;
69
+ const images = options.images;
70
+ const render = images ? { imageHref: (name) => images.dataUri(name) } : {};
69
71
  // La planche entière est rendue comme une zone : un élément semi-transparent
70
72
  // posé hors de l'artboard en est omis.
71
- const svg = options.nodeId ? subtreeToSvg(doc, options.nodeId, padding) : regionToSvg(doc, region ?? artboardOf(doc));
73
+ const svg = options.nodeId
74
+ ? subtreeToSvg(doc, options.nodeId, padding, render)
75
+ : regionToSvg(doc, region ?? artboardOf(doc), render);
72
76
  const source = svgSize(svg);
73
77
  if (!(source.width > 0) || !(source.height > 0)) {
74
78
  throw new Error("Nothing to capture: the requested area has no size.");
@@ -77,7 +81,7 @@ export function screenshotDesign(design, options = {}) {
77
81
  const requested = Math.max(0.05, Math.min(8, options.scale ?? 1));
78
82
  // L'écrêtage ne peut que réduire : un zoom demandé plus petit reste respecté.
79
83
  const scale = Math.min(requested, maxDimension / Math.max(source.width, source.height));
80
- const rendered = region ? rasterizeRegion(doc, region, scale) : rasterize(svg, scale);
84
+ const rendered = region ? rasterizeRegion(doc, region, scale, render) : rasterize(svg, scale);
81
85
  return {
82
86
  png: rendered.png,
83
87
  width: rendered.width,
@@ -372,6 +372,16 @@ app.get("/api/captures/:name", async (c) => {
372
372
  "Cache-Control": "private, max-age=31536000, immutable",
373
373
  });
374
374
  });
375
+ app.get("/api/images/:name", async (c) => {
376
+ const result = await ipcRequest("GET_IMAGE", { name: c.req.param("name") }, 10000);
377
+ if (result.error)
378
+ return c.json({ error: result.error }, 404);
379
+ return c.body(Buffer.from(result.data.data, "base64"), 200, {
380
+ "Content-Type": result.data.mimeType,
381
+ // Le nom d'une image suit l'empreinte de son contenu : il ne désigne jamais deux fichiers différents.
382
+ "Cache-Control": "private, max-age=31536000, immutable",
383
+ });
384
+ });
375
385
  // Fichiers statiques de la SPA
376
386
  app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
377
387
  function getHtmlBaseHref(appPath) {
package/dist/src/index.js CHANGED
@@ -17,7 +17,7 @@ import { MIN_LINE_HEIGHT_PX } from "../shared/render.js";
17
17
  import { CORE_ICONS, iconCount } from "../shared/icons/index.js";
18
18
  import { NODE_FIELD_DOCS } from "../shared/node-fields.js";
19
19
  import { REORDER_DIRECTIONS } from "../shared/tree.js";
20
- import { normalizeDesignGroup } from "../shared/schemas/design.schema.js";
20
+ import { IMAGE_FITS, NODE_TYPES, normalizeDesignGroup } from "../shared/schemas/design.schema.js";
21
21
  import { listDesigns } from "./tools/list-designs.js";
22
22
  import { createDesign } from "./tools/create-design.js";
23
23
  import { duplicateDesign } from "./tools/duplicate-design.js";
@@ -25,6 +25,8 @@ import { getDesign } from "./tools/get-design.js";
25
25
  import { updateDesign } from "./tools/update-design.js";
26
26
  import { deleteDesign } from "./tools/delete-design.js";
27
27
  import { addNodes } from "./tools/add-nodes.js";
28
+ import { addImage } from "./tools/add-image.js";
29
+ import { IMAGE_FORMATS } from "./core/image-store.js";
28
30
  import { updateNodes } from "./tools/update-nodes.js";
29
31
  import { deleteNodes } from "./tools/delete-nodes.js";
30
32
  import { duplicateNodes, MAX_DUPLICATE_COPIES } from "./tools/duplicate-nodes.js";
@@ -301,6 +303,15 @@ function handleIPCMessage(msg) {
301
303
  replyError(msg, "GET_CAPTURE_RESPONSE", err.message);
302
304
  }
303
305
  return;
306
+ case "GET_IMAGE":
307
+ try {
308
+ const image = store.images.read(String(msg.data?.name ?? ""));
309
+ reply(msg, "GET_IMAGE_RESPONSE", { data: image.data.toString("base64"), mimeType: image.mimeType });
310
+ }
311
+ catch (err) {
312
+ replyError(msg, "GET_IMAGE_RESPONSE", err.message);
313
+ }
314
+ return;
304
315
  case "GET_CONFIG":
305
316
  reply(msg, "GET_CONFIG_RESPONSE", { dataDir: store.getDataDir(), version: APP_VERSION });
306
317
  return;
@@ -460,6 +471,7 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
460
471
  "a text node given no height takes the exact height of its text, so its first line starts at y — " +
461
472
  "pass a height only to centre a label inside a container (same y and height as the container); " +
462
473
  "icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
474
+ "image: image (required — the stored name returned by add_image), fit; " +
463
475
  "instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
464
476
  "Any field left out falls back to a sensible default, so a node can be created from its type alone.";
465
477
  /**
@@ -494,6 +506,8 @@ const NODE_PROPERTIES = {
494
506
  strokeWidth: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.strokeWidth },
495
507
  radius: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.radius },
496
508
  icon: { type: "string", description: NODE_FIELD_DOCS.icon },
509
+ image: { type: "string", description: NODE_FIELD_DOCS.image },
510
+ fit: { type: "string", enum: [...IMAGE_FITS], description: NODE_FIELD_DOCS.fit },
497
511
  componentId: { type: "string", description: NODE_FIELD_DOCS.componentId },
498
512
  text: { type: "string", description: NODE_FIELD_DOCS.text },
499
513
  color: { type: "string", description: NODE_FIELD_DOCS.color },
@@ -628,7 +642,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
628
642
  properties: {
629
643
  type: {
630
644
  type: "string",
631
- enum: ["rect", "ellipse", "line", "text", "icon", "group", "instance"],
645
+ enum: [...NODE_TYPES],
632
646
  description: NODE_FIELD_DOCS.type,
633
647
  },
634
648
  ...NODE_PROPERTIES,
@@ -641,6 +655,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
641
655
  required: ["design_id", "nodes"],
642
656
  },
643
657
  },
658
+ {
659
+ name: "add_image",
660
+ description: "Place a picture (photo, logo, illustration, screenshot) on a design, on top of the other nodes. " +
661
+ `Give the ABSOLUTE path of the image file on the agent machine; accepted formats: ${Object.keys(IMAGE_FORMATS).join(", ")}. ` +
662
+ "The file is copied into the media library of the designs, so the design keeps working if the original moves. " +
663
+ "Without width and height the node takes the picture's natural size; with only one of them, the other follows the picture's proportions. " +
664
+ "Use it for real pictures only — icons come from the \"icon\" node, shapes from add_nodes. " +
665
+ "The answer gives the stored name of the picture: an image node in add_nodes with that name places it again without a second import.",
666
+ inputSchema: {
667
+ type: "object",
668
+ properties: {
669
+ design_id: { type: "string", description: DESIGN_ID_DOC },
670
+ file_path: { type: "string", description: "Absolute path to the image file on the agent machine." },
671
+ file_content: {
672
+ type: "string",
673
+ description: "Filled in by the mcp-http-gateway relay, never by the agent: the base64 content of the file read on the agent machine.",
674
+ },
675
+ file_name: {
676
+ type: "string",
677
+ description: "Filled in by the mcp-http-gateway relay, never by the agent: the original name of the file read on the agent machine.",
678
+ },
679
+ id: NODE_PROPERTIES.id,
680
+ name: { type: "string", description: `${NODE_FIELD_DOCS.name} Defaults to the file name without its extension.` },
681
+ parentId: NODE_PROPERTIES.parentId,
682
+ x: NODE_PROPERTIES.x,
683
+ y: NODE_PROPERTIES.y,
684
+ width: { type: "number", description: "Width of the box, in pixels. Defaults to the picture's natural width, or follows its proportions when only height is given." },
685
+ height: { type: "number", description: "Height of the box, in pixels. Defaults to the picture's natural height, or follows its proportions when only width is given." },
686
+ rotation: NODE_PROPERTIES.rotation,
687
+ opacity: NODE_PROPERTIES.opacity,
688
+ fit: NODE_PROPERTIES.fit,
689
+ },
690
+ required: ["design_id", "file_path"],
691
+ },
692
+ },
644
693
  {
645
694
  name: "update_nodes",
646
695
  description: "Patch existing nodes. Each entry needs an id plus only the fields to change; everything else is preserved. " +
@@ -995,6 +1044,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
995
1044
  notifyDesignChanged(result.design.id);
996
1045
  return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
997
1046
  }
1047
+ case "add_image": {
1048
+ const result = addImage(store, params);
1049
+ notifyDesignChanged(result.design.id);
1050
+ return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
1051
+ }
998
1052
  case "update_nodes": {
999
1053
  const result = updateNodes(store, params);
1000
1054
  notifyDesignChanged(result.design.id);
@@ -0,0 +1,3 @@
1
+ import type { DesignStore } from "../core/design-store.js";
2
+ import type { MutationResult } from "./add-nodes.js";
3
+ export declare function addImage(store: DesignStore, args: Record<string, unknown> | undefined): MutationResult;
@@ -0,0 +1,65 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { buildNode, DesignError } from "../core/node-factory.js";
4
+ import { requireString, textResult } from "./types.js";
5
+ /** Champs du noeud que l'appel peut fixer, en plus de l'image elle-même. */
6
+ const NODE_FIELDS = ["id", "name", "parentId", "x", "y", "width", "height", "rotation", "opacity", "fit"];
7
+ /**
8
+ * Octets de l'image : le contenu transmis par le relais du gateway quand il est
9
+ * présent, sinon le fichier désigné par `file_path`, lu sur cette machine.
10
+ */
11
+ function readImageBytes(args) {
12
+ if (typeof args.file_content === "string" && args.file_content !== "") {
13
+ return Buffer.from(args.file_content, "base64");
14
+ }
15
+ const filePath = requireString(args, "file_path");
16
+ if (!path.isAbsolute(filePath))
17
+ throw new DesignError(`file_path must be an absolute path: ${filePath}`);
18
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
19
+ throw new DesignError(`File not found: ${filePath}`);
20
+ return fs.readFileSync(filePath);
21
+ }
22
+ /**
23
+ * Taille de la boîte : celle demandée, sinon les dimensions naturelles de
24
+ * l'image. Une seule dimension donnée, l'autre suit les proportions de l'image.
25
+ */
26
+ function boxSize(image, width, height) {
27
+ const w = width === undefined ? undefined : Number(width);
28
+ const h = height === undefined ? undefined : Number(height);
29
+ if (w !== undefined && h !== undefined)
30
+ return { width: w, height: h };
31
+ if (w !== undefined)
32
+ return { width: w, height: Math.round(((w * image.height) / image.width) * 100) / 100 };
33
+ if (h !== undefined)
34
+ return { width: Math.round(((h * image.width) / image.height) * 100) / 100, height: h };
35
+ return { width: image.width, height: image.height };
36
+ }
37
+ export function addImage(store, args) {
38
+ const params = args ?? {};
39
+ const id = requireString(params, "design_id");
40
+ const current = store.get(id);
41
+ const image = store.images.save(readImageBytes(params));
42
+ const input = { type: "image", image: image.name };
43
+ for (const field of NODE_FIELDS) {
44
+ if (params[field] !== undefined)
45
+ input[field] = params[field];
46
+ }
47
+ if (input.name === undefined) {
48
+ const original = typeof params.file_name === "string" ? params.file_name : String(params.file_path ?? "");
49
+ const base = path.basename(original.replace(/\\/g, "/")).replace(/\.[^.]+$/, "");
50
+ if (base !== "")
51
+ input.name = base;
52
+ }
53
+ Object.assign(input, boxSize(image, params.width, params.height));
54
+ const node = buildNode(input, new Set(current.nodes.map((n) => n.id)), current.nodes.length + 1);
55
+ const design = store.mutate(id, "agent", (draft) => {
56
+ draft.nodes.push(node);
57
+ });
58
+ return {
59
+ ...textResult(`Added image ${node.id} — "${node.name}" to "${design.name}" at ${Math.round(node.x)},${Math.round(node.y)} ` +
60
+ `size ${Math.round(node.width)}x${Math.round(node.height)}, fit ${node.fit}.\n` +
61
+ `Stored as "${image.name}" (${image.format}, ${image.width}x${image.height} px, ${image.bytes} bytes). ` +
62
+ `Use that name as the "image" field of an image node in add_nodes to place the same picture again.`),
63
+ design,
64
+ };
65
+ }
@@ -12,6 +12,8 @@ export function addNodes(store, args) {
12
12
  // Tout est construit avant d'écrire : une entrée invalide n'ajoute rien du tout.
13
13
  input.forEach((raw, index) => {
14
14
  const node = buildNode(raw, existingIds, current.nodes.length + index + 1);
15
+ if (node.type === "image")
16
+ store.images.requireStored(node.image ?? "");
15
17
  existingIds.add(node.id);
16
18
  created.push(node);
17
19
  });
@@ -15,7 +15,7 @@ export function exportDesignTool(store, args) {
15
15
  throw new Error(`Unknown format "${String(format)}". Expected one of: ${EXPORT_FORMATS.join(", ")}.`);
16
16
  }
17
17
  const design = store.get(id);
18
- const content = render(design, format);
18
+ const content = render(design, format, store.images);
19
19
  const destination = typeof args?.destination_path === "string" ? args.destination_path.trim() : "";
20
20
  if (!destination) {
21
21
  return textResult(content);
@@ -13,6 +13,7 @@ export function screenshotDesignTool(store, args) {
13
13
  }
14
14
  const shot = screenshotDesign(design, {
15
15
  nodeId,
16
+ images: store.images,
16
17
  scale: args?.scale === undefined ? undefined : Number(args.scale),
17
18
  padding: args?.padding === undefined ? (nodeId ? NODE_PADDING : 0) : Number(args.padding),
18
19
  });
@@ -21,7 +21,10 @@ export function updateNodes(store, args) {
21
21
  if (Object.keys(fields).length === 0) {
22
22
  throw new DesignError(`Update for node "${nodeId}" contains no field to change.`);
23
23
  }
24
- return applyPatch(node, fields);
24
+ const next = applyPatch(node, fields);
25
+ if (next.type === "image" && fields.image !== undefined)
26
+ store.images.requireStored(next.image ?? "");
27
+ return next;
25
28
  });
26
29
  const patchedById = new Map(patched.map((n) => [n.id, n]));
27
30
  const design = store.mutate(id, "agent", (draft) => {
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.11";
1
+ export declare const APP_VERSION: string;
@@ -1,2 +1,16 @@
1
- // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.11';
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // Lit la version dans le premier package.json trouvé en remontant depuis ce module.
5
+ function readPackageVersion() {
6
+ let dir = path.dirname(fileURLToPath(import.meta.url));
7
+ while (!existsSync(path.join(dir, "package.json"))) {
8
+ const parent = path.dirname(dir);
9
+ if (parent === dir)
10
+ throw new Error("package.json introuvable");
11
+ dir = parent;
12
+ }
13
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
14
+ return pkg.version;
15
+ }
16
+ export const APP_VERSION = readPackageVersion();