PDF 解析

要点

  • AI 项目中,PDF 是最常见但也最麻烦的文档来源——RAG 知识库、文档问答、合同审查,第一步都要把 PDF 转成结构化文本
  • Cloudflare Workers 跑在 V8 isolate 上,不能用 Node.js 原生模块(canvaspdf-parse 等全部不可用),需要在纯 JavaScript 方案里选型
  • pdfjs-dist(Mozilla 官方的 PDF 解析库)可以在 Workers 里运行,但需要做一些适配:禁用 worker、处理字体解码
  • 解析只是第一步,拿到原始文本后还要清洗(去页眉页脚、合并断行段落)和分块(chunking),才能喂进向量数据库
  • 扫描版 PDF 拿不到文本内容,需要走 OCR,这部分会在下一篇文章单独处理

1. AI 项目里为什么需要解析 PDF

做过 RAG 或者文档问答的开发者都遇到过同一个问题:用户丢过来的文件,十个里有七八个是 PDF。合同、报告、论文、产品手册,PDF 几乎成了 B 端文档的默认格式。

要把这些 PDF 接入 AI 流程,中间至少要过这几步:

  1. 提取文本:把 PDF 里的文字内容按顺序读出来
  2. 提取元数据:页数、标题、作者、创建时间,用于检索和展示
  3. 文本清洗:去掉页眉页脚、页码、无意义空行,把断行的段落拼回去
  4. 分块(chunking):按语义或固定长度切成片段,准备写入向量数据库

如果哪一步做得粗糙,后面检索的质量就会打折扣。PDF 解析是整个链路的起点,起点歪了,后面再怎么用 embedding 模型兜底,效果也有限。

2. Workers 环境的限制

Cloudflare Workers 运行时基于 V8 isolate,不是完整的 Node.js 环境,对 PDF 解析有几个直接约束:

不能用 Node.js 原生模块。 canvas(渲染 PDF 页面为图片)、pdf-parse(依赖 Node 的 bufferstream)这类包在 Workers 里直接报错。它们底层依赖 C++ addon 或 Node 专有 API。

没有文件系统。 Workers 没有磁盘,所有 PDF 数据只能以 ArrayBufferUint8Array 的形式存在于内存中。

内存有上限。 单个 Worker 请求的内存限制是 128 MB(付费版)。几十页的 PDF 解析后占用二三十 MB,上百页、内嵌大量图片的 PDF 需要留意内存消耗。

CPU 时间有上限。 免费版 10ms,付费版 30s。解析 PDF 是 CPU 密集型操作,大文件需要考虑用 Queue 异步处理。

这些约束加在一起,可用方案的范围就缩小了不少。

3. 选型:为什么是 pdfjs-dist

在 Workers 环境下能用的 PDF 解析库,大致有这几个选项:

原理Workers 兼容备注
pdfjs-distMozilla 官方,纯 JS 解析✅ 需适配功能最全,社区最大
unpdf基于 pdfjs-dist 的轻量封装专门为非 Node 环境做了适配
pdf-lib侧重 PDF 创建和修改提取文本能力有限
pdf-parse依赖 Node API无法在 Workers 运行

pdfjs-dist 是最稳的选择——Firefox 浏览器 PDF 查看器的核心,功能全、社区大。提取文本、提取元数据、按页遍历都支持。

安装:

pnpm add pdfjs-dist

需要注意的是,pdfjs-dist 默认会创建 Web Worker 做解析,但 Workers 环境不支持嵌套 Worker。解决办法是初始化时禁用它:

import * as pdfjsLib from 'pdfjs-dist'
 
// 禁用 worker,让解析在主线程执行
pdfjsLib.GlobalWorkerOptions.workerSrc = ''

如果你用的是较新版本的 pdfjs-dist(5.x),导入方式可能有变化。也可以考虑直接用 unpdfpnpm add unpdf),它在 pdfjs-dist 基础上处理好了环境差异。

下面的代码示例基于 pdfjs-dist,选了 unpdf 的话 API 会略有不同,但核心流程一致。

4. 提取文本内容

拿到一个 PDF 的 ArrayBuffer 后,第一步是加载文档,然后逐页提取文本。

import * as pdfjsLib from 'pdfjs-dist'
 
pdfjsLib.GlobalWorkerOptions.workerSrc = ''
 
/**
 * 从 PDF 二进制数据中按页提取文本
 */
async function extractTextByPage(
  pdfData: ArrayBuffer
): Promise<{ page: number; text: string }[]> {
  const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) })
  const pdf = await loadingTask.promise
 
  const results: { page: number; text: string }[] = []
 
  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i)
    const textContent = await page.getTextContent()
 
    // textContent.items 是每个文字片段的数组
    // 需要把它们的 str 字段拼起来
    const pageText = textContent.items
      .filter((item): item is { str: string; dir: string; width: number; height: number; transform: number[]; fontName: string; hasEOL: boolean } => 'str' in item)
      .map((item) => item.str)
      .join('')
 
    results.push({ page: i, text: pageText })
  }
 
  return results
}

这里有几个细节:

getTextContent() 返回的是 items 数组,每个 item 代表一个文字片段(一个字、一个词、甚至一个字母),需要手动拼起来。

items 的类型定义里包含了旧版残留的 string[],TypeScript 严格模式下需要类型守卫过滤——上面的 filter 就是在处理这个问题。

直接 join('') 会得到没有空格的字符串。如果提取出来的文字黏在一起,可以改成 join(' '),或者根据 item.transform 的坐标信息判断是否需要插入空格。这在英文 PDF 里更常见,中文 PDF 问题不大。

如果不需要按页区分,只想拿全文,可以把所有页面的文本拼成一个字符串:

async function extractFullText(pdfData: ArrayBuffer): Promise<string> {
  const pages = await extractTextByPage(pdfData)
  return pages.map((p) => p.text).join('\n\n')
}

用两个换行分隔每一页,保留基本的段落边界。

5. 提取元数据

除了文本内容,PDF 还携带了元数据——页数、标题、作者、创建日期等。这些信息在 RAG 场景中有实际用途:展示检索结果时告诉用户「来自第几页」,或者按时间过滤文档。

async function extractMetadata(
  pdfData: ArrayBuffer
): Promise<{
  numPages: number
  title: string | undefined
  author: string | undefined
  subject: string | undefined
  creator: string | undefined
  producer: string | undefined
  creationDate: Date | undefined
  modificationDate: Date | undefined
}> {
  const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) })
  const pdf = await loadingTask.promise
  const metadata = await pdf.getMetadata()
 
  const info = metadata.info as Record<string, unknown>
 
  return {
    numPages: pdf.numPages,
    title: info.Title as string | undefined,
    author: info.Author as string | undefined,
    subject: info.Subject as string | undefined,
    creator: info.Creator as string | undefined,
    producer: info.Producer as string | undefined,
    creationDate: info.CreationDate
      ? new Date(info.CreationDate as string)
      : undefined,
    modificationDate: info.ModDate
      ? new Date(info.ModDate as string)
      : undefined,
  }
}

PDF 的元数据是「可选」的——很多 PDF 生成工具不会写入标题和作者,拿到 undefined 是正常的。creatorproducer 通常有值,但记录的是生成软件(「Microsoft Word」「Adobe Acrobat」),不是业务意义上的作者。

日期字段的格式也不统一。有些存标准日期字符串,有些存 PDF 规范的 D:20260621143000+0800 格式。new Date() 能处理大部分情况,遇到解析失败的加个 try/catch 会更稳。

6. 扫描版 PDF 的问题

前面提到的所有方法都基于一个前提:PDF 里的文字是可选中的文本。但很多 PDF——尤其是扫描件、图片转的 PDF、某些电子发票——内容其实是一整张图片,文字信息嵌在图片里,getTextContent() 拿到的是空字符串。

怎么判断一个 PDF 是扫描版?一个简单的方法是看每页提取到的文本长度:

function isLikelyScanned(
  pages: { page: number; text: string }[],
  threshold = 50
): boolean {
  // 超过一半的页面文本少于 threshold 个字符,大概率是扫描版
  const emptyPages = pages.filter((p) => p.text.trim().length < threshold)
  return emptyPages.length > pages.length / 2
}

这个判断不完美,但对大部分场景够用。命中扫描版检测后,后续需要走 OCR 流程——把 PDF 页面渲染成图片,再用 OCR 引擎识别文字。Workers 环境下做 OCR 有几个方向:

  • 调用外部 OCR API(Google Vision、Azure Document Intelligence)
  • 用 WebAssembly 版的 Tesseract(体积大,冷启动慢)
  • 转发给专门做文档处理的 Worker 或后端服务

OCR 会在下一篇文章展开。这里先确认一点:在解析之前先做一次扫描检测,避免拿到空文本还以为解析成功。

7. 文本清洗

从 PDF 里提取出来的原始文本,通常不能直接拿去用。常见问题:

  1. 页眉页脚混入正文:每页顶部或底部重复出现公司名、章节标题、页码
  2. 段落被换行截断:PDF 的排版机制导致一个段落在不同行被拆成了多个 textItem,拼接后中间插入了不该有的换行
  3. 多余空行:连续多个空行,或者只包含一个空格的行
  4. 乱码字符:某些 PDF 的字体编码不规范,提取出来的文字包含 `` 或不可见字符

针对这些问题,写一组清洗函数:

function cleanText(raw: string): string {
  let text = raw
 
  // 1. 去除零宽字符和不可见控制字符(保留换行和制表符)
  text = text.replace(/[​-‍]/g, '')
  text = text.replace(/[^\S\n\t]+/g, (match) => {
    // 把连续空白压缩成一个空格,但保留换行
    return match.includes('\n') ? '\n' : ' '
  })
 
  // 2. 把只有 1-2 个字的行(可能是页码)去掉
  text = text
    .split('\n')
    .filter((line) => {
      const trimmed = line.trim()
      if (trimmed.length === 0) return false
      // 过滤纯数字行(页码)
      if (/^\d{1,4}$/.test(trimmed)) return false
      return true
    })
    .join('\n')
 
  // 3. 合并被截断的段落
  // 规则:如果一行不以句号、问号、叹号、冒号结尾,
  // 且下一行以小写字母或中文开头,则合并
  text = text.replace(/([^\n。?!:\?\!])\n([a-z一-鿿])/g, '$1$2')
 
  // 4. 压缩连续空行
  text = text.replace(/\n{3,}/g, '\n\n')
 
  return text.trim()
}

这段代码的策略比较朴素,对中文 PDF 效果还可以。英文 PDF 的段落合并规则需要另外调整——换行处如果单词中间带连字符断开,需要去掉连字符并合并。

如果 PDF 来源固定(比如都是同一类报告),可以针对排版特征做更精确的清洗规则。更彻底的做法是页眉页脚检测:如果同一个字符串在多页的相同位置出现,就认定为页眉页脚并移除。这需要保留 textItem 的坐标信息(transform 里的 x、y 值),跨页比较后过滤。对准确度要求高的场景值得做,一般用途可以先跳过。

8. 分块(Chunking)

清洗后的文本还需要切成合适大小的块,才能写入向量数据库做检索。分块策略直接影响检索质量——块太大,embedding 被稀释,检索精度下降;块太小,上下文丢失,回答缺乏连贯性。

常见的分块方式有三种:

固定字符数切分:按固定长度切,相邻块之间留一段重叠(overlap)保持上下文连续。实现简单,适合大多数场景。

interface Chunk {
  text: string
  index: number
  startChar: number
  endChar: number
}
 
function splitIntoChunks(
  text: string,
  chunkSize = 500,
  overlap = 50
): Chunk[] {
  const chunks: Chunk[] = []
  let start = 0
 
  while (start < text.length) {
    let end = start + chunkSize
 
    // 尝试在句号或换行处断开,避免切断句子
    if (end < text.length) {
      const segment = text.slice(start, end)
      const lastBreak = Math.max(
        segment.lastIndexOf('。'),
        segment.lastIndexOf('\n'),
        segment.lastIndexOf('. ')
      )
      // 如果在后 30% 的位置找到了断点,就在那里切
      if (lastBreak > chunkSize * 0.7) {
        end = start + lastBreak + 1
      }
    }
 
    chunks.push({
      text: text.slice(start, end).trim(),
      index: chunks.length,
      startChar: start,
      endChar: end,
    })
 
    start = end - overlap
  }
 
  return chunks
}

按段落切分:以空行(\n\n)为分隔符,每个段落作为一个块。如果单个段落超长,再按字符数二次切分。这种方式保留了段落的完整性,适合结构清晰的文档。

按语义切分:用 embedding 模型计算相邻句子的相似度,在相似度骤降的位置切分。效果最好,但成本也最高——需要对整个文档的所有句子做 embedding。一般先用固定长度切分,后续有优化需求再升级到语义切分。

对于 RAG 场景,建议从固定字符数切分开始,chunkSize 设为 300-800 字符,overlap 设为 50-100 字符。中文场景下 500 字符约 250-350 个汉字,是一个合理的起点。

每个 chunk 建议附带元数据,方便检索时过滤和展示:

interface DocumentChunk {
  text: string
  metadata: {
    source: string       // 文件名
    page?: number        // 来自第几页
    chunkIndex: number   // 在文档内的块序号
    totalChunks: number  // 总块数
    author?: string      // PDF 元数据中的作者
    createdAt?: string   // PDF 元数据中的创建时间
  }
}

9. 完整示例:从上传到结构化输出

把前面的步骤串成一个完整的 Hono 路由:

import { Hono } from 'hono'
import * as pdfjsLib from 'pdfjs-dist'
 
pdfjsLib.GlobalWorkerOptions.workerSrc = ''
 
type Bindings = {
  BUCKET: R2Bucket
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
app.post('/api/documents/parse', async (c) => {
  // 1. 读取上传的文件
  const body = await c.req.parseBody()
  const file = body['file']
 
  if (!file || !(file instanceof File)) {
    return c.json({ error: '请上传一个 PDF 文件' }, 400)
  }
 
  // 2. 校验文件类型
  if (file.type !== 'application/pdf') {
    return c.json({ error: '仅支持 PDF 格式' }, 400)
  }
 
  // 3. 读取二进制数据
  const pdfData = await file.arrayBuffer()
 
  // 4. 检查文件大小(Workers 内存限制)
  if (pdfData.byteLength > 50 * 1024 * 1024) {
    return c.json({ error: '文件大小不能超过 50MB' }, 413)
  }
 
  // 5. 提取文本(逻辑同第 4 节)
  const loadingTask = pdfjsLib.getDocument({
    data: new Uint8Array(pdfData),
  })
  const pdf = await loadingTask.promise
 
  const pages: { page: number; text: string }[] = []
  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i)
    const textContent = await page.getTextContent()
    const pageText = textContent.items
      .filter(
        (item): item is { str: string; dir: string; width: number; height: number; transform: number[]; fontName: string; hasEOL: boolean } =>
          'str' in item
      )
      .map((item) => item.str)
      .join('')
    pages.push({ page: i, text: pageText })
  }
 
  // 6. 检测是否为扫描版(逻辑同第 6 节)
  const emptyPages = pages.filter((p) => p.text.trim().length < 50)
  if (emptyPages.length > pages.length / 2) {
    return c.json({
      isScanned: true,
      message: '该 PDF 疑似为扫描版,需要 OCR 处理',
      numPages: pdf.numPages,
    })
  }
 
  // 7. 提取元数据
  const metadata = await pdf.getMetadata()
  const info = metadata.info as Record<string, unknown>
 
  // 8. 合并全文 → 清洗 → 分块(函数定义见第 7、8 节)
  const rawText = pages.map((p) => p.text).join('\n\n')
  const cleanedText = cleanText(rawText)
  const chunks = splitIntoChunks(cleanedText, 500, 50).map(
    (chunk, index) => ({ text: chunk.text, index })
  )
 
  // 9. 返回结构化结果
  return c.json({
    metadata: {
      numPages: pdf.numPages,
      title: info.Title as string | undefined,
      author: info.Author as string | undefined,
      creationDate: info.CreationDate
        ? new Date(info.CreationDate as string).toISOString()
        : undefined,
    },
    isScanned: false,
    chunks,
    fullText: cleanedText,
  })
})
 
export default app

这个路由接收 PDF,返回结构化文本。实际项目中你可能还需要:

  • 把解析结果存到 R2 或 D1,而不是直接返回
  • 对大文件走异步队列,解析完成后通知客户端
  • 把 chunks 直接写入向量数据库(如 Vectorize)

总结

回顾一下这篇的内容:

  • PDF 解析是 AI 文档处理链路的起点,提取质量直接影响下游的检索和问答效果
  • Workers 环境下不能用 Node.js 原生模块,pdfjs-dist 是可选方案,但需要禁用 Web Worker
  • 文本提取要逐页遍历 getTextContent(),注意类型守卫和文字拼接方式
  • 元数据提取用 getMetadata(),但要接受字段可能为空
  • 扫描版 PDF 无法直接提取文本,需要先做检测,后续走 OCR
  • 原始文本需要清洗(去页眉页脚、合并断行段落、过滤页码)才能使用
  • 分块策略从固定字符数切分开始,配合 overlap 保持上下文连续
  • 大文件要考虑内存和 CPU 限制,必要时走异步队列

下一篇会处理扫描版 PDF 的 OCR 识别方案。