| | // ==UserScript== | | | // @name Vectorizer.AI Down | | | // @version 0.1.0 | | | // @description Reads the vector result straight from vectorizer.ai's in-page model and downloads it as a full SVG for free. - fix of https://gist.github.com/mateoltd/b976a33bd1c3d106d5c51b3d000a346d | | | // @author k33bs | | | // @namespace http://tampermonkey.net/ | | | // @license MIT | | | // @match https://vectorizer.ai/images/* | | | // @grant none |
| | // @run-at document-start |
| | // ==/UserScript== |
| | (function() { |
| | 'use strict'; |
| | console.log('Vectorizer.AI Down: Script injected and waiting for the vector result.'); |
| | /** |
| | * Canvas interception can't work here: the site only draws what's on screen. Loops outside the |
| | * viewport are skipped, and big shapes get their off-screen beziers drawn as straight lines. |
| | * So we grab the site's VectorImage model itself and serialize every curve from it. |
| | * |
| | * VectorImage sets isReady = true once all result chunks are loaded and committed, |
| | * so a setter on Object.prototype hands us the instance. |
| | */ |
| | let vectorImage = null; |
| | Object.defineProperty(Object.prototype, 'isReady', { |
| | get() { |
| | return this.__isReady; |
| | }, |
| | set(value) { |
| | this.__isReady = value; |
| | if (value === true && this.vectorShapes && this.topVectorShapes) { |
| | vectorImage = this; |
| | console.log(`Vector result captured: ${this.vectorShapes.length} shapes, ${this.imageWidth}x${this.imageHeight}`); |
| | createDownloadButton(); |
| | } | | | }, | | | configurable: true | | | }); | | | // UI | | | let customDownloadButton = null; | | | function createDownloadButton() { | | | if (customDownloadButton) return; // Only create it once | | | // We use a MutationObserver to wait for the page's UI to be ready ;) |
| | const observer = new MutationObserver((mutations, obs) => { |
| | const originalButton = document.querySelector('#App-DownloadLink'); |
| | if (originalButton) { |
| | console.log("Original download button found. Creating custom button."); | | | customDownloadButton = originalButton.cloneNode(true); // Clone to keep the style | | | customDownloadButton.id = 'custom-download-button'; | | | customDownloadButton.href = '#'; // Clear original link | | | customDownloadButton.style.backgroundColor = '#5cb85c'; // Green color for distinction |
| | const textSpan = customDownloadButton.querySelector('.showPaid'); |
| | if(textSpan) textSpan.textContent = 'DOWNLOAD SVG'; |
| | originalButton.parentNode.insertBefore(customDownloadButton, originalButton); |
| | customDownloadButton.addEventListener('click', (e) => { |
| | e.preventDefault(); |
| | e.stopPropagation(); |
| | const svgContent = buildSvgFromData(); |
| | const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' }); |
| | const url = URL.createObjectURL(blob); |
| | // Set filename from the original, if available | | | let filename = 'vectorized-result.svg'; | | | if (window.ResumeImage && window.ResumeImage.originalFilename) { | | | filename = window.ResumeImage.originalFilename.replace(/.[^/.]+$/, ".svg"); | | | } | | | // Create a temporary link to trigger download |
| | const tempLink = document.createElement('a'); |
| | tempLink.href = url; |
| | tempLink.download = filename; |
| | document.body.appendChild(tempLink); |
| | tempLink.click(); |
| | document.body.removeChild(tempLink); |
| | URL.revokeObjectURL(url); |
| | }, true); |
| | obs.disconnect(); // Stop observing once the button is created | | | } |
| | }); |
| | observer.observe(document.body, { childList: true, subtree: true }); |
| | } | | | // SVG Generation | | | /** | | | * Mirrors the site's VectorImage.drawTo() without the viewport culling: | | | * top-level gap fillers first, then per shape its fill followed by the gap fillers of its inner loops. | | | */ |
| | function buildSvgFromData() { |
| | const vi = vectorImage; |
| | const out = []; |
| | // Gap fillers: 2px non-scaling strokes with bevel joins, same as the site draws them |
| | const strokeInterfaces = list => { |
| | for (const i of list) { |
| | if (!i.shouldDraw()) continue; |
| | const hex = '#' + (i.argbInt & 0xffffff).toString(16).padStart(6, '0'); |
| | out.push(`<path d="${curvesToD(i.curves)}" fill="none" ${paint('stroke', hex, (i.argbInt >>> 24) / 255)} vector-effect="non-scaling-stroke"/>`); |
| | } |
| | }; |
| | for (const top of vi.topVectorShapes) strokeInterfaces(top.vectorLoops[0].vectorInterfacesToDraw); |
| | for (const shape of vi.vectorShapes) { |
| | const color = vi.userPalette.getMutRgba(shape.paletteIndex); |
| | if (color.a !== 0) { |
| | const d = shape.vectorLoopsToDraw.map(loop => curvesToD(loop.curveLoop.curves) + ' Z').join(' '); |
| | if (d) out.push(`<path d="${d}" ${paint('fill', color.toCssRgbHex(), color.a)}/>`); |
| | } |
| | for (const loop of shape.vectorLoops) { |
| | if (loop.index !== 0) strokeInterfaces(loop.vectorInterfacesToDraw); |
| | } | | | } |
| | const w = vi.imageWidth, h = vi.imageHeight; |
| | return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">` + |
| | `<g stroke-width="2" stroke-linejoin="bevel">${out.join('')}</g></svg>`; |
| | } |
| | // Helper Functions |
| | // Helper to format numbers cleanly for the SVG string |
| | const F = (val) => Number(val.toFixed(3)).toString(); |
| | // rgba() isn't valid in SVG 1.1 paint attributes, so emit hex + opacity |
| | const paint = (attr, hex, alpha) => ${attr}="${hex}" + (alpha < 1 ? ${attr}-opacity="${F(alpha)}" : ''); |
| | /** |
| | * Converts the site's curve objects into an SVG path string. |
| | * Curve classes are minified, so detect them by their (unminified) fields. |
| | * ponytail: no full-circle arc split; a single 2π arc would vanish as an SVG A. The site |
| | * pre-splits circles (0 seen in testing), add a two-half split if one ever shows up. |
| | */ |
| | function curvesToD(curves) { |
| | return `M ${F(curves[0].startX)} ${F(curves[0].startY)} ` + curves.map(c => { |
| | const end = `${F(c.endX)} ${F(c.endY)}`; |
| | if ('controlStartX' in c) return `C ${F(c.controlStartX)} ${F(c.controlStartY)} ${F(c.controlEndX)} ${F(c.controlEndY)} ${end}`; |
| | if ('controlX' in c) return `Q ${F(c.controlX)} ${F(c.controlY)} ${end}`; |
| | if ('radiusX' in c) return `A ${F(c.radiusX)} ${F(c.radiusY)} ${F(c.rotationRad * 180 / Math.PI)} ${c.largeArcFlag()} ${c.sweepFlag()} ${end}`; |
| | if ('radius' in c) return `A ${F(c.radius)} ${F(c.radius)} 0 ${c.largeArcFlag()} ${c.sweepFlag()} ${end}`; |
| | return `L ${end}`; |
| | }).join(' '); |
| | } | | | })(); |