import { app } from "/scripts/app.js"; import { ComfyWidgets } from "/scripts/widgets.js"; // Adds support for import + export as SVG including input + output images // Adds two context menu items to the canvas // Supports drag + drop import // https://codepen.io/peterhry/pen/nbMaYg function wrapText(context, text, x, y, maxWidth, lineHeight) { var words = text.split(" "), line = "", i, test, metrics; for (i = 0; i < words.length; i++) { test = words[i]; metrics = context.measureText(test); while (metrics.width > maxWidth) { // Determine how much of the word will fit test = test.substring(0, test.length - 1); metrics = context.measureText(test); } if (words[i] != test) { words.splice(i + 1, 0, words[i].substr(test.length)); words[i] = test; } test = line + words[i] + " "; metrics = context.measureText(test); if (metrics.width > maxWidth && i > 0) { context.fillText(line, x, y); line = words[i] + " "; y += lineHeight; } else { line = test; } } context.fillText(line, x, y); } function escapeXml(unsafe) { return unsafe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function unescapeXml(safe) { return safe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } let saving = false; app.registerExtension({ name: "pysssss.ExportAsSvg", init() { const stringWidget = ComfyWidgets.STRING; // Override multiline string widgets to draw text using canvas while saving as svg ComfyWidgets.STRING = function () { const w = stringWidget.apply(this, arguments); if (w.widget && w.widget.type === "customtext") { const draw = w.widget.draw; w.widget.draw = function (ctx) { draw.apply(this, arguments); if (saving) { const t = ctx.getTransform(); ctx.save(); ctx.resetTransform(); const style = document.defaultView.getComputedStyle(this.inputEl, null); const x = parseInt(this.inputEl.style.left) + 10; const y = parseInt(this.inputEl.style.top) + LiteGraph.NODE_TITLE_HEIGHT; const w = parseInt(this.inputEl.style.width); const h = parseInt(this.inputEl.style.height); ctx.fillStyle = style.getPropertyValue("background-color"); ctx.fillRect(x, y, w, h); ctx.fillStyle = style.getPropertyValue("color"); ctx.font = style.getPropertyValue("font"); wrapText(ctx, this.inputEl.value, x, y + t.d * 12, w, t.d * 12); ctx.restore(); } }; } return w; }; }, setup(app) { const script = document.createElement("script"); script.onload = function () { function exportSvg() { // Calculate the min max bounds for the nodes on the graph const bounds = app.graph._nodes.reduce( (p, n) => { if (n.pos[0] < p[0]) p[0] = n.pos[0]; if (n.pos[1] < p[1]) p[1] = n.pos[1]; const r = n.pos[0] + n.size[0]; const b = n.pos[1] + n.size[1]; if (r > p[2]) p[2] = r; if (b > p[3]) p[3] = b; return p; }, [99999, 99999, -99999, -99999] ); bounds[0] -= 100; bounds[1] -= 100; bounds[2] += 100; bounds[3] += 100; // Store current canvas values to reset after drawing const ctx = app.canvas.ctx; const scale = app.canvas.ds.scale; const width = app.canvas.canvas.width; const height = app.canvas.canvas.height; const offset = app.canvas.ds.offset; const svgCtx = new C2S(bounds[2] - bounds[0], bounds[3] - bounds[1]); svgCtx.canvas.getBoundingClientRect = function () { return { width: svgCtx.width, height: svgCtx.height }; }; // Override the c2s handling of images to draw images as canvases const drawImage = svgCtx.drawImage; svgCtx.drawImage = function (...args) { const image = args[0]; // If we are an image node and not a datauri then we need to replace with a canvas // we cant convert to data uri here as it is an async process if (image.nodeName === "IMG" && !image.src.startsWith("data:image/")) { const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height; const imgCtx = canvas.getContext("2d"); imgCtx.drawImage(image, 0, 0); args[0] = canvas; } return drawImage.apply(this, args); }; // Implement missing required functions svgCtx.getTransform = function () { return ctx.getTransform(); }; svgCtx.resetTransform = function () { return ctx.resetTransform(); }; svgCtx.roundRect = svgCtx.rect; // Force the canvas to render the whole graph to the svg context app.canvas.ds.scale = 1; app.canvas.canvas.width = bounds[2] - bounds[0]; app.canvas.canvas.height = bounds[3] - bounds[1]; app.canvas.ds.offset = [-bounds[0], -bounds[1]]; app.canvas.ctx = svgCtx; // Trigger saving saving = true; app.canvas.draw(true, true); saving = false; // Restore original settings app.canvas.ds.scale = scale; app.canvas.canvas.width = width; app.canvas.canvas.height = height; app.canvas.ds.offset = offset; app.canvas.ctx = ctx; app.canvas.draw(true, true); // Convert to SVG, embed graph and save const json = JSON.stringify(app.graph.serialize()); const svg = svgCtx .getSerializedSvg(true) .replace("", `${escapeXml(json)}`) .replace(" { app.handleFile(fileInput.files[0]); }, }); document.body.append(fileInput); } fileInput.click(); } // Override file handling to allow drag & drop of SVG const handleFile = app.handleFile; app.handleFile = function (file) { if (file && (file.type === "image/svg+xml" || file.name?.endsWith(".svg"))) { const reader = new FileReader(); reader.onload = () => { // Extract embedded workflow from desc tags const descEnd = reader.result.lastIndexOf(""); if (descEnd !== -1) { const descStart = reader.result.lastIndexOf("", descEnd); if (descStart !== -1) { const json = reader.result.substring(descStart + 6, descEnd); this.loadGraphData(JSON.parse(unescapeXml(json))); } } }; reader.readAsText(file); } else { return handleFile.apply(this, arguments); } }; // Add canvas menu options const orig = LGraphCanvas.prototype.getCanvasMenuOptions; LGraphCanvas.prototype.getCanvasMenuOptions = function () { const options = orig.apply(this, arguments); options.push( null, { content: "SVG -> Import", callback: importSvg }, { content: "SVG -> Export", callback: exportSvg } ); return options; }; }; script.src = new URL(`assets/canvas2svg.js`, import.meta.url); document.body.append(script); }, });