Skip to content

Instantly share code, notes, and snippets.

@mateoltd
Last active March 24, 2026 21:57
Show Gist options
  • Select an option

  • Save mateoltd/b976a33bd1c3d106d5c51b3d000a346d to your computer and use it in GitHub Desktop.

Select an option

Save mateoltd/b976a33bd1c3d106d5c51b3d000a346d to your computer and use it in GitHub Desktop.
Vectorizer Downloader
// ==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
}
}
})();
@yodaluca23
Copy link
Copy Markdown

Doesn't capture infills.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment