@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
package/README.md CHANGED
@@ -93,7 +93,7 @@ Chaque requête traverse la garde de [`@imenam/mcp-gui-interface`](https://www.n
93
93
  - à tout en-tête `Host` autre que `localhost`, `127.0.0.1` ou `::1` (protection contre le DNS rebinding) ;
94
94
  - aux modifications (méthodes autres que `GET`, `HEAD`, `OPTIONS`) venues d'un autre site, d'après `Sec-Fetch-Site` ou, à défaut, `Origin` (sont admis : la machine elle-même, le proxy de `PROXY_URL`).
95
95
 
96
- Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible, diffable, versionnable.
96
+ Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible, diffable, versionnable. Les images qu'il affiche sont dans `<data>/images/`.
97
97
 
98
98
  ---
99
99
 
@@ -115,6 +115,7 @@ Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible
115
115
  | Outil | Effet |
116
116
  |---|---|
117
117
  | `add_nodes` | Ajoute un ou plusieurs éléments par-dessus les autres. |
118
+ | `add_image` | Importe une image (chemin absolu d'un fichier PNG, JPEG, GIF, WebP ou SVG) et la pose par-dessus les autres éléments. |
118
119
  | `update_nodes` | Patche des éléments existants — seuls les champs fournis changent. |
119
120
  | `delete_nodes` | Supprime des éléments par identifiant. Supprimer un groupe emporte son contenu. |
120
121
  | `duplicate_nodes` | Copie des éléments ou des groupes avec tout leur contenu, sous de nouveaux identifiants. `offset_x`/`offset_y` et `count` disposent une série de copies, `parent_id` les dépose dans un groupe. Avec `target_design_id`, la copie va dans une **autre planche** : les éléments gardent leur position à l'écran, et les composants reproduits par les instances copiées sont copiés avec elles, une seule fois, à la racine. |
@@ -122,7 +123,7 @@ Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible
122
123
  | `ungroup_nodes` | Dissout des groupes : leur contenu remonte d'un niveau, toujours sans bouger. |
123
124
  | `detach_instances` | Rompt le lien d'une instance avec son composant : son contenu devient des éléments ordinaires. |
124
125
 
125
- Sept types d'éléments : `rect`, `ellipse`, `line`, `text`, `icon`, `group` et `instance`. Seul `type` est obligatoire — sauf pour `icon`, qui a besoin du nom de l'icône ; tout le reste tombe sur des valeurs par défaut visibles, donc `{ "type": "rect" }` suffit à créer quelque chose qu'on voit à l'écran.
126
+ Huit types d'éléments : `rect`, `ellipse`, `line`, `text`, `icon`, `image`, `group` et `instance`. Seul `type` est obligatoire — sauf pour `icon`, qui a besoin du nom de l'icône, et `image`, qui a besoin du nom d'une image importée ; tout le reste tombe sur des valeurs par défaut visibles, donc `{ "type": "rect" }` suffit à créer quelque chose qu'on voit à l'écran.
126
127
 
127
128
  **Champs communs** — `name`, `parentId`, `x`, `y`, `width`, `height` (en pixels, origine en haut à gauche), `rotation` (degrés, autour du centre de la boîte), `opacity` (0–1).
128
129
 
@@ -135,6 +136,7 @@ Sept types d'éléments : `rect`, `ellipse`, `line`, `text`, `icon`, `group` et
135
136
  | `line` | `stroke`, `strokeWidth` — le trait va de `(x, y)` à `(x + width, y + height)`, donc les deltas négatifs encodent la direction |
136
137
  | `text` | `text`, `color`, `fontSize`, `fontFamily`, `fontWeight`, `fontStyle`, `textAlign`, `lineHeight` (en px) |
137
138
  | `icon` | `icon` (obligatoire — le nom du dessin), `fill`, `stroke`, `strokeWidth` |
139
+ | `image` | `image` (obligatoire — le nom de l'image dans la médiathèque), `fit` (`cover` par défaut, `contain`, `fill`) |
138
140
  | `group` | aucun — c'est un pur conteneur |
139
141
  | `instance` | `componentId` (obligatoire — le groupe reproduit) ; rien d'autre, son apparence vient de ce groupe |
140
142
 
@@ -164,6 +166,25 @@ Côté GUI, l'outil « Icône » de la barre d'outils (raccourci `I`) ouvre un s
164
166
 
165
167
  La bibliothèque est figée dans le dépôt par `npm run generate-icons` ([`scripts/generate-icons.js`](scripts/generate-icons.js)) : ni le serveur publié ni le bundle de la GUI ne dépendent de Lucide à l'exécution, et la géométrie d'un design ne peut pas changer au gré d'un `npm install`.
166
168
 
169
+ ### Images
170
+
171
+ `add_image` pose une vraie image — photo, logo, illustration, capture — là où une icône ou des formes ne suffisent pas.
172
+
173
+ ```json
174
+ { "design_id": "landing", "file_path": "C:/projets/site/assets/hero.jpg", "x": 0, "y": 80, "width": 1440 }
175
+ ```
176
+
177
+ - **Le fichier est copié** dans `<MCP_DESIGN_DATA_DIR>/images/`, sous un nom tiré de l'empreinte de son contenu (`img_3f9a1c2b7d4e5f60.jpg`). Le design ne stocke que ce nom : le JSON reste lisible, une même image importée deux fois n'est stockée qu'une fois, et la planche ne dépend plus de l'emplacement du fichier d'origine. La médiathèque est commune à toutes les planches du dossier de données.
178
+ - **Taille** : sans `width` ni `height`, la boîte prend les dimensions naturelles de l'image ; avec une seule des deux, l'autre suit ses proportions.
179
+ - **`fit`** dit comment l'image remplit sa boîte : `cover` la remplit en rognant le débordement, `contain` la montre en entier, `fill` l'étire.
180
+ - **Réutiliser une image** : la réponse d'`add_image` donne son nom ; un nœud `{ "type": "image", "image": "img_…" }` dans `add_nodes` la repose sans second import. Un nom absent de la médiathèque est refusé à l'écriture.
181
+ - Le format est reconnu d'après le contenu du fichier, pas d'après son extension ; 20 Mo au plus.
182
+ - Derrière [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway), l'outil déclare la paire `file_path` / `file_content` : le relais lit le fichier sur la machine de l'agent et en transmet le contenu (dossier autorisé par `GATEWAY_INLINE_ROOTS`).
183
+
184
+ Le canvas charge les images depuis le worker (`GET /api/images/<nom>`). `screenshot_design`, les captures des remarques et `export_design` les intègrent en data URI : un export `svg`, `html` ou `jsx` s'ouvre tel quel, sans la médiathèque — mais il pèse le poids des images qu'il contient ; pour une planche illustrée, préférez `destination_path` à un retour en ligne.
185
+
186
+ Dans la GUI, une image se déplace, se redimensionne et se pivote comme les autres éléments ; l'inspecteur règle son cadrage.
187
+
167
188
  ### Hiérarchie
168
189
 
169
190
  Chaque élément porte un `parentId` : `null` à la racine de la planche, sinon l'identifiant d'un `group`. **Les coordonnées sont exprimées dans l'espace du parent** : un élément à la racine se positionne par rapport à la planche, un élément dans un groupe par rapport à l'origine de ce groupe.
@@ -292,6 +313,13 @@ mark_feedback_processed { round: 1, remark_ids: ["rm_…"], note: "…" } ←
292
313
 
293
314
  Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
294
315
 
316
+ - **Pages** — la barre de gauche présente l'arbre des groupes : chevron, dossier ouvert ou fermé et nombre de pages par groupe, avec un trait vertical qui relie chaque niveau à son parent. À chaque niveau, les sous-groupes viennent d'abord, puis les pages, chacun par ordre alphabétique ; les pages hors groupe sont regroupées en bas sous « Hors groupe ». À l'ouverture, l'interface rouvre la dernière page consultée dans ce navigateur (mémorisée localement, propre à chaque poste) ; à défaut, la première page de la liste.
317
+ - **Groupe affiché** — le sélecteur en tête montre « Tous les groupes » ou un seul groupe de premier niveau, avec son nombre de pages et de pages à synchroniser. Le choix est mémorisé dans le navigateur.
318
+ - **Recherche et filtres** — le champ de recherche porte sur les noms de pages et de dossiers, à toute profondeur, sans tenir compte des accents ni de la casse (`Échap` le vide) : un dossier dont le nom correspond reste affiché en entier, sinon seules ses pages et sous-dossiers qui correspondent restent. Les filtres « Toutes », « À synchroniser » et « Remarques » (remarques non transmises) portent leur compteur. Une recherche ou un filtre actif déplie tous les groupes.
319
+ - **Tout replier** — le bouton à droite de la recherche replie tous les dossiers du groupe affiché ; quand ils le sont déjà tous, il les déplie. Il est inactif pendant une recherche ou un filtre, qui gardent tout déplié.
320
+ - **Cartes de page** — un aperçu aux proportions de la planche, le nom, le format et la date de dernière modification ; à droite, la bulle violette des remarques non transmises et le point ambre des modifications à synchroniser. Le menu « … », visible au survol, propose **Déplacer vers…** (vide : hors groupe), **Dupliquer** (la copie s'ouvre aussitôt) et **Supprimer**. Une page se range aussi par glisser-déposer sur un groupe.
321
+ - **Nouvelle page** — le bouton en pied ouvre le formulaire de création ; le groupe est prérempli avec le groupe affiché.
322
+
295
323
  - **Outils** — sélection, rectangle, ellipse, ligne, texte, icône. Glisser pour dimensionner, cliquer pour poser à la taille par défaut. L'outil icône ouvre d'abord son sélecteur : poser une icône suppose d'avoir choisi laquelle.
296
324
  - **Édition** — déplacement au glisser, 8 poignées de redimensionnement (2 extrémités pour une ligne), sélection multiple au rectangle d'encadrement ou au Maj+clic.
297
325
  - **Groupes** — `Ctrl+G` groupe la sélection, `Ctrl+Maj+G` la dissout. Un clic dans le canvas sélectionne le **groupe le plus haut** ; `Alt+clic` vise l'élément exact à l'intérieur. Déplacer un groupe déplace tout son contenu.
@@ -309,7 +337,7 @@ Raccourcis : `V` `R` `O` `L` `T` `I` (outils), `Ctrl+Z` / `Ctrl+Y` (annuler/rét
309
337
 
310
338
  La sauvegarde est automatique et différée. Une modification faite par l'agent pendant que la GUI est ouverte y apparaît en direct, via SSE ; une modification faite dans la GUI est enregistrée comme venant de l'utilisateur, et c'est ce que `get_changes` restitue.
311
339
 
312
- Le badge « n à synchroniser » d'une planche indique le nombre de modifications que l'agent n'a pas encore acquittées.
340
+ Le point ambre d'une page indique qu'elle porte des modifications que l'agent n'a pas encore acquittées avec `mark_synced` ; leur nombre s'affiche au survol.
313
341
 
314
342
  ### Mode Remarques
315
343
 
@@ -1,6 +1,6 @@
1
1
  import { defaultLineHeight } from "./render.js";
2
2
  import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID } from "./icons/index.js";
3
- export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
3
+ export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "image", "group", "instance"];
4
4
  /** Types que l'utilisateur peut dessiner directement ; un groupe se cree a partir d'une selection. */
5
5
  export const DRAWABLE_KINDS = ["rect", "ellipse", "line", "text", "icon"];
6
6
  export const NODE_DEFAULTS = {
@@ -34,6 +34,9 @@ export const NODE_DEFAULTS = {
34
34
  stroke: "#1a1a18",
35
35
  strokeWidth: DEFAULT_ICON_STROKE_WIDTH,
36
36
  },
37
+ // Une image importee sans taille prend ses dimensions naturelles : celles-ci
38
+ // ne servent qu'a un noeud ecrit a la main sans largeur ni hauteur.
39
+ image: { width: 200, height: 150, fit: "cover" },
37
40
  // Un groupe n'a pas de taille propre : elle est deduite de son contenu.
38
41
  group: { width: 0, height: 0 },
39
42
  // Une instance non plus : sa taille est celle du composant qu'elle reproduit.
@@ -46,6 +49,7 @@ export const NODE_STYLE_FIELDS = {
46
49
  line: ["stroke", "strokeWidth"],
47
50
  text: ["text", "color", "fontSize", "fontFamily", "fontWeight", "fontStyle", "textAlign", "lineHeight"],
48
51
  icon: ["icon", "fill", "stroke", "strokeWidth"],
52
+ image: ["image", "fit"],
49
53
  group: [],
50
54
  // Rien ne se surcharge dans une instance : son apparence est celle de son
51
55
  // composant. Seul le lien lui-meme se change, ce qui l'echange de composant.
@@ -72,6 +76,7 @@ const LABELS = {
72
76
  line: "Ligne",
73
77
  text: "Texte",
74
78
  icon: "Icône",
79
+ image: "Image",
75
80
  group: "Groupe",
76
81
  instance: "Instance",
77
82
  };
@@ -22,6 +22,8 @@ export declare const NODE_FIELD_DOCS: {
22
22
  strokeWidth: string;
23
23
  radius: string;
24
24
  icon: string;
25
+ image: string;
26
+ fit: string;
25
27
  componentId: string;
26
28
  text: string;
27
29
  color: string;
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export const NODE_FIELD_DOCS = {
10
10
  id: "Explicit id. Generated when omitted; must be unique within the design.",
11
- type: "Kind of node: rect, ellipse, line, text, icon, group or instance.",
11
+ type: "Kind of node: rect, ellipse, line, text, icon, image, group or instance.",
12
12
  name: "Layer name shown in the GUI. Purely descriptive.",
13
13
  parentId: "Id of the containing group, or null for the root level of the artboard. " +
14
14
  "Changing it reparents the node — its x/y are then read in the new parent's space, so it moves on screen. " +
@@ -40,6 +40,10 @@ export const NODE_FIELD_DOCS = {
40
40
  "the result is sharp at any size, restyles in one field, and stays readable to whoever reads the design later. " +
41
41
  "Call search_icons to find a name from a description (it also understands synonyms, so \"basket\" finds \"shopping-cart\"). " +
42
42
  "The node's box is square: the drawing is scaled uniformly to fit it and centred, never stretched.",
43
+ image: "Image nodes only: name of the stored image file, e.g. \"img_3f9a1c2b7d4e.png\". It is returned by add_image, which is the way " +
44
+ "to bring a new picture into the design; reuse the same name to place that picture again without importing it twice.",
45
+ fit: "Image nodes only: how the picture fills its box. cover (default) fills the whole box and crops the overflow, " +
46
+ "contain shows the whole picture inside the box, leaving empty bands, fill stretches it to the box.",
43
47
  componentId: "Instance nodes only: id of the GROUP this instance mirrors. The group's content is NOT copied — it is resolved " +
44
48
  "at render time, so editing the group updates every instance of it at once, and a repeated block stays a single " +
45
49
  "node to read and to change. Whenever the same block appears more than once (cards, list rows, menu items, " +
@@ -12,7 +12,7 @@ export { normalizedBox };
12
12
  export type { Box, Point } from "./tree.js";
13
13
  export interface RenderNode {
14
14
  id: string;
15
- type: "rect" | "ellipse" | "line" | "text" | "icon" | "group" | "instance";
15
+ type: "rect" | "ellipse" | "line" | "text" | "icon" | "image" | "group" | "instance";
16
16
  name: string;
17
17
  /** Groupe conteneur, ou null a la racine. Les coordonnees sont relatives a ce parent. */
18
18
  parentId: string | null;
@@ -30,6 +30,10 @@ export interface RenderNode {
30
30
  radius?: number;
31
31
  /** Nom de l'icone dans la bibliotheque embarquee, p.ex. "shopping-cart". Type `icon` uniquement. */
32
32
  icon?: string;
33
+ /** Nom du fichier dans la mediatheque, p.ex. "img_3f9a1c2b7d4e.png". Type `image` uniquement. */
34
+ image?: string;
35
+ /** Remplissage de la boite par l'image. Type `image` uniquement. */
36
+ fit?: "cover" | "contain" | "fill";
33
37
  text?: string;
34
38
  color?: string;
35
39
  fontSize?: number;
@@ -131,13 +135,28 @@ export declare function iconElementsOf(node: RenderNode): IconElement[];
131
135
  * en noir sur une icone blanche.
132
136
  */
133
137
  export declare function iconStyle(node: RenderNode): Record<string, string>;
138
+ /**
139
+ * Adresse d'une image de la mediatheque dans le SVG produit.
140
+ *
141
+ * Elle depend de qui lit le SVG : le canvas la charge depuis l'API du worker,
142
+ * resvg et les exports autonomes ont besoin des octets en data URI.
143
+ */
144
+ export type ImageHref = (image: string) => string;
145
+ export interface RenderOptions {
146
+ /** Obligatoire des que la planche contient une image. */
147
+ imageHref?: ImageHref;
148
+ }
149
+ /** Valeur de `preserveAspectRatio` correspondant au `fit` du noeud. */
150
+ export declare function imageAspect(node: RenderNode): string;
151
+ /** Adresse de l'image du noeud ; echoue si l'appelant n'a pas dit comment la resoudre. */
152
+ export declare function imageSource(node: RenderNode, options: RenderOptions): string;
134
153
  export declare function iconElementToSvg([tag, attributes]: IconElement): string;
135
154
  /**
136
155
  * Markup SVG d'un seul noeud, sans wrapper — utilisé par l'export et par le canvas.
137
156
  * Un groupe ne produit rien par lui-meme : c'est un conteneur, rendu par
138
157
  * `documentToSvg` (serveur) ou par le canvas (navigateur).
139
158
  */
140
- export declare function nodeToSvg(node: RenderNode): string;
159
+ export declare function nodeToSvg(node: RenderNode, options?: RenderOptions): string;
141
160
  export interface RenderDocument {
142
161
  name: string;
143
162
  width: number;
@@ -145,13 +164,13 @@ export interface RenderDocument {
145
164
  background: string;
146
165
  nodes: RenderNode[];
147
166
  }
148
- export declare function documentToSvg(doc: RenderDocument): string;
167
+ export declare function documentToSvg(doc: RenderDocument, options?: RenderOptions): string;
149
168
  /**
150
169
  * SVG cadré sur une zone rectangulaire de la planche, en coordonnées document.
151
170
  * Tout ce qui recoupe la zone y figure, coupé à ses bords ; les éléments
152
171
  * semi-transparents qui ne la recoupent pas sont omis.
153
172
  */
154
- export declare function regionToSvg(doc: RenderDocument, region: Box): string;
173
+ export declare function regionToSvg(doc: RenderDocument, region: Box, options?: RenderOptions): string;
155
174
  /**
156
175
  * SVG cadré sur un seul élément — l'élément et son contenu, rien d'autre.
157
176
  *
@@ -159,4 +178,4 @@ export declare function regionToSvg(doc: RenderDocument, region: Box): string;
159
178
  * position document : le viewBox peut alors être exprimé dans le même repère
160
179
  * que `computeLayout`, donc directement sur la boîte englobante de l'élément.
161
180
  */
162
- export declare function subtreeToSvg(doc: RenderDocument, nodeId: string, padding?: number): string;
181
+ export declare function subtreeToSvg(doc: RenderDocument, nodeId: string, padding?: number, options?: RenderOptions): string;
@@ -175,6 +175,22 @@ export function iconStyle(node) {
175
175
  "stroke-linejoin": "round",
176
176
  };
177
177
  }
178
+ const IMAGE_ASPECT = {
179
+ cover: "xMidYMid slice",
180
+ contain: "xMidYMid meet",
181
+ fill: "none",
182
+ };
183
+ /** Valeur de `preserveAspectRatio` correspondant au `fit` du noeud. */
184
+ export function imageAspect(node) {
185
+ return IMAGE_ASPECT[node.fit ?? "cover"];
186
+ }
187
+ /** Adresse de l'image du noeud ; echoue si l'appelant n'a pas dit comment la resoudre. */
188
+ export function imageSource(node, options) {
189
+ if (!options.imageHref) {
190
+ throw new Error(`Node "${node.id}" is an image, but this render was given no way to resolve image files.`);
191
+ }
192
+ return options.imageHref(node.image ?? "");
193
+ }
178
194
  function attributesToSvg(attributes) {
179
195
  return Object.entries(attributes)
180
196
  .map(([key, value]) => `${key}="${escapeXml(value)}"`)
@@ -188,7 +204,7 @@ export function iconElementToSvg([tag, attributes]) {
188
204
  * Un groupe ne produit rien par lui-meme : c'est un conteneur, rendu par
189
205
  * `documentToSvg` (serveur) ou par le canvas (navigateur).
190
206
  */
191
- export function nodeToSvg(node) {
207
+ export function nodeToSvg(node, options = {}) {
192
208
  // Un groupe est un conteneur ; une instance n'atteint jamais le rendu, elle
193
209
  // est remplacee par un groupe en amont par `expandInstances`.
194
210
  if (node.type === "group" || node.type === "instance")
@@ -221,6 +237,11 @@ export function nodeToSvg(node) {
221
237
  const body = iconElementsOf(node).map(iconElementToSvg).join("");
222
238
  return `<g transform="${placement}" ${attributesToSvg(iconStyle(node))}${opacityAttr}>${body}</g>`;
223
239
  }
240
+ case "image": {
241
+ return (`<image x="${round(box.x)}" y="${round(box.y)}" width="${round(box.width)}" height="${round(box.height)}"` +
242
+ ` href="${escapeXml(imageSource(node, options))}" preserveAspectRatio="${imageAspect(node)}"` +
243
+ `${opacityAttr}${transformAttr} />`);
244
+ }
224
245
  case "text": {
225
246
  const m = textMetrics(node);
226
247
  const lines = textLines(node);
@@ -243,26 +264,26 @@ export function nodeToSvg(node) {
243
264
  * omis : il n'y serait pas visible, et resvg panique — en emportant le
244
265
  * processus — sur un élément semi-transparent dessiné entièrement hors du cadre.
245
266
  */
246
- function renderSiblings(nodes, children, layout, depth, frame) {
267
+ function renderSiblings(nodes, children, layout, depth, options, frame) {
247
268
  const pad = " ".repeat(depth + 1);
248
269
  return nodes.flatMap((node) => {
249
270
  const bounds = boundsOf(layout, node.id);
250
271
  if (frame && node.opacity < 1 && !boxesIntersect(bounds, frame))
251
272
  return [];
252
273
  if (node.type !== "group")
253
- return [`${pad}${nodeToSvg(node)}`];
274
+ return [`${pad}${nodeToSvg(node, options)}`];
254
275
  const origin = layout.origin.get(node.id) ?? { x: 0, y: 0 };
255
276
  const center = { cx: bounds.x + bounds.width / 2 - origin.x, cy: bounds.y + bounds.height / 2 - origin.y };
256
277
  const opacity = node.opacity >= 1 ? "" : ` opacity="${round(node.opacity)}"`;
257
278
  return [
258
279
  `${pad}<g transform="${groupTransform(node, center)}"${opacity}>`,
259
- ...renderSiblings(children.get(node.id) ?? [], children, layout, depth + 1, frame),
280
+ ...renderSiblings(children.get(node.id) ?? [], children, layout, depth + 1, options, frame),
260
281
  `${pad}</g>`,
261
282
  ];
262
283
  });
263
284
  }
264
285
  /** Contenu SVG de la planche, en coordonnées document, limité à `frame` s'il est donné. */
265
- function documentBody(doc, frame) {
286
+ function documentBody(doc, options, frame) {
266
287
  // Les instances sont resolues ici, une fois pour toutes : en dessous, il n'y
267
288
  // a plus que des groupes ordinaires.
268
289
  const nodes = expandInstances(doc.nodes);
@@ -270,10 +291,10 @@ function documentBody(doc, frame) {
270
291
  const children = childrenByParent(nodes);
271
292
  // Un groupe devient un <g> : ses enfants gardent leurs coordonnées locales,
272
293
  // c'est la transformation du conteneur qui les place.
273
- return renderSiblings(children.get(null) ?? [], children, layout, 0, frame).join("\n");
294
+ return renderSiblings(children.get(null) ?? [], children, layout, 0, options, frame).join("\n");
274
295
  }
275
- export function documentToSvg(doc) {
276
- const body = documentBody(doc);
296
+ export function documentToSvg(doc, options = {}) {
297
+ const body = documentBody(doc, options);
277
298
  return (`<svg xmlns="http://www.w3.org/2000/svg" width="${doc.width}" height="${doc.height}" ` +
278
299
  `viewBox="0 0 ${doc.width} ${doc.height}">\n` +
279
300
  ` <rect x="0" y="0" width="${doc.width}" height="${doc.height}" fill="${escapeXml(doc.background)}" />\n` +
@@ -285,8 +306,8 @@ export function documentToSvg(doc) {
285
306
  * Tout ce qui recoupe la zone y figure, coupé à ses bords ; les éléments
286
307
  * semi-transparents qui ne la recoupent pas sont omis.
287
308
  */
288
- export function regionToSvg(doc, region) {
289
- const body = documentBody(doc, region);
309
+ export function regionToSvg(doc, region, options = {}) {
310
+ const body = documentBody(doc, options, region);
290
311
  const width = round(Math.max(1, region.width));
291
312
  const height = round(Math.max(1, region.height));
292
313
  return (`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
@@ -303,7 +324,7 @@ export function regionToSvg(doc, region) {
303
324
  * position document : le viewBox peut alors être exprimé dans le même repère
304
325
  * que `computeLayout`, donc directement sur la boîte englobante de l'élément.
305
326
  */
306
- export function subtreeToSvg(doc, nodeId, padding = 0) {
327
+ export function subtreeToSvg(doc, nodeId, padding = 0, options = {}) {
307
328
  const nodes = expandInstances(doc.nodes);
308
329
  const node = nodes.find((candidate) => candidate.id === nodeId);
309
330
  if (!node)
@@ -323,8 +344,8 @@ export function subtreeToSvg(doc, nodeId, padding = 0) {
323
344
  // noeud est rendu tel quel, dans le repère de son parent.
324
345
  const origin = node.type === "group" ? (layout.origin.get(nodeId) ?? { x: 0, y: 0 }) : spaceOriginOf(layout, node.parentId);
325
346
  const inner = node.type === "group"
326
- ? renderSiblings([{ ...node, x: 0, y: 0 }], children, layout, 1).join("\n")
327
- : ` ${nodeToSvg(node)}`;
347
+ ? renderSiblings([{ ...node, x: 0, y: 0 }], children, layout, 1, options).join("\n")
348
+ : ` ${nodeToSvg(node, options)}`;
328
349
  return (`<svg xmlns="http://www.w3.org/2000/svg" width="${round(region.width)}" height="${round(region.height)}" ` +
329
350
  `viewBox="${round(region.x)} ${round(region.y)} ${round(region.width)} ${round(region.height)}">\n` +
330
351
  ` <rect x="${round(region.x)}" y="${round(region.y)}" width="${round(region.width)}" ` +
@@ -14,11 +14,12 @@ import { z } from "zod";
14
14
  * stockage reste plat, donc diffable et lisible, tandis que les coordonnees
15
15
  * d'un noeud sont exprimees dans l'espace de son parent.
16
16
  */
17
- export declare const NODE_TYPES: readonly ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
17
+ export declare const NODE_TYPES: readonly ["rect", "ellipse", "line", "text", "icon", "image", "group", "instance"];
18
18
  export declare const NodeTypeSchema: z.ZodEnum<{
19
19
  ellipse: "ellipse";
20
20
  group: "group";
21
21
  icon: "icon";
22
+ image: "image";
22
23
  instance: "instance";
23
24
  line: "line";
24
25
  rect: "rect";
@@ -31,6 +32,12 @@ export declare const TextAlignSchema: z.ZodEnum<{
31
32
  left: "left";
32
33
  right: "right";
33
34
  }>;
35
+ export declare const IMAGE_FITS: readonly ["cover", "contain", "fill"];
36
+ export declare const ImageFitSchema: z.ZodEnum<{
37
+ contain: "contain";
38
+ cover: "cover";
39
+ fill: "fill";
40
+ }>;
34
41
  /**
35
42
  * Un seul schéma pour tous les types de noeuds : les champs non pertinents pour
36
43
  * un type donné sont simplement absents. Un schéma discriminé obligerait l'agent
@@ -42,6 +49,7 @@ export declare const DesignNodeSchema: z.ZodObject<{
42
49
  ellipse: "ellipse";
43
50
  group: "group";
44
51
  icon: "icon";
52
+ image: "image";
45
53
  instance: "instance";
46
54
  line: "line";
47
55
  rect: "rect";
@@ -61,6 +69,12 @@ export declare const DesignNodeSchema: z.ZodObject<{
61
69
  strokeWidth: z.ZodOptional<z.ZodNumber>;
62
70
  radius: z.ZodOptional<z.ZodNumber>;
63
71
  icon: z.ZodOptional<z.ZodString>;
72
+ image: z.ZodOptional<z.ZodString>;
73
+ fit: z.ZodOptional<z.ZodEnum<{
74
+ contain: "contain";
75
+ cover: "cover";
76
+ fill: "fill";
77
+ }>>;
64
78
  text: z.ZodOptional<z.ZodString>;
65
79
  color: z.ZodOptional<z.ZodString>;
66
80
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -88,6 +102,7 @@ export declare const BaselineSchema: z.ZodObject<{
88
102
  ellipse: "ellipse";
89
103
  group: "group";
90
104
  icon: "icon";
105
+ image: "image";
91
106
  instance: "instance";
92
107
  line: "line";
93
108
  rect: "rect";
@@ -107,6 +122,12 @@ export declare const BaselineSchema: z.ZodObject<{
107
122
  strokeWidth: z.ZodOptional<z.ZodNumber>;
108
123
  radius: z.ZodOptional<z.ZodNumber>;
109
124
  icon: z.ZodOptional<z.ZodString>;
125
+ image: z.ZodOptional<z.ZodString>;
126
+ fit: z.ZodOptional<z.ZodEnum<{
127
+ contain: "contain";
128
+ cover: "cover";
129
+ fill: "fill";
130
+ }>>;
110
131
  text: z.ZodOptional<z.ZodString>;
111
132
  color: z.ZodOptional<z.ZodString>;
112
133
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -148,6 +169,7 @@ export declare const DesignSchema: z.ZodObject<{
148
169
  ellipse: "ellipse";
149
170
  group: "group";
150
171
  icon: "icon";
172
+ image: "image";
151
173
  instance: "instance";
152
174
  line: "line";
153
175
  rect: "rect";
@@ -167,6 +189,12 @@ export declare const DesignSchema: z.ZodObject<{
167
189
  strokeWidth: z.ZodOptional<z.ZodNumber>;
168
190
  radius: z.ZodOptional<z.ZodNumber>;
169
191
  icon: z.ZodOptional<z.ZodString>;
192
+ image: z.ZodOptional<z.ZodString>;
193
+ fit: z.ZodOptional<z.ZodEnum<{
194
+ contain: "contain";
195
+ cover: "cover";
196
+ fill: "fill";
197
+ }>>;
170
198
  text: z.ZodOptional<z.ZodString>;
171
199
  color: z.ZodOptional<z.ZodString>;
172
200
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -199,6 +227,7 @@ export declare const DesignSchema: z.ZodObject<{
199
227
  ellipse: "ellipse";
200
228
  group: "group";
201
229
  icon: "icon";
230
+ image: "image";
202
231
  instance: "instance";
203
232
  line: "line";
204
233
  rect: "rect";
@@ -218,6 +247,12 @@ export declare const DesignSchema: z.ZodObject<{
218
247
  strokeWidth: z.ZodOptional<z.ZodNumber>;
219
248
  radius: z.ZodOptional<z.ZodNumber>;
220
249
  icon: z.ZodOptional<z.ZodString>;
250
+ image: z.ZodOptional<z.ZodString>;
251
+ fit: z.ZodOptional<z.ZodEnum<{
252
+ contain: "contain";
253
+ cover: "cover";
254
+ fill: "fill";
255
+ }>>;
221
256
  text: z.ZodOptional<z.ZodString>;
222
257
  color: z.ZodOptional<z.ZodString>;
223
258
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -16,10 +16,12 @@ import { NODE_FIELD_DOCS } from "../node-fields.js";
16
16
  * stockage reste plat, donc diffable et lisible, tandis que les coordonnees
17
17
  * d'un noeud sont exprimees dans l'espace de son parent.
18
18
  */
19
- export const NODE_TYPES = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
19
+ export const NODE_TYPES = ["rect", "ellipse", "line", "text", "icon", "image", "group", "instance"];
20
20
  export const NodeTypeSchema = z.enum(NODE_TYPES);
21
21
  export const TEXT_ALIGNS = ["left", "center", "right"];
22
22
  export const TextAlignSchema = z.enum(TEXT_ALIGNS);
23
+ export const IMAGE_FITS = ["cover", "contain", "fill"];
24
+ export const ImageFitSchema = z.enum(IMAGE_FITS);
23
25
  /**
24
26
  * Un seul schéma pour tous les types de noeuds : les champs non pertinents pour
25
27
  * un type donné sont simplement absents. Un schéma discriminé obligerait l'agent
@@ -57,6 +59,13 @@ export const DesignNodeSchema = z.object({
57
59
  * se redessiner, et un agent qui relit le design sait ce qu'elle represente.
58
60
  */
59
61
  icon: z.string().describe(NODE_FIELD_DOCS.icon).optional(),
62
+ /**
63
+ * Image — le nom du fichier dans la mediatheque du dossier de donnees, jamais
64
+ * ses octets : le design reste un JSON lisible, et une meme image posee
65
+ * plusieurs fois n'est stockee qu'une fois.
66
+ */
67
+ image: z.string().describe(NODE_FIELD_DOCS.image).optional(),
68
+ fit: ImageFitSchema.describe(NODE_FIELD_DOCS.fit).optional(),
60
69
  // Texte — fontSize et lineHeight sont tous deux en PIXELS.
61
70
  text: z.string().describe(NODE_FIELD_DOCS.text).optional(),
62
71
  color: z.string().describe(NODE_FIELD_DOCS.color).optional(),
@@ -10,6 +10,7 @@ export declare const RemarkTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
10
10
  ellipse: "ellipse";
11
11
  group: "group";
12
12
  icon: "icon";
13
+ image: "image";
13
14
  instance: "instance";
14
15
  line: "line";
15
16
  rect: "rect";
@@ -38,6 +39,7 @@ export declare const RemarkTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
38
39
  ellipse: "ellipse";
39
40
  group: "group";
40
41
  icon: "icon";
42
+ image: "image";
41
43
  instance: "instance";
42
44
  line: "line";
43
45
  rect: "rect";
@@ -62,6 +64,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
62
64
  ellipse: "ellipse";
63
65
  group: "group";
64
66
  icon: "icon";
67
+ image: "image";
65
68
  instance: "instance";
66
69
  line: "line";
67
70
  rect: "rect";
@@ -90,6 +93,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
90
93
  ellipse: "ellipse";
91
94
  group: "group";
92
95
  icon: "icon";
96
+ image: "image";
93
97
  instance: "instance";
94
98
  line: "line";
95
99
  rect: "rect";
@@ -127,6 +131,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
127
131
  ellipse: "ellipse";
128
132
  group: "group";
129
133
  icon: "icon";
134
+ image: "image";
130
135
  instance: "instance";
131
136
  line: "line";
132
137
  rect: "rect";
@@ -155,6 +160,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
155
160
  ellipse: "ellipse";
156
161
  group: "group";
157
162
  icon: "icon";
163
+ image: "image";
158
164
  instance: "instance";
159
165
  line: "line";
160
166
  rect: "rect";
@@ -1,4 +1,5 @@
1
1
  import { type Design, type DesignSummary } from "../../shared/schemas/design.schema.js";
2
+ import { ImageStore } from "./image-store.js";
2
3
  export interface CreateDesignInput {
3
4
  name: string;
4
5
  width?: number;
@@ -24,6 +25,8 @@ export interface MutateOptions {
24
25
  export declare class DesignStore {
25
26
  private readonly dataDir;
26
27
  private readonly designsDir;
28
+ /** Images posées sur les planches, partagées par tous les designs du dossier de données. */
29
+ readonly images: ImageStore;
27
30
  /**
28
31
  * Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
29
32
  * fois serait inutile. Chaque entrée retient la signature du fichier au moment
@@ -7,6 +7,7 @@ import { normalizeHierarchy } from "./hierarchy.js";
7
7
  import { migrateDesign } from "./migrate.js";
8
8
  import { shortId, slugify } from "./ids.js";
9
9
  import { countChanges } from "./diff.js";
10
+ import { ImageStore } from "./image-store.js";
10
11
  /**
11
12
  * Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
12
13
  * Pas de base de données — un design tient dans quelques dizaines de Ko et le
@@ -15,6 +16,8 @@ import { countChanges } from "./diff.js";
15
16
  export class DesignStore {
16
17
  dataDir;
17
18
  designsDir;
19
+ /** Images posées sur les planches, partagées par tous les designs du dossier de données. */
20
+ images;
18
21
  /**
19
22
  * Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
20
23
  * fois serait inutile. Chaque entrée retient la signature du fichier au moment
@@ -29,6 +32,7 @@ export class DesignStore {
29
32
  this.dataDir = configured ? path.resolve(configured) : path.join(rootDir, ".design-data");
30
33
  this.designsDir = path.join(this.dataDir, "designs");
31
34
  fs.mkdirSync(this.designsDir, { recursive: true });
35
+ this.images = new ImageStore(this.dataDir);
32
36
  }
33
37
  getDataDir() {
34
38
  return this.dataDir;
@@ -1,6 +1,7 @@
1
1
  import type { Design } from "../../shared/schemas/design.schema.js";
2
+ import type { ImageStore } from "./image-store.js";
2
3
  export declare const EXPORT_FORMATS: readonly ["svg", "html", "jsx", "json", "summary"];
3
4
  export type ExportFormat = (typeof EXPORT_FORMATS)[number];
4
5
  /** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
5
6
  export declare function toSummary(design: Design): string;
6
- export declare function exportDesign(design: Design, format: ExportFormat): string;
7
+ export declare function exportDesign(design: Design, format: ExportFormat, images?: ImageStore): string;