从零构建 AI 学术论文助手(四):PDF.js 实时截图 + Gemini 视觉分析

从零构建 AI 学术论文助手(四):PDF.js 实时截图 + Gemini 视觉分析 系列文章第四篇。本篇讲一个看似简单但暗坑极多的功能在网页上展示 PDF允许用户拖拽框选任意区域截图然后发给 AI 解读图表和公式。一、需求分析学术论文里有大量图表、公式、实验结果表格这些内容用文字检索基本无效向量化的是 OCR 提取的文字公式通常是乱码。用户需要的是直接框选 PDF 页面上的图表区域发给 AI 问这个图说的是什么最初的想法很简单1. 用iframe嵌入 PDF2. 提供一个截图按钮截 iframe 内容然后发现这条路根本走不通。二、为什么 iframe 方案不可行根本原因Canvas 污染Tainted CanvasSecurityError: Failed to execute toDataURL on HTMLCanvasElement: Tainted canvases may not be exported.浏览器的安全模型如果 Canvas 上绘制了来自不同源的内容跨域图像、跨域 iframe该 Canvas 就被污染无法调用toDataURL()或getImageData()。iframe src/api/documents/{id}/file中渲染的 PDF即使是同域名因为 PDF 渲染引擎在 iframe 内的独立上下文也无法从外部 JavaScript 访问其内容。其他方案的问题方案问题html2canvas截整个 iframe无法穿透 iframe 边界window.print()只能打印无法截图截图 APIChrome Extension需要安装扩展产品体验差后端渲染 PDF → 图片延迟高需要传输大图片PDF.js Canvas 渲染✅ 完全控制可直接读取 Canvas 内容三、PDF.js 渲染原理PDF.js 是 Mozilla 开发的纯 JavaScript PDF 渲染库它把 PDF 的每一页渲染到canvas元素上。因为这些 canvas 是在当前页面的 JavaScript 上下文中创建的没有跨域问题可以直接调用canvas.toDataURL()读取像素内容。!-- 引入 PDF.js CDN -- script srchttps://cdn.jsdelivr.net/npm/pdfjs-dist3.11.174/build/pdf.min.js/script四、加载并渲染 PDF4.1 用认证接口获取 PDF直接用iframe src...无法传 JWT Token需要先用fetch下载 PDF再传给 PDF.jsasync function _loadPdfViewer(docId) { // 设置 PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc https://cdn.jsdelivr.net/npm/pdfjs-dist3.11.174/build/pdf.worker.min.js; const notice document.getElementById(pdfchatViewerNotice); notice.style.display flex; notice.querySelector(div:last-child).textContent PDF 加载中…; // 带 JWT 认证下载 PDF const resp await authFetch(/api/documents/${docId}/file); if (!resp.ok) { notice.querySelector(div:last-child).textContent 文件不存在服务重启后需重新上传; return; } const arrayBuffer await resp.arrayBuffer(); // 加载 PDF const pdfDoc await pdfjsLib.getDocument({data: arrayBuffer}).promise; _currentPdfDoc pdfDoc; notice.style.display none; // 渲染所有页面 const container document.getElementById(pdfchatPages); container.innerHTML ; for (let i 1; i pdfDoc.numPages; i) { await _renderPdfPage(pdfDoc, i, container); } }4.2 渲染单页到 Canvasasync function _renderPdfPage(pdfDoc, pageNum, container) { const page await pdfDoc.getPage(pageNum); // 根据容器宽度自动缩放 const wrap document.getElementById(pdfchatPdfWrap); const containerWidth wrap.clientWidth - 24; // 24 padding const viewport page.getViewport({scale: 1}); const scale containerWidth / viewport.width; const scaledViewport page.getViewport({scale}); // 创建 canvas支持 HiDPIRetina 屏幕 const canvas document.createElement(canvas); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; canvas.width scaledViewport.width * dpr; canvas.height scaledViewport.height * dpr; canvas.style.width scaledViewport.width px; canvas.style.height scaledViewport.height px; canvas.dataset.page pageNum; ctx.scale(dpr, dpr); await page.render({ canvasContext: ctx, viewport: scaledViewport, }).promise; container.appendChild(canvas); }五、拖拽框选 Overlay 实现在 PDF 渲染容器上方覆盖一个透明的 overlay div用来捕获鼠标事件div classpdfchat-pdf-wrap idpdfchatPdfWrap div classpdfchat-pdf-pages idpdfchatPages/div !-- 框选 overlay正常隐藏框选时显示 -- div classpdfchat-select-overlay idpdfchatSelectOverlay styledisplay:none/div /div.pdfchat-pdf-wrap { flex:1; overflow-y:auto; position:relative; } .pdfchat-select-overlay { position: absolute; inset: 0; cursor: crosshair; z-index: 20; user-select: none; } .pdfchat-select-rect { position: absolute; border: 2px solid #4a9eff; background: rgba(74,158,255,.12); pointer-events: none; }框选逻辑let _selStartX, _selStartY, _selRect; function startRegionSelect() { const overlay document.getElementById(pdfchatSelectOverlay); overlay.style.display block; // 显示提示 const hint document.createElement(div); hint.className pdfchat-select-hint; hint.textContent 拖拽框选区域松开后分析; overlay.appendChild(hint); overlay.onmousedown (e) { // 鼠标相对于 overlay 的坐标 const rect overlay.getBoundingClientRect(); _selStartX e.clientX - rect.left; _selStartY e.clientY - rect.top; // 创建选框 _selRect document.createElement(div); _selRect.className pdfchat-select-rect; overlay.appendChild(_selRect); overlay.onmousemove (e2) { const x e2.clientX - rect.left; const y e2.clientY - rect.top; const l Math.min(x, _selStartX); const t Math.min(y, _selStartY); const w Math.abs(x - _selStartX); const h Math.abs(y - _selStartY); Object.assign(_selRect.style, { left: l px, top: t px, width: w px, height: h px, }); }; overlay.onmouseup (e3) { overlay.onmousemove null; overlay.onmouseup null; overlay.style.display none; const selL parseFloat(_selRect.style.left); const selT parseFloat(_selRect.style.top); const selW parseFloat(_selRect.style.width); const selH parseFloat(_selRect.style.height); if (selW 10 selH 10) { _captureRegion(overlay.getBoundingClientRect(), selL, selT, selW, selH); } overlay.innerHTML ; }; }; }六、跨页截图合成——最复杂的部分这是整个功能最难的地方。PDF 每页是独立的 canvas用户的选框可能横跨多个页面不常见但有可能而且 overlay 是相对于wrap 容器定位的wrap 是可滚动的canvas 的实际位置需要考虑滚动偏移。function _captureRegion(overlayRect, selL, selT, selW, selH) { const wrap document.getElementById(pdfchatPdfWrap); const scrollTop wrap.scrollTop; // 关键wrap 的滚动偏移 // 选框在 wrap 内容坐标系中的位置加上滚动偏移 const absSelT selT scrollTop; const absSelB absSelT selH; // 创建输出 canvas const output document.createElement(canvas); output.width selW; output.height selH; const ctx output.getContext(2d); ctx.fillStyle #ffffff; ctx.fillRect(0, 0, selW, selH); // 遍历所有页面 canvas找出与选框重叠的部分 const canvases wrap.querySelectorAll(#pdfchatPages canvas); for (const canvas of canvases) { // canvas 相对于 wrap 的位置考虑滚动 const canvasRect canvas.getBoundingClientRect(); const wrapRect wrap.getBoundingClientRect(); const canvasTop canvasRect.top - wrapRect.top scrollTop; const canvasBottom canvasRect.bottom - wrapRect.top scrollTop; const canvasLeft canvasRect.left - wrapRect.left; // 计算选框与此 canvas 的重叠区域 const overlapT Math.max(absSelT, canvasTop); const overlapB Math.min(absSelB, canvasBottom); const overlapL Math.max(selL, canvasLeft); const overlapR Math.min(selL selW, canvasLeft canvasRect.width); if (overlapT overlapB || overlapL overlapR) continue; // 无重叠 // DPR 缩放比canvas 实际像素 vs CSS 像素 const dprScale canvas.width / canvas.offsetWidth; // 在源 canvas 上的对应区域乘以 DPR const srcX (overlapL - canvasLeft) * dprScale; const srcY (overlapT - canvasTop) * dprScale; const srcW (overlapR - overlapL) * dprScale; const srcH (overlapB - overlapT) * dprScale; // 在输出 canvas 上的目标位置 const dstX overlapL - selL; const dstY overlapT - absSelT; const dstW overlapR - overlapL; const dstH overlapB - overlapT; ctx.drawImage(canvas, srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH); } // 转为 base64 预览 const b64 output.toDataURL(image/png).split(,)[1]; _setVisionImage(b64, image/png, output.toDataURL(image/png)); }关键点总结滚动偏移wrap.scrollTop必须加到 Y 坐标上否则滚动后框选区域对不上DPR 缩放Retina 屏幕上canvas.width 2 × canvas.offsetWidth读取像素时要乘以 DPRgetBoundingClientRect()vs 滚动getBoundingClientRect()返回视口坐标随滚动变化不是文档坐标需要加scrollTop转换七、Gemini 视觉分析接口截图 base64 发给 Gemini 2.5 Flash 分析# routers/vision.py from openai import OpenAI from config import GEMINI_API_KEY, GEMINI_BASE_URL # Gemini 提供 OpenAI 兼容 API直接用 openai SDK _client OpenAI(api_keyGEMINI_API_KEY, base_urlGEMINI_BASE_URL) router.post(/analyze-image/stream) def analyze_image_stream(req: ImageAnalyzeRequest, current_userDepends(get_current_user)): messages [ {role: system, content: 你是一个学术文献分析助手擅长解读论文截图、图表、公式和表格。}, { role: user, content: [ { type: image_url, image_url: { url: fdata:{req.image_type};base64,{req.image_b64} }, }, {type: text, text: req.question}, ], }, ] def event_gen(): try: stream _client.chat.completions.create( modelgemini-2.5-flash, messagesmessages, max_tokens2048, streamTrue, ) for chunk in stream: delta chunk.choices[0].delta.content if delta: yield fdata: {json.dumps({type:text,text:delta})}\n\n except Exception as e: yield fdata: {json.dumps({type:text,text:f❌ 图像分析失败{e}})}\n\n yield data: [DONE]\n\n return StreamingResponse(event_gen(), media_typetext/event-stream)八、踩坑记录坑 1Gemini API Key 格式特殊普通 Google AI Studio 申请的 API Key 以AIzaSy...开头。但通过 Google Cloud 项目创建的 Key 是AQ.Ab8R...格式这不是无效 Key但文档里几乎没有提到。初次以为 Key 格式错误实际上用 curl 测试curl -H Authorization: Bearer AQ.Ab8R... \ -H Content-Type: application/json \ -d {model:gemini-2.5-flash,messages:[{role:user,content:hello}]} \ https://generativelanguage.googleapis.com/v1beta/openai/chat/completions能正常返回说明 Key 有效。坑 2gemini-2.0-flash和gemini-1.5-flash在某些账号免费配额为 0尝试了gemini-2.0-flash和gemini-1.5-flash返回{error: {code: 429, message: Quota exceeded, status: RESOURCE_EXHAUSTED, details: [{limit: 0}]}}limit: 0意味着该账号对这个模型完全没有免费配额。改用gemini-2.5-flash才成功推测是账号类型或区域差异。坑 3PDF.js worker 不设置会卡 UI// ❌ 不设置 workerSrcPDF.js 降级到主线程渲染时 UI 冻结 pdfjsLib.getDocument({data: arrayBuffer}) // ✅ 设置 worker解析在独立线程UI 不阻塞 pdfjsLib.GlobalWorkerOptions.workerSrc https://cdn.jsdelivr.net/npm/pdfjs-dist3.11.174/build/pdf.worker.min.js;坑 4getBoundingClientRect()是视口坐标// ❌ 直接用 getBoundingClientRect().top 作为文档坐标 const canvasTop canvasRect.top; // ✅ 加上 wrap.scrollTop 转换为 wrap 内容坐标 const canvasTop canvasRect.top - wrapRect.top wrap.scrollTop;九、最终效果实现后的完整交互流程进入 PDF 精读页PDF 自动用 PDF.js 渲染为 Canvas点击「✂️ 框选分析」鼠标变十字在 PDF 上拖拽选中图表/公式/表格区域松开鼠标右侧对话框出现截图预览缩略图直接点发送或附加问题Gemini 2.5 Flash 流式返回分析结果也支持粘贴CtrlV或上传图片文件除了框选还支持直接粘贴截图或上传图片文件同样走 Gemini 视觉分析接口。十、小结本篇核心要点iframe 方案根本不可行跨域 Canvas 污染无法截图PDF.js Canvas 渲染是唯一可行的前端截图方案滚动偏移计算getBoundingClientRect()是视口坐标需要加scrollTop转内容坐标DPR 适配Retina 屏幕 Canvas 实际像素 CSS 像素 × devicePixelRatioGemini OpenAI 兼容 API直接用openaiSDKbase_url换成 Gemini 地址即可下一篇终篇单文件 SPA 前端架构——不用 Vue/React3000 行纯 HTML/CSS/JS 实现完整工作台。