Created
December 20, 2025 15:33
-
-
Save matteohoeren/5822f2d7c1419d990244676acfcb6baa to your computer and use it in GitHub Desktop.
SVG Cleaner for Lasersaur
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
| #!/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