Skip to content

Instantly share code, notes, and snippets.

@matteohoeren
Created December 20, 2025 15:33
Show Gist options
  • Select an option

  • Save matteohoeren/5822f2d7c1419d990244676acfcb6baa to your computer and use it in GitHub Desktop.

Select an option

Save matteohoeren/5822f2d7c1419d990244676acfcb6baa to your computer and use it in GitHub Desktop.
SVG Cleaner for Lasersaur
#!/usr/bin/env python3
"""
SVG Cleaner for Driveboard Laser Cutter
Removes Inkscape metadata and problematic characters from SVG files
"""
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Tuple
class SVGCleaner:
def __init__(self):
# Namespaces to remove
self.problematic_namespaces = [
'inkscape',
'sodipodi'
]
# Character replacements (German umlauts and special chars)
self.char_replacements = {
'ä': 'ae',
'ö': 'oe',
'ü': 'ue',
'Ä': 'Ae',
'Ö': 'Oe',
'Ü': 'Ue',
'ß': 'ss',
'é': 'e',
'è': 'e',
'ê': 'e',
'á': 'a',
'à': 'a',
'â': 'a',
'ó': 'o',
'ò': 'o',
'ô': 'o',
'ú': 'u',
'ù': 'u',
'û': 'u',
'í': 'i',
'ì': 'i',
'î': 'i',
' ': '_', # Replace spaces with underscores
}
def clean_filename(self, filename: str) -> str:
"""Remove problematic characters from filename"""
name = filename
for old_char, new_char in self.char_replacements.items():
name = name.replace(old_char, new_char)
# Remove any remaining non-ASCII characters
name = name.encode('ascii', 'ignore').decode('ascii')
# Remove multiple underscores
name = re.sub(r'_+', '_', name)
return name
def clean_svg_content(self, svg_path: Path) -> Tuple[str, list]:
"""Clean SVG file content and return cleaned XML string and list of changes"""
changes = []
# Read the file
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace problematic characters in content
original_content = content
for old_char, new_char in self.char_replacements.items():
if old_char != ' ': # Don't replace spaces in content
count = content.count(old_char)
if count > 0:
content = content.replace(old_char, new_char)
changes.append(f"Replaced {count} occurrence(s) of '{old_char}' with '{new_char}'")
# Parse XML
try:
# Register namespaces to preserve them during parsing
namespaces = {
'svg': 'http://www.w3.org/2000/svg',
'inkscape': 'http://www.inkscape.org/namespaces/inkscape',
'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
}
for prefix, uri in namespaces.items():
ET.register_namespace(prefix, uri)
root = ET.fromstring(content.encode('utf-8'))
# Remove sodipodi:namedview elements
namedview_elements = root.findall('.//{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}namedview')
if namedview_elements:
for elem in namedview_elements:
root.remove(elem)
changes.append(f"Removed sodipodi:namedview metadata block")
# Remove problematic attributes from all elements
for elem in root.iter():
attrs_to_remove = []
for attr in elem.attrib:
# Check if attribute belongs to problematic namespace
for ns in self.problematic_namespaces:
if attr.startswith(f'{{{namespaces[ns]}}}') or attr.startswith(f'{ns}:'):
attrs_to_remove.append(attr)
break
for attr in attrs_to_remove:
del elem.attrib[attr]
if attrs_to_remove:
changes.append(f"Removed {len(attrs_to_remove)} problematic attribute(s) from element")
# Convert back to string
cleaned_content = ET.tostring(root, encoding='unicode', method='xml')
# Add XML declaration
if not cleaned_content.startswith('<?xml'):
cleaned_content = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + cleaned_content
# Clean up namespace declarations in root element
# Remove sodipodi and inkscape namespace declarations
cleaned_content = re.sub(r'\s+xmlns:sodipodi="[^"]*"', '', cleaned_content)
cleaned_content = re.sub(r'\s+xmlns:inkscape="[^"]*"', '', cleaned_content)
if 'xmlns:sodipodi' in original_content or 'xmlns:inkscape' in original_content:
changes.append("Removed sodipodi and inkscape namespace declarations")
# Compact whitespace
cleaned_content = re.sub(r'\n\s*\n', '\n', cleaned_content)
return cleaned_content, changes
except ET.ParseError as e:
raise Exception(f"Failed to parse SVG: {e}")
def process_file(self, input_path: Path, output_dir: Path = None, overwrite: bool = False) -> dict:
"""Process a single SVG file"""
result = {
'success': False,
'input_file': str(input_path),
'output_file': None,
'renamed': False,
'changes': [],
'error': None
}
try:
# Determine output location
if output_dir:
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir = input_path.parent
# Clean filename
clean_name = self.clean_filename(input_path.name)
output_path = output_dir / clean_name
if clean_name != input_path.name:
result['renamed'] = True
result['changes'].append(f"Renamed file: {input_path.name} -> {clean_name}")
# Check if output file exists
if output_path.exists() and not overwrite and output_path != input_path:
result['error'] = f"Output file already exists: {output_path}"
return result
# Clean SVG content
cleaned_content, content_changes = self.clean_svg_content(input_path)
result['changes'].extend(content_changes)
# Write output
with open(output_path, 'w', encoding='utf-8') as f:
f.write(cleaned_content)
result['output_file'] = str(output_path)
result['success'] = True
# Calculate size reduction
original_size = input_path.stat().st_size
new_size = output_path.stat().st_size
reduction = original_size - new_size
reduction_pct = (reduction / original_size * 100) if original_size > 0 else 0
result['changes'].append(f"File size: {original_size} -> {new_size} bytes ({reduction_pct:.1f}% reduction)")
except Exception as e:
result['error'] = str(e)
return result
def process_directory(self, directory: Path, output_dir: Path = None, recursive: bool = False, overwrite: bool = False) -> list:
"""Process all SVG files in a directory"""
results = []
pattern = '**/*.svg' if recursive else '*.svg'
svg_files = list(directory.glob(pattern))
if not svg_files:
print(f"No SVG files found in {directory}")
return results
print(f"Found {len(svg_files)} SVG file(s) to process\n")
for svg_file in svg_files:
print(f"Processing: {svg_file.name}")
result = self.process_file(svg_file, output_dir, overwrite)
results.append(result)
if result['success']:
print(f"✓ Success: {result['output_file']}")
for change in result['changes']:
print(f" - {change}")
else:
print(f"✗ Failed: {result['error']}")
print()
return results
def main():
import argparse
parser = argparse.ArgumentParser(
description='Clean SVG files for Driveboard laser cutter compatibility'
)
parser.add_argument(
'input',
help='Input SVG file or directory'
)
parser.add_argument(
'-o', '--output',
help='Output directory (default: same as input)'
)
parser.add_argument(
'-r', '--recursive',
action='store_true',
help='Process directories recursively'
)
parser.add_argument(
'--overwrite',
action='store_true',
help='Overwrite existing files'
)
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output) if args.output else None
if not input_path.exists():
print(f"Error: {input_path} does not exist")
sys.exit(1)
cleaner = SVGCleaner()
print("=" * 70)
print("SVG Cleaner for Driveboard")
print("=" * 70)
print()
if input_path.is_file():
if not input_path.suffix.lower() == '.svg':
print(f"Error: {input_path} is not an SVG file")
sys.exit(1)
result = cleaner.process_file(input_path, output_path, args.overwrite)
if result['success']:
print(f"✓ Successfully processed: {result['output_file']}")
for change in result['changes']:
print(f" - {change}")
else:
print(f"✗ Failed: {result['error']}")
sys.exit(1)
elif input_path.is_dir():
results = cleaner.process_directory(input_path, output_path, args.recursive, args.overwrite)
success_count = sum(1 for r in results if r['success'])
fail_count = len(results) - success_count
print("=" * 70)
print(f"Summary: {success_count} succeeded, {fail_count} failed")
print("=" * 70)
else:
print(f"Error: {input_path} is neither a file nor directory")
sys.exit(1)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment