@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.
- package/README.md +23 -2
- 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 +10 -0
- package/dist/src/index.js +56 -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-BXpZGU8f.css → index-DNUHDrGi.css} +1 -1
- package/gui/dist/assets/index-uk5sDkVA.js +15 -0
- package/gui/dist/assets/{search-DnrsSC4m.js → search-jSqY2fPS.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -3
- package/gui/dist/assets/index-CgZSm3aX.js +0 -15
- 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
|
|
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
|
@@ -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: [
|
|
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,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
|
-
|
|
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) => {
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION
|
|
1
|
+
export declare const APP_VERSION: string;
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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();
|