473 lines
18 KiB
Python
473 lines
18 KiB
Python
|
|
|
|
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', f'{1}px')
|
|
|
|
# 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'<?xml version="1.0" ?>', b'<?xml version="1.0" encoding="utf-8"?>')
|
|
|
|
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() |