Files
2025-08-22 15:55:42 +02:00

361 lines
20 KiB
Python

# convertSVGtoIMG.py — v12.3 (Suppression de l'entrée svg_path pour plus de clarté)
import os, re, io, json, traceback
import numpy as np
from PIL import Image, ImageDraw
from xml.etree import ElementTree as ET
# ... [TOUTES LES FONCTIONS HELPER RESTENT IDENTIQUES] ...
try: from svgpathtools import parse_path, Path
except Exception: parse_path = None
try: import torch
except Exception: torch = None
from shapely.geometry import Polygon, LinearRing, LineString, MultiLineString, Point, box, MultiPolygon
from shapely.ops import unary_union
from shapely import affinity
def _pil_image_to_comfy_image(img, keep_alpha=False):
arr = np.array(img).astype(np.float32)/255.0
if arr.ndim == 2: arr = np.stack([arr,arr,arr], -1)
if not keep_alpha and arr.shape[-1] == 4: arr = arr[...,:3]
return torch.from_numpy(arr).unsqueeze(0) if torch is not None else arr[None,...]
def _pil_mask_to_comfy_mask(mask_img):
m = np.array(mask_img.convert("L")).astype(np.float32)/255.0
return torch.from_numpy(m).unsqueeze(0) if torch is not None else m[None,...]
# ... [Toutes les autres fonctions helpers jusqu'à la classe du Node] ...
def _parse_style_inline(style_str):
out = {}
if not style_str: return out
for item in style_str.split(";"):
if ":" in item:
k,v = item.split(":",1); out[k.strip()] = v.strip()
return out
def _strip_css_comments(s): return re.sub(r"/\*.*?\*/","",s,flags=re.S)
def _parse_css_classes(root):
css = {}
for st in root.findall(".//{http://www.w3.org/2000/svg}style"):
txt = "".join(st.itertext()) or ""
if not txt:
continue
txt = _strip_css_comments(txt)
# .foo{...} et ".foo, .bar { ... }"
for m in re.finditer(r"\.([A-Za-z0-9_-]+(?:\s*,\s*\.[A-Za-z0-9_-]+)*)\s*\{([^}]*)\}", txt, flags=re.S):
selectors = [s.strip().lstrip(".") for s in m.group(1).split(",")]
body = m.group(2)
decl = _parse_style_inline(body)
for cls in selectors:
cur = css.get(cls, {}).copy()
cur.update(decl)
css[cls] = cur
return css
def _hex_norm(v):
if not v: return None
v = v.strip().lower()
if v in ("none","transparent"): return None
if v.startswith("url("): return "degraded"
m = re.match(r'rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)', v)
if m: r,g,b = (int(m.group(1)), int(m.group(2)), int(m.group(3))); return f'#{r:02x}{g:02x}{b:02x}'
if re.match(r'^#([0-9a-f]{3})$', v): a,b,c = v[1],v[2],v[3]; return f'#{a}{a}{b}{b}{c}{c}'
if re.match(r'^#([0-9a-f]{6})$', v): return v
return v
def _rgb_from_hex(h):
if not h or not isinstance(h,str) or not h.startswith("#") or len(h)!=7: return None
return [int(h[1:3],16),int(h[3:5],16),int(h[5:7],16)]
def _parse_len(val, fallback=0.0):
if val is None: return fallback
s = str(val).strip().lower()
m = re.match(r'^([+-]?\d*\.?\d+)\s*([a-z%]*)$', s)
if not m:
try: return float(s)
except Exception: return fallback
num = float(m.group(1)); unit = m.group(2)
conv = {"":1.0,"px":1.0,"pt":96.0/72.0,"pc":16.0,"in":96.0,"mm":96.0/25.4,"cm":96.0/2.54,"q":96.0/101.6}
return num * conv.get(unit, 1.0)
def _parse_transform_attr(attr):
out=[]
if not attr: return out
try:
for cmd,arg in re.findall(r'(\w+)\s*\(([^)]+)\)', attr.strip()):
args=[float(a) for a in arg.replace(","," ").split()]
out.append((cmd,args))
except Exception: pass
return out
def _apply_transform_chain(geom, chain):
if not chain: return geom
for cmd,args in chain:
if cmd=="translate": geom=affinity.translate(geom, xoff=args[0], yoff=args[1] if len(args)>1 else 0.0)
elif cmd=="scale": sx=args[0]; sy=args[1] if len(args)>1 else sx; geom=affinity.scale(geom, xfact=sx, yfact=sy, origin=(0,0))
elif cmd=="rotate": geom=affinity.rotate(geom, args[0], origin=(0,0))
elif cmd=="matrix" and len(args)==6: a,b,c,d,e,f = args; geom=affinity.affine_transform(geom,[a,b,c,d,e,f])
return geom
def _parse_hex_any(h):
if isinstance(h,dict): r,g,b=int(h.get('r',0)),int(h.get('g',0)),int(h.get('b',0)); return (max(0,min(255,r)),max(0,min(255,g)),max(0,min(255,b)))
if not isinstance(h,str): return (255,255,255)
s=h.strip().lower()
if s.startswith('#') and len(s)==7:
try: return (int(s[1:3],16),int(s[3:5],16),int(s[5:7],16))
except Exception: return (255,255,255)
m=re.match(r'rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)', s)
if m: return (int(m.group(1)),int(m.group(2)),int(m.group(3)))
return (255,255,255)
XLINK="{http://www.w3.org/1999/xlink}href"
def _collect_styles(el, inh, css):
style_inline=_parse_style_inline(el.get("style",""))
cls_decl={}
cls_attr = el.get("class","")
if cls_attr:
for c in cls_attr.split():
if c in css: cls_decl.update(css[c])
fill_here,stroke_here,sw_here=el.get("fill"),el.get("stroke"),el.get("stroke-width")
if not fill_here: fill_here = style_inline.get("fill") or cls_decl.get("fill")
if not stroke_here: stroke_here = style_inline.get("stroke") or cls_decl.get("stroke")
if not sw_here: sw_here = style_inline.get("stroke-width") or cls_decl.get("stroke-width")
eff_fill=_hex_norm(fill_here) if fill_here is not None else inh["fill"]
eff_stroke=_hex_norm(stroke_here) if stroke_here is not None else inh["stroke"]
eff_sw=_parse_len(sw_here, inh["stroke_width"])
return eff_fill, eff_stroke, eff_sw
def _collect_shapes(svg_bytes):
root = ET.fromstring(svg_bytes); css=_parse_css_classes(root); shapes=[]; visited_uses=set()
def resolve_ref(href):
if not href: return None
if href.startswith("#"): href=href[1:]
return root.find(f".//*[@id='{href}']")
def add_fill(poly, col):
try:
g=poly if (poly.is_valid and poly.area> 1e-8) else poly.buffer(0)
if g.is_valid and not g.is_empty and g.area>1e-8: shapes.append({"geom": g, "paint": col, "kind":"fill"})
except: pass
def add_stroke_lines(lines, width, col):
w=width if width>0 else 1.5
try:
buf=lines.buffer(w*0.5, cap_style=1, join_style=1)
buf=buf if (buf.is_valid and not buf.is_empty) else buf.buffer(0)
if buf.is_valid and not buf.is_empty and buf.area> 1e-8: shapes.append({"geom": buf, "paint": col, "kind":"stroke"})
except: pass
def add_stroke_polygon(poly, width, col):
try: add_stroke_lines(poly.exterior, width, col)
except: pass
def walk(el, inh):
eff_fill, eff_stroke, eff_sw = _collect_styles(el, inh, css)
tr_chain = inh["transform"] + _parse_transform_attr(el.get("transform"))
tag = el.tag.split("}")[-1]
try:
if tag=="use":
href=el.get(XLINK) or el.get("href"); ref=resolve_ref(href)
if ref is not None:
key=(ref.get("id",""), id(ref))
if key in visited_uses: return
visited_uses.add(key)
dx=_parse_len(el.get("x",0)); dy=_parse_len(el.get("y",0))
inh2={"fill":eff_fill,"stroke":eff_stroke,"stroke_width":eff_sw,"transform":tr_chain+[("translate",[dx,dy])]}
walk(ref, inh2)
return
if tag=="rect":
x,y,w,h=_parse_len(el.get("x",0)),_parse_len(el.get("y",0)),_parse_len(el.get("width",0)),_parse_len(el.get("height",0))
if w>0 and h>0:
p=box(x,y,x+w,y+h); p=_apply_transform_chain(p,tr_chain)
if eff_fill is not None: add_fill(p, eff_fill)
if eff_stroke is not None and eff_sw>0: add_stroke_polygon(p,eff_sw,eff_stroke)
elif tag in ("circle","ellipse"):
cx,cy=_parse_len(el.get("cx",0)),_parse_len(el.get("cy",0))
if tag=="circle": r=max(_parse_len(el.get("r",0)),0.1); p=Point(cx,cy).buffer(r,96)
else: rx,ry=max(_parse_len(el.get("rx",0)),0.1),max(_parse_len(el.get("ry",0)),0.1); p=Point(cx,cy).buffer(1.0,96); p=affinity.scale(p,xfact=rx,yfact=ry,origin=(cx,cy))
p=_apply_transform_chain(p,tr_chain)
if eff_fill is not None: add_fill(p, eff_fill)
if eff_stroke is not None and eff_sw>0: add_stroke_polygon(p,eff_sw,eff_stroke)
elif tag=="polygon" or tag=="polyline":
raw=(el.get("points","") or "").replace(","," "); vals=[v for v in raw.split() if v]; pts=[]
if len(vals)>=4 and len(vals)%2==0:
for i in range(0,len(vals),2): pts.append((float(vals[i]), float(vals[i+1])))
if len(pts)>=3:
is_closed = (tag=="polygon") or (np.linalg.norm(np.array(pts[0])-np.array(pts[-1]))< 1e-3)
if is_closed:
if np.linalg.norm(np.array(pts[0])-np.array(pts[-1]))>1e-6: pts.append(pts[0])
p=Polygon(LinearRing(pts)); p=_apply_transform_chain(p,tr_chain)
if eff_fill is not None: add_fill(p, eff_fill)
if eff_stroke is not None and eff_sw>0: add_stroke_polygon(p,eff_sw,eff_stroke)
else:
ls=LineString(pts); ls=_apply_transform_chain(ls,tr_chain)
if eff_stroke is not None: add_stroke_lines(ls,eff_sw,eff_stroke)
else: shapes.append({"geom": ls, "paint": eff_fill, "kind":"open"})
elif tag=="path" and parse_path is not None:
d=el.get("d","");
if d:
try: pth:Path=parse_path(d)
except: pth=None
if pth:
try: subs=pth.continuous_subpaths()
except: subs=[pth]
rings, open_lines = [], []
for sp in subs:
pts = [ (seg.point(t).real, seg.point(t).imag) for seg in sp for t in np.linspace(0,1, max(2,int(seg.length()/2)+2)) ]
if len(pts)<2: continue
closed = (np.linalg.norm(np.array(pts[0])-np.array(pts[-1]))<1e-3) or sp.isclosed()
if closed and len(pts)>=3:
if np.linalg.norm(np.array(pts[0])-np.array(pts[-1]))>1e-6: pts.append(pts[0])
try:
poly_i=Polygon(LinearRing(pts))
if poly_i.is_valid and poly_i.area>1e-9: poly_i=_apply_transform_chain(poly_i,tr_chain); rings.append(poly_i)
except: pass
else: ls=LineString(pts); ls=_apply_transform_chain(ls,tr_chain); open_lines.append(ls)
if rings and eff_fill is not None:
geom_fill=unary_union(rings).buffer(0)
if geom_fill and not geom_fill.is_empty: shapes.append({"geom": geom_fill, "paint": eff_fill, "kind":"fill"})
if open_lines:
ml = MultiLineString(open_lines)
if eff_stroke is not None: add_stroke_lines(ml,eff_sw,eff_stroke)
else: shapes.append({"geom": ml, "paint": eff_fill, "kind":"open"})
child_inh={"fill":eff_fill,"stroke":eff_stroke,"stroke_width":eff_sw,"transform":inh["transform"]+_parse_transform_attr(el.get("transform"))}
for ch in list(el): walk(ch, child_inh)
except: pass
walk(root, {"fill":None,"stroke":None,"stroke_width":0.0,"transform":[]})
return shapes, root
def _viewbox_aspect(root, shapes):
vb=root.get("viewBox")
if vb:
parts=[p for p in vb.replace(","," ").split() if p]
if len(parts)==4:
try: _,_,vw,vh=[float(x) for x in parts]; return (vw/vh) if vw>0 and vh>0 else 1.0
except: pass
try:
vw=float((root.get("width") or "0").replace("px","")); vh=float((root.get("height") or "0").replace("px",""))
if vw>0 and vh>0: return vw/vh
except: pass
polys=[s["geom"] for s in shapes if hasattr(s["geom"],"bounds")]
if not polys: return 1.0
minx,miny=min(p.bounds[0] for p in polys),min(p.bounds[1] for p in polys)
maxx,maxy=max(p.bounds[2] for p in polys),max(p.bounds[3] for p in polys)
w,h=maxx-minx,maxy-miny
return (w/h) if (w>0 and h>0) else 1.0
def _rasterize_native(shapes, out_w, out_h, bg_hex, transparent, pad_px, stroke_only, open_subpaths_px):
out_w,out_h,pad_px=int(out_w),int(out_h),int(max(0,pad_px))
mode="RGBA" if transparent else "RGB"; fill=(0,0,0,0) if transparent else _parse_hex_any(bg_hex)
if not shapes: return Image.new(mode,(out_w,out_h),fill)
geoms=[s["geom"] for s in shapes if hasattr(s["geom"],"bounds")]
minx,miny=min(g.bounds[0] for g in geoms),min(g.bounds[1] for g in geoms)
maxx,maxy=max(g.bounds[2] for g in geoms),max(g.bounds[3] for g in geoms)
w,h=float(maxx-minx),float(maxy-miny)
if w<=1e-12 or h<=1e-12: return Image.new(mode,(out_w,out_h),fill)
s=min(float(out_w-2*pad_px)/max(w,1e-8), float(out_h-2*pad_px)/max(h,1e-8))
offx,offy=float(pad_px)+(float(out_w-2*pad_px)-s*w)*0.5, float(pad_px)+(float(out_h-2*pad_px)-s*h)*0.5
def to_px(pt): return (int(round((float(pt[0])-minx)*s+offx)), int(round((float(pt[1])-miny)*s+offy)))
bg=_parse_hex_any(bg_hex); img=Image.new(mode,(out_w,out_h),(0,0,0,0) if transparent else bg)
draw=ImageDraw.Draw(img)
def paint_polygon(p, color_rgb):
ex=[to_px(v) for v in p.exterior.coords]
draw.polygon(ex, fill=(color_rgb+(255,)) if transparent else color_rgb)
for hole in p.interiors: hx=[to_px(v) for v in hole.coords]; draw.polygon(hx, fill=(0,0,0,0) if transparent else bg)
for sshape in shapes:
kind,geom,paint=sshape.get("kind","fill"),sshape["geom"],sshape["paint"]
col_rgb=_parse_hex_any(paint) if (isinstance(paint,str) and paint!="degraded") else (255,255,255)
if (stroke_only and kind not in ("stroke","open")) or ((not stroke_only) and kind not in ("fill","stroke","open")): continue
if isinstance(geom, (Polygon,MultiPolygon)):
if kind=="fill" or (not stroke_only and kind=="stroke"):
polys = geom.geoms if isinstance(geom,MultiPolygon) else [geom]
for p in polys: paint_polygon(p, col_rgb)
elif kind=="open" and open_subpaths_px>0:
w_geom=(open_subpaths_px/max(s,1e-8))*0.5
try:
buf=geom.buffer(w_geom,cap_style=1,join_style=1)
if buf.is_valid and not buf.is_empty:
polys = buf.geoms if isinstance(buf,MultiPolygon) else [buf]
for p in polys: paint_polygon(p, col_rgb)
except: pass
return img
def _rasterize_cairo(svg_bytes, width, transparent_bg, background_hex, pad_px):
try: import cairosvg
except Exception as e: raise RuntimeError("CairoSVG n'est pas installé. `pip install cairosvg`.") from e
bg = 'rgba(0,0,0,0)' if transparent_bg else _rgba_str(_parse_hex_any(background_hex))
png_bytes = cairosvg.svg2png(bytestring=svg_bytes, output_width=int(width), background_color=bg)
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA" if transparent_bg else "RGB")
if pad_px>0:
w,h=im.size; mode="RGBA" if transparent_bg else "RGB"
canvas=Image.new(mode,(w+2*pad_px,h+2*pad_px),(0,0,0,0) if transparent_bg else _parse_hex_any(background_hex))
canvas.paste(im,(pad_px,pad_px), im if im.mode=="RGBA" else None)
im=canvas
return im
def _rgba_str(rgb): return f"rgba({rgb[0]},{rgb[1]},{rgb[2]},1)" if len(rgb)==3 else "rgba(0,0,0,1)"
def _mask_coverage(img, background_hex):
if img.mode=="RGBA": return float((np.array(img.split()[-1])>0).mean())
arr,bg=np.array(img),_parse_hex_any(background_hex)
diff=(arr[:,:,0]!=bg[0])|(arr[:,:,1]!=bg[1])|(arr[:,:,2]!=bg[2])
return float(diff.mean())
# ---------- Node ----------
class ConvertSVGtoIMG:
@classmethod
def INPUT_TYPES(cls):
return {
"required":{
"svg_text": ("SVG_TEXT", {"multiline": True, "default": ""}),
},
"optional": {
# "svg_path": ("STRING", {"default": ""}), <-- SUPPRIMÉ
"width": ("INT", {"default": 512, "min":16, "max":4096}),
"scale_in_canvas": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.01}),
"transparent_bg": ("BOOLEAN", {"default": False}),
"background_hex": ("STRING", {"default": "#000000"}),
"pad_px": ("INT", {"default": 0, "min":0, "max":512}),
"keep_alpha_as_mask": ("BOOLEAN", {"default": True}),
"stroke_only": ("BOOLEAN", {"default": False}),
"open_subpaths_px": ("INT", {"default": 0, "min":0, "max":512}),
"renderer": (["auto","native","cairosvg"], {"default": "auto"}),
}
}
RETURN_TYPES=("IMAGE","MASK","STRING")
RETURN_NAMES=("image","mask","colors_json")
FUNCTION="run"
CATEGORY = "DAO_master/SVG/Convert"
def run(self, svg_text, width=512, scale_in_canvas=1.0, transparent_bg=False,
background_hex="#000000", pad_px=0, keep_alpha_as_mask=True, stroke_only=False,
open_subpaths_px=0, renderer="auto"):
# --- LOGIQUE D'ENTRÉE SIMPLIFIÉE ---
if not svg_text or not svg_text.strip():
raise ValueError("Aucune entrée SVG (svg_text) n'a été fournie.")
svg_bytes = svg_text.strip().encode('utf-8')
try:
root = ET.fromstring(svg_bytes)
viewBox = root.get('viewBox')
if viewBox and abs(scale_in_canvas - 1.0) > 1e-6:
vx, vy, vw, vh = map(float, viewBox.split())
new_w, new_h = vw / scale_in_canvas, vh / scale_in_canvas
new_x, new_y = vx - (new_w - vw) / 2, vy - (new_h - vh) / 2
root.set('viewBox', f'{new_x} {new_y} {new_w} {new_h}')
svg_bytes = ET.tostring(root, encoding='utf-8')
except Exception as e: print(f"Avertissement: Impossible d'ajuster la viewBox du SVG: {e}")
shapes, root = _collect_shapes(svg_bytes)
report=[]
for i,s in enumerate(shapes):
paint,kind=s["paint"],s.get("kind","fill")
entry={"index":i,"kind":kind,"type":"flat","hex":paint,"rgb":_rgb_from_hex(paint)} if paint not in ("degraded",None) else {"index":i,"kind":kind,"type":paint or "none"}
report.append(entry)
aspect=_viewbox_aspect(root, shapes)
out_w=int(width); out_h=max(1,int(round(out_w/max(aspect,1e-8))))
img = None
if renderer in ("native","auto"):
try: img = _rasterize_native(shapes, out_w, out_h, bg_hex=background_hex, transparent=transparent_bg, pad_px=int(pad_px), stroke_only=bool(stroke_only), open_subpaths_px=int(open_subpaths_px))
except Exception as e: print(f"Erreur du renderer 'native': {e}"); img = None
if img is None or (renderer == "auto" and _mask_coverage(img, background_hex) < 0.005):
try: img = _rasterize_cairo(svg_bytes, out_w, transparent_bg, background_hex, pad_px)
except Exception as e: raise RuntimeError(f"Les deux renderers ont échoué. Erreur CairoSVG: {e}")
if keep_alpha_as_mask:
if img.mode=="RGBA": mask_img=img.split()[-1]
else:
bg=_parse_hex_any(background_hex); arr=np.array(img.convert("RGB"))
diff=(arr[:,:,0]!=bg[0])|(arr[:,:,1]!=bg[1])|(arr[:,:,2]!=bg[2])
mask_arr=diff.astype(np.uint8)*255; mask_img=Image.fromarray(mask_arr,mode="L")
else: mask_img = Image.new("L", img.size, 255)
return (_pil_image_to_comfy_image(img, keep_alpha=transparent_bg), _pil_mask_to_comfy_mask(mask_img), json.dumps(report, ensure_ascii=False, indent=2))
NODE_CLASS_MAPPINGS={"ConvertSVGtoIMG": ConvertSVGtoIMG}
NODE_DISPLAY_NAME_MAPPINGS={"ConvertSVGtoIMG": "Convert SVG → IMG (+colors)"}