// ==UserScript== // @name LaTeXLive AI OCR // @namespace http://tampermonkey.net/ // @version 1.6 // @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 // @connect localhost // ==/UserScript== (function () { 'use strict'; // ========================= // 0) 配置 & 默认值 // ========================= const SCRIPT_VER = '16.6'; const CONFIG_KEY = 'AI_OCR_CONFIG'; 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."; 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."; 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, advancedExpanded: true, 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; if (typeof state.advancedExpanded !== 'boolean') state.advancedExpanded = true; if (typeof state.zhipuThinkingEnabled !== 'boolean') state.zhipuThinkingEnabled = 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 escapeHtmlAttr(s) { return String(s ?? '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function setStatusSafe(statusEl, titleHtml, msgText) { if (!statusEl) return; statusEl.innerHTML = titleHtml || ''; if (msgText != null && msgText !== '') { const div = document.createElement('div'); div.style.cssText = 'color:#a16207;font-size:12px;margin-top:6px;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; } } } 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); const 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_6')) return; const style = document.createElement('style'); style.id = 'ai_ocr_style_v16_6'; style.textContent = ` #li_ai_ocr { order: 99 !important; } #wrap_ai_panel { display: none; padding: 14px 16px 16px; border: 1px solid #e5e7eb; border-top: 0; background: #f6f5f8; border-radius: 0 0 12px 12px; box-sizing: border-box; } .ai-layout { display: flex; gap: 14px; align-items: stretch; margin-bottom: 12px; } .ai-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.04); box-sizing: border-box; } .ai-basic-card { flex: 1.06; padding: 14px 14px 8px; min-width: 0; } .ai-advanced-card { flex: 1; min-width: 320px; overflow: hidden; } .ai-card-title { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; font-size: 14px; font-weight: 700; color: #222; background: #f3f4f6; border-bottom: 1px solid #e5e7eb; } .ai-card-title.clickable { cursor: pointer; user-select: none; } .ai-card-arrow { font-size: 12px; color: #666; transition: transform .2s ease; } .ai-card-title.collapsed .ai-card-arrow { transform: rotate(-90deg); } .ai-card-body { padding: 12px 14px 14px; } .ai-card-body.collapsed { display: none; } .ai-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .ai-row label { width: 66px; flex-shrink: 0; margin: 0; color: #222; font-size: 13px; font-weight: 500; } .ai-select, .ai-input, .ai-textarea { width: 100%; box-sizing: border-box; border: 1px solid #d1d5db !important; border-radius: 8px !important; background: #fff; color: #111827; font-size: 13px !important; transition: border-color .15s ease, box-shadow .15s ease; } .ai-select:focus, .ai-input:focus, .ai-textarea:focus { border-color: #8b5cf6 !important; box-shadow: 0 0 0 3px rgba(139,92,246,.12); outline: none; } .ai-input, .ai-select { height: 38px !important; padding: 0 12px !important; } .ai-textarea { min-height: 98px; padding: 10px 12px; resize: vertical; line-height: 1.45; } .ai-input-wrap { position: relative; flex: 1; } .ai-input-wrap .ai-input { padding-right: 34px !important; } .ai-eye-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); border: 0; background: transparent; color: #6b7280; font-size: 14px; cursor: pointer; padding: 2px 4px; } .ai-switch-row { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; } .ai-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; margin-top: 2px; } .ai-switch input { opacity: 0; width: 0; height: 0; position: absolute; } .ai-switch-slider { position: absolute; inset: 0; border-radius: 999px; background: #d1d5db; transition: .2s ease; cursor: pointer; } .ai-switch-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.18); transition: .2s ease; } .ai-switch input:checked + .ai-switch-slider { background: #8b5cf6; } .ai-switch input:checked + .ai-switch-slider::before { transform: translateX(16px); } .ai-switch-text { font-size: 13px; color: #222; line-height: 1.42; } .ai-switch-sub { display: block; color: #6b7280; font-size: 12px; margin-top: 2px; } #ai_drop_zone { width: 100%; min-height: 128px; border: 2px dashed #a78bfa; border-radius: 14px; background: linear-gradient(180deg, #fcfaff 0%, #f7f3ff 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; cursor: pointer; box-sizing: border-box; padding: 18px 16px; transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; } #ai_drop_zone:hover, #ai_drop_zone:focus { outline: none; border-color: #8b5cf6; box-shadow: 0 0 0 4px rgba(139,92,246,.08); background: linear-gradient(180deg, #fdfbff 0%, #f5efff 100%); } .ai-status-title { font-size: 16px; font-weight: 800; margin-bottom: 6px; } .ai-status-title.loading { color: #4f46e5; } .ai-status-title.success { color: #16a34a; } .ai-status-title.error { color: #dc2626; } .ai-status-main { font-size: 18px; font-weight: 800; color: #7c3aed; line-height: 1.2; } .ai-status-sub { font-size: 12px; color: #6b7280; line-height: 1.45; } .ai-status-icon { margin-top: 4px; color: #c4b5fd; font-size: 18px; } .ai-toolbar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin-top: 10px; background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,.03); } .ai-toolbar .btn-action { appearance: none; border: 0; border-right: 1px solid #ececec; background: #fff; height: 40px; font-size: 13px; color: #222; cursor: pointer; transition: background .15s ease; white-space: nowrap; } .ai-toolbar .btn-action:last-child { border-right: 0; } .ai-toolbar .btn-action:hover { background: #f9fafb; } .ai-toolbar .btn-action .icon { margin-right: 6px; opacity: .8; } .ai-hint-bar { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: #f7f4e8; border: 1px solid #ede3b1; color: #6b5a12; font-size: 12px; line-height: 1.45; } .ai-hint-icon { flex-shrink: 0; font-size: 14px; } .ai-inline-btn { margin-left: .5rem; } .ai-toast { position: fixed; right: 16px; bottom: 16px; z-index: 999999; background: rgba(17,24,39,.88); color: #fff; padding: 9px 12px; border-radius: 10px; font-size: 12px; max-width: 380px; box-shadow: 0 8px 24px rgba(0,0,0,.22); } @media (max-width: 992px) { .ai-layout { flex-direction: column; } .ai-basic-card, .ai-advanced-card { width: 100%; min-width: 0; } } @media (max-width: 768px) { .ai-inline-btn { margin-left: .25rem; } #wrap_ai_panel { padding: 12px; } .ai-row { flex-direction: column; align-items: stretch; gap: 6px; } .ai-row label { width: auto; } .ai-toolbar { grid-template-columns: 1fr; } .ai-toolbar .btn-action { border-right: 0; border-bottom: 1px solid #ececec; } .ai-toolbar .btn-action:last-child { border-bottom: 0; } } `; 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 promptText = getActivePrompt(); const alignChecked = state.promptMode === 'align'; const advancedExpanded = !!state.advancedExpanded; const modelRow = p === 'openai' ? `
${(cfg.models || []).map((m) => ``).join('')}
` : `
`; const thinkingRow = p === 'zhipu' ? `
Deep Thinking ${state.zhipuThinkingEnabled ? '已开启(更强但更慢)' : '已关闭(更快更省)'} | 仅智谱
` : ''; return `
${modelRow} ${p !== 'openai' ? `
` : ''}
Advanced Configuration
Prompt Mode 使用 align*(整段对齐) | 勾选后仍可编辑
${thinkingRow}
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 keyToggle = panel.querySelector('#ai_toggle_key'); const dropZone = panel.querySelector('#ai_drop_zone'); const status = panel.querySelector('#ai_status'); const advancedToggle = panel.querySelector('#ai_advanced_toggle'); const advancedBody = panel.querySelector('#ai_advanced_body'); if (vSel) { vSel.onchange = () => { state.activeProvider = vSel.value; saveState(); panel.innerHTML = buildPanelHTML(); panel.removeAttribute('data-aiocr-bound'); bindEvents(panel); }; } if (advancedToggle && advancedBody) { advancedToggle.onclick = () => { state.advancedExpanded = !state.advancedExpanded; saveState(); advancedToggle.classList.toggle('collapsed', !state.advancedExpanded); advancedBody.classList.toggle('collapsed', !state.advancedExpanded); }; } 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(); }; } if (keyToggle && kInp) { keyToggle.onclick = () => { kInp.type = kInp.type === 'password' ? 'text' : 'password'; }; } 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(); 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); 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; } } 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'); const svg = svgEl.cloneNode(true); if (!svg.getAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); inlineSvgStyles(svg, wrap); 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) 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.display = 'none'; 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(); })();