569 lines
19 KiB
Python
569 lines
19 KiB
Python
"""
|
|
SVG Path Length Calculator Node for ComfyUI
|
|
|
|
Calculates the total length of all paths in an SVG file,
|
|
ignoring line thickness (stroke-width).
|
|
"""
|
|
|
|
import re
|
|
import math
|
|
|
|
|
|
def parse_path_d(d: str) -> list:
|
|
"""Parse SVG path 'd' attribute into a list of commands and coordinates."""
|
|
tokens = re.findall(r'[MmZzLlHhVvCcSsQqTtAa]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d)
|
|
|
|
commands = []
|
|
i = 0
|
|
while i < len(tokens):
|
|
token = tokens[i]
|
|
if token.isalpha():
|
|
cmd = token
|
|
i += 1
|
|
params = []
|
|
while i < len(tokens) and not tokens[i].isalpha():
|
|
params.append(float(tokens[i]))
|
|
i += 1
|
|
commands.append((cmd, params))
|
|
else:
|
|
i += 1
|
|
|
|
return commands
|
|
|
|
|
|
def calculate_bezier_length(points: list, segments: int = 100) -> float:
|
|
"""Calculate approximate length of a Bezier curve using line segments."""
|
|
length = 0.0
|
|
prev_point = None
|
|
|
|
for t in range(segments + 1):
|
|
t_val = t / segments
|
|
point = bezier_point(points, t_val)
|
|
if prev_point is not None:
|
|
length += math.dist(prev_point, point)
|
|
prev_point = point
|
|
|
|
return length
|
|
|
|
|
|
def bezier_point(points: list, t: float) -> tuple:
|
|
"""Calculate a point on a Bezier curve at parameter t."""
|
|
n = len(points) - 1
|
|
x = 0.0
|
|
y = 0.0
|
|
|
|
for i, (px, py) in enumerate(points):
|
|
coef = math.comb(n, i) * (1 - t) ** (n - i) * t ** i
|
|
x += coef * px
|
|
y += coef * py
|
|
|
|
return (x, y)
|
|
|
|
|
|
def calculate_arc_length(rx: float, ry: float, x_axis_rotation: float,
|
|
large_arc: bool, sweep: bool,
|
|
start: tuple, end: tuple) -> float:
|
|
"""Calculate approximate length of an elliptical arc."""
|
|
if rx == 0 or ry == 0:
|
|
return math.dist(start, end)
|
|
|
|
dx = end[0] - start[0]
|
|
dy = end[1] - start[1]
|
|
chord = math.sqrt(dx * dx + dy * dy)
|
|
|
|
avg_r = (abs(rx) + abs(ry)) / 2
|
|
if chord > 2 * avg_r:
|
|
return chord
|
|
|
|
angle = 2 * math.asin(min(chord / (2 * avg_r), 1.0))
|
|
if large_arc:
|
|
angle = 2 * math.pi - angle
|
|
|
|
return abs(angle * avg_r)
|
|
|
|
|
|
def calculate_path_length(d: str) -> float:
|
|
"""Calculate the total length of an SVG path."""
|
|
if not d:
|
|
return 0.0
|
|
|
|
commands = parse_path_d(d)
|
|
|
|
total_length = 0.0
|
|
current_x, current_y = 0.0, 0.0
|
|
start_x, start_y = 0.0, 0.0
|
|
last_control = None
|
|
last_command = None
|
|
|
|
for cmd, params in commands:
|
|
is_relative = cmd.islower()
|
|
cmd_upper = cmd.upper()
|
|
|
|
if cmd_upper == 'M':
|
|
i = 0
|
|
while i < len(params):
|
|
x, y = params[i], params[i + 1]
|
|
if is_relative:
|
|
x += current_x
|
|
y += current_y
|
|
|
|
if i == 0:
|
|
start_x, start_y = x, y
|
|
else:
|
|
total_length += math.dist((current_x, current_y), (x, y))
|
|
|
|
current_x, current_y = x, y
|
|
i += 2
|
|
last_control = None
|
|
|
|
elif cmd_upper == 'L':
|
|
i = 0
|
|
while i < len(params):
|
|
x, y = params[i], params[i + 1]
|
|
if is_relative:
|
|
x += current_x
|
|
y += current_y
|
|
total_length += math.dist((current_x, current_y), (x, y))
|
|
current_x, current_y = x, y
|
|
i += 2
|
|
last_control = None
|
|
|
|
elif cmd_upper == 'H':
|
|
for x in params:
|
|
if is_relative:
|
|
x += current_x
|
|
total_length += abs(x - current_x)
|
|
current_x = x
|
|
last_control = None
|
|
|
|
elif cmd_upper == 'V':
|
|
for y in params:
|
|
if is_relative:
|
|
y += current_y
|
|
total_length += abs(y - current_y)
|
|
current_y = y
|
|
last_control = None
|
|
|
|
elif cmd_upper == 'Z':
|
|
total_length += math.dist((current_x, current_y), (start_x, start_y))
|
|
current_x, current_y = start_x, start_y
|
|
last_control = None
|
|
|
|
elif cmd_upper == 'C':
|
|
i = 0
|
|
while i + 5 < len(params) or i + 5 == len(params):
|
|
if i + 5 >= len(params) and i + 5 != len(params):
|
|
break
|
|
x1, y1 = params[i], params[i + 1]
|
|
x2, y2 = params[i + 2], params[i + 3]
|
|
x, y = params[i + 4], params[i + 5]
|
|
|
|
if is_relative:
|
|
x1 += current_x
|
|
y1 += current_y
|
|
x2 += current_x
|
|
y2 += current_y
|
|
x += current_x
|
|
y += current_y
|
|
|
|
points = [(current_x, current_y), (x1, y1), (x2, y2), (x, y)]
|
|
total_length += calculate_bezier_length(points)
|
|
last_control = (x2, y2)
|
|
current_x, current_y = x, y
|
|
i += 6
|
|
|
|
elif cmd_upper == 'S':
|
|
i = 0
|
|
while i + 3 < len(params) or i + 3 == len(params):
|
|
if i + 3 >= len(params) and i + 3 != len(params):
|
|
break
|
|
x2, y2 = params[i], params[i + 1]
|
|
x, y = params[i + 2], params[i + 3]
|
|
|
|
if is_relative:
|
|
x2 += current_x
|
|
y2 += current_y
|
|
x += current_x
|
|
y += current_y
|
|
|
|
if last_command in ('C', 'c', 'S', 's') and last_control:
|
|
x1 = 2 * current_x - last_control[0]
|
|
y1 = 2 * current_y - last_control[1]
|
|
else:
|
|
x1, y1 = current_x, current_y
|
|
|
|
points = [(current_x, current_y), (x1, y1), (x2, y2), (x, y)]
|
|
total_length += calculate_bezier_length(points)
|
|
last_control = (x2, y2)
|
|
current_x, current_y = x, y
|
|
i += 4
|
|
|
|
elif cmd_upper == 'Q':
|
|
i = 0
|
|
while i + 3 < len(params) or i + 3 == len(params):
|
|
if i + 3 >= len(params) and i + 3 != len(params):
|
|
break
|
|
x1, y1 = params[i], params[i + 1]
|
|
x, y = params[i + 2], params[i + 3]
|
|
|
|
if is_relative:
|
|
x1 += current_x
|
|
y1 += current_y
|
|
x += current_x
|
|
y += current_y
|
|
|
|
points = [(current_x, current_y), (x1, y1), (x, y)]
|
|
total_length += calculate_bezier_length(points)
|
|
last_control = (x1, y1)
|
|
current_x, current_y = x, y
|
|
i += 4
|
|
|
|
elif cmd_upper == 'T':
|
|
i = 0
|
|
while i + 1 < len(params) or i + 1 == len(params):
|
|
if i + 1 >= len(params) and i + 1 != len(params):
|
|
break
|
|
x, y = params[i], params[i + 1]
|
|
|
|
if is_relative:
|
|
x += current_x
|
|
y += current_y
|
|
|
|
if last_command in ('Q', 'q', 'T', 't') and last_control:
|
|
x1 = 2 * current_x - last_control[0]
|
|
y1 = 2 * current_y - last_control[1]
|
|
else:
|
|
x1, y1 = current_x, current_y
|
|
|
|
points = [(current_x, current_y), (x1, y1), (x, y)]
|
|
total_length += calculate_bezier_length(points)
|
|
last_control = (x1, y1)
|
|
current_x, current_y = x, y
|
|
i += 2
|
|
|
|
elif cmd_upper == 'A':
|
|
i = 0
|
|
while i + 6 < len(params) or i + 6 == len(params):
|
|
if i + 6 >= len(params) and i + 6 != len(params):
|
|
break
|
|
rx = params[i]
|
|
ry = params[i + 1]
|
|
x_rotation = params[i + 2]
|
|
large_arc = bool(params[i + 3])
|
|
sweep = bool(params[i + 4])
|
|
x, y = params[i + 5], params[i + 6]
|
|
|
|
if is_relative:
|
|
x += current_x
|
|
y += current_y
|
|
|
|
arc_len = calculate_arc_length(
|
|
rx, ry, x_rotation, large_arc, sweep,
|
|
(current_x, current_y), (x, y)
|
|
)
|
|
total_length += arc_len
|
|
current_x, current_y = x, y
|
|
i += 7
|
|
last_control = None
|
|
|
|
last_command = cmd
|
|
|
|
return total_length
|
|
|
|
|
|
def get_transform_scale(transform: str) -> tuple:
|
|
"""Extract scale factors from a transform attribute."""
|
|
if not transform:
|
|
return (1.0, 1.0)
|
|
|
|
scale_x, scale_y = 1.0, 1.0
|
|
|
|
scale_match = re.search(r'scale\s*\(\s*([-\d.]+)(?:\s*[,\s]\s*([-\d.]+))?\s*\)', transform)
|
|
if scale_match:
|
|
scale_x = float(scale_match.group(1))
|
|
scale_y = float(scale_match.group(2)) if scale_match.group(2) else scale_x
|
|
|
|
matrix_match = re.search(r'matrix\s*\(\s*([-\d.]+)\s*[,\s]\s*([-\d.]+)\s*[,\s]\s*([-\d.]+)\s*[,\s]\s*([-\d.]+)', transform)
|
|
if matrix_match:
|
|
a = float(matrix_match.group(1))
|
|
b = float(matrix_match.group(2))
|
|
c = float(matrix_match.group(3))
|
|
d = float(matrix_match.group(4))
|
|
scale_x = math.sqrt(a * a + b * b)
|
|
scale_y = math.sqrt(c * c + d * d)
|
|
|
|
return (scale_x, scale_y)
|
|
|
|
|
|
def calculate_shape_perimeter(element, ns: dict) -> float:
|
|
"""Calculate the perimeter of basic SVG shapes."""
|
|
tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag
|
|
|
|
if tag == 'rect':
|
|
width = float(element.get('width', 0))
|
|
height = float(element.get('height', 0))
|
|
rx = float(element.get('rx', 0))
|
|
ry = float(element.get('ry', rx))
|
|
|
|
if rx == 0 and ry == 0:
|
|
return 2 * (width + height)
|
|
else:
|
|
straight = 2 * (width - 2 * rx) + 2 * (height - 2 * ry)
|
|
h = ((rx - ry) ** 2) / ((rx + ry) ** 2)
|
|
corner_perimeter = math.pi * (rx + ry) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
|
|
return straight + corner_perimeter
|
|
|
|
elif tag == 'circle':
|
|
r = float(element.get('r', 0))
|
|
return 2 * math.pi * r
|
|
|
|
elif tag == 'ellipse':
|
|
rx = float(element.get('rx', 0))
|
|
ry = float(element.get('ry', 0))
|
|
h = ((rx - ry) ** 2) / ((rx + ry) ** 2)
|
|
return math.pi * (rx + ry) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
|
|
|
|
elif tag == 'line':
|
|
x1 = float(element.get('x1', 0))
|
|
y1 = float(element.get('y1', 0))
|
|
x2 = float(element.get('x2', 0))
|
|
y2 = float(element.get('y2', 0))
|
|
return math.dist((x1, y1), (x2, y2))
|
|
|
|
elif tag == 'polyline':
|
|
points_str = element.get('points', '')
|
|
points = parse_points(points_str)
|
|
return calculate_polyline_length(points, closed=False)
|
|
|
|
elif tag == 'polygon':
|
|
points_str = element.get('points', '')
|
|
points = parse_points(points_str)
|
|
return calculate_polyline_length(points, closed=True)
|
|
|
|
return 0.0
|
|
|
|
|
|
def parse_points(points_str: str) -> list:
|
|
"""Parse SVG points attribute into a list of (x, y) tuples."""
|
|
numbers = re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', points_str)
|
|
points = []
|
|
for i in range(0, len(numbers) - 1, 2):
|
|
points.append((float(numbers[i]), float(numbers[i + 1])))
|
|
return points
|
|
|
|
|
|
def calculate_polyline_length(points: list, closed: bool = False) -> float:
|
|
"""Calculate the length of a polyline or polygon."""
|
|
if len(points) < 2:
|
|
return 0.0
|
|
|
|
length = 0.0
|
|
for i in range(len(points) - 1):
|
|
length += math.dist(points[i], points[i + 1])
|
|
|
|
if closed and len(points) > 2:
|
|
length += math.dist(points[-1], points[0])
|
|
|
|
return length
|
|
|
|
|
|
def calculate_svg_total_length(svg_content: str) -> float:
|
|
"""Calculate the total length of all paths in an SVG."""
|
|
from xml.etree import ElementTree as ET
|
|
|
|
try:
|
|
root = ET.fromstring(svg_content)
|
|
except ET.ParseError as e:
|
|
raise ValueError(f"Invalid SVG: {e}")
|
|
|
|
ns = {}
|
|
if root.tag.startswith('{'):
|
|
ns_uri = root.tag[1:root.tag.index('}')]
|
|
ns['svg'] = ns_uri
|
|
|
|
total_length = 0.0
|
|
|
|
def process_element(element, inherited_scale=(1.0, 1.0)):
|
|
nonlocal total_length
|
|
|
|
transform = element.get('transform', '')
|
|
scale = get_transform_scale(transform)
|
|
current_scale = (inherited_scale[0] * scale[0], inherited_scale[1] * scale[1])
|
|
avg_scale = (current_scale[0] + current_scale[1]) / 2
|
|
|
|
tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag
|
|
|
|
if tag == 'path':
|
|
d = element.get('d', '')
|
|
if d:
|
|
path_length = calculate_path_length(d)
|
|
total_length += path_length * avg_scale
|
|
|
|
elif tag in ('rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon'):
|
|
shape_length = calculate_shape_perimeter(element, ns)
|
|
total_length += shape_length * avg_scale
|
|
|
|
for child in element:
|
|
process_element(child, current_scale)
|
|
|
|
process_element(root)
|
|
|
|
return total_length
|
|
|
|
|
|
class SVGPathLengthCalculator:
|
|
"""
|
|
ComfyUI node that calculates the total length of all paths in an SVG file.
|
|
Supports path commands, basic shapes (rect, circle, ellipse, line, polyline, polygon),
|
|
and handles transforms.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"svg_content": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"placeholder": "Paste SVG content here or connect from another node"
|
|
}),
|
|
},
|
|
"optional": {
|
|
"decimal_places": ("INT", {
|
|
"default": 4,
|
|
"min": 0,
|
|
"max": 10,
|
|
"step": 1,
|
|
"display": "number"
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("FLOAT", "STRING",)
|
|
RETURN_NAMES = ("total_length", "formatted_result",)
|
|
FUNCTION = "calculate_length"
|
|
CATEGORY = "utils/svg"
|
|
OUTPUT_NODE = True
|
|
|
|
def calculate_length(self, svg_content: str, decimal_places: int = 4):
|
|
"""
|
|
Calculate the total path length of an SVG.
|
|
|
|
Args:
|
|
svg_content: The SVG file content as a string
|
|
decimal_places: Number of decimal places for the result
|
|
|
|
Returns:
|
|
Tuple of (total_length as float, formatted result string)
|
|
"""
|
|
if not svg_content or not svg_content.strip():
|
|
return (0.0, "Error: No SVG content provided")
|
|
|
|
try:
|
|
total_length = calculate_svg_total_length(svg_content)
|
|
formatted = f"Total path length: {total_length:.{decimal_places}f} units"
|
|
return (round(total_length, decimal_places), formatted)
|
|
except ValueError as e:
|
|
return (0.0, f"Error: {str(e)}")
|
|
except Exception as e:
|
|
return (0.0, f"Error processing SVG: {str(e)}")
|
|
|
|
|
|
class SVGPathLengthDetailed:
|
|
"""
|
|
ComfyUI node that calculates path lengths with detailed breakdown per element.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"svg_content": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"placeholder": "Paste SVG content here"
|
|
}),
|
|
},
|
|
"optional": {
|
|
"decimal_places": ("INT", {
|
|
"default": 4,
|
|
"min": 0,
|
|
"max": 10,
|
|
"step": 1,
|
|
"display": "number"
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("FLOAT", "STRING", "INT",)
|
|
RETURN_NAMES = ("total_length", "detailed_report", "element_count",)
|
|
FUNCTION = "calculate_detailed"
|
|
CATEGORY = "utils/svg"
|
|
OUTPUT_NODE = True
|
|
|
|
def calculate_detailed(self, svg_content: str, decimal_places: int = 4):
|
|
"""
|
|
Calculate path lengths with a detailed breakdown.
|
|
|
|
Returns:
|
|
Tuple of (total_length, detailed report string, element count)
|
|
"""
|
|
from xml.etree import ElementTree as ET
|
|
|
|
if not svg_content or not svg_content.strip():
|
|
return (0.0, "Error: No SVG content provided", 0)
|
|
|
|
try:
|
|
root = ET.fromstring(svg_content)
|
|
except ET.ParseError as e:
|
|
return (0.0, f"Error: Invalid SVG - {e}", 0)
|
|
|
|
elements_info = []
|
|
total_length = 0.0
|
|
|
|
def process_element(element, inherited_scale=(1.0, 1.0), depth=0):
|
|
nonlocal total_length
|
|
|
|
transform = element.get('transform', '')
|
|
scale = get_transform_scale(transform)
|
|
current_scale = (inherited_scale[0] * scale[0], inherited_scale[1] * scale[1])
|
|
avg_scale = (current_scale[0] + current_scale[1]) / 2
|
|
|
|
tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag
|
|
element_length = 0.0
|
|
|
|
if tag == 'path':
|
|
d = element.get('d', '')
|
|
if d:
|
|
element_length = calculate_path_length(d) * avg_scale
|
|
element_id = element.get('id', 'unnamed')
|
|
elements_info.append((tag, element_id, element_length))
|
|
total_length += element_length
|
|
|
|
elif tag in ('rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon'):
|
|
element_length = calculate_shape_perimeter(element, {}) * avg_scale
|
|
element_id = element.get('id', 'unnamed')
|
|
elements_info.append((tag, element_id, element_length))
|
|
total_length += element_length
|
|
|
|
for child in element:
|
|
process_element(child, current_scale, depth + 1)
|
|
|
|
try:
|
|
process_element(root)
|
|
except Exception as e:
|
|
return (0.0, f"Error processing SVG: {str(e)}", 0)
|
|
|
|
# Build detailed report
|
|
report_lines = ["SVG Path Length Analysis", "=" * 40]
|
|
|
|
for tag, elem_id, length in elements_info:
|
|
report_lines.append(f" {tag} (id='{elem_id}'): {length:.{decimal_places}f} units")
|
|
|
|
report_lines.append("=" * 40)
|
|
report_lines.append(f"Total elements: {len(elements_info)}")
|
|
report_lines.append(f"Total length: {total_length:.{decimal_places}f} units")
|
|
|
|
detailed_report = "\n".join(report_lines)
|
|
|
|
return (round(total_length, decimal_places), detailed_report, len(elements_info)) |