返回博客

React 中实现 PDF 批注的完整指南 — 涵盖高亮、批注、手绘与印章

~9 分钟阅读
InkLayer

PDF 批注让静态文档变成可交互的审阅工具。如果你在构建文档协作平台、在线教育系统或合同审核流程,用户最核心的需求就是能在 PDF 上高亮文字、添加评论、自由涂鸦和加盖印章。

本指南将完整讲解 React 中 PDF 批注的系统架构——从 PDF.js 底层原理到实现一个功能完备的批注系统(含工具栏、评论回复和导出能力)。所有代码示例都基于真实 SDK,没有伪代码。


什么是 PDF 批注?为什么你的 React 应用需要它?

批注是 PDF 页面上的交互式覆盖层。与简单的 HTML 文字叠加不同,PDF 批注是空间感知的——它们知道自己在页面上的精确位置,在缩放时保持视觉一致,并且可以被序列化、存储和恢复。

真实使用场景

应用类型批注需求关键要求
文档审阅高亮、评论、回复带用户追踪的协作批注
法律 / 合规签名、印章、遮盖可验证的审计日志,标准 PDF 导出
在线教育自由手绘、便签、下划线低延迟笔触,压感识别
医疗影像形状、箭头、测量高倍缩放下的像素级精确渲染

PDF.js 原生提供什么

PDF.js——Mozilla 的 JavaScript PDF 渲染器——原生支持少数几种批注类型:FREETEXTHIGHLIGHTSTAMPINK。但以下能力不包含在内:

  • 没有可定制的批注工具栏和 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 定义”哪个变体”(如 highlight vs underline),使序列化更清晰且易于扩展。

第三层:批注 UI

React 层提供工具栏、侧边栏、评论回复组件和事件处理。它通过核心 API 与数据模型通信:

用户点击"高亮"工具 → Core 激活高亮模式
用户在 canvas 上选中文字 → Core 捕获 QuadPoints 坐标
Core 创建批注对象 → 批注显示在页面上
Core 触发 onAnnotationAdded 回调 → React 更新侧边栏

事件流:从点击到渲染

  1. 指针事件在 canvas 上触发 → 命中检测判断鼠标下方是否有批注
  2. 选中状态更新 → 批注高亮显示,缩放控制点出现
  3. 拖拽/缩放 → 几何坐标在 PDF 用户空间中重新计算
  4. 渲染 → Konva 层在新坐标处重绘批注
  5. 保存 → 所有批注序列化为 JSON 用于持久化存储

你能添加的 PDF 批注类型

文字标记(TextMarkup)

最常见的批注类型。通过捕获文字选区坐标(Quads)并在文字上方渲染彩色覆盖层来实现。

变体外观使用场景
highlight半透明彩色矩形标记重要段落
underline文字下方横线强调特定短语
strikeout文字中间删除线标记待删除内容

自由手绘(Ink)

以点序列记录的自由笔画。支持可变笔触宽度和颜色——这是签名和手写批注的重要能力。

评论和便签

点击放置的文字批注,带弹出式备注窗口。支持回复——非常适合协作审阅场景。

印章和形状

类型变体使用场景
stamp自定义图片印章”已批准”、“已审阅”、公司印章
shaperect, 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。要添加高亮功能,你还需要:

  1. 捕获文字选区坐标(并不简单——PDF.js 文字层 API 相当复杂)
  2. 在独立的 canvas 层上绘制彩色矩形
  3. 实现用于选中和删除的命中检测
  4. 处理缩放/滚动时的坐标变换
  5. 序列化所有批注数据用于持久化
  6. 实现撤销/重做

现实检查:这条路通常需要 4-8 周 才能做出最基本可用的批注系统,其中 80% 的时间会花在边缘情况上。

方案二:使用开源批注库

基于 PDF.js 的开源库提供预构建的批注 UI 和数据模型。主流选项:

框架批注类型Stars维护
InkLayerReact + Vue14 种活跃✅ 活跃
react-pdf-highlighterReact高亮 + 评论3K+⚠️ 较少
pdfjs-annotation-extension原生 JS全套活跃⚠️ 较少

取舍

  • ✅ 免费(MIT 协议),完全可定制,无供应商锁定
  • ⚠️ 你需要自行负责部署和维护
  • ⚠️ 社区支持参差不齐

方案三:使用商业 SDK

商业方案如 Nutrient (PSPDFKit)Foxit PDF SDKApryse 提供完全精致的批注体验和专属支持。

维度开源商业
成本免费(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 属性标识当前的批注者——对于协作场景至关重要。

常用属性

属性类型说明
urlstring | URLPDF 文件地址
datastring | ArrayBufferPDF 二进制数据(跳过 URL 获取)
user{ id, name }当前用户身份
appearance"auto" | "dark" | "light"主题模式
theme"violet" | "blue" | ...主色调(25+ 选项)
locale"en-US" | "zh-CN"界面语言
enableRangeboolean | "auto"大文件流式加载模式
layoutStyleCSSProperties容器尺寸样式

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)
  }}
/>

底层工作原理

  1. 用户选中文字 → PDF.js 提供 QuadPoints(4 个角点坐标的集合)
  2. Core 将四边形映射为 QuadGeometry 对象
  3. 创建 kind: "text-markup"variant: "highlight" 的批注
  4. Konva 层在对应坐标处渲染半透明彩色矩形
  5. 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 批注方案

决策框架:

  1. 内部工具、原型或创业 MVP → 开源库(InkLayer、pdfjs-annotation-extension)。完全掌控,零成本,数小时内上线。

  2. 有定制 UX 需求的中端 SaaS → 开源库 + 自定义工具栏和侧边栏。MIT 协议允许你修改一切。

  3. 有合规需求的企业(等保、SOC2) → 评估商业 SDK。合规文档本身通常就值回票价。

  4. 有离线需求的受监管行业 → 基于 PDF.js 的开源方案有天然优势:完全在浏览器端运行,无需服务器依赖。


常见问题

PDF.js 原生支持批注吗?

支持,但仅有 4 种基础类型(FREETEXTHIGHLIGHTSTAMPINK)。没有批注工具栏、没有评论功能、没有撤销重做、没有序列化 API。你需要批注库来提供可用的批注体验。

批注如何在页面刷新后保持?

批注是 JSON 对象。通过 onSave 回调保存到后端(或原型阶段的 localStorage),通过 initialAnnotations 属性加载回来。完整持久化流程见 React SDK 保存与加载指南。坐标体系使用 PDF 用户空间,所以无论视口大小如何变化,批注都能精确对齐。

能为非技术用户导出含批注的 PDF 吗?

可以。调用 exportToPdf() 将所有批注烧录到新的 PDF 文件中。输出是标准 PDF,任何人都可以用任意 PDF 阅读器打开——批注以原生 PDF 批注对象呈现,而非独立数据。

批注的 “kind” 和 PDF 子类型有什么区别?

批注 kind 是语义分类(如 text-markupshapeink)——描述用户意图。PDF 子类型(如 HighlightSquareInk)是原始 PDF 规范类型。批注库负责映射:

  • text-markup + variant: highlight → PDF 子类型 Highlight
  • shape + shape: rect → PDF 子类型 Square
  • ink → PDF 子类型 Ink

下一步

PDF 批注是一个深度的技术领域,但合适的库会帮你抽象掉困难的部分——坐标映射、命中检测和序列化——让你专注于构建应用独特的批注体验。

准备好构建 PDF 批注功能了吗?

InkLayer provides a complete PDF annotation SDK for React & Vue — 14 annotation types, pixel-perfect rendering, and one-command setup.