-
-
Save trinityhades/8973653f9f2c72ccf2f879c86578ad54 to your computer and use it in GitHub Desktop.
Extract shapes from a single SVG to multiple files. good to create glyphes from a single file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- encoding: UTF-8 -*- | |
| ''' | |
| SVG Element Extractor - Extract individual components from SVG files. | |
| Based on github.com/revolunet/glyphes.py | |
| Extracts all first-level elements (groups and paths) from an SVG file into separate, | |
| properly scaled and centered SVG files. Automatically splits compound paths with | |
| multiple M commands into individual elements. | |
| Supports: Line, Rect, Circle, Polyline, Polygon, Path, and Groups | |
| Features: Automatic scaling, centering with padding, compound path splitting, | |
| customizable output size, and color-based naming | |
| Usage: python svgextract.py [input.svg] [--no-split] [--width 500] | |
| ''' | |
| import os | |
| import re | |
| import sys | |
| from xml.etree import ElementTree | |
| ROOT_PATH = os.path.abspath(os.path.dirname(__file__)) | |
| SVG_NS = "http://www.w3.org/2000/svg" | |
| class Tag(object): | |
| x_attr = 'x' | |
| y_attr = 'y' | |
| def __init__(self, element): | |
| self.element = element | |
| def _get_x(self): | |
| return self.element.attrib.get(self.x_attr) | |
| def _get_y(self): | |
| return self.element.attrib.get(self.y_attr) | |
| @property | |
| def width(self): | |
| raise NotImplementedError | |
| @property | |
| def height(self): | |
| raise NotImplementedError | |
| @property | |
| def x(self): | |
| return float(self._get_x()) | |
| @property | |
| def y(self): | |
| return float(self._get_y()) | |
| def update(self, min_x, min_y): | |
| self.element.set(self.x_attr, str(self.x - min_x)) | |
| self.element.set(self.y_attr, str(self.y - min_y)) | |
| class Rect(Tag): | |
| @property | |
| def width(self): | |
| return float(self.element.attrib.get('width')) | |
| @property | |
| def height(self): | |
| return float(self.element.attrib.get('height')) | |
| class Circle(Tag): | |
| x_attr = 'cx' | |
| y_attr = 'cy' | |
| def _get_x(self): | |
| return float(self.element.attrib.get(self.x_attr)) - float(self.element.attrib.get('r')) | |
| def _get_y(self): | |
| return float(self.element.attrib.get(self.y_attr)) - float(self.element.attrib.get('r')) | |
| def update(self, min_x, min_y): | |
| self.element.set(self.x_attr, str(float(self.element.attrib.get(self.x_attr)) - min_x)) | |
| self.element.set(self.y_attr, str(float(self.element.attrib.get(self.y_attr)) - min_y)) | |
| @property | |
| def width(self): | |
| return float(self.element.attrib.get('r')) * 2 | |
| @property | |
| def height(self): | |
| return float(self.element.attrib.get('r')) * 2 | |
| class Polygon(Tag): | |
| def _get_points(self): | |
| return re.split('\s+', self.element.attrib.get('points').strip()) | |
| def _get_x(self): | |
| min_x = float("inf") | |
| for coord in self._get_points(): | |
| x, _ = coord.split(',') | |
| min_x = min(min_x, float(x)) | |
| return min_x | |
| def _get_y(self): | |
| min_y = float("inf") | |
| for coord in self._get_points(): | |
| _, y = coord.split(',') | |
| min_y = min(min_y, float(y)) | |
| return min_y | |
| def update(self, min_x, min_y): | |
| new_points = [] | |
| for coord in self._get_points(): | |
| x, y = coord.split(',') | |
| new_points.append('{0},{1}'.format( | |
| float(x) - min_x, | |
| float(y) - min_y | |
| )) | |
| self.element.set('points', ' '.join(new_points)) | |
| def _get_size(self): | |
| min_x = float('inf') | |
| max_x = float('-inf') | |
| min_y = float('inf') | |
| max_y = float('-inf') | |
| height = 0 | |
| for point in self._get_points(): | |
| x, y = map(lambda a: float(a), point.split(',')) | |
| min_x = min(min_x, x) | |
| max_x = max(max_x, x) | |
| min_y = min(min_y, y) | |
| max_y = max(max_y, y) | |
| width = max_x - min_x | |
| height = max_y - min_y | |
| return width, height | |
| @property | |
| def width(self): | |
| return self._get_size()[0] | |
| @property | |
| def height(self): | |
| return self._get_size()[1] | |
| class Path(Tag): | |
| REGEXP = r'[mM]([-\d.]+,[-\d.]+)' | |
| def _get_start(self): | |
| d = self.element.attrib.get('d', '') | |
| match = re.search(self.REGEXP, d) | |
| if match: | |
| return match.group(1) | |
| return '0,0' | |
| def _get_x(self): | |
| d = self.element.attrib.get('d', '') | |
| coords = re.findall(r'[-]?\d+\.?\d*', d) | |
| if coords: | |
| xs = [float(coords[i]) for i in range(0, len(coords), 2)] | |
| return min(xs) | |
| return 0 | |
| def _get_y(self): | |
| d = self.element.attrib.get('d', '') | |
| coords = re.findall(r'[-]?\d+\.?\d*', d) | |
| if coords: | |
| ys = [float(coords[i]) for i in range(1, len(coords), 2)] | |
| return min(ys) | |
| return 0 | |
| def update(self, min_x, min_y): | |
| d = self.element.attrib.get('d', '') | |
| numbers = re.findall(r'[-]?\d+\.?\d*', d) | |
| new_numbers = [] | |
| for i, num in enumerate(numbers): | |
| f = float(num) | |
| if i % 2 == 0: | |
| f -= min_x | |
| else: | |
| f -= min_y | |
| new_numbers.append(str(f)) | |
| new_d = re.sub(r'[-]?\d+\.?\d*', lambda m: new_numbers.pop(0) if new_numbers else m.group(0), d) | |
| self.element.set('d', new_d) | |
| def get_size(self): | |
| d = self.element.attrib.get('d', '') | |
| coords = re.findall(r'[-]?\d+\.?\d*', d) | |
| if not coords: | |
| return 0, 0 | |
| coords = [float(c) for c in coords] | |
| xs = coords[::2] | |
| ys = coords[1::2] | |
| if xs and ys: | |
| width = max(xs) - min(xs) | |
| height = max(ys) - min(ys) | |
| return width, height | |
| return 0, 0 | |
| @property | |
| def width(self): | |
| return self.get_size()[0] | |
| @property | |
| def height(self): | |
| return self.get_size()[1] | |
| class Line(Tag): | |
| def _get_x(self): | |
| min_x = min(float(self.element.attrib.get('x1')), float(self.element.attrib.get('x2'))) | |
| return min_x | |
| def _get_y(self): | |
| min_y = min(float(self.element.attrib.get('y1')), float(self.element.attrib.get('y2'))) | |
| return min_y | |
| def update(self, min_x, min_y): | |
| x1 = str(float(self.element.attrib['x1']) - min_x) | |
| y1 = str(float(self.element.attrib['y1']) - min_y) | |
| x2 = str(float(self.element.attrib['x2']) - min_x) | |
| y2 = str(float(self.element.attrib['y2']) - min_y) | |
| self.element.set('x1', x1) | |
| self.element.set('x2', x2) | |
| self.element.set('y1', y1) | |
| self.element.set('y2', y2) | |
| @property | |
| def width(self): | |
| return float(self.element.attrib.get('x2')) - float(self.element.attrib.get('x1')) | |
| @property | |
| def height(self): | |
| return float(self.element.attrib.get('y2')) - float(self.element.attrib.get('y1')) | |
| class Group(object): | |
| def __init__(self, element, output_width=350): | |
| self.element = element | |
| self.tag_id = element.attrib.get('id') or 'extracted' | |
| self.output_width = output_width | |
| self.paths = [] | |
| self._init_paths() | |
| self.update_paths() | |
| def _init_paths(self): | |
| ''' | |
| convert group path to local objects | |
| ''' | |
| self.paths = [] | |
| tag_name = self.element.tag.replace('{' + SVG_NS + '}', '') | |
| if tag_name == 'g': | |
| elements = self.element.findall('./*') | |
| else: | |
| elements = [self.element] | |
| for path in elements: | |
| tag = path.tag.replace('{' + SVG_NS + '}', '') | |
| tag2 = None | |
| if tag == 'circle': | |
| tag2 = Circle(path) | |
| elif tag == 'path': | |
| tag2 = Path(path) | |
| elif tag == 'line': | |
| tag2 = Line(path) | |
| elif tag == 'rect': | |
| tag2 = Rect(path) | |
| elif tag in ['polyline', 'polygon']: | |
| tag2 = Polygon(path) | |
| if tag2: | |
| self.paths.append(tag2) | |
| def update_paths(self): | |
| ''' | |
| compute min x, min y, width, height, scale and reposition the paths | |
| to fit and center in the output canvas | |
| ''' | |
| if not self.paths: | |
| return | |
| # Find bounding box of all paths | |
| min_x = float("inf") | |
| min_y = float("inf") | |
| max_x = float("-inf") | |
| max_y = float("-inf") | |
| for path in self.paths: | |
| min_x = min(min_x, path.x) | |
| min_y = min(min_y, path.y) | |
| max_x = max(max_x, path.x + path.width) | |
| max_y = max(max_y, path.y + path.height) | |
| # Calculate actual content dimensions | |
| content_width = max_x - min_x | |
| content_height = max_y - min_y | |
| if content_width <= 0 or content_height <= 0: | |
| return | |
| # Calculate scale to fit in output canvas with some padding | |
| padding = 20 # pixels of padding on each side | |
| available_width = self.output_width - (2 * padding) | |
| available_height = self.output_width - (2 * padding) | |
| self.scale = min(available_width / content_width, available_height / content_height) | |
| # Calculate offset to center the scaled content | |
| scaled_width = content_width * self.scale | |
| scaled_height = content_height * self.scale | |
| offset_x = (self.output_width - scaled_width) / 2 | |
| offset_y = (self.output_width - scaled_height) / 2 | |
| # Store these for use in coordinate transformation | |
| self.min_x = min_x | |
| self.min_y = min_y | |
| self.offset_x = offset_x | |
| self.offset_y = offset_y | |
| def as_svg(self): | |
| svg = [] | |
| # Build the transform string for scaling and positioning | |
| transform = 'translate({0},{1}) scale({2})'.format( | |
| self.offset_x, | |
| self.offset_y, | |
| self.scale | |
| ) | |
| for path in self.paths: | |
| # Get the element as string and apply transformations | |
| elem_str = ElementTree.tostring(path.element).replace('ns0:', '') | |
| # Update coordinates to be relative to min_x, min_y | |
| svg.append(elem_str) | |
| return """<svg width="{0}" height="{0}" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> | |
| <g id="{1}" transform="translate({2},{3}) scale({4})"> | |
| {5} | |
| </g> | |
| </svg>""".format( | |
| self.output_width, | |
| self.tag_id, | |
| self.offset_x - self.min_x * self.scale, | |
| self.offset_y - self.min_y * self.scale, | |
| self.scale, | |
| ''.join(svg) | |
| ) | |
| def split_compound_path(path_element): | |
| ''' | |
| Split a path with multiple M commands into separate path elements | |
| ''' | |
| d = path_element.attrib.get('d', '') | |
| # Split on M commands (case-insensitive) but keep them using lookahead | |
| # This will split before each M/m command | |
| parts = re.split(r'\s*(?=[Mm]\s*[-\d])', d) | |
| parts = [p.strip() for p in parts if p.strip() and re.match(r'[Mm]', p.strip())] | |
| if len(parts) <= 1: | |
| return [path_element] | |
| # Create separate path elements | |
| paths = [] | |
| for i, part in enumerate(parts): | |
| new_path = ElementTree.Element(path_element.tag, attrib=dict(path_element.attrib)) | |
| # Ensure the path starts with M (absolute) for proper positioning | |
| if part.strip().startswith('m') and i > 0: | |
| # Keep relative m commands as they are | |
| new_path.set('d', part) | |
| else: | |
| new_path.set('d', part) | |
| paths.append(new_path) | |
| return paths | |
| def convertGroup(group): | |
| tags = [] | |
| if group.tag.replace('{' + SVG_NS + '}', '') != 'g': | |
| group = [group] | |
| for path in group: | |
| tag = path.tag.replace('{' + SVG_NS + '}', '') | |
| tag2 = None | |
| if tag == 'circle': | |
| tag2 = Circle(path) | |
| elif tag == 'path': | |
| tag2 = Path(path) | |
| elif tag == 'line': | |
| tag2 = Line(path) | |
| elif tag == 'rect': | |
| tag2 = Rect(path) | |
| elif tag in ['polyline', 'polygon']: | |
| tag2 = Polygon(path) | |
| if tag2: | |
| tags.append(tag2) | |
| return Group(tags) | |
| if __name__=='__main__': | |
| # ===== CONFIGURATION ===== | |
| # Can be overridden via command line: python svgextract.py [input.svg] [--no-split] [--width 500] | |
| source = os.path.join(ROOT_PATH, 'WallpaperExtractorIcon.svg') | |
| split_compound = True # Split paths with multiple M commands into separate files | |
| output_dir = ROOT_PATH # Output directory (default: same as script) | |
| output_width = 350 # Width/height of extracted SVG files | |
| # Parse command line arguments | |
| if len(sys.argv) > 1 and not sys.argv[1].startswith('--'): | |
| source = sys.argv[1] | |
| if '--no-split' in sys.argv: | |
| split_compound = False | |
| if '--width' in sys.argv: | |
| try: | |
| width_idx = sys.argv.index('--width') | |
| output_width = int(sys.argv[width_idx + 1]) | |
| except (IndexError, ValueError): | |
| print('Warning: Invalid --width value, using default {0}'.format(output_width)) | |
| # ========================= | |
| if not os.path.exists(source): | |
| print('Error: Source file not found: {0}'.format(source)) | |
| sys.exit(1) | |
| print('Extracting from: {0}'.format(source)) | |
| print('Split compound paths: {0}'.format(split_compound)) | |
| print('Output size: {0}x{0}px'.format(output_width)) | |
| print('') | |
| svg = ElementTree.parse(source) | |
| svg = svg.getroot() | |
| # extract all first level elements | |
| counter = 0 | |
| for idx, element in enumerate(svg.findall('./*')): | |
| tag = element.tag.replace('{' + SVG_NS + '}', '') | |
| # Split compound paths if requested | |
| elements_to_process = [element] | |
| if split_compound and tag == 'path': | |
| elements_to_process = split_compound_path(element) | |
| for sub_idx, sub_element in enumerate(elements_to_process): | |
| # convert to svg and write iter(collection) | |
| transformer = Group(sub_element, output_width=output_width) | |
| # Generate descriptive filename | |
| element_id = sub_element.attrib.get('id') | |
| if not element_id: | |
| fill_color = sub_element.attrib.get('fill', 'none').replace('#', '') | |
| if len(elements_to_process) > 1: | |
| element_id = 'element_{0}_{1}_{2}'.format(idx, sub_idx, fill_color) | |
| else: | |
| element_id = 'element_{0}_{1}'.format(idx, fill_color) | |
| filename = '{0}.svg'.format(element_id) | |
| outfile = os.path.join(output_dir, filename) | |
| with open(outfile, 'w') as f: | |
| f.write(transformer.as_svg()) | |
| print('[+] extracted', filename) | |
| counter += 1 | |
| print('\nTotal extracted: {0} SVG files'.format(counter)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment