浏览器端 PDF 解析与文本提取完全指南:从 pdf.js 到 RAG 管道

深入讲解如何在浏览器中使用 pdf.js 解析 PDF 文档、提取文本、渲染页面、处理加密文件,以及将 PDF 解析集成到 RAG 管道的实战方案。附完整可运行代码。

前端开发 2026-06-01 15 分钟

根据 npm 下载数据,pdf.js(pdfjs-dist)周下载量超过 300 万次,是前端生态中使用最广泛的 PDF 处理库。在 RAG(检索增强生成)系统爆发的 2026 年,PDF 文本提取已经成为知识库构建的第一步——但大多数开发者对 pdf.js 的理解还停留在"渲染一下"的层面。本文将从底层 API 出发,系统讲解如何在浏览器端完成 PDF 解析、文本提取、页面渲染、搜索定位,并最终集成到 RAG 管道中。

🔧 一、pdf.js 核心 API 与基础解析

1.1 初始化与文档加载

pdf.js 的核心入口是 getDocument() 方法,它返回一个 PDFDocumentProxy 对象。加载方式支持 URL、ArrayBuffer、Base64 三种,选择取决于你的数据来源。

// 从 URL 加载 PDF(最常见场景)
import * as pdfjsLib from 'pdfjs-dist'

// ⚠️ 必须设置 Worker 路径,否则会在主线程解析导致卡顿
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.worker.min.mjs`

async function loadPDFFromURL(url) {
  const loadingTask = pdfjsLib.getDocument(url)
  const pdf = await loadingTask.promise
  console.log(`PDF 共 ${pdf.numPages} 页`)
  return pdf
}

// 从 ArrayBuffer 加载(适合用户上传场景)
async function loadPDFFromFile(file) {
  const arrayBuffer = await file.arrayBuffer()
  const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })
  const pdf = await loadingTask.promise
  return pdf
}

// 从 Base64 加载(适合 API 返回场景)
async function loadPDFFromBase64(base64String) {
  const raw = atob(base64String)
  const uint8Array = new Uint8Array(raw.length)
  for (let i = 0; i < raw.length; i++) {
    uint8Array[i] = raw.charCodeAt(i)
  }
  const loadingTask = pdfjsLib.getDocument({ data: uint8Array })
  return await loadingTask.promise
}

⚠️ **警告:**永远不要在不设置 workerSrc 的情况下加载大 PDF。pdf.js 默认在主线程运行解析,超过 5 页的 PDF 就会导致明显的 UI 卡顿。

1.2 获取文档元数据

PDF 文件包含丰富的元数据——标题、作者、创建时间、关键词等。这些信息对于 RAG 系统的文档分类和索引非常有价值。

// 提取 PDF 元数据
async function extractMetadata(pdf) {
  const metadata = await pdf.getMetadata()
  const { info, metadata: xmpMetadata } = metadata

  return {
    title: info.Title || '未知标题',
    author: info.Author || '未知作者',
    subject: info.Subject || '',
    creator: info.Creator || '',
    producer: info.Producer || '',
    creationDate: info.CreationDate || '',
    modDate: info.ModDate || '',
    pageCount: pdf.numPages,
    // XMP 元数据(如果存在)
    keywords: xmpMetadata
      ? xmpMetadata.get('dc:subject') || ''
      : ''
  }
}
属性 类型 说明 RAG 场景用途
Title string 文档标题 索引标题
Author string 作者 来源标注
CreationDate string 创建时间 时间排序
Keywords string 关键词 初始标签
numPages number 总页数 分批策略

📄 二、文本提取与高级处理

2.1 逐页提取文本

文本提取是 PDF 解析最核心的功能。pdf.js 的 getTextContent() 返回一个包含 items 数组的对象,每个 item 代表一个文本片段(TextItem),包含文本内容、位置坐标和字体信息。

// 提取单页文本
async function extractPageText(pdf, pageNumber) {
  const page = await pdf.getPage(pageNumber)
  const textContent = await page.getTextContent()

  // textContent.items 中每个 item 包含:
  // - str: 文本内容
  // - transform: 位置变换矩阵 [scaleX, skewX, skewY, scaleY, translateX, translateY]
  // - width, height: 文本尺寸
  // - fontName: 字体名称

  const textItems = textContent.items
  const lines = []
  let currentLine = ''
  let lastY = null

  for (const item of textItems) {
    const y = item.transform[5] // translateY

    // Y 坐标变化超过阈值视为换行
    if (lastY !== null && Math.abs(y - lastY) > 5) {
      if (currentLine.trim()) {
        lines.push(currentLine.trim())
      }
      currentLine = item.str
    } else {
      // 同一行内拼接,添加适当的空格
      if (currentLine && item.str && !currentLine.endsWith(' ') && !item.str.startsWith(' ')) {
        currentLine += ' '
      }
      currentLine += item.str
    }
    lastY = y
  }

  if (currentLine.trim()) {
    lines.push(currentLine.trim())
  }

  return lines.join('\n')
}

// 提取全文(所有页面)
async function extractFullText(pdf) {
  const pages = []
  for (let i = 1; i <= pdf.numPages; i++) {
    const pageText = await extractPageText(pdf, i)
    pages.push({ page: i, text: pageText })
  }
  return pages
}

💡 提示:getTextContent() 返回的文本顺序是按 PDF 内部结构排列的,不一定是阅读顺序。对于多栏排版的 PDF,需要根据坐标进行重排序。

2.2 处理多栏布局与文本重排

学术论文和技术文档经常采用双栏排版。直接拼接 getTextContent() 的结果会得到混乱的文本。解决方案是根据 X 坐标将文本分为左栏和右栏,分别提取后再合并。

// 处理双栏 PDF 的文本提取
async function extractTwoColumnText(pdf, pageNumber) {
  const page = await pdf.getPage(pageNumber)
  const textContent = await page.getTextContent()
  const viewport = page.getViewport({ scale: 1.0 })
  const pageWidth = viewport.width

  const leftColumn = []
  const rightColumn = []
  const midPoint = pageWidth / 2

  for (const item of textContent.items) {
    const x = item.transform[4] // translateX
    if (x < midPoint) {
      leftColumn.push(item)
    } else {
      rightColumn.push(item)
    }
  }

  // 分别按 Y 坐标排序并拼接
  const sortAndJoin = (items) => {
    items.sort((a, b) => b.transform[5] - a.transform[5]) // Y 从上到下
    return items.map(i => i.str).join(' ')
  }

  return {
    left: sortAndJoin(leftColumn),
    right: sortAndJoin(rightColumn),
    full: sortAndJoin(leftColumn) + '\n' + sortAndJoin(rightColumn)
  }
}

⚠️ **警告:**扫描版 PDF(图片型)的 getTextContent() 会返回空数组。需要使用 OCR 方案(如 Tesseract.js)进行文本识别。检测方法:如果某页的 textContent.items.length === 0page.getViewport() 有尺寸,说明该页是扫描件。

2.3 文本搜索与定位

在 PDF 中搜索文本并定位到具体位置,是构建文档查看器的核心功能。

// PDF 全文搜索,返回匹配位置
async function searchTextInPDF(pdf, keyword) {
  const results = []

  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i)
    const textContent = await page.getTextContent()

    for (const item of textContent.items) {
      const index = item.str.toLowerCase().indexOf(keyword.toLowerCase())
      if (index !== -1) {
        // 计算匹配文本在视口中的位置
        const viewport = page.getViewport({ scale: 1.5 })
        const tx = pdfjsLib.Util.transform(
          viewport.transform,
          item.transform
        )

        results.push({
          page: i,
          text: item.str,
          position: {
            x: tx[4],
            y: tx[5],
            width: item.width * viewport.scale,
            height: item.height * viewport.scale
          }
        })
      }
    }
  }

  return results
}

🎨 三、页面渲染与可视化

3.1 Canvas 渲染

将 PDF 页面渲染到 Canvas 是最常见的可视化需求。关键是正确处理分辨率和缩放。

// 高清渲染 PDF 页面到 Canvas
async function renderPageToCanvas(pdf, pageNumber, canvas, options = {}) {
  const {
    scale = 2.0,           // 2x 缩放适配 Retina 屏幕
    rotation = 0,          // 旋转角度(0/90/180/270)
    backgroundColor = '#ffffff'
  } = options

  const page = await pdf.getPage(pageNumber)
  const viewport = page.getViewport({ scale, rotation })

  // 设置 Canvas 尺寸
  canvas.width = viewport.width
  canvas.height = viewport.height

  const ctx = canvas.getContext('2d')
  ctx.fillStyle = backgroundColor
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  const renderContext = {
    canvasContext: ctx,
    viewport: viewport,
    // 可选:启用文本层(用于文本选择)
    enableWebGL: true,      // 启用 WebGL 加速(大页面提升明显)
  }

  const renderTask = page.render(renderContext)

  // 支持取消渲染(页面切换时有用)
  return {
    promise: renderTask.promise,
    cancel: () => renderTask.cancel()
  }
}
缩放倍数 分辨率效果 文件大小影响 推荐场景
1.0 标清(72 DPI) 最小 缩略图预览
1.5 中清(108 DPI) 适中 移动端展示
2.0 高清(144 DPI) 较大 桌面端查看(推荐)
3.0 超清(216 DPI) 最大 打印预览

💡 提示:scale = 2.0 是性价比最高的选择。在 Retina 屏幕上清晰可辨,同时 Canvas 内存占用可控。超过 3.0 会导致大页面(如 A0 图纸)内存溢出。

3.2 渲染缩略图与虚拟滚动

当需要展示 PDF 所有页面的缩略图时,逐页渲染会导致大量内存占用。使用虚拟滚动 + 按需渲染是最佳方案。

// 生成 PDF 缩略图(低分辨率,节省内存)
async function generateThumbnail(pdf, pageNumber, maxWidth = 200) {
  const page = await pdf.getPage(pageNumber)
  const unscaledViewport = page.getViewport({ scale: 1 })
  const scale = maxWidth / unscaledViewport.width
  const viewport = page.getViewport({ scale })

  const canvas = document.createElement('canvas')
  canvas.width = viewport.width
  canvas.height = viewport.height

  const ctx = canvas.getContext('2d')
  await page.render({
    canvasContext: ctx,
    viewport
  }).promise

  // 转为 Blob 以便后续管理内存
  return new Promise((resolve) => {
    canvas.toBlob((blob) => {
      resolve({
        url: URL.createObjectURL(blob),
        width: viewport.width,
        height: viewport.height,
        revoke: () => URL.revokeObjectURL(url)
      })
    }, 'image/png', 0.8)
  })
}

🔒 四、加密 PDF 与错误处理

4.1 处理密码保护的 PDF

pdf.js 支持打开密码保护的 PDF,密码通过 password 参数传入。

// 打开加密 PDF
async function loadEncryptedPDF(url, password) {
  try {
    const loadingTask = pdfjsLib.getDocument({
      url,
      password,  // 所有者密码或用户密码
    })

    const pdf = await loadingTask.promise
    return { success: true, pdf }
  } catch (error) {
    if (error.name === 'PasswordException') {
      if (error.code === 1) {
        // 需要密码但未提供
        return { success: false, needPassword: true, message: '此 PDF 需要密码' }
      } else if (error.code === 2) {
        // 密码错误
        return { success: false, wrongPassword: true, message: '密码错误' }
      }
    }
    return { success: false, message: `加载失败: ${error.message}` }
  }
}

4.2 常见错误类型与处理策略

错误类型 error.name 原因 处理策略
密码缺失 PasswordException (code=1) PDF 需要密码 弹窗让用户输入
密码错误 PasswordException (code=2) 密码不匹配 提示重试
文件损坏 InvalidPDFException 文件传输不完整 提示重新上传
格式不支持 MissingPDFException 非 PDF 文件 文件类型校验
网络错误 UnexpectedResponseException 404/500 重试机制

🤖 五、集成到 RAG 管道

5.1 文本分块策略

将 PDF 全文提取出来后,需要切分为合适大小的文本块(Chunk)才能送入向量数据库。分块策略直接影响检索质量。

// PDF 文本分块器
function chunkText(text, options = {}) {
  const {
    chunkSize = 500,        // 每块最大字符数
    overlap = 50,           // 块之间的重叠字符数
    separator = '\n'        // 优先在换行处切分
  } = options

  const chunks = []
  const paragraphs = text.split(separator)
  let currentChunk = ''

  for (const paragraph of paragraphs) {
    if ((currentChunk + paragraph).length > chunkSize && currentChunk) {
      chunks.push(currentChunk.trim())
      // 保留末尾 overlap 个字符作为上下文重叠
      currentChunk = currentChunk.slice(-overlap) + paragraph
    } else {
      currentChunk += (currentChunk ? separator : '') + paragraph
    }
  }

  if (currentChunk.trim()) {
    chunks.push(currentChunk.trim())
  }

  return chunks.map((content, index) => ({
    id: `chunk-${index}`,
    content,
    startIndex: text.indexOf(content.slice(0, 20)),
    tokenEstimate: Math.ceil(content.length / 2)  // 粗略估算:中文约 2 字符/token
  }))
}
分块策略 块大小 重叠 适用场景 检索精度
小块 200-300 字符 30 字符 精准问答 ⭐⭐⭐⭐⭐
中块 500-800 字符 50 字符 通用 RAG(推荐) ⭐⭐⭐⭐
大块 1000-1500 字符 100 字符 上下文理解 ⭐⭐⭐
按段落 自然段落 0 结构化文档 ⭐⭐⭐⭐

⚠️ **警告:**块大小和重叠比例需要根据你的 Embedding 模型的上下文窗口调整。OpenAI text-embedding-3-small 的上下文是 8192 token,每个块不应超过 2000 token。

5.2 完整的 PDF → RAG 管道

将以上所有技术组合起来,构建一个端到端的 PDF 文档处理管道:

// 完整的 PDF → RAG 处理管道
class PDFRAGPipeline {
  constructor(options = {}) {
    this.chunkSize = options.chunkSize || 500
    this.chunkOverlap = options.chunkOverlap || 50
    this.maxPages = options.maxPages || 1000
  }

  async process(file) {
    // 第一步:加载 PDF
    const arrayBuffer = await file.arrayBuffer()
    const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise

    // 第二步:提取元数据
    const metadata = await this.extractMetadata(pdf)

    // 第三步:逐页提取文本
    const pageTexts = []
    const totalPages = Math.min(pdf.numPages, this.maxPages)

    for (let i = 1; i <= totalPages; i++) {
      const page = await pdf.getPage(i)
      const textContent = await page.getTextContent()
      const pageText = textContent.items
        .map(item => item.str)
        .join(' ')
        .replace(/\s+/g, ' ')
        .trim()

      if (pageText) {
        pageTexts.push({ page: i, text: pageText })
      }
    }

    // 第四步:合并文本并分块
    const fullText = pageTexts.map(p => p.text).join('\n')
    const chunks = chunkText(fullText, {
      chunkSize: this.chunkSize,
      overlap: this.chunkOverlap
    })

    // 第五步:附加来源信息
    return {
      metadata,
      chunks: chunks.map(chunk => ({
        ...chunk,
        source: metadata.title,
        author: metadata.author,
        file: file.name
      })),
      stats: {
        totalPages: pdf.numPages,
        processedPages: pageTexts.length,
        totalChunks: chunks.length,
        totalCharacters: fullText.length,
        avgChunkSize: Math.round(fullText.length / chunks.length)
      }
    }
  }

  async extractMetadata(pdf) {
    const { info } = await pdf.getMetadata()
    return {
      title: info.Title || '未知标题',
      author: info.Author || '未知作者',
      pageCount: pdf.numPages
    }
  }
}

// 使用示例
const pipeline = new PDFRAGPipeline({ chunkSize: 500, chunkOverlap: 50 })
document.getElementById('fileInput').addEventListener('change', async (e) => {
  const file = e.target.files[0]
  const result = await pipeline.process(file)
  console.log(`处理完成:${result.stats.totalChunks} 个文本块`)
  console.log(`来源:${result.metadata.title} (${result.metadata.author})`)
  // 将 result.chunks 送入 Embedding 模型 → 向量数据库
})

💡 六、性能优化与注意事项

6.1 性能优化要点

  • 始终使用 Web Worker:pdf.js Worker 避免主线程阻塞,对大文件尤为重要
  • 按需加载页面:不要一次性 Promise.all 加载所有页面,使用队列控制并发
  • 及时释放内存:渲染完的 Canvas 调用 canvas.width = 0 释放 GPU 内存
  • 使用 getPage() 缓存:pdf.js 内部会缓存已解析的页面对象
  • 避免在主线程解析超过 50 页的 PDF:会导致明显卡顿
  • 不要忽略 renderTask.cancel():页面切换时必须取消未完成的渲染

📌 **记住:**pdf.js 的 getDocument() 会将整个 PDF 文件读入内存。对于 100MB 以上的大文件,考虑使用 Range Request 分片加载(range: { length: 65536 })。

6.2 浏览器兼容性

功能 Chrome Firefox Safari Edge
Canvas 渲染
WebGL 加速 ⚠️ 部分
TextContent
Worker 加载
Stream 加载

💡 **提示:**Safari 16 以下版本对 pdf.js 的 WebGL 渲染支持不完整,建议在生产环境中做特性检测,降级到 Canvas 2D 渲染。

✅ 总结

在浏览器端解析 PDF 不再是"能做到"的问题,而是"怎么做得更好"的问题。pdf.js 提供了完整的解析和渲染能力,关键在于:

  1. 文本提取getTextContent() 是基础,但多栏布局需要坐标重排序
  2. 渲染优化:2x 缩放 + WebGL 加速 + 虚拟滚动是最佳组合
  3. RAG 集成:文本分块策略直接影响检索质量,推荐 500 字符 + 50 字符重叠
  4. 错误处理:密码保护、文件损坏、格式错误都需要友好处理

🔗 相关工具推荐

📚 相关文章