Files

531 lines
23 KiB
Python

"""
SVG Group Layout Node for ComfyUI
Renders selected SVG groups (<g>) separately and places them on a canvas at given (x,y) positions.
Pure-Python renderer reuses the simple path drawing used in SVGToImage (Pillow-based).
Inputs:
- svg_string: SVG XML string
- canvas_width, canvas_height: output canvas size
- background: transparent/white/black/custom
- background_color: used when background=custom (#RRGGBB or #RRGGBBAA or rgba())
- group_positions_json: JSON array of placement rules
Example:
[
{"selector": "#hair", "x": 100, "y": 80, "scale": 1.0, "rotate": 0.0,
"stroke": "#000000", "stroke_width": 2.0, "fill": ""},
{"selector": ".shadow", "x": 260, "y": 50, "opacity": 0.5}
]
- auto_layout: when no rules or empty, tile all top-level <g> groups into a grid
- tile_cols, tile_spacing: grid layout params
Outputs:
- IMAGE: Composite canvas
- STRING: meta JSON with match stats
"""
import json
import math
import re
from typing import List, Tuple, Optional, Dict, Any
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
from xml.etree import ElementTree as ET
class SVGGroupLayout:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"svg_string": ("STRING", {"forceInput": True}),
"canvas_width": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}),
"canvas_height": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}),
"background": (["transparent", "white", "black", "custom"],),
"background_color": ("STRING", {"default": "#00000000"}),
},
"optional": {
"group_positions_json": ("STRING", {"default": "[]", "multiline": True, "tooltip": "Array of {selector, x, y, scale?, rotate?, stroke?, stroke_width?, fill?, opacity?}"}),
"auto_layout": ("BOOLEAN", {"default": True}),
"tile_cols": ("INT", {"default": 4, "min": 1, "max": 32, "step": 1}),
"tile_spacing": ("INT", {"default": 16, "min": 0, "max": 512, "step": 1}),
"show_all_background": ("BOOLEAN", {"default": False, "tooltip": "Show all paths in background at low opacity"}),
"background_opacity": ("FLOAT", {"default": 0.15, "min": 0.0, "max": 1.0, "step": 0.05}),
"background_stroke_color": ("STRING", {"default": "#888888"}),
"override_fill_color": ("STRING", {"default": "", "tooltip": "Override all fill colors (empty = use original)"}),
"override_stroke_color": ("STRING", {"default": "", "tooltip": "Override all stroke colors (empty = use original)"}),
"show_grid_lines": ("BOOLEAN", {"default": True}),
"grid_line_color": ("STRING", {"default": "#CCCCCC"}),
"grid_line_width": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
"show_labels": ("BOOLEAN", {"default": True}),
"label_color": ("STRING", {"default": "#000000"}),
"label_size": ("INT", {"default": 12, "min": 6, "max": 48, "step": 1}),
"show_control_points": ("BOOLEAN", {"default": False}),
"control_point_size": ("INT", {"default": 3, "min": 1, "max": 20, "step": 1}),
"control_point_color": ("STRING", {"default": "#00AEEF"}),
},
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "meta")
FUNCTION = "layout"
CATEGORY = "TJ_Vector"
def layout(self,
svg_string: str,
canvas_width: int = 1024,
canvas_height: int = 1024,
background: str = "transparent",
background_color: str = "#00000000",
group_positions_json: str = "[]",
auto_layout: bool = True,
tile_cols: int = 4,
tile_spacing: int = 16,
show_all_background: bool = False,
background_opacity: float = 0.15,
background_stroke_color: str = "#888888",
override_fill_color: str = "",
override_stroke_color: str = "",
show_grid_lines: bool = True,
grid_line_color: str = "#CCCCCC",
grid_line_width: int = 1,
show_labels: bool = True,
label_color: str = "#000000",
label_size: int = 12,
show_control_points: bool = False,
control_point_size: int = 3,
control_point_color: str = "#00AEEF",
):
# Parse SVG
try:
root = ET.fromstring(svg_string)
except Exception as e:
# return empty transparent canvas with error meta
img = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0))
tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
return (tensor, json.dumps({"error": f"SVG parse error: {e}"}))
viewbox = root.get("viewBox") or root.get("viewbox")
vb_x = vb_y = 0.0
vb_w = canvas_width
vb_h = canvas_height
if viewbox:
try:
parts = [float(p) for p in viewbox.replace(",", " ").split()]
if len(parts) == 4:
vb_x, vb_y, vb_w, vb_h = parts
except Exception:
pass
# Prepare canvas
if background == "transparent":
canvas = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0))
elif background == "white":
canvas = Image.new("RGBA", (canvas_width, canvas_height), (255, 255, 255, 255))
elif background == "black":
canvas = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 255))
else:
canvas = Image.new("RGBA", (canvas_width, canvas_height), self._parse_rgba(self._normalize_color(background_color)))
# Collect top-level groups
top_groups = [] # [(elem, id, classes)]
ns_tag = "{http://www.w3.org/2000/svg}g"
for g in root.iter(ns_tag):
# only consider groups that are direct or any depth? we'll include any depth but provide id/class
gid = g.get("id") or ""
cls = g.get("class") or ""
top_groups.append((g, gid, cls))
# Parse positions JSON
placements: List[Dict[str, Any]] = []
try:
if group_positions_json and group_positions_json.strip():
placements = json.loads(group_positions_json)
if not isinstance(placements, list):
placements = []
except Exception:
placements = []
# Auto layout if requested and no placements provided
auto_used = False
layout_items = [] # items to tile: list of (elem, id, cls)
if (not placements) and auto_layout:
# If <=1 group (or no groups), treat each <path> as individual item
ns_path = "{http://www.w3.org/2000/svg}path"
all_paths = list(root.iter(ns_path))
if len(top_groups) <= 1 and len(all_paths) > 1:
# Single/no group with many paths → tile each path
for p in all_paths:
pid = p.get("id") or ""
pcls = p.get("class") or ""
layout_items.append((p, pid, pcls))
elif top_groups:
# Multiple groups → tile by group
layout_items = top_groups
if (not placements) and auto_layout and layout_items:
auto_used = True
cols = max(1, int(tile_cols))
spacing = max(0, int(tile_spacing))
cell_w = (canvas_width - spacing * (cols + 1)) / cols
rows = math.ceil(len(layout_items) / cols)
cell_h = (canvas_height - spacing * (rows + 1)) / max(1, rows)
for idx, (_, gid, cls) in enumerate(layout_items):
r = idx // cols
c = idx % cols
x = int(spacing + c * (cell_w + spacing))
y = int(spacing + r * (cell_h + spacing))
placements.append({
"selector": f"#{gid}" if gid else f".auto_{idx}",
"x": x,
"y": y,
"scale": min(cell_w / max(vb_w, 1), cell_h / max(vb_h, 1)),
"cell_index": idx, # for label display
})
# Draw grid lines and labels for auto layout
if auto_used and show_grid_lines:
draw = ImageDraw.Draw(canvas, "RGBA")
grid_rgba = self._color_to_rgba(grid_line_color, 1.0)
if grid_rgba:
cols = max(1, int(tile_cols))
spacing = max(0, int(tile_spacing))
cell_w = (canvas_width - spacing * (cols + 1)) / cols
rows = math.ceil(len(layout_items) / cols)
cell_h = (canvas_height - spacing * (rows + 1)) / max(1, rows)
# Vertical lines (column separators)
for c in range(cols + 1):
x = int(spacing + c * (cell_w + spacing))
draw.line([(x, 0), (x, canvas_height)], fill=grid_rgba, width=grid_line_width)
# Horizontal lines (row separators)
for r in range(rows + 1):
y = int(spacing + r * (cell_h + spacing))
draw.line([(0, y), (canvas_width, y)], fill=grid_rgba, width=grid_line_width)
# Draw all paths as background (low opacity) in each tile if requested
if show_all_background and placements:
draw = ImageDraw.Draw(canvas, "RGBA")
ns_path = "{http://www.w3.org/2000/svg}path"
bg_stroke_rgba = self._color_to_rgba(background_stroke_color, background_opacity)
if bg_stroke_rgba:
# Draw background in each placement tile
for rule in placements:
x = float(rule.get("x", 0))
y = float(rule.get("y", 0))
scale = float(rule.get("scale", 1.0))
# Draw all paths at this tile position with same scale as foreground
for path_elem in root.iter(ns_path):
d = path_elem.get("d")
if not d:
continue
coords = self._parse_svg_path(d, vb_x, vb_y, scale, scale)
if coords and len(coords) >= 2:
# Apply placement offset
coords_off = [(x + px, y + py) for (px, py) in coords]
# Draw stroke only (no fill) for background reference
draw.line(coords_off, fill=bg_stroke_rgba, width=max(1, int(1 * scale)))
# Build index for selector resolution
# Allow selectors: #id, .class, tag (g), [attr=value]
def selector_match(elem: ET.Element, selector: str) -> bool:
if not selector:
return False
s = selector.strip()
if s.startswith("#"):
return elem.get("id") == s[1:]
if s.startswith("."):
classes = (elem.get("class") or "").split()
return s[1:] in classes
if s == "g" or s.lower() == "group":
return elem.tag.endswith('}g')
# [attr=value]
if s.startswith("[") and s.endswith("]") and "=" in s:
k, v = s[1:-1].split("=", 1)
return (elem.get(k) or "") == v
return False
# Helper: does path belong to any matching group for a selector
def path_in_selector(path_elem: ET.Element, selector: str) -> bool:
p = path_elem
while p is not None:
if selector_match(p, selector):
return True
p = p.getparent() if hasattr(p, 'getparent') else None
# xml.etree doesn't have getparent by default; emulate by walking from root
if p is None:
# emulate: walk up by searching parent
p = _parent_of(root, path_elem) if selector_match(root, selector) else None
return False
def _parent_of(root_elem: ET.Element, child: ET.Element) -> Optional[ET.Element]:
# simple DFS to find parent; acceptable for moderate SVG sizes
stack = [root_elem]
while stack:
node = stack.pop()
for ch in list(node):
if ch is child:
return node
stack.append(ch)
return None
# Render placements
ns_path = "{http://www.w3.org/2000/svg}path"
stats = {"placements": [], "auto_layout_used": auto_used}
for rule in placements:
selector = str(rule.get("selector", "")).strip()
x = float(rule.get("x", 0))
y = float(rule.get("y", 0))
scale = float(rule.get("scale", 1.0))
# style overrides per placement
ov_stroke = rule.get("stroke", "")
ov_sw = float(rule.get("stroke_width", 0) or 0)
ov_fill = rule.get("fill", "")
ov_opacity = float(rule.get("opacity", 1.0))
drawn = 0
draw = ImageDraw.Draw(canvas, "RGBA")
# Iterate all paths; draw those under selector
for path_elem in root.iter(ns_path):
if selector and not self._path_in_group_by_selector(root, path_elem, selector):
continue
d = path_elem.get("d")
if not d:
continue
# Priority: placement rule > global override > SVG original
stroke_color = ov_stroke or override_stroke_color or (path_elem.get("stroke") or "black")
fill_color = ov_fill or override_fill_color or (path_elem.get("fill") or "none")
stroke_width = ov_sw if ov_sw > 0 else float(path_elem.get("stroke-width", 1) or 1)
opacity = max(0.0, min(1.0, float(path_elem.get("opacity", ov_opacity))))
coords = self._parse_svg_path(d, vb_x, vb_y, scale, scale)
if not coords or len(coords) < 2:
continue
# apply placement offset
coords_off = [(x + px, y + py) for (px, py) in coords]
# fill first
if fill_color and fill_color.lower() != "none":
rgba = self._color_to_rgba(fill_color, opacity)
if rgba:
draw.polygon(coords_off, fill=rgba)
# stroke
if stroke_color and stroke_color.lower() != "none":
rgba = self._color_to_rgba(stroke_color, opacity)
if rgba:
draw.line(coords_off, fill=rgba, width=max(1, int(stroke_width * scale)))
# optional control points
if show_control_points:
cp_rgba = self._color_to_rgba(control_point_color, 1.0)
if cp_rgba:
half = max(1, int(control_point_size)) // 2
for (px, py) in coords_off:
draw.rectangle([px - half, py - half, px + half, py + half], fill=cp_rgba, outline=cp_rgba)
drawn += 1
stats["placements"].append({"selector": selector, "drawn_paths": drawn})
# Draw labels (ID/index) for each placement
if show_labels and placements:
draw = ImageDraw.Draw(canvas, "RGBA")
label_rgba = self._color_to_rgba(label_color, 1.0)
if label_rgba:
# Try to load a font, fallback to default
try:
font = ImageFont.truetype("arial.ttf", label_size)
except Exception:
try:
font = ImageFont.truetype("DejaVuSans.ttf", label_size)
except Exception:
font = ImageFont.load_default()
for rule in placements:
selector = str(rule.get("selector", "")).strip()
x = int(rule.get("x", 0))
y = int(rule.get("y", 0))
# Determine label text: show ID (without #) or cell index
if selector.startswith("#"):
label_text = selector[1:] # Remove # prefix
elif "cell_index" in rule:
label_text = str(rule["cell_index"])
else:
label_text = selector if selector else "?"
# Draw label at top-left corner of cell with padding
text_x = x + 2
text_y = y + 2
# Draw text with slight shadow for readability
draw.text((text_x + 1, text_y + 1), label_text, fill=(0, 0, 0, 128), font=font)
draw.text((text_x, text_y), label_text, fill=label_rgba, font=font)
# Convert to tensor
arr = np.array(canvas).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr).unsqueeze(0)
return (tensor, json.dumps(stats, indent=2))
# -------- helpers (shared-style with svg_to_raster) --------
def _element_matches_selector(self, elem: ET.Element, selector: str) -> bool:
if not selector:
return False
s = selector.strip()
if s.startswith("#"):
return elem.get("id") == s[1:]
if s.startswith("."):
classes = (elem.get("class") or "").split()
return s[1:] in classes
if s == "g" or s.lower() == "group":
return elem.tag.endswith('}g')
if s.startswith("[") and s.endswith("]") and "=" in s:
k, v = s[1:-1].split("=", 1)
return (elem.get(k) or "") == v
return False
def _find_parent(self, root: ET.Element, child: ET.Element) -> Optional[ET.Element]:
# xml.etree.ElementTree lacks getparent; do a DFS to find parent
stack = [root]
while stack:
node = stack.pop()
for ch in list(node):
if ch is child:
return node
stack.append(ch)
return None
def _path_in_group_by_selector(self, root: ET.Element, path_elem: ET.Element, selector: str) -> bool:
# Return True if path_elem or any ancestor matches selector
node: Optional[ET.Element] = path_elem
visited = set()
while node is not None and node not in visited:
visited.add(node)
if self._element_matches_selector(node, selector):
return True
node = self._find_parent(root, node)
return False
def _normalize_color(self, s: str) -> str:
s = (s or "").strip()
if not s:
return "#00000000"
if s.startswith("#"):
if len(s) in (7, 9):
return s
if s.startswith("rgba"):
try:
inside = s[s.find("(") + 1 : s.find(")")]
parts = [p.strip() for p in inside.split(",")]
if len(parts) == 4:
r = max(0, min(255, int(float(parts[0]))))
g = max(0, min(255, int(float(parts[1]))))
b = max(0, min(255, int(float(parts[2]))))
a = parts[3]
if float(a) <= 1.0:
a = int(round(float(a) * 255))
else:
a = int(round(float(a)))
a = max(0, min(255, int(a)))
return f"#{r:02X}{g:02X}{b:02X}{a:02X}"
except Exception:
return "#00000000"
return s
def _parse_rgba(self, s: str):
s = (s or "#00000000").lstrip("#")
if len(s) == 6:
s += "FF"
r = int(s[0:2], 16)
g = int(s[2:4], 16)
b = int(s[4:6], 16)
a = int(s[6:8], 16)
return (r, g, b, a)
def _color_to_rgba(self, color: str, opacity: float = 1.0) -> Optional[Tuple[int, int, int, int]]:
if not color or color.lower() == "none":
return None
if color.startswith("#"):
r, g, b, a = self._parse_rgba(self._normalize_color(color))
a = int(max(0, min(255, a * opacity))) if a > 1 else int(max(0, min(255, 255 * opacity)))
return (r, g, b, a)
named = {
"black": (0, 0, 0, int(255 * opacity)),
"white": (255, 255, 255, int(255 * opacity)),
"red": (255, 0, 0, int(255 * opacity)),
"green": (0, 128, 0, int(255 * opacity)),
"blue": (0, 0, 255, int(255 * opacity)),
}
return named.get(color.lower(), (0, 0, 0, int(255 * opacity)))
def _parse_svg_path(self, d: str, offset_x: float, offset_y: float, scale_x: float, scale_y: float) -> List[Tuple[float, float]]:
coords = []
tokens = re.findall(r'[MLHVCSQTAZmlhvcsqtaz]|[-+]?[0-9]*\.?[0-9]+', d)
i = 0
current_x = current_y = 0.0
start_x = start_y = 0.0
last_cmd = ''
def abs_pt(x, y):
return ((x - offset_x) * scale_x, (y - offset_y) * scale_y)
while i < len(tokens):
cmd = tokens[i]
i += 1
last_cmd = cmd
rel = cmd.islower()
cu = cmd.upper()
if cu == 'M':
x = float(tokens[i]); i += 1
y = float(tokens[i]); i += 1
if rel:
x += current_x; y += current_y
current_x, current_y = x, y
start_x, start_y = x, y
coords.append(abs_pt(current_x, current_y))
elif cu == 'L':
x = float(tokens[i]); i += 1
y = float(tokens[i]); i += 1
if rel:
x += current_x; y += current_y
current_x, current_y = x, y
coords.append(abs_pt(current_x, current_y))
elif cu == 'H':
x = float(tokens[i]); i += 1
if rel:
x += current_x
current_x = x
coords.append(abs_pt(current_x, current_y))
elif cu == 'V':
y = float(tokens[i]); i += 1
if rel:
y += current_y
current_y = y
coords.append(abs_pt(current_x, current_y))
elif cu == 'C':
# cubic Bezier: (x1,y1,x2,y2,x,y) -> we sample end point only (simple)
# For better quality, adaptive sampling can be added later
i += 4 # skip control points (x1,y1,x2,y2)
x = float(tokens[i]); i += 1
y = float(tokens[i]); i += 1
if rel:
x += current_x; y += current_y
current_x, current_y = x, y
coords.append(abs_pt(current_x, current_y))
elif cu == 'Z':
if start_x != current_x or start_y != current_y:
coords.append(abs_pt(start_x, start_y))
current_x, current_y = start_x, start_y
return coords
NODE_CLASS_MAPPINGS = {
"SVGGroupLayout": SVGGroupLayout
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SVGGroupLayout": "SVG Group Layout"
}