import os import re import xml.etree.ElementTree as ET from xml.dom import minidom import argparse def analyze_and_normalize_path(d, path_type, path_id=""): """Strictly normalize path direction and generate detailed report""" original_d = d report = [] normalized = [] changed = False # First check if path contains any arc commands if 'A' in d or 'a' in d: report.append(" Path contains arc commands - leaving unchanged") return original_d, report, False # Ensure path starts with M command if not d.strip().startswith('M'): d = 'M' + d[1:] if d.startswith('L') else 'M ' + d report.append(" Fix: Added M command at path start") changed = True # Parse path commands commands = [] current_cmd = None for token in re.split('([A-Za-z])', d): if not token: continue if token in 'MLAZHVCSQTa-z': current_cmd = token else: if current_cmd: commands.append((current_cmd, token.strip())) prev_x, prev_y = None, None for cmd, params in commands: params = [float(p) for p in re.split('[, ]+', params.strip()) if p] if cmd == 'M': if len(params) >= 2: x, y = params[0], params[1] normalized.append(f"M {x} {y}") prev_x, prev_y = x, y continue if path_type == 'Line segment' and cmd == 'L': if len(params) >= 2 and prev_x is not None: x, y = params[0], params[1] need_swap = x < prev_x or (x == prev_x and y < prev_y) if need_swap: new_segment = [f"M {x} {y}", f"L {prev_x} {prev_y}"] report.append(f" Need to swap start/end → New path: {' '.join(new_segment)}") normalized = new_segment changed = True else: normalized.append(f"L {x} {y}") report.append(" Path direction already correct, no changes made") prev_x, prev_y = x, y continue if path_type == 'Arc' and cmd == 'A': if len(params) >= 7 and prev_x is not None: rx, ry, xrot, large, sweep, x, y = params[0], params[1], params[2], int(params[3]), int(params[4]), params[5], params[6] # Force left-to-right clockwise need_swap = x < prev_x new_sweep = 1 if need_swap: new_segment = [f"M {x} {y}", f"A {rx} {ry} {xrot} {large} {new_sweep} {prev_x} {prev_y}"] report.append(f" Need to swap start/end → New path: {' '.join(new_segment)}") normalized = new_segment changed = True else: if sweep != new_sweep: new_segment = [f"A {rx} {ry} {xrot} {large} {new_sweep} {x} {y}"] report.append(f" Need to adjust sweep to 1 → New path: {' '.join(new_segment)}") normalized.extend(new_segment) changed = True else: normalized.append(f"A {rx} {ry} {xrot} {large} {sweep} {x} {y}") report.append(" Arc direction already correct, no changes made") prev_x, prev_y = x, y continue normalized.append(f"{cmd} {' '.join(map(str, params))}") new_d = ' '.join(normalized) report_header = f"[{path_id}-Analysis] Type: {path_type}" full_report = [report_header, f"Original path: {original_d}"] + report if changed: full_report.append(f"Modified path: {new_d}") else: full_report.append("Path not modified") return new_d, '\n'.join(full_report) def analyze_and_normalize_path(d, path_type, path_id=""): """Strictly normalize path direction and generate detailed report""" original_d = d report = [] normalized = [] changed = False # First check if path contains any arc commands if 'A' in d or 'a' in d: report_header = f"[{path_id}-Analysis] Type: {path_type} (contains arcs)" full_report = [report_header, f"Original path: {original_d}", " Path contains arc commands - leaving unchanged", "Path not modified"] return original_d, '\n'.join(full_report) # Ensure path starts with M command if not d.strip().startswith('M'): d = 'M' + d[1:] if d.startswith('L') else 'M ' + d report.append(" Fix: Added M command at path start") changed = True # Parse path commands commands = [] current_cmd = None for token in re.split('([A-Za-z])', d): if not token: continue if token in 'MLAZHVCSQTa-z': current_cmd = token else: if current_cmd: commands.append((current_cmd, token.strip())) prev_x, prev_y = None, None for cmd, params in commands: params = [float(p) for p in re.split('[, ]+', params.strip()) if p] if cmd == 'M': if len(params) >= 2: x, y = params[0], params[1] normalized.append(f"M {x} {y}") prev_x, prev_y = x, y continue if path_type == 'Line segment' and cmd == 'L': if len(params) >= 2 and prev_x is not None: x, y = params[0], params[1] need_swap = x < prev_x or (x == prev_x and y < prev_y) if need_swap: new_segment = [f"M {x} {y}", f"L {prev_x} {prev_y}"] report.append(f" Need to swap start/end → New path: {' '.join(new_segment)}") normalized = new_segment changed = True else: normalized.append(f"L {x} {y}") report.append(" Path direction already correct, no changes made") prev_x, prev_y = x, y continue normalized.append(f"{cmd} {' '.join(map(str, params))}") new_d = ' '.join(normalized) report_header = f"[{path_id}-Analysis] Type: {path_type}" full_report = [report_header, f"Original path: {original_d}"] + report if changed: full_report.append(f"Modified path: {new_d}") else: full_report.append("Path not modified") return new_d, '\n'.join(full_report) ''' def analyze_and_normalize_path(d, path_type, path_id=""): """Strictly normalize path direction and generate detailed report""" original_d = d report = [] normalized = [] changed = False # First check if path contains any arc commands if 'A' in d or 'a' in d: report.append(" Path contains arc commands - leaving unchanged") return original_d, report, False # Ensure path starts with M command if not d.strip().startswith('M'): d = 'M' + d[1:] if d.startswith('L') else 'M ' + d report.append(" Fix: Added M command at path start") changed = True # Parse path commands commands = [] current_cmd = None for token in re.split('([A-Za-z])', d): if not token: continue if token in 'MLAZHVCSQTa-z': current_cmd = token else: if current_cmd: commands.append((current_cmd, token.strip())) prev_x, prev_y = None, None for cmd, params in commands: params = [float(p) for p in re.split('[, ]+', params.strip()) if p] if cmd == 'M': if len(params) >= 2: x, y = params[0], params[1] normalized.append(f"M {x} {y}") prev_x, prev_y = x, y continue if path_type == 'Line segment' and cmd == 'L': if len(params) >= 2 and prev_x is not None: x, y = params[0], params[1] need_swap = x < prev_x or (x == prev_x and y < prev_y) if need_swap: new_segment = [f"M {x} {y}", f"L {prev_x} {prev_y}"] report.append(f" Need to swap start/end → New path: {' '.join(new_segment)}") normalized = new_segment changed = True else: normalized.append(f"L {x} {y}") report.append(" Path direction already correct, no changes made") prev_x, prev_y = x, y continue normalized.append(f"{cmd} {' '.join(map(str, params))}") normalized_path = ' '.join(normalized) return normalized_path if changed else original_d, report, changed ''' def optimize_svg(input_path, output_path): """Process single SVG file with all optimizations""" try: # Register namespace ET.register_namespace('', 'http://www.w3.org/2000/svg') # Parse SVG file tree = ET.parse(input_path) root = tree.getroot() print(f"\nProcessing file: {os.path.basename(input_path)}") print("="*60) # Create parent map parent_map = {c: p for p in tree.iter() for c in p} # 1. Remove xlink namespace for attr in list(root.attrib): if 'xlink' in attr: del root.attrib[attr] # 2. Set standard dimensions root.set('width', '1e3') root.set('height', '1e3') root.set('viewBox', '0 0 1e3 1e3') # 3. Remove all clipPath definitions and references defs = root.find('{http://www.w3.org/2000/svg}defs') if defs is not None: clip_paths = defs.findall('{http://www.w3.org/2000/svg}clipPath') for cp in clip_paths: defs.remove(cp) if len(defs) == 0: root.remove(defs) # 4. Remove clip-path attributes for elem in tree.iter(): if 'clip-path' in elem.attrib: del elem.attrib['clip-path'] # 5. Force square line caps for g in root.findall('.//{http://www.w3.org/2000/svg}g'): g.set('stroke-linecap', 'square') # 6. Remove dashed line styles for elem in tree.iter(): if 'stroke-dasharray' in elem.attrib: del elem.attrib['stroke-dasharray'] # 7. Completely remove empty groups removed_groups = True while removed_groups: removed_groups = False for g in root.findall('.//{http://www.w3.org/2000/svg}g'): is_empty = ( len(g) == 0 and not (g.text or '').strip() and not (g.tail or '').strip() and all(not k.startswith('{') for k in g.attrib)) if is_empty: parent = parent_map.get(g) if parent is not None: parent.remove(g) removed_groups = True parent_map = {c: p for p in tree.iter() for c in p} # 8. Convert polylines to paths for polyline in root.findall('.//{http://www.w3.org/2000/svg}polyline'): points = polyline.get('points', '').strip() if points: coords = [p for p in re.split(r'[\s,]', points) if p] path_data = [] for i in range(0, len(coords), 2): if i == 0: path_data.append(f"M {coords[i]} {coords[i+1]}") else: path_data.append(f"L {coords[i]} {coords[i+1]}") path = ET.Element('{http://www.w3.org/2000/svg}path') new_d, analysis_report = analyze_and_normalize_path( ' '.join(path_data), 'Line segment', "Polyline conversion" ) path.set('d', new_d) print(f"\n[Polyline conversion analysis]\n{analysis_report}") for attr, value in polyline.attrib.items(): if attr not in ('points', 'stroke-dasharray'): path.set(attr, value) parent = parent_map.get(polyline) if parent is not None: parent.remove(polyline) parent.append(path) # 9. Normalize path directions (final step) group_num = 0 for g in root.findall('.//{http://www.w3.org/2000/svg}g'): group_num += 1 path_num = 0 group_report = [] for path in g.findall('.//{http://www.w3.org/2000/svg}path'): path_num += 1 if 'd' not in path.attrib: continue # Auto-detect path type path_type = 'Arc' if 'A' in path.get('d') else 'Line segment' path_id = f"Group{group_num}-Path{path_num}" new_d, analysis_report = analyze_and_normalize_path( path.get('d'), path_type, path_id ) if new_d != path.get('d'): path.set('d', new_d) group_report.append(analysis_report) # Print all path reports for current group if group_report: print(f"\n=== Group {group_num} Path Analysis ===") print('\n\n'.join(group_report)) # 10. Update colors and line width for elem in root.findall('.//*[@stroke]'): stroke = elem.get('stroke', '').lower() if stroke == '#0ff' or stroke == 'rgb(0,255,255)': elem.set('stroke', '#ffe31b') elif stroke == '#000' or stroke == 'rgb(255,0,0)'or stroke == 'rgb(0,0,0)': elem.set('stroke', '#ffe31b') elem.set('stroke-width', '1') # Generate final XML rough_string = ET.tostring(root, encoding='utf-8', xml_declaration=True) rough_string = rough_string.replace(b'standalone="no"', b'') # Format output (remove empty lines) reparsed = minidom.parseString(rough_string) pretty_svg = reparsed.toprettyxml(indent=' ', encoding='utf-8') pretty_svg = b'\n'.join( line for line in pretty_svg.splitlines() if line.strip() ).replace(b'', b'') with open(output_path, 'wb') as f: f.write(pretty_svg) print("\n" + "="*60) return True except Exception as e: print(f"\nProcessing error: {str(e)}") print("="*60) return False def batch_process_svgs(input_dir, output_dir): if not os.path.exists(output_dir): os.makedirs(output_dir) success_count = 0 failure_count = 0 for filename in os.listdir(input_dir): if os.path.isdir(os.path.join(input_dir, filename)): continue if filename.lower().endswith('.svg') or '_new.svg' in filename.lower(): input_path = os.path.join(input_dir, filename) output_filename = filename.replace('_new.svg', '_optimized.svg') output_path = os.path.join(output_dir, output_filename) if optimize_svg(input_path, output_path): success_count += 1 print(f"✓ Processed successfully: {filename} → {output_filename}") else: failure_count += 1 print(f"✗ Processing failed: {filename}") print("\n" + "="*60 + "\n") print("\nProcessing summary:") print(f"Successfully processed: {success_count} files") print(f"Failed to process: {failure_count} files") if __name__ == '__main__': parser = argparse.ArgumentParser(description='copies svg files from directory and optimizes them', prog='svg_optimizer') parser.add_argument('-f', '--file', action='store', help='just optimize this file') parser.add_argument('-i', '--inputdir', action='store', help='input directory for all svg files which should be rewritten. Mandatory argument') parser.add_argument('-o', '--outputdir', action='store', help='output directory for all svg files which are writte new') parser.add_argument('-c', '--console', action='store_true', help='put the result to console') parser.add_argument( '--bogen', action='store_true', help='just optimize all "bogen"') parser.add_argument( '--tefbogen', action='store_true', help='just optimize all "tefbogen"') parser.add_argument( '--weichen', action='store_true', help='just optimize all "weichen"') parser.add_argument( '--tefweichen', action='store_true', help='just optimize all "tefweichen"') args = parser.parse_args() in_dir = None if args.bogen: in_dir = os.environ.get('RD_CONF_BOGEN') if args.tefbogen: in_dir = os.environ.get('RD_CONF_TEFBOGEN') if args.weichen: in_dir = os.environ.get('RD_CONF_WEICHEN') if args.tefweichen: in_dir = os.environ.get('RD_CONF_TEFWEICHEN') out_dir = os.environ.get('RD_CONF_WORK') if args.outputdir: out_dir = args.outputdir if args.file: filename = args.file input_path = os.path.join(in_dir, filename) if os.path.exists(input_path): optimize_svg(input_path, out_dir) else: print(f"file {filename} does not exist") if in_dir: """Batch process SVG files""" batch_process_svgs(in_dir, out_dir) else: parser.print_help()