// ==UserScript==
// @name LaTeXLive AI OCR
// @namespace http://tampermonkey.net/
// @version 1.5
// @description AI OCR
// @match https://www.latexlive.com/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect dashscope.aliyuncs.com
// @connect open.bigmodel.cn
// @connect api.openai.com
// ==/UserScript==
(function () {
'use strict';
// =========================
// 0) 配置 & 默认值
// =========================
const SCRIPT_VER = '16.5';
const CONFIG_KEY = 'AI_OCR_CONFIG';
// ✅ 提示词版本:你改了 DEFAULT_PROMPT / ALIGN_PROMPT 时,把这个 +1
const PROMPT_VER = 3;
const DEFAULT_PROMPT =
"Extract the content from the image and format it as LaTeX text. " +
"Wrap only mathematical or logical expressions in inline math \\( ... \\). " +
"Do not wrap normal text or full sentences in math mode. " +
"Do not use display math or add explanations.";
// ✅ align 模式:整段 align*(不重复、不 enumerate)
const ALIGN_PROMPT =
"Extract the content from the image and output LaTeX only. " +
"Keep the original numbering and one numbered item per line. " +
"Output EVERYTHING inside a single \\begin{align*}...\\end{align*} block, one item per line, " +
"use '&' before the main formula, and keep the Chinese text on the same line using \\text{...}. " +
"Use \\( ... \\) only if needed inside \\text. Replace '|' with '\\\\mid'. Do NOT add extra lines.";
// 运行时缓存:用于“复制原始响应latex”
const runtime = {
lastRawLatex: '',
lastCleanLatex: ''
};
const DEFAULT_STATE = {
activeProvider: 'zhipu',
promptMode: 'inline',
prompts: { inline: DEFAULT_PROMPT, align: ALIGN_PROMPT },
promptsCustomized: { inline: false, align: false },
promptVersion: PROMPT_VER,
zhipuThinkingEnabled: false,
aliyun: {
key: '',
model: 'qwen-vl-plus',
models: ['qwen-vl-plus', 'qwen-vl-max', 'qwen-vl-ocr'],
url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'
},
zhipu: {
key: '',
model: 'glm-4.5v',
models: ['glm-4.5v', 'glm-4v-plus', 'glm-4v'],
url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
},
openai: {
key: '',
model: '',
models: [],
url: 'https://api.openai.com/v1/chat/completions'
}
};
let state = GM_getValue(CONFIG_KEY, DEFAULT_STATE);
const saveState = () => GM_setValue(CONFIG_KEY, state);
function migrateState() {
if (!state.prompts) state.prompts = { inline: DEFAULT_PROMPT, align: ALIGN_PROMPT };
if (!state.prompts.inline) state.prompts.inline = DEFAULT_PROMPT;
if (!state.prompts.align) state.prompts.align = ALIGN_PROMPT;
if (!state.promptMode) state.promptMode = 'inline';
if (!state.promptsCustomized) state.promptsCustomized = { inline: false, align: false };
if (typeof state.promptsCustomized.inline !== 'boolean') state.promptsCustomized.inline = false;
if (typeof state.promptsCustomized.align !== 'boolean') state.promptsCustomized.align = false;
const oldVer = Number(state.promptVersion || 0);
if (oldVer < PROMPT_VER) {
if (!state.promptsCustomized.inline) state.prompts.inline = DEFAULT_PROMPT;
if (!state.promptsCustomized.align) state.prompts.align = ALIGN_PROMPT;
state.promptVersion = PROMPT_VER;
saveState();
}
}
migrateState();
function getActivePrompt() {
const mode = state.promptMode === 'align' ? 'align' : 'inline';
return (state.prompts?.[mode] || (mode === 'align' ? ALIGN_PROMPT : DEFAULT_PROMPT)).trim();
}
function setStatusSafe(statusEl, titleHtml, msgText) {
if (!statusEl) return;
statusEl.innerHTML = titleHtml || '';
if (msgText != null && msgText !== '') {
const div = document.createElement('div');
div.style.cssText = 'color:#b00;font-size:12px;margin-top:4px;white-space:pre-wrap';
div.textContent = String(msgText);
statusEl.appendChild(div);
}
}
function errToText(e) {
if (!e) return 'Unknown error';
const name = e.name || 'Error';
const msg = e.message || String(e);
return `${name}: ${msg}`.slice(0, 240);
}
function isTaintedBlobError(e) {
const t = (e?.message || String(e || '')).toLowerCase();
return t.includes('taint') || t.includes('toblob failed');
}
// =========================
// 1) 供应商策略
// =========================
function parseOpenAIContent(json) {
const c = json?.choices?.[0]?.message?.content;
if (!c) return '';
if (typeof c === 'string') return c;
if (Array.isArray(c)) return c.find(x => x?.type === 'text')?.text || '';
return '';
}
const Providers = {
aliyun: {
fetchModels: (_cfg, cb) => cb(['qwen-vl-plus', 'qwen-vl-max', 'qwen-vl-ocr']),
buildPayload: (img, model, prompt) => ({
model,
input: { messages: [{ role: 'user', content: [{ image: img }, { text: prompt }] }] }
}),
parseResponse: (json) => json?.output?.choices?.[0]?.message?.content?.[0]?.text
},
zhipu: {
fetchModels: (cfg, cb) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://open.bigmodel.cn/api/paas/v4/models',
headers: { Authorization: `Bearer ${cfg.key}` },
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
const list = (data?.data || []).map((m) => m.id).filter((id) => /v/i.test(id));
cb(list.length ? list : ['glm-4.5v', 'glm-4v-plus', 'glm-4v']);
} catch (_e) {
cb(['glm-4.5v', 'glm-4v-plus', 'glm-4v']);
}
},
onerror: () => cb(['glm-4.5v', 'glm-4v-plus', 'glm-4v'])
});
},
buildPayload: (img, model, prompt) => ({
model,
...(state.zhipuThinkingEnabled
? { thinking: { type: "enabled" } }
: { thinking: { type: "disabled" } }
),
messages: [{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: img } }
]
}]
}),
parseResponse: (json) => json?.choices?.[0]?.message?.content
},
openai: {
fetchModels: (cfg, cb) => {
let base = cfg.url || 'https://api.openai.com/v1/chat/completions';
if (base.includes('/v1/')) base = base.split('/v1/')[0] + '/v1';
else if (base.includes('/chat/')) base = base.split('/chat/')[0] + '/v1';
GM_xmlhttpRequest({
method: 'GET',
url: `${base}/models`,
headers: { Authorization: `Bearer ${cfg.key}` },
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
const ids = (data?.data || []).map((m) => m.id);
const list = ids.filter((id) => /vision|vl|ocr|gpt-4|gpt-4o|4\.1|4\.5/i.test(id));
cb(list.length ? list : ids);
} catch (_e) {
cb([]);
}
},
onerror: () => cb([])
});
},
buildPayload: (img, model, prompt) => ({
model,
messages: [{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: img } }
]
}]
}),
parseResponse: (json) => parseOpenAIContent(json)
}
};
function getProvider() {
return Providers[state.activeProvider] || Providers.zhipu;
}
// =========================
// 2) LaTeX 清洗
// =========================
function cleanLatex(raw) {
if (!raw) return '';
let s = String(raw).trim();
s = s.replace(/```latex\s*/gi, '').replace(/```/g, '');
s = s.replace(/\\\[/g, '').replace(/\\\]/g, '');
s = s.replace(/\\\(/g, '').replace(/\\\)/g, '');
s = s.replace(/^\s*\$\$\s*/g, '').replace(/\s*\$\$\s*$/g, '');
s = s.replace(/^\s*\$\s*/g, '').replace(/\s*\$\s*$/g, '');
return s.trim();
}
// =========================
// 3) Clipboard 复制
// =========================
async function copyTextToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (_e) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (_e2) {
return false;
}
}
}
// ✅ 更稳的 PNG 复制:权限探测 + 详细错误
async function copyPngBlobToClipboard(pngBlob) {
if (!pngBlob) throw new Error('No PNG blob');
if (pngBlob.type !== 'image/png') throw new Error(`Blob is not PNG (${pngBlob.type || 'unknown'})`);
if (!navigator.clipboard) throw new Error('navigator.clipboard not available');
if (!window.ClipboardItem) throw new Error('ClipboardItem not supported');
try {
if (navigator.permissions?.query) {
const p = await navigator.permissions.query({ name: 'clipboard-write' });
if (p && p.state === 'denied') throw new Error('clipboard-write permission denied');
}
} catch (_ignore) {}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
return true;
}
// =========================
// 4) 拦截 paste 防站点弹登录提示
// =========================
function hasImageInClipboard(e) {
const items = e.clipboardData?.items;
if (!items) return false;
return [...items].some((i) => i.type && i.type.startsWith('image/'));
}
function shouldHandlePasteHere() {
const panel = document.getElementById('wrap_ai_panel');
if (!panel) return false;
const tabActive = !!document.querySelector('#li_ai_ocr a.nav-link.active');
const panelVisible = panel.style.display !== 'none';
return tabActive || panelVisible;
}
async function handlePasteOCR(e) {
const panel = document.getElementById('wrap_ai_panel');
if (!panel) return;
const status = panel.querySelector('#ai_status');
const item = [...e.clipboardData.items].find((i) => i.type && i.type.startsWith('image/'));
if (!item) return;
const p = state.activeProvider;
const cfg = state[p];
if (!cfg.key) return setStatusSafe(status, `缺少 API Key`);
if (p === 'openai' && !cfg.model) return setStatusSafe(status, `请填写/选择 OpenAI 模型`);
setStatusSafe(status, `⌛ 正在提取...`);
const file = item.getAsFile();
const reader = new FileReader();
reader.onload = (ev) => {
const prov = getProvider();
const prompt = getActivePrompt();
const payload = prov.buildPayload(ev.target.result, cfg.model, prompt);
GM_xmlhttpRequest({
method: 'POST',
url: cfg.url,
headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
data: JSON.stringify(payload),
onload: (res) => {
try {
const json = JSON.parse(res.responseText);
let content = prov.parseResponse(json);
if (!content) {
const msg =
json?.error?.message ||
json?.message ||
json?.msg ||
json?.error ||
res.responseText?.slice(0, 240);
return setStatusSafe(status, `❌ 接口返回异常`, msg);
}
runtime.lastRawLatex = String(content);
const cleaned = cleanLatex(content);
runtime.lastCleanLatex = cleaned;
const ta = document.getElementById('txta_input');
if (!ta) return setStatusSafe(status, `❌ 找不到 #txta_input`);
ta.value = cleaned;
ta.dispatchEvent(new Event('input', { bubbles: true }));
setStatusSafe(status, `✅ 提取成功`);
} catch (_err) {
setStatusSafe(status, `❌ 解析失败`, res.responseText?.slice(0, 240));
}
},
onerror: () => setStatusSafe(status, `❌ 网络请求错误`)
});
};
reader.readAsDataURL(file);
}
document.addEventListener(
'paste',
(e) => {
if (!shouldHandlePasteHere()) return;
if (!hasImageInClipboard(e)) return;
e.preventDefault();
e.stopImmediatePropagation();
handlePasteOCR(e);
},
true
);
// =========================
// 5) UI 注入
// =========================
function addStyleOnce() {
if (document.getElementById('ai_ocr_style_v16_5')) return;
const style = document.createElement('style');
style.id = 'ai_ocr_style_v16_5';
style.textContent = `
#li_ai_ocr { order: 99 !important; }
.ai-row { display:flex; gap:8px; margin-bottom:8px; align-items:center; }
.ai-row label { width:75px; font-size:11px; color:#666; margin:0; flex-shrink:0; font-weight:bold; }
.ai-select, .ai-input, .ai-textarea {
flex:1;
font-size:12px !important;
border:1px solid #dee2e6 !important;
border-radius:4px !important;
}
.ai-input, .ai-select { height:30px !important; padding:0 8px !important; }
.ai-textarea { height:70px; padding:6px 8px; resize:vertical; }
#ai_drop_zone {
width:100%;
height:120px;
border:2px dashed #6f42c1;
border-radius:6px;
margin-top:10px;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
background:#fff;
cursor:pointer;
}
#ai_drop_zone:focus { outline:none; background:#fcfaff; border-color:#007bff; }
.btn-action {
padding:3px 8px;
font-size:11px;
border:1px solid #ddd;
background:#fff;
border-radius:4px;
cursor:pointer;
white-space:nowrap;
}
.btn-action:hover { background:#f6f6f6; }
.ai-hint { font-size:12px; color:#999; margin-top:6px; text-align:center; }
.ai-toast {
position:fixed; right:16px; bottom:16px; z-index:999999;
background:rgba(0,0,0,0.75); color:#fff;
padding:8px 10px; border-radius:8px; font-size:12px; max-width:360px;
}
.ai-inline-btn { margin-left: .5rem; }
@media (max-width: 768px) { .ai-inline-btn { margin-left: .25rem; } }
.ai-switch-wrap, .ai-preset-wrap { display:flex; align-items:center; gap:6px; flex:1; }
.ai-switch-wrap input[type="checkbox"], .ai-preset-wrap input[type="checkbox"] { transform: scale(1.05); cursor: pointer; }
.ai-switch-tip, .ai-preset-tip { font-size:12px; color:#666; user-select:none; }
.ai-preset-tip b { color:#6f42c1; }
`;
document.head.appendChild(style);
}
function toastMini(text) {
const div = document.createElement('div');
div.className = 'ai-toast';
div.textContent = text;
document.body.appendChild(div);
setTimeout(() => div.remove(), 2200);
}
function buildPanelHTML() {
const p = state.activeProvider;
const cfg = state[p];
const modelRow =
p === 'openai'
? `
`
: `
`;
const thinkingRow =
p === 'zhipu'
? `
`
: '';
const alignChecked = state.promptMode === 'align';
const promptPresetRow = `
`;
const promptText = getActivePrompt();
const quickRow = `
`;
return `
${modelRow}
${thinkingRow}
${promptPresetRow}
${quickRow}
Ctrl + V 粘贴图片
PNG 若因跨域污染无法导出,会自动降级为下载 SVG
提示:出现 “canvas tainted” 代表 SVG 引用了跨域字体/资源,浏览器不允许转 PNG。
`;
}
function bindEvents(panel) {
const boundVer = panel.getAttribute('data-aiocr-bound');
if (boundVer === SCRIPT_VER) return;
panel.setAttribute('data-aiocr-bound', SCRIPT_VER);
const vSel = panel.querySelector('#ai_vendor');
const pInp = panel.querySelector('#ai_prompt');
const kInp = panel.querySelector('#ai_key');
const dropZone = panel.querySelector('#ai_drop_zone');
const status = panel.querySelector('#ai_status');
if (vSel) {
vSel.onchange = () => {
state.activeProvider = vSel.value;
saveState();
panel.innerHTML = buildPanelHTML();
panel.removeAttribute('data-aiocr-bound');
bindEvents(panel);
};
}
if (pInp) {
pInp.onchange = () => {
const mode = state.promptMode === 'align' ? 'align' : 'inline';
const v = (pInp.value || '').trim();
state.prompts[mode] = v || (mode === 'align' ? ALIGN_PROMPT : DEFAULT_PROMPT);
state.promptsCustomized[mode] = true;
state.promptVersion = PROMPT_VER;
saveState();
toastMini('✅ 提示词已保存');
};
}
if (kInp) {
kInp.onchange = () => {
state[state.activeProvider].key = (kInp.value || '').trim();
saveState();
};
}
const alignChk = panel.querySelector('#ai_prompt_align');
if (alignChk) {
alignChk.onchange = () => {
state.promptMode = alignChk.checked ? 'align' : 'inline';
saveState();
toastMini(state.promptMode === 'align' ? '✅ 已切换:align* 模式' : '✅ 已切换:行内模式');
panel.innerHTML = buildPanelHTML();
panel.removeAttribute('data-aiocr-bound');
bindEvents(panel);
};
}
const modelSel = panel.querySelector('#ai_model_list');
const modelInp = panel.querySelector('#ai_model_input');
if (modelSel) {
modelSel.onchange = () => {
state[state.activeProvider].model = modelSel.value;
saveState();
};
}
if (modelInp) {
modelInp.onchange = () => {
state.openai.model = (modelInp.value || '').trim();
saveState();
};
}
const urlInp = panel.querySelector('#ai_url');
if (urlInp) {
urlInp.onchange = () => {
state.openai.url = (urlInp.value || '').trim();
saveState();
};
}
const thinkingChk = panel.querySelector('#ai_zhipu_thinking');
if (thinkingChk) {
thinkingChk.onchange = () => {
state.zhipuThinkingEnabled = !!thinkingChk.checked;
saveState();
toastMini(state.zhipuThinkingEnabled ? '✅ 已开启深度思考' : '✅ 已关闭深度思考');
panel.innerHTML = buildPanelHTML();
panel.removeAttribute('data-aiocr-bound');
bindEvents(panel);
};
}
const copyRawBtn = panel.querySelector('#ai_copy_raw_latex');
if (copyRawBtn) {
copyRawBtn.onclick = async () => {
const raw = (runtime.lastRawLatex || '').trim();
if (!raw) return toastMini('⚠️ 还没有原始响应(先识别一次)');
const ok = await copyTextToClipboard(raw);
toastMini(ok ? '✅ 已复制原始响应LaTeX' : '❌ 复制失败(权限限制)');
};
}
const clearBtn = panel.querySelector('#ai_clear_raw_cache');
if (clearBtn) {
clearBtn.onclick = () => {
runtime.lastRawLatex = '';
runtime.lastCleanLatex = '';
toastMini('✅ 已清空缓存');
};
}
const resetBtn = panel.querySelector('#ai_reset_prompts');
if (resetBtn) {
resetBtn.onclick = () => {
state.prompts.inline = DEFAULT_PROMPT;
state.prompts.align = ALIGN_PROMPT;
state.promptsCustomized.inline = false;
state.promptsCustomized.align = false;
state.promptVersion = PROMPT_VER;
saveState();
toastMini('✅ 已重置提示词为默认');
panel.innerHTML = buildPanelHTML();
panel.removeAttribute('data-aiocr-bound');
bindEvents(panel);
};
}
const refreshBtn = panel.querySelector('#ai_refresh');
if (refreshBtn) {
refreshBtn.onclick = () => {
const p = state.activeProvider;
const cfg = state[p];
if (!cfg.key) return setStatusSafe(status, `请先填写 API Key`);
setStatusSafe(status, `⌛ 正在获取模型列表...`);
const prov = getProvider();
prov.fetchModels(cfg, (list) => {
if (Array.isArray(list) && list.length) {
state[p].models = list;
if (!state[p].model || !list.includes(state[p].model)) state[p].model = list[0];
saveState();
panel.innerHTML = buildPanelHTML();
panel.removeAttribute('data-aiocr-bound');
bindEvents(panel);
toastMini('模型列表已刷新');
} else {
setStatusSafe(status, `⚠️ 获取失败,保留旧模型列表`);
toastMini('获取失败(保留旧列表)');
}
});
};
}
if (dropZone) {
dropZone.onclick = () => dropZone.focus();
dropZone.addEventListener('paste', (e) => {
if (!hasImageInClipboard(e)) return;
e.preventDefault();
e.stopImmediatePropagation();
handlePasteOCR(e);
}, true);
}
}
// =========================
// 6) 外置按钮:样式继承同级元素(只保留 PNG)
// =========================
function deriveButtonClassFrom(anchorBtn) {
const cls = (anchorBtn?.className || '').trim();
return cls || 'btn btn-light btn-sm';
}
function deriveButtonInlineStyleFrom(anchorBtn) {
if (!anchorBtn) return '';
const cs = window.getComputedStyle(anchorBtn);
const keys = ['font-size', 'height', 'line-height', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-radius'];
return keys.map(k => `${k}:${cs.getPropertyValue(k)};`).join('');
}
function buildInlineButton(anchorBtn, id, text) {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = id;
btn.textContent = text;
btn.className = deriveButtonClassFrom(anchorBtn);
btn.classList.add('ai-inline-btn');
const styleText = deriveButtonInlineStyleFrom(anchorBtn);
if (styleText) btn.setAttribute('style', styleText);
btn.setAttribute('aria-label', text);
return btn;
}
function ensureInlineActionButtons() {
const wrapAction = document.querySelector('#wrap_action');
if (!wrapAction) return;
const imgBtn = document.getElementById('action_drop0'); // “输出图片”
if (imgBtn && !document.getElementById('ai_btn_copy_png')) {
const btn = buildInlineButton(imgBtn, 'ai_btn_copy_png', '复制PNG(透明)');
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// 1) 优先导出 PNG
try {
const pngBlob = await exportWrapOutputAsTransparentPngBlob();
try {
await copyPngBlobToClipboard(pngBlob);
toastMini('✅ 已复制 PNG(透明)到剪贴板');
} catch (clipErr) {
console.warn('[AI OCR] clipboard write failed:', clipErr);
toastMini('⚠️ 复制失败:' + errToText(clipErr) + '(已改为下载 PNG)');
downloadBlob(pngBlob, 'latexlive.png');
}
return;
} catch (pngErr) {
console.warn('[AI OCR] export png failed:', pngErr);
// 2) 如果是 tainted:PNG 无解(浏览器安全限制),自动降级导出 SVG
if (isTaintedBlobError(pngErr)) {
try {
const svgBlob = await exportWrapOutputAsSvgBlob();
toastMini('⚠️ PNG 被跨域污染阻止,已改为下载 SVG');
downloadBlob(svgBlob, 'latexlive.svg');
return;
} catch (svgErr) {
console.warn('[AI OCR] export svg failed:', svgErr);
toastMini('❌ SVG 导出失败:' + errToText(svgErr));
return;
}
}
// 3) 非 tainted:直接提示错误
toastMini('❌ 导出失败:' + errToText(pngErr));
}
});
imgBtn.insertAdjacentElement('afterend', btn);
}
const oldInline = document.getElementById('ai_btn_copy_inline');
if (oldInline) oldInline.remove();
}
// =========================
// 7) 导出实现(PNG / SVG)
// =========================
async function exportWrapOutputAsTransparentPngBlob() {
const wrap = document.querySelector('#wrap_output');
if (!wrap) throw new Error('no wrap_output');
const svg = wrap.querySelector('svg');
if (svg) {
const scale = Math.max(2, Math.round((window.devicePixelRatio || 1) * 2));
return await svgElementToPngBlob(svg, scale, wrap);
}
const canvas = wrap.querySelector('canvas');
if (canvas) return await canvasToPngBlob(canvas);
const img = wrap.querySelector('img');
if (img) return await imgToPngBlob(img);
throw new Error('no exportable element (svg/canvas/img)');
}
async function exportWrapOutputAsSvgBlob() {
const wrap = document.querySelector('#wrap_output');
if (!wrap) throw new Error('no wrap_output');
const svgEl = wrap.querySelector('svg');
if (!svgEl) throw new Error('no svg in wrap_output');
// clone + 注入 style(尽量自包含)
const svg = svgEl.cloneNode(true);
if (!svg.getAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
inlineSvgStyles(svg, wrap);
// 尝试补齐 viewBox/尺寸,方便外部打开
const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
if (vb && vb.width && vb.height) {
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.width} ${vb.height}`);
svg.setAttribute('width', String(vb.width));
svg.setAttribute('height', String(vb.height));
} else {
try {
const box = svgEl.getBBox();
svg.setAttribute('viewBox', `${box.x} ${box.y} ${box.width} ${box.height}`);
svg.setAttribute('width', String(box.width || 1));
svg.setAttribute('height', String(box.height || 1));
} catch (_e) {}
}
const xml = new XMLSerializer().serializeToString(svg);
return new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
}
function inlineSvgStyles(svg, rootNode) {
const styleTags = rootNode?.querySelectorAll?.('style');
if (!styleTags || !styleTags.length) return;
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = Array.from(styleTags).map(s => s.textContent || '').join('\n');
svg.insertBefore(style, svg.firstChild);
}
async function svgElementToPngBlob(svgEl, scale = 2, rootNodeForStyle) {
const svg = svgEl.cloneNode(true);
if (!svg.getAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
inlineSvgStyles(svg, rootNodeForStyle);
let width = 0, height = 0;
const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
if (vb && vb.width && vb.height) {
width = vb.width;
height = vb.height;
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.width} ${vb.height}`);
} else {
try {
const box = svgEl.getBBox();
width = box.width || 1;
height = box.height || 1;
svg.setAttribute('viewBox', `${box.x} ${box.y} ${box.width} ${box.height}`);
} catch (_e) {
const r = svgEl.getBoundingClientRect();
width = Math.max(1, r.width);
height = Math.max(1, r.height);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
}
}
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
const xml = new XMLSerializer().serializeToString(svg);
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
let img;
try {
img = await loadImage(url);
} finally {
URL.revokeObjectURL(url);
}
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(width * scale);
canvas.height = Math.ceil(height * scale);
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) throw new Error('canvas ctx is null');
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.drawImage(img, 0, 0);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) {
// ✅ 这里就是你遇到的:跨域字体/资源污染(tainted)导致 toBlob 失败
throw new Error('toBlob failed (canvas tainted or unsupported)');
}
return blob;
}
async function canvasToPngBlob(canvas) {
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('toBlob failed');
return blob;
}
async function imgToPngBlob(imgEl) {
const src = imgEl.currentSrc || imgEl.src;
const r = await fetch(src, { mode: 'cors' });
const b = await r.blob();
if (b.type === 'image/png') return b;
return new Blob([await b.arrayBuffer()], { type: 'image/png' });
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'file.bin';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// =========================
// 8) 注入主流程
// =========================
function tryInject() {
addStyleOnce();
const tabs = document.getElementById('ul_navtabs');
const container = document.getElementById('wrap_shortcut');
if (!tabs || !container) return;
if (!document.getElementById('li_ai_ocr')) {
const li = document.createElement('li');
li.className = 'nav-item';
li.id = 'li_ai_ocr';
li.innerHTML = `🤖 AI OCR`;
tabs.appendChild(li);
li.onclick = (e) => {
e.preventDefault();
document.querySelectorAll('#ul_navtabs .nav-link').forEach((l) => l.classList.remove('active'));
document.querySelectorAll('#wrap_shortcut .wrap-shortcut').forEach((w) => (w.style.display = 'none'));
li.querySelector('a').classList.add('active');
const panel = document.getElementById('wrap_ai_panel');
if (panel) {
panel.style.display = 'block';
const dz = panel.querySelector('#ai_drop_zone');
dz && dz.focus();
}
};
}
let panel = document.getElementById('wrap_ai_panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'wrap_ai_panel';
panel.className = 'wrap-shortcut';
panel.style.cssText = 'display:none; padding:1.2rem; border:1px solid #ddd; border-top:0; background:#fff;';
panel.innerHTML = buildPanelHTML();
panel.setAttribute('data-aiocr-panel', SCRIPT_VER);
container.appendChild(panel);
bindEvents(panel);
} else {
if (!panel.querySelector('#ai_vendor') || panel.getAttribute('data-aiocr-panel') !== SCRIPT_VER) {
panel.innerHTML = buildPanelHTML();
panel.setAttribute('data-aiocr-panel', SCRIPT_VER);
panel.removeAttribute('data-aiocr-bound');
bindEvents(panel);
}
}
ensureInlineActionButtons();
}
// =========================
// 9) 监控重注入(更低CPU)
// =========================
let rafPending = false;
function scheduleInjectRaf() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
tryInject();
});
}
function startObserverWhenBodyReady() {
if (!document.body) {
setTimeout(startObserverWhenBodyReady, 50);
return;
}
tryInject();
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'childList') continue;
for (const n of m.addedNodes) {
if (!(n instanceof Element)) continue;
if (
n.id === 'ul_navtabs' ||
n.id === 'wrap_shortcut' ||
n.id === 'wrap_action' ||
n.querySelector?.('#ul_navtabs, #wrap_shortcut, #wrap_action')
) {
scheduleInjectRaf();
return;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
startObserverWhenBodyReady();
})();