系列文章第四篇。本篇讲一个看似简单但暗坑极多的功能:在网页上展示 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'));
}

关键点总结

  1. 滚动偏移wrap.scrollTop 必须加到 Y 坐标上,否则滚动后框选区域对不上
  2. DPR 缩放:Retina 屏幕上 canvas.width = 2 × canvas.offsetWidth,读取像素时要乘以 DPR
  3. 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-flashgemini-1.5-flash 在某些账号免费配额为 0

尝试了 gemini-2.0-flashgemini-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;

九、最终效果

实现后的完整交互流程:

  1. 进入 PDF 精读页,PDF 自动用 PDF.js 渲染为 Canvas
  2. 点击「✂️ 框选分析」,鼠标变十字
  3. 在 PDF 上拖拽选中图表/公式/表格区域
  4. 松开鼠标,右侧对话框出现截图预览缩略图
  5. 直接点发送或附加问题,Gemini 2.5 Flash 流式返回分析结果
  6. 也支持粘贴(Ctrl+V)或上传图片文件

除了框选,还支持直接粘贴截图或上传图片文件,同样走 Gemini 视觉分析接口。


十、小结

本篇核心要点:

  1. iframe 方案根本不可行,跨域 Canvas 污染无法截图
  2. PDF.js Canvas 渲染是唯一可行的前端截图方案
  3. 滚动偏移计算getBoundingClientRect() 是视口坐标,需要加 scrollTop 转内容坐标
  4. DPR 适配:Retina 屏幕 Canvas 实际像素 = CSS 像素 × devicePixelRatio
  5. Gemini OpenAI 兼容 API:直接用 openai SDK,base_url 换成 Gemini 地址即可

下一篇(终篇):单文件 SPA 前端架构——不用 Vue/React,3000 行纯 HTML/CSS/JS 实现完整工作台。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐