PDF 批注让静态文档变成可交互的审阅工具。如果你在构建文档协作平台、在线教育系统或合同审核流程,用户最核心的需求就是能在 PDF 上高亮文字、添加评论、自由涂鸦和加盖印章。
本指南将完整讲解 React 中 PDF 批注的系统架构——从 PDF.js 底层原理到实现一个功能完备的批注系统(含工具栏、评论回复和导出能力)。所有代码示例都基于真实 SDK,没有伪代码。
什么是 PDF 批注?为什么你的 React 应用需要它?
批注是 PDF 页面上的交互式覆盖层。与简单的 HTML 文字叠加不同,PDF 批注是空间感知的——它们知道自己在页面上的精确位置,在缩放时保持视觉一致,并且可以被序列化、存储和恢复。
真实使用场景
| 应用类型 | 批注需求 | 关键要求 |
|---|---|---|
| 文档审阅 | 高亮、评论、回复 | 带用户追踪的协作批注 |
| 法律 / 合规 | 签名、印章、遮盖 | 可验证的审计日志,标准 PDF 导出 |
| 在线教育 | 自由手绘、便签、下划线 | 低延迟笔触,压感识别 |
| 医疗影像 | 形状、箭头、测量 | 高倍缩放下的像素级精确渲染 |
PDF.js 原生提供什么
PDF.js——Mozilla 的 JavaScript PDF 渲染器——原生支持少数几种批注类型:FREETEXT、HIGHLIGHT、STAMP、INK。但以下能力不包含在内:
- 没有可定制的批注工具栏和 UI
- 没有评论回复系统
- 没有用于持久化的序列化/反序列化方案
- 没有编程式创建批注的 API
- 没有撤销/重做支持
- 没有印章自定义
- 没有形状批注(矩形、椭圆、箭头)
一句话总结:PDF.js 负责渲染 PDF,它不提供批注体验。这就是批注库存在的价值。
React PDF 批注系统架构
在写代码之前,先理解所有批注系统都遵循的三层模型:
┌──────────────────────────────────┐
│ 批注 UI 层(React 组件) │ ← 工具栏、侧边栏、评论框
├──────────────────────────────────┤
│ 批注数据模型层(Core) │ ← JSON 对象、坐标体系
├──────────────────────────────────┤
│ PDF 渲染层(PDF.js / Konva) │ ← Canvas 页面渲染
└──────────────────────────────────┘
第一层:PDF 渲染器
PDF.js 将每一页渲染到 <canvas> 元素上。在这个 canvas 上方,一个独立的绘制层(通常使用 Konva.js)叠加批注图形。这种分离至关重要——它意味着批注可以被拖动、缩放和删除,而无需重新渲染底层 PDF。
第二层:批注数据模型
批注以结构明确的 JSON 对象存储。以下是 InkLayer 核心数据模型中一个真实的高亮批注:
{
"id": "7iTCOVHhhkjSft3sHjGOt",
"kind": "text-markup",
"target": {
"pageIndex": 0,
"geometry": {
"type": "quad",
"quads": [{
"p1": { "x": 233.37, "y": 107.75 },
"p2": { "x": 361.89, "y": 107.75 },
"p3": { "x": 233.37, "y": 155.45 },
"p4": { "x": 361.89, "y": 155.45 }
}]
},
"coordinateSystem": "pdf-user-space"
},
"payload": {
"kind": "text-markup",
"variant": "highlight",
"color": "#b4fa56"
},
"appearance": {
"strokeColor": "#b4fa56",
"fillColor": "rgba(180, 250, 86, 0.3)",
"opacity": 1
}
}
关键概念:
coordinateSystem:所有坐标都在 PDF 用户空间中——与 PDF.js 内部使用的坐标系统一致。这意味着无论缩放到什么级别,批注都能精确对齐。Geometry类型:rect(矩形)、quad(文字选区四边形)、path(自由路径)、line(直线)、poly(多边形)。kind/payload分离:kind描述批注”是什么”(如text-markup),payload定义”哪个变体”(如highlightvsunderline),使序列化更清晰且易于扩展。
第三层:批注 UI
React 层提供工具栏、侧边栏、评论回复组件和事件处理。它通过核心 API 与数据模型通信:
用户点击"高亮"工具 → Core 激活高亮模式
用户在 canvas 上选中文字 → Core 捕获 QuadPoints 坐标
Core 创建批注对象 → 批注显示在页面上
Core 触发 onAnnotationAdded 回调 → React 更新侧边栏
事件流:从点击到渲染
- 指针事件在 canvas 上触发 → 命中检测判断鼠标下方是否有批注
- 选中状态更新 → 批注高亮显示,缩放控制点出现
- 拖拽/缩放 → 几何坐标在 PDF 用户空间中重新计算
- 渲染 → Konva 层在新坐标处重绘批注
- 保存 → 所有批注序列化为 JSON 用于持久化存储
你能添加的 PDF 批注类型
文字标记(TextMarkup)
最常见的批注类型。通过捕获文字选区坐标(Quads)并在文字上方渲染彩色覆盖层来实现。
| 变体 | 外观 | 使用场景 |
|---|---|---|
highlight | 半透明彩色矩形 | 标记重要段落 |
underline | 文字下方横线 | 强调特定短语 |
strikeout | 文字中间删除线 | 标记待删除内容 |
自由手绘(Ink)
以点序列记录的自由笔画。支持可变笔触宽度和颜色——这是签名和手写批注的重要能力。
评论和便签
点击放置的文字批注,带弹出式备注窗口。支持回复——非常适合协作审阅场景。
印章和形状
| 类型 | 变体 | 使用场景 |
|---|---|---|
stamp | 自定义图片印章 | ”已批准”、“已审阅”、公司印章 |
shape | rect, ellipse, cloud | 圈出重点区域 |
line | 箭头、直线 | 指向特定位置 |
从零实现的难度评估:
| 批注类型 | 复杂度 | 原因 |
|---|---|---|
| 文字标记 | 中等 | PDF.js 中文字选区处理并不简单 |
| 评论 | 中等 | 需要 UI + 回复功能 + 持久化 |
| 手绘 | 困难 | 平滑笔触、压感处理、橡皮擦 |
| 形状 | 困难 | 缩放、旋转、各缩放级别下的命中检测 |
| 印章 | 简单 | 图片矩形叠加 |
查看 完整 API 参考 了解全部 14 种批注类型及代码示例。
React 中实现 PDF 批注的三种方案
根据时间线和需求选择合适的方案。
方案一:基于原始 PDF.js 从零构建
// 使用原始 PDF.js 的最小可行高亮渲染
import * as pdfjsLib from 'pdfjs-dist'
async function renderPdf(canvasRef, url) {
const pdf = await pdfjsLib.getDocument(url).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.5 })
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvasContext: ctx, viewport }).promise
}
这段代码能渲染一页 PDF。要添加高亮功能,你还需要:
- 捕获文字选区坐标(并不简单——PDF.js 文字层 API 相当复杂)
- 在独立的 canvas 层上绘制彩色矩形
- 实现用于选中和删除的命中检测
- 处理缩放/滚动时的坐标变换
- 序列化所有批注数据用于持久化
- 实现撤销/重做
现实检查:这条路通常需要 4-8 周 才能做出最基本可用的批注系统,其中 80% 的时间会花在边缘情况上。
方案二:使用开源批注库
基于 PDF.js 的开源库提供预构建的批注 UI 和数据模型。主流选项:
| 库 | 框架 | 批注类型 | Stars | 维护 |
|---|---|---|---|---|
| InkLayer | React + Vue | 14 种 | 活跃 | ✅ 活跃 |
| react-pdf-highlighter | React | 高亮 + 评论 | 3K+ | ⚠️ 较少 |
| pdfjs-annotation-extension | 原生 JS | 全套 | 活跃 | ⚠️ 较少 |
取舍:
- ✅ 免费(MIT 协议),完全可定制,无供应商锁定
- ⚠️ 你需要自行负责部署和维护
- ⚠️ 社区支持参差不齐
方案三:使用商业 SDK
商业方案如 Nutrient (PSPDFKit)、Foxit PDF SDK、Apryse 提供完全精致的批注体验和专属支持。
| 维度 | 开源 | 商业 |
|---|---|---|
| 成本 | 免费(MIT) | ¥15,000–¥350,000+/年 |
| 集成时间 | 数小时 | 当天 |
| 定制性 | 完全掌控 UI 和数据 | 受限于厂商 API |
| HIPAA/SOC2 | 自行负责 | 已包含 |
| 离线支持 | 内置(本地 PDF.js) | 取决于厂商 |
| 最适合 | 创业公司、内部工具、自定义 UX | 企业、受监管行业 |
逐步教程:为 React 应用添加 PDF 批注
现在进入实战部分。我们将使用 InkLayer 作为批注库——它基于 PDF.js 原生,MIT 协议,覆盖全部 14 种批注类型。这些模式适用于任何基于 PDF.js 的批注库。
5.1 项目初始化
创建 Vite + React 项目:
npm create vite@latest pdf-annotator-demo -- --template react-ts
cd pdf-annotator-demo
npm install inklayer-react
你的 package.json 依赖将如下:
{
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"inklayer-react": "^0.1.0"
}
}
在 src/main.tsx 中全局导入样式:
import 'inklayer-react/style'
5.2 使用内置阅读器渲染 PDF
PdfAnnotator 组件封装了全功能 PDF 阅读器,内置页面导航、缩放控制和搜索:
import { PdfAnnotator } from 'inklayer-react'
import 'inklayer-react/style'
function App() {
return (
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
locale="zh-CN"
/>
)
}
export default App
这样就完成了。你获得了一个完整的 PDF 阅读器,默认启用批注功能。user 属性标识当前的批注者——对于协作场景至关重要。
常用属性:
| 属性 | 类型 | 说明 |
|---|---|---|
url | string | URL | PDF 文件地址 |
data | string | ArrayBuffer | PDF 二进制数据(跳过 URL 获取) |
user | { id, name } | 当前用户身份 |
appearance | "auto" | "dark" | "light" | 主题模式 |
theme | "violet" | "blue" | ... | 主色调(25+ 选项) |
locale | "en-US" | "zh-CN" | 界面语言 |
enableRange | boolean | "auto" | 大文件流式加载模式 |
layoutStyle | CSSProperties | 容器尺寸样式 |
5.3 启用批注工具栏
批注器自带内置工具栏,你可以配置可使用哪些工具并添加自定义操作按钮:
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
locale="zh-CN"
defaultShowAnnotationsSidebar={false}
actions={(props) => (
<>
<button onClick={() => props.save()}>
💾 保存
</button>
<button onClick={() => console.log(props.getAnnotations())}>
📦 获取批注数据
</button>
<button onClick={() => props.exportToPdf('导出PDF')}>
📄 导出 PDF
</button>
<button onClick={() => props.exportToExcel('导出Excel')}>
📊 导出 Excel
</button>
</>
)}
/>
actions 渲染属性让你可以访问核心 API 方法:save()、getAnnotations()、exportToPdf()、exportToExcel()。
5.4 添加高亮和文字标记
高亮通过文字选择来工作——用户在 PDF 中选中文字,库会捕获选区四边形坐标并渲染高亮覆盖层。
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
locale="zh-CN"
onAnnotationAdded={(annotation) => {
console.log('新增批注:', annotation.id, annotation.kind)
// annotation.kind → "text-markup"
// annotation.payload.variant → "highlight"
}}
onAnnotationSelected={(annotation, isClick) => {
console.log('选中批注:', annotation?.id, '通过点击:', isClick)
}}
/>
底层工作原理:
- 用户选中文字 → PDF.js 提供
QuadPoints(4 个角点坐标的集合) - Core 将四边形映射为
QuadGeometry对象 - 创建
kind: "text-markup"、variant: "highlight"的批注 - Konva 层在对应坐标处渲染半透明彩色矩形
onAnnotationAdded触发并返回完整批注对象
5.5 添加评论和便签
评论是放置在页面特定位置的文字批注,支持回复功能:
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
locale="zh-CN"
onAnnotationUpdated={(annotation) => {
// 添加、编辑或回复评论时触发
console.log('更新批注:', annotation.id)
}}
/>
评论数据结构包含回复:
{
"kind": "stamp",
"meta": {
"authorId": { "id": "9527", "name": "InkLayer" },
"createdAt": "D:20260629000818+08'00'"
},
"extensions": {
"legacy": {
"comments": [
{
"id": "NYGCSZtaKLS",
"title": "InkLayer",
"date": "D:20260629000922+08'00'",
"content": "是的,看起来不错!"
}
]
}
}
}
5.6 自由手绘和签名
墨迹批注支持可变笔触宽度和颜色的自由绘制:
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
locale="zh-CN"
defaultOptions={{
signature: {
defaultSignature: [
'data:image/png;base64,...'
],
defaultFont: [
{ label: '楷体', value: 'STKaiti', external: false },
{ label: '手写体', value: 'customFont', external: true, url: '/fonts/handwriting.ttf' },
]
}
}}
/>
墨迹批注以点序列的形式存储笔画路径:
{
"kind": "ink",
"target": {
"geometry": {
"type": "path",
"points": [
{ "x": 405.34, "y": 310.56 },
{ "x": 410.94, "y": 315.56 },
{ "x": 418.14, "y": 315.56 }
],
"closed": false
}
},
"payload": {
"kind": "ink",
"color": "#ffe066",
"width": 10
}
}
5.7 保存、加载和导出批注
onSave 回调以单个 JSON 数组返回所有批注——存储到后端或 localStorage:
import { useCallback } from 'react'
import type { Annotation } from 'inklayer-react'
function App() {
const handleSave = useCallback((annotations: Annotation[]) => {
// 发送到后端
fetch('/api/annotations', {
method: 'POST',
body: JSON.stringify({ annotations }),
headers: { 'Content-Type': 'application/json' }
})
}, [])
return (
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
onSave={handleSave}
/>
)
}
页面挂载时加载批注:
function App() {
const [initialAnnotations, setInitialAnnotations] = useState<Annotation[]>([])
useEffect(() => {
fetch('/api/annotations?docId=123')
.then(r => r.json())
.then(data => setInitialAnnotations(data.annotations))
}, [])
return (
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
initialAnnotations={initialAnnotations}
onSave={handleSave}
/>
)
}
导出:库支持两种导出格式:
- PDF 导出:将批注烧录进 PDF,生成任何人都能打开的标准 PDF 文件
- Excel 导出:将批注导出为结构化的电子表格(适用于审阅日志)
5.8 进阶:编程式批注
你可以在代码中创建批注而无需用户操作——适用于自动高亮搜索词或预填审阅数据:
import { useRef } from 'react'
import { PdfAnnotator, type AnnotationCore } from 'inklayer-react'
function App() {
const coreRef = useRef<AnnotationCore>(null)
const autoHighlight = () => {
coreRef.current?.addAnnotation({
type: 'highlight',
pageIndex: 0,
rects: [{ x: 100, y: 200, width: 300, height: 20 }],
color: '#FFEB3B',
opacity: 0.4,
})
}
return (
<div>
<button onClick={autoHighlight}>自动高亮指定区域</button>
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: '张三' }}
annotationCoreRef={coreRef}
/>
</div>
)
}
生产环境的性能考量
虚拟化渲染
当有 100 多个批注时,每帧渲染所有批注会严重拖垮性能。解决方案是视口裁剪——只渲染当前可见视口内的批注。
InkLayer 的 Konva 适配器自动处理这一点:只有边界矩形与可见视口相交的批注才会被渲染。在 300% 缩放时,即使文档有数百个批注,实际可见的可能只有 5-10 个。具体实现细节见 React SDK 性能文档。
防抖缩放和滚动
每次滚动事件都重绘会导致卡顿。以 50-100ms 防抖重绘:
// Konva 适配器中使用的内部模式
const debouncedRedraw = debounce(() => {
layer.batchDraw()
}, 50)
Core Web Vitals 目标
| 指标 | 目标 | 策略 |
|---|---|---|
| LCP | < 2.5s | 首屏渲染后延迟加载 PDF;显示骨架 UI |
| INP | < 200ms | 防抖渲染;避免滚动时的 React 重渲染 |
| CLS | < 0.1 | 固定容器尺寸;避免 PDF 布局偏移 |
如何为你的项目选择正确的 PDF 批注方案
决策框架:
-
内部工具、原型或创业 MVP → 开源库(InkLayer、pdfjs-annotation-extension)。完全掌控,零成本,数小时内上线。
-
有定制 UX 需求的中端 SaaS → 开源库 + 自定义工具栏和侧边栏。MIT 协议允许你修改一切。
-
有合规需求的企业(等保、SOC2) → 评估商业 SDK。合规文档本身通常就值回票价。
-
有离线需求的受监管行业 → 基于 PDF.js 的开源方案有天然优势:完全在浏览器端运行,无需服务器依赖。
常见问题
PDF.js 原生支持批注吗?
支持,但仅有 4 种基础类型(FREETEXT、HIGHLIGHT、STAMP、INK)。没有批注工具栏、没有评论功能、没有撤销重做、没有序列化 API。你需要批注库来提供可用的批注体验。
批注如何在页面刷新后保持?
批注是 JSON 对象。通过 onSave 回调保存到后端(或原型阶段的 localStorage),通过 initialAnnotations 属性加载回来。完整持久化流程见 React SDK 保存与加载指南。坐标体系使用 PDF 用户空间,所以无论视口大小如何变化,批注都能精确对齐。
能为非技术用户导出含批注的 PDF 吗?
可以。调用 exportToPdf() 将所有批注烧录到新的 PDF 文件中。输出是标准 PDF,任何人都可以用任意 PDF 阅读器打开——批注以原生 PDF 批注对象呈现,而非独立数据。
批注的 “kind” 和 PDF 子类型有什么区别?
批注 kind 是语义分类(如 text-markup、shape、ink)——描述用户意图。PDF 子类型(如 Highlight、Square、Ink)是原始 PDF 规范类型。批注库负责映射:
text-markup+variant: highlight→ PDF 子类型Highlightshape+shape: rect→ PDF 子类型Squareink→ PDF 子类型Ink
下一步
- 完整 API 参考 — 全部 14 种批注类型及代码示例
- React SDK 文档 — 组件属性、Hooks 和事件处理器
- 批注数据模型 — 核心数据架构深度解析
- Vue SDK 文档 — 同样的 API,Vue 3 Composition API 风格
PDF 批注是一个深度的技术领域,但合适的库会帮你抽象掉困难的部分——坐标映射、命中检测和序列化——让你专注于构建应用独特的批注体验。