SVG2DrawIOLib 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,70 @@
1
+ """List command - List all icons in a DrawIO library."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import rich_click as rc
8
+ from rich.table import Table
9
+
10
+ from SVG2DrawIOLib.cli.helpers import console, setup_logging
11
+ from SVG2DrawIOLib.library_manager import LibraryManager
12
+
13
+
14
+ @rc.command(name="list")
15
+ @rc.argument(
16
+ "library_file",
17
+ type=rc.Path(exists=True, dir_okay=False, path_type=Path),
18
+ )
19
+ @rc.option(
20
+ "--verbose",
21
+ "-v",
22
+ is_flag=True,
23
+ help="Enable verbose debug logging.",
24
+ )
25
+ @rc.option(
26
+ "--quiet",
27
+ "-q",
28
+ is_flag=True,
29
+ help="Suppress all output except errors.",
30
+ )
31
+ def list(
32
+ library_file: Path,
33
+ verbose: bool,
34
+ quiet: bool,
35
+ ) -> None:
36
+ """List all icons in a DrawIO library.
37
+
38
+ \b
39
+ Displays all icon names in the specified library file.
40
+
41
+ Example:
42
+ List all icons:
43
+ $ SVG2DrawIOLib list my-library.xml
44
+ """
45
+ setup_logging(verbose, quiet)
46
+ logger = logging.getLogger(__name__)
47
+
48
+ try:
49
+ manager = LibraryManager()
50
+ icon_names = manager.list_icons(library_file)
51
+
52
+ if not icon_names:
53
+ console.print(f"[yellow]Library is empty:[/yellow] {library_file}")
54
+ return
55
+
56
+ # Create a nice table
57
+ table = Table(title=f"Icons in {library_file.name}", show_header=True)
58
+ table.add_column("Icon Name", style="cyan")
59
+
60
+ for name in sorted(icon_names):
61
+ table.add_row(name)
62
+
63
+ console.print(table)
64
+ console.print(f"\n[green]Total:[/green] {len(icon_names)} icon(s)")
65
+
66
+ except Exception as e:
67
+ logger.error(f"Error: {e}")
68
+ if verbose:
69
+ raise
70
+ sys.exit(1)
@@ -0,0 +1,71 @@
1
+ """Remove command - Remove icons from a DrawIO library by name."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import rich_click as rc
8
+
9
+ from SVG2DrawIOLib.cli.helpers import console, setup_logging
10
+ from SVG2DrawIOLib.library_manager import LibraryManager
11
+
12
+
13
+ @rc.command()
14
+ @rc.argument(
15
+ "library_file",
16
+ type=rc.Path(exists=True, dir_okay=False, path_type=Path),
17
+ )
18
+ @rc.argument("icon_names", nargs=-1, required=True, metavar="ICON_NAMES...")
19
+ @rc.option(
20
+ "--verbose",
21
+ "-v",
22
+ is_flag=True,
23
+ help="Enable verbose debug logging.",
24
+ )
25
+ @rc.option(
26
+ "--quiet",
27
+ "-q",
28
+ is_flag=True,
29
+ help="Suppress all output except errors.",
30
+ )
31
+ def remove(
32
+ library_file: Path,
33
+ icon_names: tuple[str, ...],
34
+ verbose: bool,
35
+ quiet: bool,
36
+ ) -> None:
37
+ """Remove icons from a DrawIO library by name.
38
+
39
+ Removes one or more icons from an existing library file.
40
+
41
+ Examples:
42
+ Remove single icon:
43
+ $ SVG2DrawIOLib remove my-library.xml old-icon
44
+
45
+
46
+ Remove multiple icons:
47
+ $ SVG2DrawIOLib remove my-library.xml icon1 icon2 icon3
48
+ """
49
+ setup_logging(verbose, quiet)
50
+ logger = logging.getLogger(__name__)
51
+
52
+ try:
53
+ manager = LibraryManager()
54
+ metadata, removed_count = manager.remove_icons_from_library(library_file, list(icon_names))
55
+
56
+ if removed_count == 0:
57
+ console.print(
58
+ "[yellow]Warning:[/yellow] No icons were removed. "
59
+ "Requested icons not found in library."
60
+ )
61
+ else:
62
+ console.print(
63
+ f"[green]✓[/green] Removed {removed_count} icon(s). "
64
+ f"Library now has {metadata.icon_count} icon(s): [cyan]{library_file}[/cyan]"
65
+ )
66
+
67
+ except Exception as e:
68
+ logger.error(f"Error: {e}")
69
+ if verbose:
70
+ raise
71
+ sys.exit(1)
@@ -0,0 +1,269 @@
1
+ """DrawIO library management functionality."""
2
+
3
+ import json
4
+ import logging
5
+ import xml.etree.ElementTree as ET
6
+ from pathlib import Path
7
+
8
+ from SVG2DrawIOLib.models import DrawIOIcon, LibraryMetadata
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class LibraryManager:
14
+ """Manages DrawIO library files."""
15
+
16
+ def __init__(self) -> None:
17
+ """Initialize the library manager."""
18
+ pass
19
+
20
+ def create_library(self, icons: list[DrawIOIcon], output_path: Path) -> LibraryMetadata:
21
+ """Create a new DrawIO library from icons.
22
+
23
+ Args:
24
+ icons: List of DrawIO icons to include.
25
+ output_path: Path where the library file will be saved.
26
+
27
+ Returns:
28
+ LibraryMetadata with information about the created library.
29
+ """
30
+ logger.info(f"Creating library with {len(icons)} icon(s)")
31
+
32
+ # Sort icons alphabetically by name
33
+ sorted_icons = sorted(icons, key=lambda icon: icon.name)
34
+
35
+ # Convert to library JSON format
36
+ library_data = [icon.to_dict() for icon in sorted_icons]
37
+ library_json = json.dumps(library_data)
38
+
39
+ # Create XML structure
40
+ mxlibrary = ET.Element("mxlibrary")
41
+ mxlibrary.text = library_json
42
+
43
+ tree = ET.ElementTree(mxlibrary)
44
+
45
+ # Write to file
46
+ tree.write(output_path, encoding="utf-8", xml_declaration=True)
47
+
48
+ logger.info(f"Library created successfully: {output_path}")
49
+
50
+ return LibraryMetadata(
51
+ name=output_path.stem,
52
+ icon_count=len(icons),
53
+ source_files=[],
54
+ )
55
+
56
+ def load_library(self, library_path: Path) -> list[DrawIOIcon]:
57
+ """Load icons from an existing DrawIO library.
58
+
59
+ Args:
60
+ library_path: Path to the library file.
61
+
62
+ Returns:
63
+ List of DrawIO icons from the library.
64
+
65
+ Raises:
66
+ FileNotFoundError: If the library file does not exist.
67
+ ValueError: If the library file is invalid.
68
+ """
69
+ if not library_path.exists():
70
+ logger.error(f"Library file not found: {library_path}")
71
+ raise FileNotFoundError(f"Library file not found: {library_path}")
72
+
73
+ logger.debug(f"Loading library: {library_path}")
74
+
75
+ try:
76
+ tree = ET.parse(library_path) # nosec B314 - User-provided library file, user controls input
77
+ root = tree.getroot()
78
+
79
+ if root.tag != "mxlibrary":
80
+ raise ValueError(
81
+ f"Invalid library file: root element is {root.tag}, expected mxlibrary"
82
+ )
83
+
84
+ if not root.text:
85
+ logger.warning("Library file is empty")
86
+ return []
87
+
88
+ library_data = json.loads(root.text)
89
+
90
+ icons = []
91
+ for item in library_data:
92
+ # Convert from library format back to DrawIOIcon
93
+ # Note: We need to re-encode the XML data
94
+ from SVG2DrawIOLib.models import SVGDimensions
95
+
96
+ icon = DrawIOIcon(
97
+ name=item["title"],
98
+ xml_data=item["xml"].encode("ascii"),
99
+ dimensions=SVGDimensions(width=item["w"], height=item["h"]),
100
+ )
101
+ icons.append(icon)
102
+
103
+ logger.info(f"Loaded {len(icons)} icon(s) from library")
104
+ return icons
105
+
106
+ except ET.ParseError as e:
107
+ logger.error(f"Failed to parse library file: {e}")
108
+ raise ValueError(f"Invalid library file: {e}") from e
109
+ except (json.JSONDecodeError, KeyError) as e:
110
+ logger.error(f"Invalid library format: {e}")
111
+ raise ValueError(f"Invalid library format: {e}") from e
112
+
113
+ def add_icons_to_library(
114
+ self,
115
+ library_path: Path,
116
+ new_icons: list[DrawIOIcon],
117
+ replace_duplicates: bool = False,
118
+ add_duplicates: bool = False,
119
+ ) -> LibraryMetadata:
120
+ """Add icons to an existing library.
121
+
122
+ Args:
123
+ library_path: Path to the existing library file.
124
+ new_icons: List of icons to add.
125
+ replace_duplicates: If True, replace icons with duplicate names.
126
+ add_duplicates: If True, add duplicates with modified names (e.g., icon_2, icon_3).
127
+
128
+ Returns:
129
+ LibraryMetadata with updated library information.
130
+
131
+ Raises:
132
+ FileNotFoundError: If the library file does not exist.
133
+ ValueError: If the library file is invalid.
134
+ """
135
+ logger.info(f"Adding {len(new_icons)} icon(s) to library: {library_path}")
136
+
137
+ # Load existing icons
138
+ existing_icons = self.load_library(library_path)
139
+
140
+ # Check for duplicate names in existing icons and make them unique
141
+ existing_names = [icon.name for icon in existing_icons]
142
+ if len(existing_names) != len(set(existing_names)):
143
+ # Find duplicates and rename them
144
+ name_counts: dict[str, int] = {}
145
+ renamed_count = 0
146
+
147
+ for i, icon in enumerate(existing_icons):
148
+ if icon.name in name_counts:
149
+ # This is a duplicate - create unique name
150
+ name_counts[icon.name] += 1
151
+ counter = name_counts[icon.name]
152
+ new_name = f"{icon.name}_{counter}"
153
+
154
+ # Ensure the new name is also unique
155
+ while new_name in existing_names or new_name in name_counts:
156
+ counter += 1
157
+ new_name = f"{icon.name}_{counter}"
158
+
159
+ # Update name_counts to reflect the actual counter used
160
+ name_counts[icon.name] = counter
161
+
162
+ # Create new icon with unique name
163
+ existing_icons[i] = DrawIOIcon(
164
+ name=new_name,
165
+ xml_data=icon.xml_data,
166
+ dimensions=icon.dimensions,
167
+ )
168
+ renamed_count += 1
169
+ logger.debug(f"Renamed duplicate icon: {icon.name} -> {new_name}")
170
+ else:
171
+ name_counts[icon.name] = 1
172
+
173
+ logger.info(
174
+ f"Renamed {renamed_count} duplicate icon(s) to preserve all icons from library"
175
+ )
176
+
177
+ # Create a map of existing icons by name (now all unique)
178
+ icon_map = {icon.name: icon for icon in existing_icons}
179
+
180
+ # Add or replace icons
181
+ added_count = 0
182
+ replaced_count = 0
183
+ skipped_count = 0
184
+
185
+ for new_icon in new_icons:
186
+ if new_icon.name in icon_map:
187
+ if replace_duplicates:
188
+ icon_map[new_icon.name] = new_icon
189
+ replaced_count += 1
190
+ logger.debug(f"Replaced icon: {new_icon.name}")
191
+ elif add_duplicates:
192
+ # Find a unique name by appending _2, _3, etc.
193
+ base_name = new_icon.name
194
+ counter = 2
195
+ unique_name = f"{base_name}_{counter}"
196
+ while unique_name in icon_map:
197
+ counter += 1
198
+ unique_name = f"{base_name}_{counter}"
199
+
200
+ # Create new icon with unique name
201
+ unique_icon = DrawIOIcon(
202
+ name=unique_name,
203
+ xml_data=new_icon.xml_data,
204
+ dimensions=new_icon.dimensions,
205
+ )
206
+ icon_map[unique_name] = unique_icon
207
+ added_count += 1
208
+ logger.debug(f"Added duplicate icon with modified name: {unique_name}")
209
+ else:
210
+ skipped_count += 1
211
+ logger.debug(f"Skipped duplicate icon: {new_icon.name}")
212
+ else:
213
+ icon_map[new_icon.name] = new_icon
214
+ added_count += 1
215
+ logger.debug(f"Added icon: {new_icon.name}")
216
+
217
+ logger.info(f"Added: {added_count}, Replaced: {replaced_count}, Skipped: {skipped_count}")
218
+
219
+ # Save updated library
220
+ all_icons = list(icon_map.values())
221
+ return self.create_library(all_icons, library_path)
222
+
223
+ def remove_icons_from_library(
224
+ self, library_path: Path, icon_names: list[str]
225
+ ) -> tuple[LibraryMetadata, int]:
226
+ """Remove icons from a library by name.
227
+
228
+ Args:
229
+ library_path: Path to the library file.
230
+ icon_names: List of icon names to remove.
231
+
232
+ Returns:
233
+ Tuple of (LibraryMetadata with updated library information, number of icons actually removed).
234
+
235
+ Raises:
236
+ FileNotFoundError: If the library file does not exist.
237
+ ValueError: If the library file is invalid.
238
+ """
239
+ logger.info(f"Removing {len(icon_names)} icon(s) from library: {library_path}")
240
+
241
+ # Load existing icons
242
+ existing_icons = self.load_library(library_path)
243
+
244
+ # Filter out icons to remove
245
+ names_to_remove = set(icon_names)
246
+ filtered_icons = [icon for icon in existing_icons if icon.name not in names_to_remove]
247
+
248
+ removed_count = len(existing_icons) - len(filtered_icons)
249
+ logger.info(f"Removed {removed_count} icon(s)")
250
+
251
+ # Save updated library
252
+ metadata = self.create_library(filtered_icons, library_path)
253
+ return metadata, removed_count
254
+
255
+ def list_icons(self, library_path: Path) -> list[str]:
256
+ """List all icon names in a library.
257
+
258
+ Args:
259
+ library_path: Path to the library file.
260
+
261
+ Returns:
262
+ List of icon names.
263
+
264
+ Raises:
265
+ FileNotFoundError: If the library file does not exist.
266
+ ValueError: If the library file is invalid.
267
+ """
268
+ icons = self.load_library(library_path)
269
+ return [icon.name for icon in icons]
@@ -0,0 +1,129 @@
1
+ """Data models for SVG to DrawIO conversion."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class SVGDimensions:
9
+ """Dimensions for an SVG shape in DrawIO.
10
+
11
+ Attributes:
12
+ width: Width in pixels.
13
+ height: Height in pixels.
14
+ """
15
+
16
+ width: float
17
+ height: float
18
+
19
+ @classmethod
20
+ def from_max_dimension(cls, max_dimension: float, aspect_ratio: float) -> "SVGDimensions":
21
+ """Create dimensions from a maximum dimension and aspect ratio.
22
+
23
+ Scales dimensions so the longest side equals max_dimension while
24
+ maintaining the aspect ratio.
25
+
26
+ Args:
27
+ max_dimension: Maximum dimension (width or height) in pixels.
28
+ aspect_ratio: Width/height ratio.
29
+
30
+ Returns:
31
+ SVGDimensions with scaled width and height.
32
+
33
+ Example:
34
+ >>> # For a 100x50 image with max_dimension=40
35
+ >>> dims = SVGDimensions.from_max_dimension(40, 2.0)
36
+ >>> dims.width, dims.height
37
+ (40.0, 20.0)
38
+ """
39
+ if aspect_ratio >= 1.0:
40
+ # Width is longer
41
+ return cls(width=max_dimension, height=max_dimension / aspect_ratio)
42
+ else:
43
+ # Height is longer
44
+ return cls(width=max_dimension * aspect_ratio, height=max_dimension)
45
+
46
+ @classmethod
47
+ def from_fixed_dimensions(cls, width: float, height: float) -> "SVGDimensions":
48
+ """Create dimensions from fixed width and height.
49
+
50
+ Args:
51
+ width: Width in pixels.
52
+ height: Height in pixels.
53
+
54
+ Returns:
55
+ SVGDimensions with specified dimensions.
56
+ """
57
+ return cls(width=width, height=height)
58
+
59
+
60
+ @dataclass
61
+ class DrawIOIcon:
62
+ """Represents a single icon in a DrawIO library.
63
+
64
+ Attributes:
65
+ name: Icon name (typically filename without extension).
66
+ xml_data: Compressed and encoded XML data.
67
+ dimensions: Icon dimensions.
68
+ """
69
+
70
+ name: str
71
+ xml_data: bytes
72
+ dimensions: SVGDimensions
73
+
74
+ def to_dict(self) -> dict[str, str | int | float]:
75
+ """Convert to DrawIO library JSON format.
76
+
77
+ Returns:
78
+ Dictionary with DrawIO library format fields.
79
+ """
80
+ return {
81
+ "xml": self.xml_data.decode("ascii"),
82
+ "w": int(self.dimensions.width),
83
+ "h": int(self.dimensions.height),
84
+ "aspect": "fixed",
85
+ "title": self.name,
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class SVGProcessingOptions:
91
+ """Options for processing SVG files.
92
+
93
+ Attributes:
94
+ add_css: Whether to add CSS classes for color editing.
95
+ css_color: Default CSS fill color.
96
+ xml_namespace: XML namespace for SVG elements.
97
+ css_tag: XML tag to add CSS classes to.
98
+ """
99
+
100
+ add_css: bool = False
101
+ css_color: str = "#000000"
102
+ xml_namespace: str = "http://www.w3.org/2000/svg"
103
+ css_tag: str = "path"
104
+
105
+ @property
106
+ def namespaced_tag(self) -> str:
107
+ """Get the CSS tag with namespace prefix.
108
+
109
+ Returns:
110
+ Tag name with namespace in Clark notation.
111
+ """
112
+ if self.xml_namespace:
113
+ return f"{{{self.xml_namespace}}}{self.css_tag}"
114
+ return self.css_tag
115
+
116
+
117
+ @dataclass
118
+ class LibraryMetadata:
119
+ """Metadata for a DrawIO library.
120
+
121
+ Attributes:
122
+ name: Library name.
123
+ icon_count: Number of icons in the library.
124
+ source_files: List of source SVG files.
125
+ """
126
+
127
+ name: str
128
+ icon_count: int
129
+ source_files: list[Path] = field(default_factory=list)