Setting the file. One moment.
Add Icon To Diagram · Excalidraw Diagram Generator · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page scripts/ add-icon-to-diagram.py
Python · 406 lines · 13 KB
16
17 Examples:
18 python add-icon-to-diagram.py diagram.excalidraw EC2 500 300
19 python add-icon-to-diagram.py diagram.excalidraw EC2 500 300 --label "Web Server"
20 python add-icon-to-diagram.py diagram.excalidraw VPC 200 150 --library-path libraries/gcp-icons
21 python add-icon-to-diagram.py diagram.excalidraw EC2 500 300 --use-edit-suffix
22 """
23
24 import json
25 import sys
26 import uuid
27 from pathlib import Path
28 from typing import Dict, List, Any, Tuple
29
30
31 def generate_unique_id () -> str :
32 """Generate a unique ID for Excalidraw elements."""
33 return str (uuid.uuid4()).replace( '-' , '' )[: 16 ]
34
35
36 def calculate_bounding_box (elements: List[Dict[ str , Any]]) -> Tuple[ float , float , float , float ]:
37 """Calculate the bounding box (min_x, min_y, max_x, max_y) of icon elements."""
38 if not elements:
39 return ( 0 , 0 , 0 , 0 )
40
41 min_x = float ( 'inf' )
42 min_y = float ( 'inf' )
43 max_x = float ( '-inf' )
44 max_y = float ( '-inf' )
45
46 for element in elements:
47 if 'x' in element and 'y' in element:
48 x = element[ 'x' ]
49 y = element[ 'y' ]
50 width = element.get( 'width' , 0 )
51 height = element.get( 'height' , 0 )
52
53 min_x = min (min_x, x)
54 min_y = min (min_y, y)
55 max_x = max (max_x, x + width)
56 max_y = max (max_y, y + height)
57
58 return (min_x, min_y, max_x, max_y)
59
60
61 def transform_icon_elements (
62 elements: List[Dict[ str , Any]],
63 target_x: float ,
64 target_y: float
65 ) -> List[Dict[ str , Any]]:
66 """
67 Transform icon elements to target coordinates with unique IDs.
68
69 Args:
70 elements: Icon elements from JSON file
71 target_x: Target X coordinate (top-left position)
72 target_y: Target Y coordinate (top-left position)
73
74 Returns:
75 Transformed elements with new coordinates and IDs
76 """
77 if not elements:
78 return []
79
80 # Calculate bounding box
81 min_x, min_y, max_x, max_y = calculate_bounding_box(elements)
82
83 # Calculate offset
84 offset_x = target_x - min_x
85 offset_y = target_y - min_y
86
87 # Create ID mapping: old_id -> new_id
88 id_mapping = {}
89 for element in elements:
90 if 'id' in element:
91 old_id = element[ 'id' ]
92 id_mapping[old_id] = generate_unique_id()
93
94 # Create group ID mapping
95 group_id_mapping = {}
96 for element in elements:
97 if 'groupIds' in element:
98 for old_group_id in element[ 'groupIds' ]:
99 if old_group_id not in group_id_mapping:
100 group_id_mapping[old_group_id] = generate_unique_id()
101
102 # Transform elements
103 transformed = []
104 for element in elements:
105 new_element = element.copy()
106
107 # Update coordinates
108 if 'x' in new_element:
109 new_element[ 'x' ] = new_element[ 'x' ] + offset_x
110 if 'y' in new_element:
111 new_element[ 'y' ] = new_element[ 'y' ] + offset_y
112
113 # Update ID
114 if 'id' in new_element:
115 new_element[ 'id' ] = id_mapping[new_element[ 'id' ]]
116
117 # Update group IDs
118 if 'groupIds' in new_element:
119 new_element[ 'groupIds' ] = [
120 group_id_mapping[gid] for gid in new_element[ 'groupIds' ]
121 ]
122
123 # Update binding references if they exist
124 if 'startBinding' in new_element and new_element[ 'startBinding' ]:
125 if 'elementId' in new_element[ 'startBinding' ]:
126 old_id = new_element[ 'startBinding' ][ 'elementId' ]
127 if old_id in id_mapping:
128 new_element[ 'startBinding' ][ 'elementId' ] = id_mapping[old_id]
129
130 if 'endBinding' in new_element and new_element[ 'endBinding' ]:
131 if 'elementId' in new_element[ 'endBinding' ]:
132 old_id = new_element[ 'endBinding' ][ 'elementId' ]
133 if old_id in id_mapping:
134 new_element[ 'endBinding' ][ 'elementId' ] = id_mapping[old_id]
135
136 # Update containerId if it exists
137 if 'containerId' in new_element and new_element[ 'containerId' ]:
138 old_id = new_element[ 'containerId' ]
139 if old_id in id_mapping:
140 new_element[ 'containerId' ] = id_mapping[old_id]
141
142 # Update boundElements if they exist
143 if 'boundElements' in new_element and new_element[ 'boundElements' ]:
144 new_bound_elements = []
145 for bound_elem in new_element[ 'boundElements' ]:
146 if isinstance (bound_elem, dict ) and 'id' in bound_elem:
147 old_id = bound_elem[ 'id' ]
148 if old_id in id_mapping:
149 bound_elem[ 'id' ] = id_mapping[old_id]
150 new_bound_elements.append(bound_elem)
151 new_element[ 'boundElements' ] = new_bound_elements
152
153 transformed.append(new_element)
154
155 return transformed
156
157
158 def load_icon (icon_name: str , library_path: Path) -> List[Dict[ str , Any]]:
159 """
160 Load icon elements from library.
161
162 Args:
163 icon_name: Name of the icon (e.g., "EC2", "VPC")
164 library_path: Path to the icon library directory
165
166 Returns:
167 List of icon elements
168 """
169 icon_file = library_path / "icons" / f " { icon_name } .json"
170
171 if not icon_file.exists():
172 raise FileNotFoundError ( f "Icon file not found: { icon_file } " )
173
174 with open (icon_file, 'r' , encoding = 'utf-8' ) as f:
175 icon_data = json.load(f)
176
177 return icon_data.get( 'elements' , [])
178
179
180 def prepare_edit_path (diagram_path: Path, use_edit_suffix: bool ) -> tuple[Path, Path | None ]:
181 """
182 Prepare a safe edit path to avoid editor overwrite issues.
183
184 Returns:
185 (work_path, final_path)
186 - work_path: file path to read/write during edit
187 - final_path: file path to rename back to (or None if not used)
188 """
189 if not use_edit_suffix:
190 return diagram_path, None
191
192 if diagram_path.suffix != ".excalidraw" :
193 return diagram_path, None
194
195 edit_path = diagram_path.with_suffix(diagram_path.suffix + ".edit" )
196
197 if diagram_path.exists():
198 if edit_path.exists():
199 raise FileExistsError ( f "Edit file already exists: { edit_path } " )
200 diagram_path.rename(edit_path)
201
202 return edit_path, diagram_path
203
204
205 def finalize_edit_path (work_path: Path, final_path: Path | None ) -> None :
206 """Finalize edit by renaming .edit back to .excalidraw if needed."""
207 if final_path is None :
208 return
209
210 if final_path.exists():
211 final_path.unlink()
212
213 work_path.rename(final_path)
214
215
216 def create_text_label (text: str , x: float , y: float ) -> Dict[ str , Any]:
217 """
218 Create a text label element.
219
220 Args:
221 text: Label text
222 x: X coordinate
223 y: Y coordinate
224
225 Returns:
226 Text element dictionary
227 """
228 return {
229 "id" : generate_unique_id(),
230 "type" : "text" ,
231 "x" : x,
232 "y" : y,
233 "width" : len (text) * 10 , # Approximate width
234 "height" : 20 ,
235 "angle" : 0 ,
236 "strokeColor" : "#1e1e1e" ,
237 "backgroundColor" : "transparent" ,
238 "fillStyle" : "solid" ,
239 "strokeWidth" : 2 ,
240 "strokeStyle" : "solid" ,
241 "roughness" : 1 ,
242 "opacity" : 100 ,
243 "groupIds" : [],
244 "frameId" : None ,
245 "index" : "a0" ,
246 "roundness" : None ,
247 "seed" : 1000000000 + hash (text) % 1000000000 ,
248 "version" : 1 ,
249 "versionNonce" : 2000000000 + hash (text) % 1000000000 ,
250 "isDeleted" : False ,
251 "boundElements" : [],
252 "updated" : 1738195200000 ,
253 "link" : None ,
254 "locked" : False ,
255 "text" : text,
256 "fontSize" : 16 ,
257 "fontFamily" : 5 , # Excalifont
258 "textAlign" : "center" ,
259 "verticalAlign" : "top" ,
260 "containerId" : None ,
261 "originalText" : text,
262 "autoResize" : True ,
263 "lineHeight" : 1.25
264 }
265
266
267 def add_icon_to_diagram (
268 diagram_path: Path,
269 icon_name: str ,
270 x: float ,
271 y: float ,
272 library_path: Path,
273 label: str = None
274 ) -> None :
275 """
276 Add an icon to an Excalidraw diagram.
277
278 Args:
279 diagram_path: Path to the Excalidraw diagram file
280 icon_name: Name of the icon to add
281 x: Target X coordinate
282 y: Target Y coordinate
283 library_path: Path to the icon library directory
284 label: Optional text label to add below the icon
285 """
286 # Load icon elements
287 print ( f "Loading icon: { icon_name } " )
288 icon_elements = load_icon(icon_name, library_path)
289 print ( f " Loaded { len (icon_elements) } elements" )
290
291 # Transform icon elements
292 print ( f "Transforming to position ( { x } , { y } )" )
293 transformed_elements = transform_icon_elements(icon_elements, x, y)
294
295 # Calculate icon bounding box for label positioning
296 if label and transformed_elements:
297 min_x, min_y, max_x, max_y = calculate_bounding_box(transformed_elements)
298 icon_width = max_x - min_x
299 icon_height = max_y - min_y
300
301 # Position label below icon, centered
302 label_x = min_x + (icon_width / 2 ) - ( len (label) * 5 )
303 label_y = max_y + 10
304
305 label_element = create_text_label(label, label_x, label_y)
306 transformed_elements.append(label_element)
307 print ( f " Added label: ' { label } '" )
308
309 # Load diagram
310 print ( f "Loading diagram: { diagram_path } " )
311 with open (diagram_path, 'r' , encoding = 'utf-8' ) as f:
312 diagram = json.load(f)
313
314 # Add transformed elements
315 if 'elements' not in diagram:
316 diagram[ 'elements' ] = []
317
318 original_count = len (diagram[ 'elements' ])
319 diagram[ 'elements' ].extend(transformed_elements)
320 print ( f " Added { len (transformed_elements) } elements (total: { original_count } -> { len (diagram[ 'elements' ]) } )" )
321
322 # Save diagram
323 print ( f "Saving diagram" )
324 with open (diagram_path, 'w' , encoding = 'utf-8' ) as f:
325 json.dump(diagram, f, indent = 2 , ensure_ascii = False )
326
327 print ( f "✓ Successfully added ' { icon_name } ' icon to diagram" )
328
329
330 def main ():
331 """Main entry point."""
332 if hasattr (sys.stdout, "reconfigure" ):
333 # Ensure consistent UTF-8 output on Windows consoles.
334 sys.stdout.reconfigure( encoding = "utf-8" )
335 if len (sys.argv) < 5 :
336 print ( "Usage: python add-icon-to-diagram.py <diagram_path> <icon_name> <x> <y> [OPTIONS]" )
337 print ( " \n Options:" )
338 print ( " --library-path PATH Path to icon library directory" )
339 print ( " --label TEXT Add text label below icon" )
340 print ( " --use-edit-suffix Edit via .excalidraw.edit to avoid editor overwrite issues (enabled by default; use --no-use-edit-suffix to disable)" )
341 print ( " \n Examples:" )
342 print ( " python add-icon-to-diagram.py diagram.excalidraw EC2 500 300" )
343 print ( " python add-icon-to-diagram.py diagram.excalidraw EC2 500 300 --label 'Web Server'" )
344 sys.exit( 1 )
345
346 diagram_path = Path(sys.argv[ 1 ])
347 icon_name = sys.argv[ 2 ]
348 x = float (sys.argv[ 3 ])
349 y = float (sys.argv[ 4 ])
350
351 # Default library path
352 script_dir = Path( __file__ ).parent
353 default_library_path = script_dir.parent / "libraries" / "aws-architecture-icons"
354
355 # Parse optional arguments
356 library_path = default_library_path
357 label = None
358 # Default: use edit suffix to avoid editor overwrite issues
359 use_edit_suffix = True
360
361 i = 5
362 while i < len (sys.argv):
363 if sys.argv[i] == '--library-path' :
364 if i + 1 < len (sys.argv):
365 library_path = Path(sys.argv[i + 1 ])
366 i += 2
367 else :
368 print ( "Error: --library-path requires a path argument" )
369 sys.exit( 1 )
370 elif sys.argv[i] == '--label' :
371 if i + 1 < len (sys.argv):
372 label = sys.argv[i + 1 ]
373 i += 2
374 else :
375 print ( "Error: --label requires a text argument" )
376 sys.exit( 1 )
377 elif sys.argv[i] == '--use-edit-suffix' :
378 use_edit_suffix = True
379 i += 1
380 elif sys.argv[i] == '--no-use-edit-suffix' :
381 use_edit_suffix = False
382 i += 1
383 else :
384 print ( f "Error: Unknown option: { sys.argv[i] } " )
385 sys.exit( 1 )
386
387 # Validate inputs
388 if not diagram_path.exists():
389 print ( f "Error: Diagram file not found: { diagram_path } " )
390 sys.exit( 1 )
391
392 if not library_path.exists():
393 print ( f "Error: Library path not found: { library_path } " )
394 sys.exit( 1 )
395
396 try :
397 work_path, final_path = prepare_edit_path(diagram_path, use_edit_suffix)
398 add_icon_to_diagram(work_path, icon_name, x, y, library_path, label)
399 finalize_edit_path(work_path, final_path)
400 except Exception as e:
401 print ( f "Error: { e } " )
402 sys.exit( 1 )
403
404
405 if __name__ == '__main__' :
406 main()
407