// ==UserScript==
// @name vectorizer-preview-导出svg
// @namespace mumu.vectorizer-ai-svg-export
// @version 0.4.2
// @author 木木
// @description Capture Vectorizer.AI interactive-preview paths and export them locally as SVG.
// @match https://vectorizer.ai/*
// @match https://www.vectorizer.ai/*
// @run-at document-start
// @sandbox raw
// @inject-into page
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.__VAI_SVG_CAPTURE__) return;
const VERSION = '0.4.2';
const UI_POSITION_KEY = 'vectorizer-ai-svg-export-ui-position-v1';
const NativePath2D = window.Path2D;
const contextPrototype = window.CanvasRenderingContext2D?.prototype;
if (!NativePath2D || !contextPrototype) return;
const TWO_PI = Math.PI * 2;
const EPSILON = 1e-6;
const pathData = new WeakMap();
const state = {
inputWidth: 0,
inputHeight: 0,
inputFilename: '',
activeBatch: [],
batchTimer: 0,
bestFrame: [],
frameSerial: 0,
lastCaptureAt: 0,
pathConstructed: 0,
fillCalls: 0,
wrappedFillCalls: 0,
resultCanvasFillCalls: 0,
wrappedCanvasIds: new Set(),
};
function cloneCommands(commands) {
return commands.map((command) => ({
op: command.op,
args: command.args.slice(),
}));
}
function unwrapPath(value) {
return pathData.get(value)?.native || value;
}
function CapturedPath2D(source) {
if (!(this instanceof CapturedPath2D)) return new CapturedPath2D(source);
const sourceData = source && pathData.get(source);
const nativeSource = sourceData ? sourceData.native : source;
const native = source === undefined
? new NativePath2D()
: new NativePath2D(nativeSource);
pathData.set(this, {
native,
commands: sourceData ? cloneCommands(sourceData.commands) : [],
opaqueSource: sourceData ? false : typeof source === 'string',
});
state.pathConstructed += 1;
}
function definePathMethod(name, op, recorder) {
Object.defineProperty(CapturedPath2D.prototype, name, {
configurable: true,
writable: true,
value: function (...args) {
const data = pathData.get(this);
if (!data) throw new TypeError('Illegal Path2D receiver');
const nativeArgs = name === 'addPath'
? [unwrapPath(args[0]), ...args.slice(1)]
: args;
data.native[name](...nativeArgs);
if (recorder) recorder(data, args);
else data.commands.push({ op, args: args.map(Number) });
},
});
}
definePathMethod('moveTo', 'M');
definePathMethod('lineTo', 'L');
definePathMethod('quadraticCurveTo', 'Q');
definePathMethod('bezierCurveTo', 'C');
definePathMethod('arc', 'A');
definePathMethod('arcTo', 'AT');
definePathMethod('ellipse', 'E');
definePathMethod('rect', 'R');
if (typeof NativePath2D.prototype.roundRect === 'function') {
definePathMethod('roundRect', 'RR', (data, args) => {
const radii = Array.isArray(args[4]) ? args[4] : [args[4] ?? 0];
data.commands.push({
op: 'RR',
args: [Number(args[0]), Number(args[1]), Number(args[2]), Number(args[3]), ...radii.map(Number)],
});
});
}
definePathMethod('closePath', 'Z', (data) => {
data.commands.push({ op: 'Z', args: [] });
});
definePathMethod('addPath', 'ADD', (data, args) => {
const other = pathData.get(args[0]);
if (!other) {
data.opaqueSource = true;
return;
}
const transform = args[1];
const isIdentity = !transform || (
transform.a === 1 && transform.b === 0 && transform.c === 0 &&
transform.d === 1 && transform.e === 0 && transform.f === 0
);
if (isIdentity) data.commands.push(...cloneCommands(other.commands));
else data.opaqueSource = true;
});
Object.defineProperty(CapturedPath2D.prototype, Symbol.toStringTag, {
configurable: true,
value: 'Path2D',
});
window.Path2D = CapturedPath2D;
function isResultCanvas(context) {
const id = context?.canvas?.id || '';
return id === 'App-ImageView-LeftCanvas' || id === 'App-ImageView-RightCanvas';
}
function stablePathKey(commands) {
let hash = 2166136261;
let length = 0;
for (const command of commands) {
const text = `${command.op}:${command.args.join(',')};`;
length += text.length;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
}
return `${hash >>> 0}:${length}:${commands.length}`;
}
function finalizeBatch() {
state.batchTimer = 0;
if (!state.activeBatch.length) return;
const unique = new Map();
for (const record of state.activeBatch) {
const existing = unique.get(record.key);
if (!existing || existing.commandText !== record.commandText) {
unique.set(record.key, record);
} else {
existing.fill = record.fill;
existing.alpha = record.alpha;
existing.fillRule = record.fillRule;
}
}
const frame = [...unique.values()];
state.activeBatch = [];
if (frame.length >= state.bestFrame.length) {
state.bestFrame = frame;
state.frameSerial += 1;
state.lastCaptureAt = Date.now();
updateUi();
}
}
function captureFill(context, capturedPath, fillRule) {
// The app's custom canvas wrapper does not expose the backing canvas id
// through the native context. Explicit Path2D fills are the vector result
// draw calls, so capture them directly and let the frame-size heuristic
// discard incidental redraws.
state.resultCanvasFillCalls += 1;
const data = pathData.get(capturedPath);
if (!data || data.opaqueSource || !data.commands.length) return;
const commands = cloneCommands(data.commands);
const commandText = JSON.stringify(commands);
state.activeBatch.push({
key: stablePathKey(commands),
commandText,
commands,
fill: String(context.fillStyle),
alpha: Number(context.globalAlpha),
fillRule: fillRule === 'evenodd' ? 'evenodd' : 'nonzero',
});
if (!state.batchTimer) state.batchTimer = window.setTimeout(finalizeBatch, 0);
}
const nativeFill = contextPrototype.fill;
contextPrototype.fill = function (...args) {
state.fillCalls += 1;
const captured = args[0] && pathData.has(args[0]) ? args[0] : null;
if (captured) {
state.wrappedFillCalls += 1;
state.wrappedCanvasIds.add(String(this.canvas?.id || ''));
}
if (captured) captureFill(this, captured, args[1]);
const nativeArgs = captured ? [unwrapPath(captured), ...args.slice(1)] : args;
return nativeFill.apply(this, nativeArgs);
};
for (const methodName of ['stroke', 'clip', 'isPointInPath', 'isPointInStroke', 'drawFocusIfNeeded', 'scrollPathIntoView']) {
const original = contextPrototype[methodName];
if (typeof original !== 'function') continue;
contextPrototype[methodName] = function (...args) {
if (args[0] && pathData.has(args[0])) args[0] = unwrapPath(args[0]);
return original.apply(this, args);
};
}
function rememberFile(file) {
if (!file || !String(file.type || '').startsWith('image/')) return;
resetCapture();
state.inputFilename = file.name || state.inputFilename;
createImageBitmap(file).then((bitmap) => {
state.inputWidth = bitmap.width;
state.inputHeight = bitmap.height;
bitmap.close?.();
updateUi();
}).catch(() => {});
}
document.addEventListener('change', (event) => {
const files = event.target?.files;
if (files?.length) rememberFile(files[0]);
}, true);
document.addEventListener('drop', (event) => {
const files = event.dataTransfer?.files;
if (files?.length) rememberFile(files[0]);
}, true);
document.addEventListener('paste', (event) => {
const file = [...(event.clipboardData?.files || [])][0];
if (file) rememberFile(file);
}, true);
let lastPathname = location.pathname;
window.setInterval(() => {
if (location.pathname === lastPathname) return;
lastPathname = location.pathname;
if (lastPathname === '/images/processing') resetCapture();
}, 400);
function number(value) {
if (!Number.isFinite(value)) return '0';
if (Math.abs(value) < 1e-7) return '0';
return String(Number(value.toFixed(5)));
}
function samePoint(a, b) {
return a && b && Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON;
}
function ellipsePoint(cx, cy, rx, ry, rotation, angle) {
const cosRotation = Math.cos(rotation);
const sinRotation = Math.sin(rotation);
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
return {
x: cx + rx * cosAngle * cosRotation - ry * sinAngle * sinRotation,
y: cy + rx * cosAngle * sinRotation + ry * sinAngle * cosRotation,
};
}
function ellipseDerivative(rx, ry, rotation, angle) {
const cosRotation = Math.cos(rotation);
const sinRotation = Math.sin(rotation);
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
return {
x: -rx * sinAngle * cosRotation - ry * cosAngle * sinRotation,
y: -rx * sinAngle * sinRotation + ry * cosAngle * cosRotation,
};
}
function normalizedArcDelta(start, end, counterclockwise) {
let delta = end - start;
if (!counterclockwise && delta >= TWO_PI) return TWO_PI;
if (counterclockwise && -delta >= TWO_PI) return -TWO_PI;
delta %= TWO_PI;
if (!counterclockwise && delta < 0) delta += TWO_PI;
if (counterclockwise && delta > 0) delta -= TWO_PI;
return delta;
}
function commandsToPathData(commands) {
const parts = [];
let current = null;
let subpathStart = null;
function move(point) {
parts.push(`M${number(point.x)} ${number(point.y)}`);
current = point;
subpathStart = point;
}
function line(point) {
parts.push(`L${number(point.x)} ${number(point.y)}`);
current = point;
}
function appendEllipse(cx, cy, rx, ry, rotation, start, end, counterclockwise) {
rx = Math.abs(rx);
ry = Math.abs(ry);
if (!rx || !ry) return;
const delta = normalizedArcDelta(start, end, counterclockwise);
if (Math.abs(delta) < EPSILON) return;
const startPoint = ellipsePoint(cx, cy, rx, ry, rotation, start);
if (!current) move(startPoint);
else if (!samePoint(current, startPoint)) line(startPoint);
// Illustrator's SVG importer is unreliable with some rotated A arcs.
// Approximate every ellipse arc with <= 90-degree cubic segments, which
// matches Vectorizer.AI's own Adobe-compatible export strategy.
const segmentCount = Math.max(1, Math.ceil(Math.abs(delta) / (Math.PI / 2)));
const segmentDelta = delta / segmentCount;
for (let index = 0; index < segmentCount; index += 1) {
const theta1 = start + segmentDelta * index;
const theta2 = theta1 + segmentDelta;
const endpoint = ellipsePoint(cx, cy, rx, ry, rotation, theta2);
const derivative1 = ellipseDerivative(rx, ry, rotation, theta1);
const derivative2 = ellipseDerivative(rx, ry, rotation, theta2);
const handle = 4 / 3 * Math.tan(segmentDelta / 4);
const control1 = {
x: ellipsePoint(cx, cy, rx, ry, rotation, theta1).x + handle * derivative1.x,
y: ellipsePoint(cx, cy, rx, ry, rotation, theta1).y + handle * derivative1.y,
};
const control2 = {
x: endpoint.x - handle * derivative2.x,
y: endpoint.y - handle * derivative2.y,
};
parts.push(`C${number(control1.x)} ${number(control1.y)} ${number(control2.x)} ${number(control2.y)} ${number(endpoint.x)} ${number(endpoint.y)}`);
current = endpoint;
}
}
for (const command of commands) {
const a = command.args;
switch (command.op) {
case 'M':
move({ x: a[0], y: a[1] });
break;
case 'L':
line({ x: a[0], y: a[1] });
break;
case 'Q':
if (current) {
const control = { x: a[0], y: a[1] };
const endpoint = { x: a[2], y: a[3] };
const control1 = {
x: current.x + 2 / 3 * (control.x - current.x),
y: current.y + 2 / 3 * (control.y - current.y),
};
const control2 = {
x: endpoint.x + 2 / 3 * (control.x - endpoint.x),
y: endpoint.y + 2 / 3 * (control.y - endpoint.y),
};
parts.push(`C${number(control1.x)} ${number(control1.y)} ${number(control2.x)} ${number(control2.y)} ${number(endpoint.x)} ${number(endpoint.y)}`);
current = endpoint;
}
break;
case 'C':
parts.push(`C${number(a[0])} ${number(a[1])} ${number(a[2])} ${number(a[3])} ${number(a[4])} ${number(a[5])}`);
current = { x: a[4], y: a[5] };
break;
case 'A':
appendEllipse(a[0], a[1], a[2], a[2], 0, a[3], a[4], Boolean(a[5]));
break;
case 'E':
appendEllipse(a[0], a[1], a[2], a[3], a[4], a[5], a[6], Boolean(a[7]));
break;
case 'R': {
const [x, y, width, height] = a;
move({ x, y });
line({ x: x + width, y });
line({ x: x + width, y: y + height });
line({ x, y: y + height });
parts.push('Z');
current = { x, y };
subpathStart = current;
break;
}
case 'RR': {
const [x, y, width, height] = a;
move({ x, y });
line({ x: x + width, y });
line({ x: x + width, y: y + height });
line({ x, y: y + height });
parts.push('Z');
current = { x, y };
subpathStart = current;
break;
}
case 'AT':
// Vectorizer.AI's preview geometry currently does not use arcTo.
if (a.length >= 4) line({ x: a[2], y: a[3] });
break;
case 'Z':
parts.push('Z');
current = subpathStart;
break;
default:
break;
}
}
return parts.join(' ');
}
function boundsFromCommands(commands) {
let maxX = 0;
let maxY = 0;
for (const command of commands) {
const a = command.args;
for (let index = 0; index + 1 < a.length; index += 2) {
const x = Number(a[index]);
const y = Number(a[index + 1]);
if (Number.isFinite(x)) maxX = Math.max(maxX, x);
if (Number.isFinite(y)) maxY = Math.max(maxY, y);
}
if (command.op === 'A') {
const radius = Math.abs(Number(a[2]));
maxX = Math.max(maxX, Number(a[0]) + radius);
maxY = Math.max(maxY, Number(a[1]) + radius);
} else if (command.op === 'E') {
const rx = Math.abs(Number(a[2]));
const ry = Math.abs(Number(a[3]));
const rotation = Number(a[4]);
const cosRotation = Math.cos(rotation);
const sinRotation = Math.sin(rotation);
const xRadius = Math.sqrt((rx * cosRotation) ** 2 + (ry * sinRotation) ** 2);
const yRadius = Math.sqrt((rx * sinRotation) ** 2 + (ry * cosRotation) ** 2);
maxX = Math.max(maxX, Number(a[0]) + xRadius);
maxY = Math.max(maxY, Number(a[1]) + yRadius);
}
}
return { width: Math.ceil(maxX), height: Math.ceil(maxY) };
}
function inferDimensions() {
if (state.inputWidth > 0 && state.inputHeight > 0) {
return { width: state.inputWidth, height: state.inputHeight };
}
const text = document.body?.innerText || '';
const match = text.match(/([\d,]+)\s*[x×]\s*([\d,]+)\s*px/i);
if (match) {
return {
width: Number(match[1].replaceAll(',', '')),
height: Number(match[2].replaceAll(',', '')),
};
}
let width = 0;
let height = 0;
for (const record of state.bestFrame) {
const bounds = boundsFromCommands(record.commands);
width = Math.max(width, bounds.width);
height = Math.max(height, bounds.height);
}
return { width: width || 1, height: height || 1 };
}
function escapeAttribute(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
async function resolveDimensions() {
const local = inferDimensions();
if (state.inputWidth > 0 && state.inputHeight > 0) return local;
const tokenMatch = location.pathname.match(/^\/images\/([^/]+)\/edit(?:\/)?$/);
if (!tokenMatch) return local;
try {
const response = await fetch(`${location.origin}/images/${encodeURIComponent(tokenMatch[1])}`, {
credentials: 'same-origin',
});
if (!response.ok) return local;
const html = await response.text();
const match = html.match(/([\d,]+)\s*[x×]\s*([\d,]+)\s*px/i);
if (match) {
return {
width: Number(match[1].replaceAll(',', '')),
height: Number(match[2].replaceAll(',', '')),
};
}
} catch (_) {
// Keep the geometry-derived fallback when the metadata page is unavailable.
}
return local;
}
async function exportSvgString() {
finalizeBatch();
if (!state.bestFrame.length) throw new Error('尚未捕获到矢量预览,请等待处理完成并保持“缩放至适合”视图。');
const dimensions = await resolveDimensions();
const paths = [];
for (const record of state.bestFrame) {
const d = commandsToPathData(record.commands);
if (!d) continue;
const alpha = Number.isFinite(record.alpha) ? record.alpha : 1;
paths.push(` `);
}
return [
'',
`',
'',
].join('\n');
}
function safeFilename() {
const fallback = document.title.split(' - ')[0] || 'vectorizer-preview';
const source = state.inputFilename || fallback;
const stem = source.replace(/\.[^.]+$/, '').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').trim() || 'vectorizer-preview';
return `${stem}.preview-capture.svg`;
}
async function downloadSvg() {
try {
const svg = await exportSvgString();
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = safeFilename();
document.documentElement.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch (error) {
window.alert(error?.message || String(error));
}
}
function resetCapture() {
state.activeBatch = [];
state.bestFrame = [];
state.frameSerial += 1;
updateUi();
}
let uiRoot = null;
let uiDrag = null;
function clampUiPosition(left, top) {
const rect = uiRoot.getBoundingClientRect();
const margin = 8;
return {
left: Math.min(Math.max(margin, left), Math.max(margin, window.innerWidth - rect.width - margin)),
top: Math.min(Math.max(margin, top), Math.max(margin, window.innerHeight - rect.height - margin)),
};
}
function setUiPosition(left, top, persist = false) {
const position = clampUiPosition(left, top);
uiRoot.style.left = `${position.left}px`;
uiRoot.style.top = `${position.top}px`;
uiRoot.style.right = 'auto';
uiRoot.style.bottom = 'auto';
if (persist) {
try {
localStorage.setItem(UI_POSITION_KEY, JSON.stringify(position));
} catch (_) {
// Position persistence is optional when storage is unavailable.
}
}
}
function restoreUiPosition() {
try {
const saved = JSON.parse(localStorage.getItem(UI_POSITION_KEY) || 'null');
if (Number.isFinite(saved?.left) && Number.isFinite(saved?.top)) {
setUiPosition(saved.left, saved.top);
}
} catch (_) {
// Keep the default bottom-right position for invalid or blocked storage.
}
}
function beginUiDrag(event) {
if (event.button !== 0 && event.pointerType !== 'touch') return;
const handle = event.currentTarget;
const rect = uiRoot.getBoundingClientRect();
uiDrag = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
handle,
};
setUiPosition(rect.left, rect.top);
handle.setPointerCapture?.(event.pointerId);
handle.style.cursor = 'grabbing';
document.documentElement.style.userSelect = 'none';
event.preventDefault();
}
function moveUi(event) {
if (!uiDrag || event.pointerId !== uiDrag.pointerId) return;
setUiPosition(event.clientX - uiDrag.offsetX, event.clientY - uiDrag.offsetY);
event.preventDefault();
}
function endUiDrag(event) {
if (!uiDrag || event.pointerId !== uiDrag.pointerId) return;
const { handle, pointerId } = uiDrag;
uiDrag = null;
handle.releasePointerCapture?.(pointerId);
handle.style.cursor = 'move';
document.documentElement.style.userSelect = '';
const rect = uiRoot.getBoundingClientRect();
setUiPosition(rect.left, rect.top, true);
event.preventDefault();
}
function updateUi() {
if (!uiRoot) return;
const status = uiRoot.querySelector('[data-vai-status]');
const button = uiRoot.querySelector('button');
const count = state.bestFrame.length;
status.textContent = count ? `已捕获 ${count} 个图形` : '等待矢量预览';
button.disabled = !count;
}
function installUi() {
if (uiRoot || !document.documentElement) return;
uiRoot = document.createElement('div');
uiRoot.id = 'vai-svg-capture-controls';
uiRoot.style.cssText = [
'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483647',
'display:flex', 'align-items:center', 'gap:9px', 'padding:10px 12px',
'border:1px solid rgba(255,255,255,.18)', 'border-radius:12px',
'background:rgba(20,24,32,.94)', 'box-shadow:0 8px 30px rgba(0,0,0,.35)',
'color:#ecf3ff', 'font:13px/1.2 system-ui,-apple-system,Segoe UI,sans-serif',
'backdrop-filter:blur(10px)',
].join(';');
uiRoot.innerHTML = '⠿等待矢量预览';
const dragHandle = uiRoot.querySelector('[data-vai-drag-handle]');
dragHandle.style.cssText = [
'display:flex', 'align-items:center', 'gap:6px', 'cursor:move',
'user-select:none', 'touch-action:none', 'white-space:nowrap',
].join(';');
dragHandle.addEventListener('pointerdown', beginUiDrag);
dragHandle.addEventListener('pointermove', moveUi);
dragHandle.addEventListener('pointerup', endUiDrag);
dragHandle.addEventListener('pointercancel', endUiDrag);
const button = uiRoot.querySelector('button');
button.disabled = true;
button.style.cssText = [
'border:0', 'border-radius:8px', 'padding:7px 10px', 'font:inherit',
'font-weight:650', 'background:#62d5a8', 'color:#10231c', 'cursor:pointer',
].join(';');
button.addEventListener('click', downloadSvg);
document.documentElement.appendChild(uiRoot);
restoreUiPosition();
updateUi();
}
window.addEventListener('resize', () => {
if (!uiRoot || uiRoot.style.left === '') return;
const rect = uiRoot.getBoundingClientRect();
setUiPosition(rect.left, rect.top, true);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', installUi, { once: true });
} else {
installUi();
}
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.shiftKey && event.code === 'KeyS') {
event.preventDefault();
downloadSvg();
}
}, true);
window.__VAI_SVG_CAPTURE__ = Object.freeze({
version: VERSION,
exportSvgString,
downloadSvg,
resetCapture,
setInputDimensions(width, height, filename = '') {
state.inputWidth = Number(width) || 0;
state.inputHeight = Number(height) || 0;
state.inputFilename = String(filename || '');
updateUi();
},
status() {
const dimensions = inferDimensions();
return {
version: VERSION,
shapes: state.bestFrame.length,
width: dimensions.width,
height: dimensions.height,
filename: safeFilename(),
frameSerial: state.frameSerial,
lastCaptureAt: state.lastCaptureAt,
pathConstructed: state.pathConstructed,
fillCalls: state.fillCalls,
wrappedFillCalls: state.wrappedFillCalls,
resultCanvasFillCalls: state.resultCanvasFillCalls,
wrappedCanvasIds: [...state.wrappedCanvasIds],
};
},
});
})();