@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.
- package/README.md +31 -3
- package/dist/shared/node-defaults.js +6 -1
- package/dist/shared/node-fields.d.ts +2 -0
- package/dist/shared/node-fields.js +5 -1
- package/dist/shared/render.d.ts +24 -5
- package/dist/shared/render.js +34 -13
- package/dist/shared/schemas/design.schema.d.ts +36 -1
- package/dist/shared/schemas/design.schema.js +10 -1
- package/dist/shared/schemas/feedback.schema.d.ts +6 -0
- package/dist/src/core/design-store.d.ts +3 -0
- package/dist/src/core/design-store.js +4 -0
- package/dist/src/core/export.d.ts +2 -1
- package/dist/src/core/export.js +35 -7
- package/dist/src/core/feedback-store.js +2 -2
- package/dist/src/core/image-store.d.ts +45 -0
- package/dist/src/core/image-store.js +175 -0
- package/dist/src/core/node-factory.js +16 -0
- package/dist/src/core/screenshot.d.ts +3 -0
- package/dist/src/core/screenshot.js +8 -4
- package/dist/src/gui-worker.js +23 -0
- package/dist/src/index.js +66 -2
- package/dist/src/tools/add-image.d.ts +3 -0
- package/dist/src/tools/add-image.js +65 -0
- package/dist/src/tools/add-nodes.js +2 -0
- package/dist/src/tools/export-design.js +1 -1
- package/dist/src/tools/screenshot-design.js +1 -0
- package/dist/src/tools/update-nodes.js +4 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +16 -2
- package/gui/dist/assets/index-CmUxiS-4.css +1 -0
- package/gui/dist/assets/index-SUmtOh8T.js +15 -0
- package/gui/dist/assets/{search-DnrsSC4m.js → search-DHQx5ZT0.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -3
- package/gui/dist/assets/index-BXpZGU8f.css +0 -1
- package/gui/dist/assets/index-CgZSm3aX.js +0 -15
- package/scripts/generate-version.js +0 -20
package/dist/src/core/export.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -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: [
|
|
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,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
|
+
}
|