Skip to content

Instantly share code, notes, and snippets.

@trinityhades
Forked from revolunet/glyphes.py
Last active November 4, 2025 20:44
Show Gist options
  • Select an option

  • Save trinityhades/8973653f9f2c72ccf2f879c86578ad54 to your computer and use it in GitHub Desktop.

Select an option

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.
# -*- 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