# -*- coding: utf-8 -*- # DAO Text Maker — IMAGE + SVG + MASK, alpha en %, largeur alpha, vectorisation "holes safe" import os, re import numpy as np from aiohttp import web from server import PromptServer try: import torch except Exception: torch = None from PIL import Image, ImageDraw, ImageFont # Vectorisation propre (polygones) pour Illustrator try: from matplotlib.textpath import TextPath from matplotlib.font_manager import FontProperties _HAS_MPL = True except Exception: _HAS_MPL = False # ---------- helpers ---------- _HEX_ANY = re.compile(r"#([0-9A-Fa-f]{3,8})") def _rgb_from_any_hex(s: str, default=(0, 0, 0)): """Extrait le 1er #hex (#RGB/#RGBA/#RRGGBB/#RRGGBBAA).""" if not s: return default m = _HEX_ANY.search(str(s)) if not m: return default h = m.group(1) try: if len(h) == 3: # RGB r, g, b = (int(c*2, 16) for c in h) elif len(h) == 4: # RGBA -> ignore A r, g, b, _ = (int(c*2, 16) for c in h) elif len(h) == 6: # RRGGBB r, g, b = int(h[0:2],16), int(h[2:4],16), int(h[4:6],16) else: # RRGGBBAA r, g, b = int(h[0:2],16), int(h[2:4],16), int(h[4:6],16) return (r, g, b) except Exception: return default def _alpha_pct_to_01(a): """ Accepte 0..100 (%) ou 0..1 (fraction). Convertit en [0,1]. """ try: v = float(a) except Exception: return 1.0 if v <= 1.0: return max(0.0, min(1.0, v)) return max(0.0, min(1.0, v / 100.0)) def _to_tensor_rgb(img: Image.Image): arr = np.asarray(img, dtype=np.float32) / 255.0 if arr.ndim == 2: arr = np.stack([arr, arr, arr], axis=-1) arr = arr[..., :3] return torch.from_numpy(arr).unsqueeze(0) if torch is not None else arr[None, ...] def _to_tensor_mask(mask: Image.Image): arr = np.asarray(mask, dtype=np.float32) / 255.0 return torch.from_numpy(arr).unsqueeze(0) if torch is not None else arr[None, ...] def _load_font(font_path: str, size: int): try: if font_path and os.path.isfile(font_path): return ImageFont.truetype(font_path, size=size, layout_engine=ImageFont.Layout.BASIC) except Exception: pass try: return ImageFont.load_default() except Exception: return None # ---------- SVG builders ---------- def _make_svg_text(lines, width, height, font_family, font_size, fill_hex, fill_alpha, stroke_hex, stroke_width, stroke_alpha, align): ta = {"center": "middle", "left": "start", "right": "end"}.get(align, "middle") x = width // 2 if ta == "middle" else (0 if ta == "start" else width) line_h = font_size * 1.2 start_y = height/2 - (len(lines)-1)*line_h/2 fill = (fill_hex or "#000000").strip() stroke = (stroke_hex or "#000000").strip() fill_op = _alpha_pct_to_01(fill_alpha) stroke_op = _alpha_pct_to_01(stroke_alpha) tspans = [] for i, ln in enumerate(lines): dy = 0 if i == 0 else line_h esc = ln.replace("&","&").replace("<","<") if i == 0: tspans.append(f'{esc}') else: tspans.append(f'{esc}') return f''' {''.join(tspans)} ''' def _make_svg_paths(lines, width, height, font_path, font_size, fill_hex, fill_alpha, stroke_hex, stroke_width, stroke_alpha, align): """ Vectorisation robuste : 1 seul par ligne, trous OK (evenodd), et alignements 'left' / 'center' / 'right' identiques à PIL. """ if not _HAS_MPL: ff = os.path.basename(font_path) if font_path else "sans-serif" return _make_svg_text(lines, width, height, ff, font_size, fill_hex, fill_alpha, stroke_hex, stroke_width, stroke_alpha, align) # --- couleurs / opacités --- fill = (fill_hex or "#000000").strip() stroke = (stroke_hex or "#000000").strip() fill_op = _alpha_pct_to_01(fill_alpha) stroke_op = _alpha_pct_to_01(stroke_alpha) # --- font pour TextPath (vecteur) --- fp = FontProperties(fname=font_path if font_path else None, size=font_size) # --- font PIL pour mesurer la largeur "logique" (comme PIL dessine) --- try: pil_font = ImageFont.truetype(font_path, size=font_size, layout_engine=ImageFont.Layout.BASIC) \ if font_path else ImageFont.load_default() except Exception: pil_font = ImageFont.load_default() dummy = Image.new("L", (1, 1)) draw = ImageDraw.Draw(dummy) # --- placement vertical (milieu de ligne) --- line_h = font_size * 1.2 start_y = height/2 - (len(lines)-1)*line_h/2 def poly_to_d(poly, tx, ty): if len(poly) == 0: return "" parts = [] for j, (x, y) in enumerate(poly): X = x + tx Ysvg = height - (y + ty) # inversion Y pour SVG parts.append(("M" if j == 0 else "L") + f" {X:.3f} {Ysvg:.3f}") parts.append("Z") return " ".join(parts) paths = [] for i, ln in enumerate(lines): if not ln: continue # 1) largeur mesurée à la PIL (comme le rendu bitmap) # -> textbbox avec ancre 'lm' (left/middle) à x=0 donne un bbox # dont la largeur correspond à la logique PIL. bx0, by0, bx1, by1 = draw.textbbox((0, 0), ln, font=pil_font, anchor="lm") logical_w = bx1 - bx0 # 2) position horizontale désirée selon align (en coordonnées canvas) if align == "left": desired_left = 0.0 elif align == "right": desired_left = float(width) - float(logical_w) else: # center desired_left = float(width)/2.0 - float(logical_w)/2.0 # 3) géométrie réelle des contours (TextPath) tp = TextPath((0, 0), ln, prop=fp, size=font_size) bb = tp.get_extents() cx = (bb.x0 + bb.x1) / 2.0 cy = (bb.y0 + bb.y1) / 2.0 # 4) translation horizontale : on veut que le bord gauche du contour # (bb.x0) tombe sur desired_left -> tx = desired_left - bb.x0 tx = desired_left - bb.x0 # 5) translation verticale : on veut que le centre vertical tombe à dest_cy dest_cy = start_y + i * line_h ty = (height - dest_cy) - cy # correspond au flip Y dans poly_to_d # 6) concatène les sous-polygones de la ligne en un seul path d_all = [] for poly in tp.to_polygons(): d = poly_to_d(np.asarray(poly), tx, ty) if d: d_all.append(d) if not d_all: continue d_str = " ".join(d_all) paths.append( f'' ) return f''' {''.join(paths)} ''' # ---------- node ---------- class DAOTextMaker: CATEGORY = "DAO_master/Text" FUNCTION = "render" RETURN_TYPES = ("IMAGE", "SVG_TEXT", "MASK") RETURN_NAMES = ("image", "svg", "mask") OUTPUT_NODE = False @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"default": "HELLO DAO!\nTEST", "multiline": True}), "font_file": ("STRING", {"default": "", "multiline": False}), "font_size": ("INT", {"default": 128, "min": 4, "max": 4096}), "canvas_width": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}), "canvas_height": ("INT", {"default": 512, "min": 16, "max": 8192, "step": 1}), # Couleurs / alpha (en %) "fill_hex": ("STRING", {"default": "#FFFFFF"}), "fill_alpha": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 100.0}), # Contour visuel "stroke_width": ("INT", {"default": 0, "min": 0, "max": 256}), "stroke_hex": ("STRING", {"default": "#000000"}), "stroke_alpha": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 100.0}), # Fond "bg_transparent": ("BOOLEAN", {"default": True}), "bg_hex": ("STRING", {"default": "#000000"}), # Alignement / SVG / sortie "align": (["center", "left", "right"], {"default": "center"}), "svg_vectorize": ("BOOLEAN", {"default": True}), "image_rgba": ("BOOLEAN", {"default": True}), # Epaisseur supplémentaire du MASK (n’influence pas le rendu visuel) "stroke_width_alpha": ("INT", {"default": 0, "min": 0, "max": 256}), } } # Fonts utils @classmethod def _fonts_dir(cls): return os.path.join(os.path.dirname(__file__), "Fonts") @classmethod def _ensure_fonts_dir(cls): d = cls._fonts_dir(); os.makedirs(d, exist_ok=True); return d @classmethod def _available_fonts(cls): d = cls._ensure_fonts_dir() try: return sorted([f for f in os.listdir(d) if f.lower().endswith((".ttf",".otf",".ttc",".otc"))], key=str.lower) except Exception: return [] def render(self, text, font_file, font_size, canvas_width, canvas_height, fill_hex, fill_alpha, stroke_width, stroke_hex, stroke_alpha, bg_transparent, bg_hex, align, svg_vectorize, image_rgba, stroke_width_alpha): W = int(canvas_width); H = int(canvas_height) fill_rgb = _rgb_from_any_hex(fill_hex, default=(255,255,255)) stroke_rgb = _rgb_from_any_hex(stroke_hex, default=(0,0,0)) bg_rgb = _rgb_from_any_hex(bg_hex, default=(0,0,0)) fill_op = _alpha_pct_to_01(fill_alpha) stroke_op = _alpha_pct_to_01(stroke_alpha) stroke_w = max(0, int(stroke_width)) grow_alpha = max(0, int(stroke_width_alpha)) # Police font_path = font_file if font_path and not os.path.isabs(font_path): font_path = os.path.join(self._fonts_dir(), font_path) font = _load_font(font_path, int(font_size)) or ImageFont.load_default() # --- calque texte (RGBA) pour rendu visuel --- text_rgba = Image.new("RGBA", (W, H), (0,0,0,0)) draw = ImageDraw.Draw(text_rgba) lines = (text or "").splitlines() or [""] line_h = int(font_size * 1.2) start_y = H/2 - (len(lines)-1)*line_h/2 anchor = {"center":"mm", "left":"lm", "right":"rm"}[align] x = W//2 if align=="center" else (0 if align=="left" else W) for i, ln in enumerate(lines): y = start_y + i*line_h draw.text( (x, y), ln, font=font, fill=(fill_rgb[0], fill_rgb[1], fill_rgb[2], int(round(fill_op*255))), stroke_width=stroke_w, stroke_fill=(stroke_rgb[0], stroke_rgb[1], stroke_rgb[2], int(round(stroke_op*255))), anchor=anchor ) # --- MASK : forme du texte avec marge alpha supplémentaire --- mask_L = Image.new("L", (W, H), 0) mdraw = ImageDraw.Draw(mask_L) for i, ln in enumerate(lines): y = start_y + i*line_h mdraw.text( (x, y), ln, font=font, fill=255, stroke_width=stroke_w + grow_alpha, # << épaisseur supplémentaire uniquement sur le MASK stroke_fill=255, anchor=anchor ) mask_out = _to_tensor_mask(mask_L) # --- compose fond + texte (pour sortie IMAGE) --- if bg_transparent: base = Image.new("RGBA", (W, H), (0,0,0,0)) else: base = Image.new("RGBA", (W, H), (bg_rgb[0], bg_rgb[1], bg_rgb[2], 255)) rgba = Image.alpha_composite(base, text_rgba) # IMAGE de sortie if bg_transparent and image_rgba: arr = (np.asarray(rgba, dtype=np.float32) / 255.0) # HxWx4 image_out = torch.from_numpy(arr).unsqueeze(0) if torch is not None else arr[None, ...] else: image_out = _to_tensor_rgb(rgba.convert("RGB")) # --- SVG --- if svg_vectorize: svg = _make_svg_paths(lines, W, H, font_path, int(font_size), fill_hex, fill_alpha, stroke_hex, stroke_w, stroke_alpha, align) else: svg = _make_svg_text(lines, W, H, font_family=os.path.basename(font_path) if font_path else "sans-serif", font_size=int(font_size), fill_hex=fill_hex, fill_alpha=fill_alpha, stroke_hex=stroke_hex, stroke_width=stroke_w, stroke_alpha=stroke_alpha, align=align) return (image_out, svg, mask_out) # ---------- HTTP (fonts listing) ---------- def _register_routes_once(): ps = PromptServer.instance if getattr(ps, "_dao_text_maker_routes", False): return async def list_fonts(request): files = DAOTextMaker._available_fonts() return web.json_response({"fonts": files}) ps.routes.get("/dao/text/fonts")(list_fonts) ps._dao_text_maker_routes = True try: _register_routes_once() except Exception as e: print(f"[DAO Text Maker] Route registration error: {e}")