commit ca7267461d90db411e46cb09093473ffe7bc099e Author: stormcenter Date: Sun Nov 24 23:41:12 2024 +0800 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac2824b --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# ComfyUI-SVGFullfill + +A ComfyUI custom node that enables SVG file integration and editing. This node allows you to upload SVG files, preview them in real-time, replace images and text elements within the SVG, and export the result as a PNG image. + +## Features + +- 🖼️ SVG file upload and preview +- 🔄 Replace up to 3 images in SVG +- ✏️ Edit up to 10 text elements in SVG +- 🈯 Full Chinese text support +- 🖼️ Real-time canvas preview +- 📤 PNG export + +## Installation + +1. Clone this repository to your ComfyUI custom_nodes directory: +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/YourUsername/ComfyUI-SVGFullfill.git +``` + +2. Install the required dependencies: +```bash +pip install cairosvg Pillow +``` + +3. Copy your fonts into the font directory: +```bash +cd ComfyUI-SVGFullfill +mkdir font +# Copy your .ttf or .otf fonts into the font directory +``` + +## Usage + +### SVG Requirements + +Your SVG file should: +1. Have text elements with id attributes like `text1`, `text2`, etc., for text replacement +2. Have `` elements for image replacement +3. Use UTF-8 encoding (especially important for Chinese text) + +Example SVG structure: +```xml + + Replaceable Text 1 + 替换文本2 + + +``` + +### Nodes + +The extension provides two main nodes: + +1. **SVG Uploader** + - Allows SVG file upload + - Provides real-time canvas preview + - Outputs SVG content to SVG Editor + +2. **SVG Editor** + - Accepts SVG file input from SVG Uploader + - Has optional inputs for 3 images and 10 text replacements + - Outputs the final PNG image + +### Workflow Example + +1. Add SVG Uploader node +2. Add SVG Editor node +3. Connect SVG Uploader's output to SVG Editor's input +4. Upload your SVG file using the SVG Uploader +5. Connect your images to SVG Editor's image inputs (optional) +6. Input text replacements in SVG Editor (optional) +7. Run the workflow to get your final image + +### Example Workflow +```mermaid +graph TD + A[SVG Uploader] -->|SVG File| B[SVG Editor] + C[Image 1] -->|Optional| B + D[Image 2] -->|Optional| B + E[Image 3] -->|Optional| B + B -->|Final Image| F[Output] +``` + +## Font Support + +To use custom fonts (especially for Chinese text): +1. Place your `.ttf` or `.otf` font files in the `font` directory +2. The fonts will be automatically detected and used for rendering + +## Known Limitations + +- Maximum 3 image replacements per SVG +- Maximum 10 text replacements per SVG +- SVG animations are not supported +- External images in SVG must be base64 encoded + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Credits + +Thanks to the ComfyUI community for their support and inspiration. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3625c5f --- /dev/null +++ b/__init__.py @@ -0,0 +1,11 @@ +try: + from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +except ImportError: + NODE_CLASS_MAPPINGS = {} + NODE_DISPLAY_NAME_MAPPINGS = {} + +__version__ = "1.0.0" + +WEB_DIRECTORY = "./js" +TYPE_NAME = "SVG" +EXTENSION_NAME = "ComfyUI-SVGFullfill" diff --git a/examples/Screenshot_2024-11-24_15-39-43.png b/examples/Screenshot_2024-11-24_15-39-43.png new file mode 100644 index 0000000..3214d21 Binary files /dev/null and b/examples/Screenshot_2024-11-24_15-39-43.png differ diff --git a/examples/template.svg b/examples/template.svg new file mode 100644 index 0000000..deebeb5 --- /dev/null +++ b/examples/template.svg @@ -0,0 +1,59 @@ + + + + + + + + 主标题文本 + + + + + 副标题内容 + + + + + + + + + + + + + + 左侧描述文本 + + + + 中间描述文本 + + + + 右侧描述文本 + + + + + 底部补充说明文本1 + + + + 底部补充说明文本2 + + + + 底部补充说明文本3 + + + + + 左侧页脚文本 + + + + 右侧页脚文本 + + diff --git a/examples/workflow.json b/examples/workflow.json new file mode 100644 index 0000000..aafec8b --- /dev/null +++ b/examples/workflow.json @@ -0,0 +1,185 @@ +{ + "last_node_id": 16, + "last_link_id": 8, + "nodes": [ + { + "id": 15, + "type": "SVGEditor", + "pos": { + "0": -837, + "1": 112 + }, + "size": { + "0": 400, + "1": 586 + }, + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "svg_file", + "type": "SVG_FILE", + "link": 7, + "label": "svg_file" + }, + { + "name": "image1", + "type": "IMAGE", + "link": null, + "shape": 7, + "label": "image1" + }, + { + "name": "image2", + "type": "IMAGE", + "link": null, + "shape": 7, + "label": "image2" + }, + { + "name": "image3", + "type": "IMAGE", + "link": null, + "shape": 7, + "label": "image3" + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 8 + ], + "slot_index": 0, + "label": "IMAGE" + } + ], + "properties": { + "Node name for S&R": "SVGEditor" + }, + "widgets_values": [ + "11111", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + { + "id": 14, + "type": "SVGUploader", + "pos": { + "0": -1389, + "1": 116 + }, + "size": { + "0": 422.3057556152344, + "1": 513.9155883789062 + }, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "svg_file", + "type": "SVG_FILE", + "links": [ + 7 + ], + "slot_index": 0, + "label": "svg_file" + } + ], + "properties": { + "Node name for S&R": "SVGUploader" + }, + "widgets_values": [ + "\n\n \n \n \n \n \n 主标题文本\n \n \n \n \n 副标题内容\n \n \n \n \n \n \n \n \n \n \n \n \n \n 左侧描述文本\n \n \n \n 中间描述文本\n \n \n \n 右侧描述文本\n \n \n \n \n 底部补充说明文本1\n \n \n \n 底部补充说明文本2\n \n \n \n 底部补充说明文本3\n \n \n \n \n 左侧页脚文本\n \n \n \n 右侧页脚文本\n \n\n", + null, + null + ] + }, + { + "id": 16, + "type": "PreviewImage", + "pos": { + "0": -358, + "1": 109 + }, + "size": [ + 455.8845222550208, + 406.6712703372059 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 8, + "label": "images" + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + } + ], + "links": [ + [ + 2, + 1, + 0, + 3, + 0, + "IMAGE" + ], + [ + 6, + 10, + 0, + 1, + 0, + "SVG_FILE" + ], + [ + 7, + 14, + 0, + 15, + 0, + "SVG_FILE" + ], + [ + 8, + 15, + 0, + 16, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.5445000000000001, + "offset": [ + 784.5764511150434, + 328.22220991991054 + ] + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/font/AaYuanWeiTuSi-2.ttf b/font/AaYuanWeiTuSi-2.ttf new file mode 100644 index 0000000..c73e396 Binary files /dev/null and b/font/AaYuanWeiTuSi-2.ttf differ diff --git a/js/svg_uploader.js b/js/svg_uploader.js new file mode 100644 index 0000000..3308850 --- /dev/null +++ b/js/svg_uploader.js @@ -0,0 +1,156 @@ +import { app } from "../../scripts/app.js"; +import { $el } from "../../scripts/ui.js"; + +app.registerExtension({ + name: "Comfy.SVGFullfill", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "SVGUploader") { + nodeType.prototype.onNodeCreated = function () { + console.log("Node created: SVGUploader"); + + // 隐藏默认的字符串输入 + const svgInput = this.widgets.find(w => w.name === "svg_string"); + if (svgInput) { + svgInput.hidden = true; + } + + // 创建上传按钮(先创建按钮,这样它会在预览区域上方) + this.addWidget("button", "Upload SVG", null, () => { + console.log("Upload button clicked"); + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".svg"; + fileInput.style.display = "none"; + document.body.appendChild(fileInput); + + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (file) { + console.log("File selected:", file.name); + const reader = new FileReader(); + reader.onload = async (e) => { + const svgContent = e.target.result; + console.log("SVG content loaded"); + + // 存储SVG内容 + const svgInput = this.widgets.find(w => w.name === "svg_string"); + if (svgInput) { + svgInput.value = svgContent; + } + + // 更新预览 + try { + console.log("Parsing SVG content..."); + const parser = new DOMParser(); + const doc = parser.parseFromString(svgContent, "image/svg+xml"); + const svgElement = doc.documentElement; + + if (!svgElement || svgElement.nodeName !== "svg") { + throw new Error("Invalid SVG content"); + } + + console.log("Setting SVG attributes..."); + // 设置SVG属性 + svgElement.style.width = "100%"; + svgElement.style.height = "100%"; + svgElement.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + // 更新预览 + console.log("Updating preview container..."); + widget.div.innerHTML = ""; + widget.div.appendChild(svgElement.cloneNode(true)); + console.log("Preview updated successfully"); + + // 强制重绘 + app.canvas.setDirty(true); + + } catch (error) { + console.error("Error updating preview:", error); + widget.div.innerHTML = ` +
+ Error loading SVG: ${error.message} +
+ `; + } + }; + reader.readAsText(file); + } + document.body.removeChild(fileInput); + }; + fileInput.click(); + }); + + // 创建预览widget + const widget = { + type: "div", + name: "preview", + draw(ctx, node, widget_width, y, widget_height) { + const margin = 10; + const buttonHeight = 40; // 为按钮预留空间 + const elRect = ctx.canvas.getBoundingClientRect(); + const transform = new DOMMatrix() + .scaleSelf( + elRect.width / ctx.canvas.width, + elRect.height / ctx.canvas.height + ) + .multiplySelf(ctx.getTransform()) + .translateSelf(margin, margin + y + buttonHeight); // 添加buttonHeight偏移 + + Object.assign(this.div.style, { + transformOrigin: "0 0", + transform: transform, + position: "absolute", + left: document.querySelector('.comfy-menu').style.display === 'none' ? '60px' : '0', + top: "0", + width: `${widget_width - margin * 2}px`, + height: "200px", // 减小高度,给按钮留空间 + zIndex: 1 + }); + } + }; + + // 创建预览容器 + widget.div = $el("div", { + style: { + width: "100%", + height: "100%", + border: "2px solid #ccc", + borderRadius: "4px", + backgroundColor: "#f0f0f0", + overflow: "hidden", + display: "flex", + alignItems: "center", + justifyContent: "center" + } + }); + + // 创建占位符 + const placeholderText = $el("div", { + textContent: "SVG Preview", + style: { + color: "#666", + textAlign: "center", + userSelect: "none", + pointerEvents: "none" + } + }); + widget.div.appendChild(placeholderText); + + // 添加预览widget到节点 + document.body.appendChild(widget.div); + this.addCustomWidget(widget); + + // 设置节点大小 + this.setSize([220, 320]); + + // 清理函数 + const onRemoved = this.onRemoved; + this.onRemoved = () => { + widget.div.remove(); + return onRemoved?.(); + }; + }; + } + } +}); + diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..56f028a --- /dev/null +++ b/nodes.py @@ -0,0 +1,191 @@ +import os +import base64 +from PIL import Image +import numpy as np +from xml.etree import ElementTree as ET +import cairosvg +from io import BytesIO +import torch +import re + +def tensor2pil(image): + return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + +class SVGUploader: + @classmethod + def INPUT_TYPES(s): + return {"required": { + "svg_string": ("STRING", {"default": "", "multiline": False, "visible": False}), + }} + + RETURN_TYPES = ("SVG_FILE",) + RETURN_NAMES = ("svg_file",) + OUTPUT_NODE = True + FUNCTION = "upload_svg" + CATEGORY = "SVGFullfill" + + # 添加节点尺寸定义 + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("NaN") # 总是更新 + + # 定义节点的默认大小 + SIZE = [200, 320] # [宽度, 高度] + + def __init__(self): + self._svg_content = None + + def upload_svg(self, svg_string): + if not svg_string: + return ("") + return (svg_string,) + +class SVGEditor: + def __init__(self): + self.font_dir = os.path.join(os.path.dirname(__file__), "font") + if not os.path.exists(self.font_dir): + os.makedirs(self.font_dir) + # 获取字体目录下的所有字体文件 + self.fonts = [f for f in os.listdir(self.font_dir) if f.endswith(('.ttf', '.otf'))] + if not self.fonts: + print("警告:font 目录下没有找到字体文件(.ttf 或 .otf)") + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "svg_file": ("SVG_FILE",), + }, + "optional": { + "image1": ("IMAGE", {"default": None}), + "image2": ("IMAGE", {"default": None}), + "image3": ("IMAGE", {"default": None}), + "text1": ("STRING", {"default": "", "multiline": True}), + "text2": ("STRING", {"default": "", "multiline": True}), + "text3": ("STRING", {"default": "", "multiline": True}), + "text4": ("STRING", {"default": "", "multiline": True}), + "text5": ("STRING", {"default": "", "multiline": True}), + "text6": ("STRING", {"default": "", "multiline": True}), + "text7": ("STRING", {"default": "", "multiline": True}), + "text8": ("STRING", {"default": "", "multiline": True}), + "text9": ("STRING", {"default": "", "multiline": True}), + "text10": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "edit_svg" + CATEGORY = "SVGFullfill" + + def edit_svg(self, svg_file, image1=None, image2=None, image3=None, **kwargs): + try: + if not svg_file or len(svg_file.strip()) == 0: + print("No SVG content received") + blank_image = torch.zeros((1, 64, 64, 3)) + return (blank_image,) + + # 使用XML解析器处理SVG + parser = ET.XMLParser(encoding='utf-8') + root = ET.fromstring(svg_file.encode('utf-8'), parser=parser) + + # 在处理文本之前,添加字体样式定义 + # 查找或创建 defs 元素 + defs = root.find('{http://www.w3.org/2000/svg}defs') + if defs is None: + defs = ET.SubElement(root, '{http://www.w3.org/2000/svg}defs') + + # 添加字体样式 + if self.fonts: + font_path = os.path.join(self.font_dir, self.fonts[0]) + style = ET.SubElement(defs, '{http://www.w3.org/2000/svg}style') + style.set('type', 'text/css') + font_face = f""" + @font-face {{ + font-family: 'CustomFont'; + src: url('file://{font_path}'); + }} + """ + style.text = font_face + + # 处理文本替换时设置字体 + for i in range(1, 11): + text_key = f"text{i}" + if text_key in kwargs and kwargs[text_key]: + for text_elem in root.findall(f".//*[@id='{text_key}']"): + if text_elem is not None: + text_elem.text = kwargs[text_key].strip() + # 设置字体样式 + text_elem.set('style', 'font-family: CustomFont;') + print(f"Updated text {i}: {kwargs[text_key]}") + + # 处理图片替换 + for i, img_batch in enumerate([image1, image2, image3], 1): + if img_batch is not None and img_batch.shape[0] > 0: + print(f"Processing image {i}") + # 将tensor转换为PIL图像 + img_tensor = img_batch[0] + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + img = Image.fromarray(img_np) + + # 转换为PNG base64 + buffer = BytesIO() + img.save(buffer, format="PNG") + img_str = base64.b64encode(buffer.getvalue()).decode() + + # 修改查找方式,增加命名空间处理 + for image_elem in root.findall(f".//*[@id='image{i}']"): + if image_elem is not None: + # 保持原有的宽度和高度属性 + current_width = image_elem.get('width', '200') + current_height = image_elem.get('height', '200') + image_elem.set('width', current_width) + image_elem.set('height', current_height) + # 保持原有的位置属性 + current_x = image_elem.get('x', str(50 + (i-1)*250)) + current_y = image_elem.get('y', '180') + image_elem.set('x', current_x) + image_elem.set('y', current_y) + # 更新图片内容 + image_elem.set('{http://www.w3.org/1999/xlink}href', f'data:image/png;base64,{img_str}') + + # 转换回字符串 + svg_content = ET.tostring(root, encoding='utf-8', method='xml').decode('utf-8') + + # 使用cairosvg进行转换 + png_data = cairosvg.svg2png( + bytestring=svg_content.encode('utf-8'), + output_width=800, + output_height=600, + scale=1.0, + background_color='white', + unsafe=True, + dpi=96 + ) + + # 转换为PIL图像 + png_image = Image.open(BytesIO(png_data)) + png_image = png_image.convert('RGB') + + # 转换为tensor(值范围0-1) + image_tensor = torch.from_numpy(np.array(png_image).astype(np.float32) / 255.0) + image_tensor = image_tensor.unsqueeze(0) + + return (image_tensor,) + + except Exception as e: + print(f"Error processing SVG: {str(e)}") + import traceback + traceback.print_exc() + blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32) + return (blank_image,) + +# Register nodes +NODE_CLASS_MAPPINGS = { + "SVGUploader": SVGUploader, + "SVGEditor": SVGEditor +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SVGUploader": "SVG Uploader", + "SVGEditor": "SVG Editor" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be294a1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=9.0.0 +cairosvg>=2.7.0 +numpy>=1.22.0