Files
coulterj-comfyui-svg-visual…/svg_visual_normalize_node.py

276 lines
13 KiB
Python

import lxml.etree as ET
import re
import io # For in-memory byte streams
# --- Dependency Check ---
try:
import cairosvg
CAIROSVG_AVAILABLE = True
except ImportError:
CAIROSVG_AVAILABLE = False
try:
from PIL import Image
PILLOW_AVAILABLE = True
except ImportError:
PILLOW_AVAILABLE = False
# ----------------------
class SVGVisualBoundsNormalize:
def __init__(self):
if not CAIROSVG_AVAILABLE:
print("\n\n\033[31m[ERROR] SVGVisualBoundsNormalize Node:\033[0m The required 'CairoSVG' library is not installed.")
print("\033[33m[ERROR] Please run 'pip install CairoSVG' and ensure system Cairo libraries are installed.\033[0m\n\n")
if not PILLOW_AVAILABLE:
print("\n\n\033[31m[ERROR] SVGVisualBoundsNormalize Node:\033[0m The required 'Pillow' library is not installed.")
print("\033[33m[ERROR] Please run 'pip install Pillow'.\033[0m\n\n")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"svg_string": ("STRING", {"multiline": True, "default": ""}),
"margin_percent": ("FLOAT", {
"default": 0.00, "min": 0.0, "max": 0.499, "step": 0.001,
"display": "number", "precision": 4
}),
"visual_bbox_padding_percent": ("FLOAT", { # Padding for the visual bbox
"default": 0.00, "min": 0.0, "max": 0.1, # Usually less padding needed if visual bbox is accurate
"step": 0.001, "display": "number", "precision": 4
}),
# Manual center offsets are kept as a final fallback if needed
"center_offset_x_percent": ("FLOAT", {
"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001,
"display": "number", "precision": 4
}),
"center_offset_y_percent": ("FLOAT", {
"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001,
"display": "number", "precision": 4
}),
"output_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"output_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"render_scale_for_bbox": ("FLOAT", { # Scale factor for temporary rendering
"default": 1.0, "min": 0.5, "max": 4.0, "step": 0.1,
"display": "number", "precision": 2
}),
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "svg"
def _parse_dimension(self, dim_str):
if dim_str is None: return None
cleaned_dim_str = re.sub(r"[^\d\.\-]", "", dim_str)
if not cleaned_dim_str: return None
try: return float(cleaned_dim_str)
except ValueError: return None
def _get_visual_bbox(self, svg_string_content, declared_width, declared_height, render_scale):
if not CAIROSVG_AVAILABLE or not PILLOW_AVAILABLE:
print("SVGVisualBoundsNormalize: Missing CairoSVG or Pillow for visual bbox calculation.")
return None
try:
# Determine rendering dimensions
# If declared_width/height are valid, use them scaled by render_scale
# Otherwise, CairoSVG will use intrinsic size or a default
render_w, render_h = None, None
if declared_width > 0 and declared_height > 0:
render_w = int(declared_width * render_scale)
render_h = int(declared_height * render_scale)
# print(f"DEBUG: Rendering SVG at {render_w}x{render_h} (scale: {render_scale}) for bbox detection.")
png_bytes = cairosvg.svg2png(
bytestring=svg_string_content.encode('utf-8'),
output_width=render_w if render_w else None, # Pass None if not determined, CairoSVG will decide
output_height=render_h if render_h else None
)
img = Image.open(io.BytesIO(png_bytes))
# Ensure image is RGBA to get accurate bbox based on transparency
if img.mode != 'RGBA':
img = img.convert('RGBA')
pixel_bbox = img.getbbox() # Returns (left, upper, right, lower) or None
if pixel_bbox is None:
print("SVGVisualBoundsNormalize: Pillow getbbox returned None (image might be empty/fully transparent).")
return None # Could default to full canvas: (0, 0, declared_width, declared_height)
left, upper, right, lower = pixel_bbox
# Map pixel bbox back to SVG units
# If we rendered at scaled dimensions, we need to scale back
# The coordinate system of the rendered PNG is 0,0 at top-left.
# This should map directly to SVG units if the SVG's viewBox is effectively 0,0 to declared_width, declared_height
svg_bbox_x = left / render_scale
svg_bbox_y = upper / render_scale
svg_bbox_w = (right - left) / render_scale
svg_bbox_h = (lower - upper) / render_scale
return (svg_bbox_x, svg_bbox_y, svg_bbox_w, svg_bbox_h)
except Exception as e:
print(f"SVGVisualBoundsNormalize: Error during visual bbox calculation: {e}")
return None
def process(self, svg_string, margin_percent, visual_bbox_padding_percent,
center_offset_x_percent, center_offset_y_percent,
output_width, output_height, render_scale_for_bbox):
print("\n--- SVGVisualBoundsNormalize: Process Start ---") # DEBUG
if not CAIROSVG_AVAILABLE or not PILLOW_AVAILABLE:
print("SVGVisualBoundsNormalize: ERROR - CairoSVG or Pillow library is missing.")
return (svg_string,)
if not svg_string.strip(): return ("<svg></svg>",)
# Original SVG string needed for rendering if lxml modifies it too much (e.g. strips comments)
original_svg_for_render = svg_string
processed_svg_string = svg_string
if processed_svg_string.startswith("<?xml"):
declaration_end = processed_svg_string.find("?>")
if declaration_end != -1:
processed_svg_string = processed_svg_string[declaration_end + 2:].lstrip()
if not processed_svg_string.strip(): return ("<svg></svg>",)
root = None
try:
byte_input = processed_svg_string.encode('utf-8')
parser = ET.XMLParser(remove_blank_text=True, recover=True)
root = ET.fromstring(byte_input, parser=parser)
if not isinstance(root, ET._Element): return (svg_string,)
except ET.XMLSyntaxError: return (svg_string,)
if root is None or ET.QName(root).localname != 'svg': return (svg_string,)
# Determine initial canvas_w/h (these define the viewBox coordinate system)
# These are also used as the base for rendering scale if render_scale_for_bbox is 1.0
initial_canvas_w_str, initial_canvas_h_str = root.get('width'), root.get('height')
initial_canvas_w, initial_canvas_h = 0.0, 0.0
parsed_from_attrs = False
if initial_canvas_w_str and initial_canvas_h_str:
w = self._parse_dimension(initial_canvas_w_str)
h = self._parse_dimension(initial_canvas_h_str)
if w is not None and h is not None and w > 0 and h > 0:
initial_canvas_w, initial_canvas_h = w, h
parsed_from_attrs = True
if not parsed_from_attrs:
viewbox_str = root.get('viewBox')
if viewbox_str:
parts = [p.strip() for p in viewbox_str.replace(',', ' ').split()]
if len(parts) == 4:
try:
_, _, vb_w, vb_h = map(float, parts)
if vb_w > 0 and vb_h > 0:
initial_canvas_w, initial_canvas_h = vb_w, vb_h
except ValueError: pass
if initial_canvas_w <= 0 or initial_canvas_h <= 0:
print("SVGVisualBoundsNormalize: Error determining initial canvas dimensions for rendering/viewBox.")
return (svg_string,)
print(f"DEBUG: Initial SVG canvas (viewBox system) w={initial_canvas_w}, h={initial_canvas_h}")
# --- Get VISUAL Bounding Box ---
vis_bbox = self._get_visual_bbox(original_svg_for_render, initial_canvas_w, initial_canvas_h, render_scale_for_bbox)
if vis_bbox is None:
print("SVGVisualBoundsNormalize: EXITING - Visual Bbox could not be determined.")
return (svg_string,)
vis_bbox_x, vis_bbox_y, vis_bbox_w, vis_bbox_h = vis_bbox
print(f"DEBUG: Visual Bbox (SVG units): x={vis_bbox_x:.2f}, y={vis_bbox_y:.2f}, w={vis_bbox_w:.2f}, h={vis_bbox_h:.2f}")
# Effective visual bbox after padding
eff_vis_bbox_x, eff_vis_bbox_y, eff_vis_bbox_w, eff_vis_bbox_h = vis_bbox_x, vis_bbox_y, vis_bbox_w, vis_bbox_h
if visual_bbox_padding_percent > 0.0:
padding_offset_w = vis_bbox_w * visual_bbox_padding_percent
padding_offset_h = vis_bbox_h * visual_bbox_padding_percent
eff_vis_bbox_x = vis_bbox_x - padding_offset_w
eff_vis_bbox_y = vis_bbox_y - padding_offset_h
eff_vis_bbox_w = vis_bbox_w + (2 * padding_offset_w)
eff_vis_bbox_h = vis_bbox_h + (2 * padding_offset_h)
print(f"DEBUG: Effective Visual Bbox (padding {visual_bbox_padding_percent*100:.1f}%): x={eff_vis_bbox_x:.2f}, y={eff_vis_bbox_y:.2f}, w={eff_vis_bbox_w:.2f}, h={eff_vis_bbox_h:.2f}")
if eff_vis_bbox_w <= 0 or eff_vis_bbox_h <= 0:
print(f"SVGVisualBoundsNormalize: EXITING - Effective Visual Bbox width or height is <= 0.")
return (svg_string,)
# Target area for content *within the viewBox coordinate system*
viewbox_margin_x = initial_canvas_w * margin_percent
viewbox_margin_y = initial_canvas_h * margin_percent
viewbox_target_w = initial_canvas_w - (2 * viewbox_margin_x)
viewbox_target_h = initial_canvas_h - (2 * viewbox_margin_y)
print(f"DEBUG: ViewBox target area: w={viewbox_target_w:.2f}, h={viewbox_target_h:.2f}")
if viewbox_target_w <=0 or viewbox_target_h <=0: return (svg_string,) # Margin too large
# Scale to fit the effective VISUAL bbox into the target area
scale_x_fit = viewbox_target_w / eff_vis_bbox_w
scale_y_fit = viewbox_target_h / eff_vis_bbox_h
scale = min(scale_x_fit, scale_y_fit)
print(f"DEBUG: Final Scale (to fit eff_vis_bbox into viewbox_target): {scale:.4f}")
if scale <= 0: return (svg_string,)
scaled_content_w = eff_vis_bbox_w * scale
scaled_content_h = eff_vis_bbox_h * scale
# Translate to center the scaled effective VISUAL bbox
base_translate_x = viewbox_margin_x + (viewbox_target_w - scaled_content_w) / 2.0 - (eff_vis_bbox_x * scale)
base_translate_y = viewbox_margin_y + (viewbox_target_h - scaled_content_h) / 2.0 - (eff_vis_bbox_y * scale)
manual_offset_x = viewbox_target_w * center_offset_x_percent
manual_offset_y = viewbox_target_h * center_offset_y_percent
final_translate_x = base_translate_x + manual_offset_x
final_translate_y = base_translate_y + manual_offset_y
print(f"DEBUG: Final Translate: tx={final_translate_x:.2f}, ty={final_translate_y:.2f}")
transform_str = f"translate({final_translate_x} {final_translate_y}) scale({scale})"
print(f"DEBUG: Final <g> transform string: {transform_str}")
all_children = list(root)
nsmap = root.nsmap if hasattr(root, 'nsmap') and root.nsmap else {None: "http://www.w3.org/2000/svg"}
group_tag_name = ET.QName(nsmap.get(None, "http://www.w3.org/2000/svg"), "g")
group = ET.Element(group_tag_name, nsmap=nsmap)
group.set("transform", transform_str)
for child in all_children:
root.remove(child)
group.append(child)
root.append(group)
# Final output dimensions and viewBox
final_output_w, final_output_h = initial_canvas_w, initial_canvas_h
if output_width > 0 and output_height > 0:
final_output_w, final_output_h = float(output_width), float(output_height)
elif output_width > 0:
final_output_w = float(output_width)
if initial_canvas_w > 0: final_output_h = float(output_width * (initial_canvas_h / initial_canvas_w))
else: final_output_h = float(output_width)
elif output_height > 0:
final_output_h = float(output_height)
if initial_canvas_h > 0: final_output_w = float(output_height * (initial_canvas_w / initial_canvas_h))
else: final_output_w = float(output_height)
root.set('viewBox', f"0 0 {initial_canvas_w} {initial_canvas_h}")
root.set('width', str(final_output_w))
root.set('height', str(final_output_h))
final_svg_string = ET.tostring(root, encoding="unicode", xml_declaration=False)
print("--- SVGVisualBoundsNormalize: Process End ---") # DEBUG
return (final_svg_string,)
NODE_CLASS_MAPPINGS = {
"SVGVisualBoundsNormalize": SVGVisualBoundsNormalize
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SVGVisualBoundsNormalize": "SVG Visual Normalize & Margin"
}