Files

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))