从零构建 AI 学术论文助手(四):PDF.js 实时截图 + Gemini 视觉分析
系列文章第四篇。本篇讲一个看似简单但暗坑极多的功能:在网页上展示 PDF,允许用户拖拽框选任意区域截图,然后发给 AI 解读图表和公式。
一、需求分析
学术论文里有大量图表、公式、实验结果表格,这些内容用文字检索基本无效(向量化的是 OCR 提取的文字,公式通常是乱码)。
用户需要的是:直接框选 PDF 页面上的图表区域,发给 AI 问"这个图说的是什么?"
最初的想法很简单:
1. 用 <iframe> 嵌入 PDF
2. 提供一个截图按钮,截 iframe 内容
然后发现这条路根本走不通。
二、为什么 iframe 方案不可行
根本原因:Canvas 污染(Tainted Canvas)
SecurityError: 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() |
只能打印,无法截图 |
| 截图 API(Chrome Extension) | 需要安装扩展,产品体验差 |
| 后端渲染 PDF → 图片 | 延迟高,需要传输大图片 |
| PDF.js Canvas 渲染 | ✅ 完全控制,可直接读取 Canvas 内容 |
三、PDF.js 渲染原理
PDF.js 是 Mozilla 开发的纯 JavaScript PDF 渲染库,它把 PDF 的每一页渲染到 <canvas> 元素上。因为这些 canvas 是在当前页面的 JavaScript 上下文中创建的,没有跨域问题,可以直接调用 canvas.toDataURL() 读取像素内容。
<!-- 引入 PDF.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
四、加载并渲染 PDF
4.1 用认证接口获取 PDF
直接用 <iframe src="..."> 无法传 JWT Token,需要先用 fetch 下载 PDF,再传给 PDF.js:
async function _loadPdfViewer(docId) {
// 设置 PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.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 渲染单页到 Canvas
async 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,支持 HiDPI(Retina 屏幕)
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 class="pdfchat-pdf-wrap" id="pdfchatPdfWrap">
<div class="pdfchat-pdf-pages" id="pdfchatPages"></div>
<!-- 框选 overlay,正常隐藏,框选时显示 -->
<div class="pdfchat-select-overlay" id="pdfchatSelectOverlay" style="display: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,读取像素时要乘以 DPR getBoundingClientRect()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_key=GEMINI_API_KEY, base_url=GEMINI_BASE_URL)
@router.post("/analyze-image/stream")
def analyze_image_stream(req: ImageAnalyzeRequest, current_user=Depends(get_current_user)):
messages = [
{"role": "system", "content": "你是一个学术文献分析助手,擅长解读论文截图、图表、公式和表格。"},
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{req.image_type};base64,{req.image_b64}"
},
},
{"type": "text", "text": req.question},
],
},
]
def event_gen():
try:
stream = _client.chat.completions.create(
model="gemini-2.5-flash",
messages=messages,
max_tokens=2048,
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield f"data: {json.dumps({'type':'text','text':delta})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type':'text','text':f'❌ 图像分析失败:{e}'})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_gen(), media_type="text/event-stream")
八、踩坑记录
坑 1:Gemini 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 有效。
坑 2:gemini-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 才成功,推测是账号类型或区域差异。
坑 3:PDF.js worker 不设置会卡 UI
// ❌ 不设置 workerSrc:PDF.js 降级到主线程,渲染时 UI 冻结
pdfjsLib.getDocument({data: arrayBuffer})
// ✅ 设置 worker:解析在独立线程,UI 不阻塞
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
坑 4:getBoundingClientRect() 是视口坐标
// ❌ 直接用 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 流式返回分析结果
- 也支持粘贴(Ctrl+V)或上传图片文件
除了框选,还支持直接粘贴截图或上传图片文件,同样走 Gemini 视觉分析接口。
十、小结
本篇核心要点:
- iframe 方案根本不可行,跨域 Canvas 污染无法截图
- PDF.js Canvas 渲染是唯一可行的前端截图方案
- 滚动偏移计算:
getBoundingClientRect()是视口坐标,需要加scrollTop转内容坐标 - DPR 适配:Retina 屏幕 Canvas 实际像素 = CSS 像素 × devicePixelRatio
- Gemini OpenAI 兼容 API:直接用
openaiSDK,base_url换成 Gemini 地址即可
下一篇(终篇):单文件 SPA 前端架构——不用 Vue/React,3000 行纯 HTML/CSS/JS 实现完整工作台。
更多推荐

所有评论(0)