PDF 解析
要点
- AI 项目中,PDF 是最常见但也最麻烦的文档来源——RAG 知识库、文档问答、合同审查,第一步都要把 PDF 转成结构化文本
- Cloudflare Workers 跑在 V8 isolate 上,不能用 Node.js 原生模块(
canvas、pdf-parse等全部不可用),需要在纯 JavaScript 方案里选型 pdfjs-dist(Mozilla 官方的 PDF 解析库)可以在 Workers 里运行,但需要做一些适配:禁用 worker、处理字体解码- 解析只是第一步,拿到原始文本后还要清洗(去页眉页脚、合并断行段落)和分块(chunking),才能喂进向量数据库
- 扫描版 PDF 拿不到文本内容,需要走 OCR,这部分会在下一篇文章单独处理
1. AI 项目里为什么需要解析 PDF
做过 RAG 或者文档问答的开发者都遇到过同一个问题:用户丢过来的文件,十个里有七八个是 PDF。合同、报告、论文、产品手册,PDF 几乎成了 B 端文档的默认格式。
要把这些 PDF 接入 AI 流程,中间至少要过这几步:
- 提取文本:把 PDF 里的文字内容按顺序读出来
- 提取元数据:页数、标题、作者、创建时间,用于检索和展示
- 文本清洗:去掉页眉页脚、页码、无意义空行,把断行的段落拼回去
- 分块(chunking):按语义或固定长度切成片段,准备写入向量数据库
如果哪一步做得粗糙,后面检索的质量就会打折扣。PDF 解析是整个链路的起点,起点歪了,后面再怎么用 embedding 模型兜底,效果也有限。
2. Workers 环境的限制
Cloudflare Workers 运行时基于 V8 isolate,不是完整的 Node.js 环境,对 PDF 解析有几个直接约束:
不能用 Node.js 原生模块。 canvas(渲染 PDF 页面为图片)、pdf-parse(依赖 Node 的 buffer 和 stream)这类包在 Workers 里直接报错。它们底层依赖 C++ addon 或 Node 专有 API。
没有文件系统。 Workers 没有磁盘,所有 PDF 数据只能以 ArrayBuffer 或 Uint8Array 的形式存在于内存中。
内存有上限。 单个 Worker 请求的内存限制是 128 MB(付费版)。几十页的 PDF 解析后占用二三十 MB,上百页、内嵌大量图片的 PDF 需要留意内存消耗。
CPU 时间有上限。 免费版 10ms,付费版 30s。解析 PDF 是 CPU 密集型操作,大文件需要考虑用 Queue 异步处理。
这些约束加在一起,可用方案的范围就缩小了不少。
3. 选型:为什么是 pdfjs-dist
在 Workers 环境下能用的 PDF 解析库,大致有这几个选项:
| 库 | 原理 | Workers 兼容 | 备注 |
|---|---|---|---|
pdfjs-dist | Mozilla 官方,纯 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),导入方式可能有变化。也可以考虑直接用 unpdf(pnpm 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 是正常的。creator 和 producer 通常有值,但记录的是生成软件(「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 里提取出来的原始文本,通常不能直接拿去用。常见问题:
- 页眉页脚混入正文:每页顶部或底部重复出现公司名、章节标题、页码
- 段落被换行截断:PDF 的排版机制导致一个段落在不同行被拆成了多个
textItem,拼接后中间插入了不该有的换行 - 多余空行:连续多个空行,或者只包含一个空格的行
- 乱码字符:某些 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 识别方案。