@imenam/mcp-design 1.0.11 → 1.0.13

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 (37) hide show
  1. package/README.md +31 -3
  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 +23 -0
  21. package/dist/src/index.js +66 -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-CmUxiS-4.css +1 -0
  31. package/gui/dist/assets/index-SUmtOh8T.js +15 -0
  32. package/gui/dist/assets/{search-DnrsSC4m.js → search-DHQx5ZT0.js} +1 -1
  33. package/gui/dist/index.html +2 -2
  34. package/package.json +2 -3
  35. package/gui/dist/assets/index-BXpZGU8f.css +0 -1
  36. package/gui/dist/assets/index-CgZSm3aX.js +0 -15
  37. package/scripts/generate-version.js +0 -20
@@ -1,4 +1,4 @@
1
- import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, iconElementToSvg, iconElementsOf, iconStyle, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
1
+ import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, iconElementToSvg, iconElementsOf, iconStyle, imageSource, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
2
2
  import { ICON_GRID } from "../../shared/icons/index.js";
3
3
  import { boundsOf, childrenByParent, computeLayout } from "../../shared/tree.js";
4
4
  import { expandInstances } from "../../shared/instances.js";
@@ -131,7 +131,19 @@ function contextOf(design) {
131
131
  const nodes = toExpandedNodes(design);
132
132
  return { layout: computeLayout(nodes), children: childrenByParent(nodes) };
133
133
  }
134
- function toHtml(design) {
134
+ /**
135
+ * Résolution des images en data URI : un export se suffit à lui-même, il
136
+ * s'ouvre tel quel sans la médiathèque du serveur.
137
+ */
138
+ function renderOptions(images) {
139
+ return images ? { imageHref: (name) => images.dataUri(name) } : {};
140
+ }
141
+ /** Source et cadrage d'une image pour les exports HTML et JSX. */
142
+ function imageTag(node, images) {
143
+ const render = node;
144
+ return { src: imageSource(render, renderOptions(images)), fit: render.fit ?? "cover" };
145
+ }
146
+ function toHtml(design, images) {
135
147
  const { layout, children } = contextOf(design);
136
148
  const rules = [];
137
149
  const walk = (nodes, parentBox, depth) => {
@@ -150,6 +162,12 @@ function toHtml(design) {
150
162
  return [`${pad}<div class="${cls}">${escapeXml(node.text ?? "")}</div>`];
151
163
  if (node.type === "icon")
152
164
  return [`${pad}<div class="${cls}">${iconSvg(node, identity)}</div>`];
165
+ if (node.type === "image") {
166
+ const image = imageTag(node, images);
167
+ return [
168
+ `${pad}<img class="${cls}" src="${escapeXml(image.src)}" alt="${escapeXml(node.name)}" style="object-fit: ${image.fit}" />`,
169
+ ];
170
+ }
153
171
  return [`${pad}<div class="${cls}"></div>`];
154
172
  });
155
173
  };
@@ -189,7 +207,7 @@ function toComponentName(name) {
189
207
  .replace(/^[a-z]/, (letter) => letter.toUpperCase());
190
208
  return /^[A-Za-z]/.test(pascal) ? pascal : "Design";
191
209
  }
192
- function toJsx(design) {
210
+ function toJsx(design, images) {
193
211
  const { layout, children } = contextOf(design);
194
212
  const styleOf = (node, parentBox) => nodeCss(node, layout, parentBox)
195
213
  .map((declaration) => {
@@ -218,6 +236,13 @@ function toJsx(design) {
218
236
  if (node.type === "icon") {
219
237
  return [comment, `${pad}<div style={{ ${style} }}>${iconSvg(node, toCamelCase)}</div>`];
220
238
  }
239
+ if (node.type === "image") {
240
+ const image = imageTag(node, images);
241
+ return [
242
+ comment,
243
+ `${pad}<img src=${JSON.stringify(image.src)} alt=${JSON.stringify(node.name)} style={{ ${style}, objectFit: ${JSON.stringify(image.fit)} }} />`,
244
+ ];
245
+ }
221
246
  return [comment, `${pad}<div style={{ ${style} }} />`];
222
247
  });
223
248
  };
@@ -273,6 +298,9 @@ export function toSummary(design) {
273
298
  lines.push(`${indent}- ${node.id} icon "${node.name}" @ ${geometry} — ${node.icon ?? "?"}, ` +
274
299
  `stroke ${node.stroke ?? "none"} ${node.strokeWidth ?? 2}/24${node.fill && node.fill !== "none" ? `, fill ${node.fill}` : ""}`);
275
300
  }
301
+ else if (node.type === "image") {
302
+ lines.push(`${indent}- ${node.id} image "${node.name}" @ ${geometry} — ${node.image ?? "?"}, fit ${node.fit ?? "cover"}`);
303
+ }
276
304
  else if (node.type === "line") {
277
305
  lines.push(`${indent}- ${node.id} line "${node.name}" @ ${geometry} — ${node.strokeWidth}px ${node.stroke}`);
278
306
  }
@@ -285,7 +313,7 @@ export function toSummary(design) {
285
313
  walk(children.get(null) ?? [], 0);
286
314
  return lines.join("\n");
287
315
  }
288
- export function exportDesign(design, format) {
316
+ export function exportDesign(design, format, images) {
289
317
  switch (format) {
290
318
  case "svg":
291
319
  // `documentToSvg` resout les instances lui-meme : le canvas de la GUI
@@ -296,11 +324,11 @@ export function exportDesign(design, format) {
296
324
  height: design.height,
297
325
  background: design.background,
298
326
  nodes: toRenderNodes(design),
299
- });
327
+ }, renderOptions(images));
300
328
  case "html":
301
- return toHtml(design);
329
+ return toHtml(design, images);
302
330
  case "jsx":
303
- return toJsx(design);
331
+ return toJsx(design, images);
304
332
  case "json":
305
333
  return JSON.stringify({
306
334
  id: design.id,
@@ -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,
@@ -264,6 +264,19 @@ app.put("/api/designs/:id/group", async (c) => {
264
264
  return c.json({ error: result.error }, 400);
265
265
  return c.json(result.data);
266
266
  });
267
+ app.post("/api/designs/:id/duplicate", async (c) => {
268
+ let body = {};
269
+ try {
270
+ body = await c.req.json();
271
+ }
272
+ catch {
273
+ return c.json({ error: "Invalid JSON body" }, 400);
274
+ }
275
+ const result = await ipcRequest("DUPLICATE_DESIGN", { id: c.req.param("id"), name: body?.name }, 10000);
276
+ if (result.error)
277
+ return c.json({ error: result.error }, 400);
278
+ return c.json(result.data);
279
+ });
267
280
  app.delete("/api/designs/:id", async (c) => {
268
281
  const result = await ipcRequest("DELETE_DESIGN", { id: c.req.param("id") }, 10000);
269
282
  if (result.error)
@@ -372,6 +385,16 @@ app.get("/api/captures/:name", async (c) => {
372
385
  "Cache-Control": "private, max-age=31536000, immutable",
373
386
  });
374
387
  });
388
+ app.get("/api/images/:name", async (c) => {
389
+ const result = await ipcRequest("GET_IMAGE", { name: c.req.param("name") }, 10000);
390
+ if (result.error)
391
+ return c.json({ error: result.error }, 404);
392
+ return c.body(Buffer.from(result.data.data, "base64"), 200, {
393
+ "Content-Type": result.data.mimeType,
394
+ // Le nom d'une image suit l'empreinte de son contenu : il ne désigne jamais deux fichiers différents.
395
+ "Cache-Control": "private, max-age=31536000, immutable",
396
+ });
397
+ });
375
398
  // Fichiers statiques de la SPA
376
399
  app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
377
400
  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";
@@ -188,6 +190,16 @@ function handleIPCMessage(msg) {
188
190
  replyError(msg, "DELETE_DESIGN_RESPONSE", err.message);
189
191
  }
190
192
  return;
193
+ case "DUPLICATE_DESIGN":
194
+ try {
195
+ const name = typeof msg.data?.name === "string" ? msg.data.name : undefined;
196
+ const design = store.duplicate(String(msg.data?.id), { name });
197
+ reply(msg, "DUPLICATE_DESIGN_RESPONSE", { design, designs: store.list() });
198
+ }
199
+ catch (err) {
200
+ replyError(msg, "DUPLICATE_DESIGN_RESPONSE", err.message);
201
+ }
202
+ return;
191
203
  case "SET_DESIGN_GROUP":
192
204
  try {
193
205
  store.mutate(String(msg.data?.id), "user", (draft) => {
@@ -301,6 +313,15 @@ function handleIPCMessage(msg) {
301
313
  replyError(msg, "GET_CAPTURE_RESPONSE", err.message);
302
314
  }
303
315
  return;
316
+ case "GET_IMAGE":
317
+ try {
318
+ const image = store.images.read(String(msg.data?.name ?? ""));
319
+ reply(msg, "GET_IMAGE_RESPONSE", { data: image.data.toString("base64"), mimeType: image.mimeType });
320
+ }
321
+ catch (err) {
322
+ replyError(msg, "GET_IMAGE_RESPONSE", err.message);
323
+ }
324
+ return;
304
325
  case "GET_CONFIG":
305
326
  reply(msg, "GET_CONFIG_RESPONSE", { dataDir: store.getDataDir(), version: APP_VERSION });
306
327
  return;
@@ -460,6 +481,7 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
460
481
  "a text node given no height takes the exact height of its text, so its first line starts at y — " +
461
482
  "pass a height only to centre a label inside a container (same y and height as the container); " +
462
483
  "icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
484
+ "image: image (required — the stored name returned by add_image), fit; " +
463
485
  "instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
464
486
  "Any field left out falls back to a sensible default, so a node can be created from its type alone.";
465
487
  /**
@@ -494,6 +516,8 @@ const NODE_PROPERTIES = {
494
516
  strokeWidth: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.strokeWidth },
495
517
  radius: { type: "number", minimum: 0, description: NODE_FIELD_DOCS.radius },
496
518
  icon: { type: "string", description: NODE_FIELD_DOCS.icon },
519
+ image: { type: "string", description: NODE_FIELD_DOCS.image },
520
+ fit: { type: "string", enum: [...IMAGE_FITS], description: NODE_FIELD_DOCS.fit },
497
521
  componentId: { type: "string", description: NODE_FIELD_DOCS.componentId },
498
522
  text: { type: "string", description: NODE_FIELD_DOCS.text },
499
523
  color: { type: "string", description: NODE_FIELD_DOCS.color },
@@ -628,7 +652,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
628
652
  properties: {
629
653
  type: {
630
654
  type: "string",
631
- enum: ["rect", "ellipse", "line", "text", "icon", "group", "instance"],
655
+ enum: [...NODE_TYPES],
632
656
  description: NODE_FIELD_DOCS.type,
633
657
  },
634
658
  ...NODE_PROPERTIES,
@@ -641,6 +665,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
641
665
  required: ["design_id", "nodes"],
642
666
  },
643
667
  },
668
+ {
669
+ name: "add_image",
670
+ description: "Place a picture (photo, logo, illustration, screenshot) on a design, on top of the other nodes. " +
671
+ `Give the ABSOLUTE path of the image file on the agent machine; accepted formats: ${Object.keys(IMAGE_FORMATS).join(", ")}. ` +
672
+ "The file is copied into the media library of the designs, so the design keeps working if the original moves. " +
673
+ "Without width and height the node takes the picture's natural size; with only one of them, the other follows the picture's proportions. " +
674
+ "Use it for real pictures only — icons come from the \"icon\" node, shapes from add_nodes. " +
675
+ "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.",
676
+ inputSchema: {
677
+ type: "object",
678
+ properties: {
679
+ design_id: { type: "string", description: DESIGN_ID_DOC },
680
+ file_path: { type: "string", description: "Absolute path to the image file on the agent machine." },
681
+ file_content: {
682
+ type: "string",
683
+ description: "Filled in by the mcp-http-gateway relay, never by the agent: the base64 content of the file read on the agent machine.",
684
+ },
685
+ file_name: {
686
+ type: "string",
687
+ description: "Filled in by the mcp-http-gateway relay, never by the agent: the original name of the file read on the agent machine.",
688
+ },
689
+ id: NODE_PROPERTIES.id,
690
+ name: { type: "string", description: `${NODE_FIELD_DOCS.name} Defaults to the file name without its extension.` },
691
+ parentId: NODE_PROPERTIES.parentId,
692
+ x: NODE_PROPERTIES.x,
693
+ y: NODE_PROPERTIES.y,
694
+ 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." },
695
+ 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." },
696
+ rotation: NODE_PROPERTIES.rotation,
697
+ opacity: NODE_PROPERTIES.opacity,
698
+ fit: NODE_PROPERTIES.fit,
699
+ },
700
+ required: ["design_id", "file_path"],
701
+ },
702
+ },
644
703
  {
645
704
  name: "update_nodes",
646
705
  description: "Patch existing nodes. Each entry needs an id plus only the fields to change; everything else is preserved. " +
@@ -995,6 +1054,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
995
1054
  notifyDesignChanged(result.design.id);
996
1055
  return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
997
1056
  }
1057
+ case "add_image": {
1058
+ const result = addImage(store, params);
1059
+ notifyDesignChanged(result.design.id);
1060
+ return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
1061
+ }
998
1062
  case "update_nodes": {
999
1063
  const result = updateNodes(store, params);
1000
1064
  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
+ }