Last active
March 24, 2026 21:57
-
-
Save mateoltd/b976a33bd1c3d106d5c51b3d000a346d to your computer and use it in GitHub Desktop.
Vectorizer Downloader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Vectorizer.AI Downloader | |
| // @version 0.1.0 | |
| // @description Intercepts the canvas drawing process on vectorizer.ai to reconstruct and download the full SVG result for free. | |
| // @author mateoltd | |
| // @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 Downloader: Script injected and waiting for drawing commands.'); | |
| // State Management | |
| // We'll store all the intercepted vector data in this object | |
| const vectorData = { | |
| width: 1, | |
| height: 1, | |
| mainShapes: [], | |
| gapFillers: [], | |
| errors: 0 | |
| }; | |
| // A state machine to understand the sequence of drawing operations | |
| let drawingState = 'IDLE'; // States: IDLE, AWAITING_CLIP, CAPTURING_SHAPES | |
| // Interception Logic | |
| /** | |
| * This is the core of the script. We "monkey-patch" the browser's built-in | |
| * drawing functions. When the website's code calls a function like `moveTo` or `fill`, | |
| * our code runs first, records the information, and then passes the call | |
| * to the original function | |
| */ | |
| // Intercept Path2D commands to build an SVG path string ('d' attribute) | |
| const originalPath2D = Path2D.prototype; | |
| const pathCommands = ['moveTo', 'lineTo', 'bezierCurveTo', 'quadraticCurveTo', 'ellipse', 'closePath']; | |
| pathCommands.forEach(command => { | |
| const originalFunc = originalPath2D[command]; | |
| originalPath2D[command] = new Proxy(originalFunc, { | |
| apply: function(target, thisArg, args) { | |
| // Initialize the SVG path string if it doesn't exist | |
| thisArg.svgPathString = thisArg.svgPathString || ''; | |
| // Convert the canvas command to an SVG path command and append it | |
| thisArg.svgPathString += canvasPathCommandToSvg(command, args) + ' '; | |
| // Call the origin function | |
| return target.apply(thisArg, args); | |
| } | |
| }); | |
| }); | |
| // Intercept CanvasRenderingContext2D commands to capture colors, dimensions, and drawing sequences | |
| const originalContext2D = CanvasRenderingContext2D.prototype; | |
| const originalFill = originalContext2D.fill; | |
| originalContext2D.fill = new Proxy(originalFill, { | |
| apply: function(target, thisArg, args) { | |
| // We only care about shapes being drawn in the main capture state | |
| if (drawingState === 'CAPTURING_SHAPES') { | |
| const pathObject = args[0]; // The Path2D object is the first argument to fill() | |
| if (pathObject && pathObject.svgPathString) { | |
| vectorData.mainShapes.push({ | |
| fill: thisArg.fillStyle, | |
| d: pathObject.svgPathString.trim() | |
| }); | |
| } else { | |
| vectorData.errors++; | |
| console.error('Error: A shape was filled without a captured path. The result may be incomplete.'); | |
| } | |
| } | |
| return target.apply(thisArg, args); | |
| } | |
| }); | |
| // We need to proxy other context functions to manage our state machine | |
| proxyContextFunction('drawImage', (target, thisArg, args) => { | |
| // The first large image drawn is our main canvas content | |
| if (drawingState === 'IDLE' && args.length >= 5) { | |
| vectorData.width = args[3]; | |
| vectorData.height = args[4]; | |
| drawingState = 'AWAITING_CLIP'; | |
| console.log(`Canvas dimensions captured: ${vectorData.width}x${vectorData.height}`); | |
| } | |
| }); | |
| proxyContextFunction('clip', () => { | |
| // 'clip' is called right before the main vector shapes are drawn | |
| if (drawingState === 'AWAITING_CLIP') { | |
| drawingState = 'CAPTURING_SHAPES'; | |
| vectorData.mainShapes = []; // Reset for new drawing | |
| vectorData.errors = 0; | |
| console.log('State -> CAPTURING_SHAPES: Ready to intercept main vector shapes.'); | |
| } | |
| }); | |
| proxyContextFunction('restore', () => { | |
| // 'restore' is called after the main shapes are drawn | |
| if (drawingState === 'CAPTURING_SHAPES') { | |
| drawingState = 'AWAITING_CLIP'; | |
| console.log('State -> AWAITING_CLIP: Main shapes captured.', vectorData.mainShapes); | |
| updateDownloadButtonStatus(); | |
| } | |
| }); | |
| /** | |
| * The site uses a custom object property 'isReady' as a signal. | |
| * By intercepting its 'set' operation, we can capture the | |
| * "gap filler" data, which is rendered separately from the main shapes | |
| */ | |
| Object.defineProperty(Object.prototype, 'isReady', { | |
| get() { | |
| return this.__isReady; | |
| }, | |
| set(value) { | |
| this.__isReady = value; | |
| // Check if this object contains the gap filler data we need | |
| if (this.vectorInterfaces) { | |
| console.log('Gap filler data detected via isReady setter.'); | |
| vectorData.gapFillers = this.vectorInterfaces | |
| .filter(i => i.color0 && i.color0.a > 0 && i.color1 && i.color1.a > 0) | |
| .map(i => ({ | |
| d: i.path.svgPathString.trim(), | |
| stroke: i.css | |
| })); | |
| console.log('Gap fillers captured:', vectorData.gapFillers); | |
| // Now that we have this data, we can create the custom download button | |
| createDownloadButton(); | |
| } | |
| return value; | |
| }, | |
| configurable: true | |
| }); | |
| // UI and SVG Generation | |
| 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 | |
| updateDownloadButtonStatus(); | |
| } | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| function updateDownloadButtonStatus() { | |
| if (!customDownloadButton) return; | |
| const textSpan = customDownloadButton.querySelector('.showPaid'); | |
| if (!textSpan) return; | |
| if (vectorData.errors > 0) { | |
| textSpan.textContent = `DOWNLOAD SVG (INCOMPLETE)`; | |
| customDownloadButton.style.backgroundColor = '#f0ad4e'; // Orange for warning | |
| } else { | |
| textSpan.textContent = 'DOWNLOAD SVG'; | |
| customDownloadButton.style.backgroundColor = '#5cb85c'; // Green for success | |
| } | |
| } | |
| function buildSvgFromData() { | |
| const { width, height, mainShapes, gapFillers } = vectorData; | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); | |
| svg.setAttribute('viewBox', `0 0 ${width} ${height}`); | |
| svg.setAttribute('width', width); | |
| svg.setAttribute('height', height); | |
| // Add main filled shapes | |
| mainShapes.forEach(({ fill, d }) => { | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| path.setAttribute('d', d); | |
| path.setAttribute('fill', fill); | |
| svg.appendChild(path); | |
| }); | |
| // Add gap filler stroked paths | |
| if (gapFillers.length > 0) { | |
| const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); | |
| g.setAttribute('fill', 'none'); | |
| gapFillers.forEach(({ d, stroke }) => { | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| path.setAttribute('d', d); | |
| path.setAttribute('stroke', stroke); | |
| // This attribute is important for scaling strokes correctly | |
| path.setAttribute('vector-effect', 'non-scaling-stroke'); | |
| g.appendChild(path); | |
| }); | |
| svg.appendChild(g); | |
| } | |
| return new XMLSerializer().serializeToString(svg); | |
| } | |
| // Helper Functions | |
| /** | |
| * Helper to create a proxy for a specific function on the CanvasRenderingContext2D prototype | |
| * @param {string} name - The name of the function to proxy | |
| * @param {function} customLogic - The function to run before the original call | |
| */ | |
| function proxyContextFunction(name, customLogic) { | |
| const originalFunc = originalContext2D[name]; | |
| if (!originalFunc) return; | |
| originalContext2D[name] = new Proxy(originalFunc, { | |
| apply: function(target, thisArg, args) { | |
| customLogic(target, thisArg, args); | |
| return target.apply(thisArg, args); | |
| } | |
| }); | |
| } | |
| /** | |
| * Converts a canvas path command and its arguments into an SVG path string component | |
| * @param {string} name - The name of the canvas command (e.g., 'moveTo') | |
| * @param {Array} args - The arguments passed to the command | |
| * @returns {string} The corresponding SVG path command string | |
| */ | |
| function canvasPathCommandToSvg(name, args) { | |
| // Helper to format numbers cleanly for the SVG string | |
| const F = (val) => Number(val.toFixed(3)).toString(); | |
| switch (name) { | |
| case "moveTo": | |
| return `M ${F(args[0])} ${F(args[1])}`; | |
| case "lineTo": | |
| return `L ${F(args[0])} ${F(args[1])}`; | |
| case "bezierCurveTo": | |
| return `C ${F(args[0])} ${F(args[1])} ${F(args[2])} ${F(args[3])} ${F(args[4])} ${F(args[5])}`; | |
| case "quadraticCurveTo": | |
| return `Q ${F(args[0])} ${F(args[1])} ${F(args[2])} ${F(args[3])}`; | |
| case "ellipse": { | |
| const [cx, cy, rx, ry, rotation, startAngle, endAngle, anticlockwise] = args; | |
| const sweepFlag = anticlockwise ? 0 : 1; | |
| const largeArcFlag = Math.abs(endAngle - startAngle) > Math.PI ? 1 : 0; | |
| const x1 = cx + rx * Math.cos(startAngle); | |
| const y1 = cy + ry * Math.sin(startAngle); | |
| const x2 = cx + rx * Math.cos(endAngle); | |
| const y2 = cy + ry * Math.sin(endAngle); | |
| // Note: This conversion for rotated ellipses is complex, this is a simplified approach | |
| const ex = cx + rx * Math.cos(endAngle); | |
| const ey = cy + ry * Math.sin(endAngle); | |
| // For a full ellipse, we often need two arc commands. For this site's usage, one seems to work | |
| return `A ${F(rx)} ${F(ry)} ${F(rotation * 180 / Math.PI)} ${largeArcFlag} ${sweepFlag} ${F(ex)} ${F(ey)}`; | |
| } | |
| case "closePath": | |
| return 'Z'; | |
| default: | |
| return ''; // Ignore other commands | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Doesn't capture infills.