// ==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 [ '', ``, ` vectorizer-preview-导出svg ${VERSION}; Designed by 木木`, ...paths, '', '', ].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], }; }, }); })();