从零构建全功能 PDF 标注查看器:Vite+Vue 3 + PDF.js

293 阅读7分钟

在日常工作中,你是否遇到过这样的场景:需要给 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,降低性能消耗)
  • 优化工具栏布局,采用底部弹出式设计适配小屏幕

未来规划:从可用到好用

目前的版本已经实现了核心功能,但还有很大优化空间:

  1. 云同步:添加用户系统,将标注数据同步到云端(可基于 Firebase 或自建后端)
  2. OCR 支持:集成 Tesseract.js,实现图片 PDF 的文字识别与标注
  3. 协作功能:支持多人实时标注,基于 WebSocket 实现同步
  4. 格式导出:支持将标注导出为 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…