在日常工作中,你是否遇到过这样的场景:需要给 PDF 文档做标注却找不到免费好用的工具?要么功能受限,要么动辄几百元的年费让人望而却步。作为开发者,我们完全可以自己动手,打造一个开源、可定制的 PDF 标注工具。
本文将带你从零开始,基于 Vue 3 和 PDF.js 构建一个功能完整的 PDF 标注查看器。无论你是想学习前端 PDF 处理技术,还是需要一个可二次开发的标注工具,这篇实战指南都能为你提供清晰的实现思路。
为什么需要自建 PDF 标注工具?
市面上的 PDF 工具存在三个典型问题:
-
免费工具功能残缺(如仅支持高亮,不支持自定义形状)
-
付费工具成本高,且难以根据业务需求定制
-
多数工具不支持本地标注存储,隐私性差
自建工具的优势显而易见:完全掌控功能边界、可按需定制、数据存储在本地更安全,同时还能深入学习 PDF 渲染和图形交互的核心技术。
核心功能设计:从 "能看" 到 "会标"
一个合格的 PDF 标注工具需要兼顾基础查看和专业标注能力,我们规划了两大模块共 16 项功能:
基础查看功能
- 页面导航:支持上 / 下一页切换、直接输入页码跳转
- 缩放控制:提供放大、缩小、实际大小、适合页面 / 页宽等模式
- 旋转操作:支持左右旋转调整阅读角度
- 全屏模式:沉浸式阅读体验
专业标注功能
- 基础标注:高亮(自定义颜色)、下划线(自定义颜色)
- 文本工具:可调整字体大小和颜色的文本框
- 绘图工具:支持自定义颜色和线条粗细的自由绘图
- 形状标注:矩形、圆形、三角形、箭头等常用形状
- 标注管理:支持删除、编辑、列表查看和本地保存
核心实现:从 PDF 渲染到标注系统
1. PDF 渲染:从加载到显示
PDF 渲染是基础,我们需要先解决 "如何在页面中显示 PDF" 的问题。PDF.js 的核心是通过 worker 解析 PDF 数据,再通过 canvas 渲染到页面。
首先配置 PDF.js 的 worker(负责 PDF 解析的后台线程):
javascript
import * as pdfjsLib from 'pdfjs-dist'
import PdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url"
// 配置worker路径,避免主线程阻塞
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
然后实现 PDF 加载逻辑,这里需要处理加载状态、错误捕获,并实现初始页面渲染:
javascript
const loadPdf = async () => {
try {
setLoading(true)
// 加载PDF文档(src可以是URL、Base64或File对象)
const loadingTask = pdfjsLib.getDocument(src)
const pdf = await loadingTask.promise
setPdfDoc(pdf) // 保存PDF文档实例
setTotalPages(pdf.numPages) // 获取总页数
// 渲染第一页
await renderPage(1)
setCurrentPage(1)
} catch (err) {
setError(`加载失败: ${err.message}`)
} finally {
setLoading(false)
}
}
渲染单页的关键是获取页面数据,再通过 canvas 绘制:
javascript
const renderPage = async (pageNum) => {
const page = await pdfDoc.getPage(pageNum)
const viewport = page.getViewport({ scale: scale.value }) // 根据缩放比例计算视口
// 设置canvas尺寸
const canvas = document.getElementById(`page-${pageNum}`)
const ctx = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
// 渲染页面内容
await page.render({
canvasContext: ctx,
viewport: viewport
}).promise
// 渲染该页的标注(核心:先画PDF再画标注)
drawAnnotations(pageNum, ctx, viewport)
}
2. 标注系统:数据结构与绘制逻辑
标注系统是核心,需要解决 "如何定义标注" 和 "如何绘制标注" 两个问题。
标注数据结构设计
我们用 TypeScript 接口统一所有标注类型的结构,确保数据一致性:
typescript
interface Annotation {
id: string; // 唯一标识
type: 'highlight' | 'underline' | 'text' | 'draw' | 'rectangle' | 'circle'; // 标注类型
pageNum: number; // 所属页码
position: { x: number; y: number; width?: number; height?: number }; // 位置信息
points?: { x: number; y: number }[]; // 绘图/形状的点集(如自由绘制的路径)
text?: string; // 文本框内容
color: string; // 颜色
lineWidth?: number; // 线条粗细
fontSize?: number; // 字体大小
timestamp: number; // 创建时间
}
这种设计的优势是:便于统一存储和管理,新增标注类型时只需扩展type即可。
标注绘制实现
标注绘制的核心是在 PDF 渲染完成后,在同一个 canvas 上叠加绘制标注。以高亮和文本框为例:
javascript
// 绘制高亮(半透明背景)
const drawHighlight = (ctx, annotation) => {
const { position, color } = annotation
ctx.save()
ctx.fillStyle = `${color}40`; // 末尾添加透明度
ctx.fillRect(position.x, position.y, position.width, position.height)
ctx.restore()
}
// 绘制文本框(带背景和文字)
const drawTextAnnotation = (ctx, annotation) => {
const { position, text, color, fontSize = 16 } = annotation
ctx.save()
// 绘制背景
ctx.fillStyle = `${color}40`
ctx.fillRect(position.x, position.y, position.width, position.height)
// 绘制文字
ctx.fillStyle = color
ctx.font = `${fontSize}px Arial`
ctx.fillText(text || '', position.x + 5, position.y + fontSize) // 偏移5px避免贴边
ctx.restore()
}
绘制时机很关键:必须在 PDF 页面渲染完成后再绘制标注,且每次缩放、滚动后都需要重新绘制,确保标注位置与 PDF 内容对齐。
3. 交互设计:工具栏与事件处理
良好的交互是用户体验的关键,我们将交互分为三级:
-
一级控制:顶部工具栏负责 PDF 基础操作(翻页、缩放、旋转)
-
二级工具:底部工具栏负责标注工具选择(高亮、文本框等)
-
三级设置:右侧抽屉面板用于调整标注样式(颜色、线条粗细等)
以标注工具选择为例,通过状态管理切换工具类型,再监听 canvas 事件处理绘制逻辑:
javascript
// 工具状态管理
const activeTool = ref('select') // 默认选择工具
// 监听canvas鼠标事件
const handleMouseDown = (e) => {
if (activeTool.value === 'highlight') {
// 记录高亮起始位置
startHighlight(e)
} else if (activeTool.value === 'text') {
// 创建文本框
createTextAnnotation(e)
}
// 其他工具...
}
踩坑与解决方案:从理论到实践
开发过程中遇到了不少实际问题,这里分享三个典型场景的解决方案:
1. 标注位置偏移:不同缩放级别下的坐标对齐
问题:PDF 缩放后,标注位置与内容错位。
原因:标注存储的是基于原始尺寸的坐标,缩放后未同步转换。
解决方案:存储相对坐标(相对于页面宽度的比例),渲染时根据当前视口尺寸计算实际位置:
javascript
// 保存时转换为相对坐标
const getRelativePosition = (absolutePos, viewport) => {
return {
x: absolutePos.x / viewport.width,
y: absolutePos.y / viewport.height,
width: absolutePos.width / viewport.width,
height: absolutePos.height / viewport.height
}
}
// 渲染时转换为绝对坐标
const getAbsolutePosition = (relativePos, viewport) => {
return {
x: relativePos.x * viewport.width,
y: relativePos.y * viewport.height,
width: relativePos.width * viewport.width,
height: relativePos.height * viewport.height
}
}
2. 大型 PDF 渲染卡顿:性能优化
问题:100 页以上的 PDF 加载缓慢,滚动时卡顿。
解决方案:
- 按需渲染:只渲染当前可见区域的页面,其他页面滚动到视口时再加载
- 虚拟滚动:用定位模拟页面列表,只保留视口内的 DOM 元素
- 缓存机制:已渲染的页面缓存到内存,避免重复解析
3. 移动端交互不流畅:触摸事件适配
问题:手机上触摸绘制时延迟高,标注不准确。
解决方案:
- 用
touchstart/touchmove/touchend替代鼠标事件,处理触摸点坐标 - 增加防抖处理,减少绘制频率(从 60fps 降为 30fps,降低性能消耗)
- 优化工具栏布局,采用底部弹出式设计适配小屏幕
未来规划:从可用到好用
目前的版本已经实现了核心功能,但还有很大优化空间:
- 云同步:添加用户系统,将标注数据同步到云端(可基于 Firebase 或自建后端)
- OCR 支持:集成 Tesseract.js,实现图片 PDF 的文字识别与标注
- 协作功能:支持多人实时标注,基于 WebSocket 实现同步
- 格式导出:支持将标注导出为 PDF(嵌入标注层)或单独的 JSON 文件
快速上手
如果你想立即体验或二次开发,可以按以下步骤操作:
bash
# 克隆项目(替换为实际仓库地址)
git clone <项目仓库地址>
cd pdfjs-annotation-view
# 安装依赖
npm install
# 启动开发服务器
npm run dev
访问http://localhost:5173,上传 PDF 即可开始标注。所有标注会保存在本地localStorage中,刷新页面不会丢失。
结语
构建 PDF 标注工具不仅是解决实际需求,更是一个学习前端图形交互、状态管理和性能优化的绝佳实践。Vue 3 的 Composition API 让复杂逻辑的组织更清晰。
如果你对项目感兴趣,欢迎贡献代码或提出建议。让我们一起打造一个真正免费、开源、强大的 PDF 标注工具!
项目地址:
github: github.com/shunxing-w/…
gitee:gitee.com/shunxing/pd…