纯浏览器PDF 编辑器:PDF.js + pdf-lib + Canvas 三件套深度拆解

🎯 在线体验geekformat.com/zh-CN/pdf/edit(不用注册、不上传文件,浏览器里就能玩)

故事开头

老板:这份 50 页的合同 PDF,你帮我在每页右下角加个页码,再把所有"乙方"的位置画个箭头。

你:好的(熟练打开 Adobe Acrobat 准备买会员)。

老板:用免费的开源工具,预算 0 元。

你:......(打开搜索引擎,开始研究 PDF.jspdf-lib

如果你也遇到过类似的「给 PDF 加点东西」的需求,这篇文章就是为你写的。

我们最后做出来的工具长这样:

  • 左侧 12 个工具栏(选择 / 文字 / 矩形 / 圆 / 箭头 / 高亮 / 下划线 / 删除线 / 画笔 / 橡皮 / 填色 / 删除)
  • 中间画布,所见即所得
  • 右侧缩略图,一眼看 50 页
  • 顶部导出按钮,一键下载新 PDF

整套流程不上传任何文件到服务器 ------PDF 解析、渲染、编辑、导出全部在浏览器里跑,老板的合同也不会被传到我们的服务器。

整体效果展示

技术栈一览

类别 技术 用途
框架 Next.js 14 (App Router) 服务端组件、路由、i18n
UI 库 React 18 + TypeScript 5 组件、Hooks、严格类型
样式 Tailwind CSS 3 + shadcn/ui 工具栏 / 状态栏
渲染引擎 PDF.js(CDN 按需加载) 把 PDF 页面渲染到 Canvas
导出引擎 pdf-lib (按需 import('pdf-lib') 把标注写回 PDF 字节流
画布 原生 Canvas 2D 矢量标注绘制、离屏合成
Portal react-dom createPortal 文本输入浮层(fixed 定位)
图标 lucide-react 12 个工具 + 动作按钮

核心选型逻辑 :PDF.js 是 Mozilla 维护的 PDF 渲染标准库(Chrome 自带的内置 PDF 阅读器底层就是它 ),没有比它更可靠的 PDF 解析方案pdf-lib 负责「」------能在不破坏原 PDF 结构的情况下追加图形元素。

一、整体架构:输入 → 渲染 → 编辑 → 导出

整个工具的数据流非常清晰,4 个阶段:

阶段 入口 关键对象 输出
输入 <input type="file"> / 拖拽 FileArrayBuffer pdfDocument(PDF.js 实例)
渲染 renderPage pdfDocument.getPage(n) 主 Canvas 像素
编辑 鼠标 / 触摸 / 键盘 EditElement[] 矢量元素列表 重绘到主 Canvas
导出 exportPDF pdf-lib PDFDocument 新 PDF Blob + 下载

最关键的抽象EditElement 类型 ------所有矢量标注(文字、矩形、圆、箭头等)都用同一个数据结构表示:

ts 复制代码
// types/edit-element.ts --- 所有标注的统一类型
type ToolType =
  | "select" | "text" | "rectangle" | "circle" | "arrow"
  | "fill" | "highlight" | "underline" | "strikethrough"
  | "pen" | "eraser" | "delete"

interface PathPoint { x: number; y: number }

interface EditElement {
  id: string
  type: ToolType
  x: number                            // 画布坐标(scale 倍)
  y: number
  width?: number
  height?: number
  content?: string                     // text 类型用
  color?: string                       // 描边 / 文字色
  fillColor?: string                   // 填充色,'transparent' 表示无填充
  fontSize?: number
  lineWidth?: number
  opacity?: number
  pageIndex: number                    // 属于哪一页(0-based)
  points?: PathPoint[]                 // 画笔 / 橡皮路径点
}

为什么所有标注统一一个类型

  • 撤销/重做 只需保存 EditElement[] 快照;
  • 导出 只需 for 循环每个 element 调 pdf-lib 对应 API;
  • 跨页支持pageIndex 字段------elements所有页的并集 ,渲染时按 el.pageIndex === currentPage - 1 过滤。

二、PDF.js 加载:按需 + 重试机制

PDF.js 不打包进首屏 bundle ------体积太大(~500KB),用户没上传 PDF 前根本不需要。CDN 按需加载 + 重试

ts 复制代码
// hooks/usePdfjsLoader.ts --- 自定义 Hook
export function usePdfjsLoader() {
  const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
  const [errorMessage, setErrorMessage] = useState<string | null>(null)

  useEffect(() => {
    if (typeof window === 'undefined') return
    if ((window as any).pdfjsLib) { setStatus('ready'); return }

    setStatus('loading')
    const script = document.createElement('script')
    script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs'
    script.type = 'module'
    script.onload = () => setStatus('ready')
    script.onerror = () => {
      setStatus('error')
      setErrorMessage('PDF.js 加载失败,请检查网络后重试')
    }
    document.head.appendChild(script)
  }, [])

  const retry = useCallback(() => {
    setStatus('idle')
    setErrorMessage(null)
    // 重新触发 useEffect
  }, [])

  return { status, errorMessage, isReady: status === 'ready', retry }
}

为什么用 CDN 而不是 npm 包 :PDF.js 的 worker 线程需要单独的 pdf.worker.js 文件,Next.js 打包会牵涉到 public 目录 + Webpack 配置CDN 引入省心得多 。代价是依赖第三方 CDN 的可用性------所以内置了重试机制

加载完成后才能用:

ts 复制代码
const loadPDF = useCallback(async (file: File) => {
  if (!isPdfJsLoaded) {
    showNotice(t('notices.pdfLoading'))
    return
  }
  try {
    const pdfjsLib = (window as any).pdfjsLib
    const arrayBuffer = await file.arrayBuffer()
    const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
    setPdfDocument(pdf)
    setTotalPages(pdf.numPages)
    setCurrentPage(1)
    setPdfFile(file)
    setElements([])              // 重要:清空旧标注
    setHistory([[]])
    setHistoryIndex(0)
    setSelectedElement(null)
  } catch (error) {
    console.error('Error loading PDF:', error)
    showNotice(t('notices.pdfLoadError'))
  }
}, [isPdfJsLoaded, showNotice, t])

// 修复 Bug2:确保 PDF.js 加载完成后自动重新加载用户已选的文件
useEffect(() => {
  if (pdfFile && isPdfJsLoaded && !pdfDocument) {
    loadPDF(pdfFile)
  }
}, [pdfFile, isPdfJsLoaded])

两个隐藏 bug

  1. pdfFile 状态先于 pdfDocument 。用户选文件时 PDF.js 还没加载完,loadPDF 提前 return;等 PDF.js 加载完成后,必须自动重新触发 loadPDF(pdfFile)------这个 useEffect 就是修这个的。
  2. 新文件必须清空标注setElements([]) + setHistory([[]]) + setHistoryIndex(0)------忘了一个,旧标注就会显示在新 PDF 上,非常尴尬。

PDF.js 加载流程

三、PDF 渲染:离屏 Canvas 消除闪烁

PDF 渲染 + 标注叠加是个频繁重绘 的操作(拖一个矩形要 30+ FPS 实时显示预览)。直接在主 Canvas 上画会闪烁 ------PDF 页面渲染是异步的,在主 Canvas 上半帧 PDF + 半帧标注的状态肉眼可见。

解决方案:离屏 Canvas 合成

ts 复制代码
// renderPage 核心逻辑
const renderPage = useCallback(
  async (previewEl, hoveredDelId, hoveredFillId) => {
    if (!pdfDocument || !canvasRef.current || !isPdfJsLoaded) return
    try {
      const page = await pdfDocument.getPage(currentPage)
      const canvas = canvasRef.current
      const viewport = page.getViewport({ scale })

      // 关键:先渲染到离屏 Canvas
      const offscreen = document.createElement('canvas')
      offscreen.width = viewport.width
      offscreen.height = viewport.height
      const offCtx = offscreen.getContext('2d')!
      await page.render({ canvasContext: offCtx, viewport }).promise

      // 在离屏 Canvas 上叠加标注
      const pageElements = elements.filter(
        (el) => el.pageIndex === currentPage - 1
      )
      const delId = hoveredDelId !== undefined ? hoveredDelId : hoveredForDelete
      const fillHId = hoveredFillId !== undefined ? hoveredFillId : hoveredForFill
      drawElements(offCtx, pageElements, previewEl, delId, fillHId)

      // 一次性 blit 到主 Canvas ------ 用户看不到中间态
      canvas.width = viewport.width
      canvas.height = viewport.height
      const ctx = canvas.getContext('2d')!
      ctx.drawImage(offscreen, 0, 0)
    } catch (err) {
      console.error('renderPage error', err)
    }
  },
  [pdfDocument, currentPage, scale, elements, isPdfJsLoaded, drawElements, hoveredForDelete, hoveredForFill]
)

3 个性能优化点

  1. 离屏 Canvas 一次性 blitdrawImage(offscreen, 0, 0) 是 GPU 加速的位图拷贝,比逐个图形重绘快 10 倍。
  2. canvas.width = ... 一次性设置 。改 width / height重置整个 Canvas 上下文 (fillStyle 之类都重置),所以这步必须在 getContext 之前。
  3. previewEl 参数支持「绘制中预览」 。拖矩形时,矩形还没松开(没进 elements),但要实时显示------previewEl 是个 Partial<EditElement>,drawElements 会把真实元素 + 预览元素都画上。

四、撤销 / 重做:双数组栈模式

撤销重做的核心是「历史栈 + 索引」:

ts 复制代码
const [history, setHistory] = useState<EditElement[][]>([[]])
const [historyIndex, setHistoryIndex] = useState(0)

const addToHistory = useCallback(
  (newElements: EditElement[]) => {
    setHistory((prev) => {
      // 关键:截断当前位置之后的所有历史
      const trimmed = prev.slice(0, historyIndex + 1)
      return [...trimmed, [...newElements]]
    })
    setHistoryIndex((prev) => prev + 1)
  },
  [historyIndex]
)

const undo = useCallback(() => {
  if (historyIndex > 0) {
    const idx = historyIndex - 1
    setHistoryIndex(idx)
    setElements([...history[idx]])     // 拷贝一份,避免后续修改污染历史
  }
}, [history, historyIndex])

const redo = useCallback(() => {
  if (historyIndex < history.length - 1) {
    const idx = historyIndex + 1
    setHistoryIndex(idx)
    setElements([...history[idx]])
  }
}, [history, historyIndex])

4 个关键决策

  1. 截断未来prev.slice(0, historyIndex + 1)------撤销后新操作 必须丢弃 当前位置之后的历史,否则时间线会错乱(典型 bug:撤销 → 新增 → 重做 报错)。
  2. [...newElements] 深拷贝elements 数组是引用,直接 push 进去未来修改会污染历史
  3. historyEditElement[][]外层数组是历史快照内层数组是某一时刻的 elements 列表
  4. 撤销后要重新选 selectedElement这个工具没自动做 ------撤销时如果当前选中的元素被删了,红框还在但元素没了 。后续优化可以用 useEffect 监听 elements 清理 selectedElement

什么时候不该 addToHistory

性能优化点拖动过程中isDragging 持续触发)不应该每个像素都加历史 ------拖 100 个像素就是 100 条历史。正确做法

  • mousedown 不加历史;
  • mousemove 不加历史,只更新 selectedElement 的 x/y;
  • mouseup一次 历史(addToHistory(elements))。
ts 复制代码
// mouseup 时
if (isDragging && selectedElement) {
  setIsDragging(false)
  addToHistory(elements)   // 拖动结束,只加一次
  return
}

五、12 种工具实现

12 种工具(select / text / rectangle / circle / arrow / fill / highlight / underline / strikethrough / pen / eraser / delete)按交互模式分 3 类:

5.1 形状类(矩形 / 圆 / 箭头 / 高亮 / 下划线 / 删除线)

统一模式mousedown 记起点 → mousemovepreviewEl 实时显示 → mouseup 提交到 elements

ts 复制代码
// mousedown:记起点
setIsDrawing(true)
setDrawStart(pos)

// mousemove:实时画预览(不进 elements)
const dx = pos.x - drawStart.x, dy = pos.y - drawStart.y
renderPage({
  type: activeTool,
  x: Math.min(drawStart.x, pos.x),
  y: Math.min(drawStart.y, pos.y),
  width: Math.abs(dx),
  height: Math.abs(dy),
  color: strokeColor,
  fillColor,
  lineWidth,
  opacity: activeTool === 'highlight' ? highlightOpacity : 1,
})

// mouseup:提交到 elements
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return   // 过滤 5px 以下的"误触"
const newEl: EditElement = {
  id: generateId(),
  type: activeTool,
  x: Math.min(drawStart.x, pos.x),
  y: Math.min(drawStart.y, pos.y),
  width: Math.abs(dx),
  height: Math.abs(dy),
  // ... 其它字段
  pageIndex: currentPage - 1,
}
const newElements = [...elements, newEl]
setElements(newElements)
addToHistory(newElements)

Math.abs(dx) < 5 是必须的 ------否则用户误点 就会产生一个 0×0 的元素,导出会留下一个像素点

5.2 画笔 / 橡皮:路径点列表

和形状类不同 ,画笔需要记录所有路径点

ts 复制代码
// mousedown
if (activeTool === 'pen' || activeTool === 'eraser') {
  setIsDrawing(true)
  setPenPoints([pos])
  return
}

// mousemove:每帧追加一个点
const newPts = [...penPoints, pos]
setPenPoints(newPts)
const ctx = canvasRef.current.getContext('2d')
if (ctx && newPts.length >= 2) {
  // 关键:只画"最后一段"而不是整条路径 ------ 性能优化
  const last = newPts[newPts.length - 2]
  const w = activeTool === 'eraser' ? eraserWidth : lineWidth
  const col = activeTool === 'eraser' ? ERASER_COLOR : strokeColor
  ctx.save()
  ctx.strokeStyle = col
  ctx.lineWidth = w
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'
  ctx.beginPath()
  ctx.moveTo(last.x, last.y)
  ctx.lineTo(pos.x, pos.y)
  ctx.stroke()
  ctx.restore()
}

// mouseup:保存完整路径
const newEl: EditElement = {
  id: generateId(),
  type: 'pen',   // 或 'eraser'
  x: Math.min(...xs),
  y: Math.min(...ys),
  width: Math.max(...xs) - Math.min(...xs),
  height: Math.max(...ys) - Math.min(...ys),
  points: penPoints,
  color: strokeColor,
  lineWidth,
  pageIndex: currentPage - 1,
}

关键优化 :mousemove 时只画最后一段moveTo(last) → lineTo(pos)),不重画整条路径 ------100 点的画笔轨迹,每帧只画 1 段,性能提升 100 倍

5.3 橡皮擦的特殊逻辑:直接删除相交文字

橡皮擦不只是「画白色线条」------它真的删除与之相交的文字标注:

ts 复制代码
if (activeTool === 'eraser') {
  // 计算擦除轨迹的 AABB
  const eb = eraserStrokeBounds(penPoints, eraserWidth / 2)
  // 关键:过滤掉被擦除轨迹相交的文字
  const withoutHitText = elements.filter((el) => {
    if (el.pageIndex !== pIdx || el.type !== 'text') return true
    return !aabbIntersects(textAnnotationBounds(el), eb)
  })
  const merged = [...withoutHitText, newEl]
  setElements(merged)
}

为什么只对文字生效 :矩形、圆、箭头是几何形状 ,擦掉不优雅(会留下半截);文字是有语义的,擦掉代表"删除这段" ------只对文字做这个逻辑是有意识的设计选择

5.4 文字标注:浮层输入

文字工具最复杂------需要弹出浮层输入框

tsx 复制代码
{textPlacement && typeof document !== 'undefined' && createPortal(
  <input
    ref={textPlacementInputRef}
    type="text"
    value={textInput}
    onChange={(e) => setTextInput(e.target.value)}
    onBlur={() => confirmTextPlacement(textPlacement.sessionId)}
    onKeyDown={(e) => {
      if (e.key === 'Enter') { e.preventDefault(); confirmTextPlacement(textPlacement.sessionId) }
      if (e.key === 'Escape') { e.preventDefault(); cancelTextPlacement() }
    }}
    placeholder={t('common.textContent')}
    className="fixed z-[51] ..."
    style={(() => {
      // 关键:把画布坐标转换为视口坐标
      const canvas = canvasRef.current
      const rect = canvas?.getBoundingClientRect()
      const sx = rect && rect.width > 0 ? rect.width / canvas.width : 1
      const sy = rect && rect.height > 0 ? rect.height / canvas.height : 1
      const anchorLeft = rect ? rect.left + textPlacement.x * sx : textPlacement.viewX
      const anchorTop = rect ? rect.top + textPlacement.y * sy : textPlacement.viewY
      // 视口边界保护
      let left = Math.min(anchorLeft, window.innerWidth - inputW - 8)
      left = Math.max(8, left)
      let top = Math.min(anchorTop, window.innerHeight - inputH - 8)
      top = Math.max(8, top)
      return { left, top, width: inputW, fontSize: `${textPlacement.fontSize}px`, ... }
    })()}
  />,
  document.body
)}

3 个值得说的设计

  1. createPortaldocument.body用 absolute 定位会被父容器的 overflow: hidden 截断 ------Portal 出来 fixed 定位永远在视口
  2. 画布坐标 → 视口坐标rect.left + textPlacement.x * (rect.width / canvas.width) 这个公式考虑了缩放(scale)和滚动
  3. 视口边界保护Math.min(anchorLeft, window.innerWidth - inputW - 8) + Math.max(8, left)------输入框不能超出视口 。这种细节用户感知不到,但少了它滚动到边缘时输入框就飞出去了

文字浮层 Portal 到 body

5.5 fill(油漆桶):只对矩形/圆生效

ts 复制代码
if (activeTool === 'fill') {
  const clicked = [...pageEls].reverse().find((el) => isPointInFillableShape(el, pos))
  if (clicked) {
    const paintColor = fillColor !== 'transparent' ? fillColor : strokeColor
    const newElements = elements.map((el) =>
      el.id === clicked.id ? { ...el, fillColor: paintColor } : el
    )
    setElements(newElements)
    addToHistory(newElements)
    setSelectedElement(clicked.id)
  }
}

椭圆判定用归一化坐标

ts 复制代码
function isPointInFillableShape(el: EditElement, pos: PathPoint): boolean {
  if (el.type !== 'rectangle' && el.type !== 'circle') return false
  if (el.type === 'rectangle') {
    return pos.x >= el.x && pos.x <= el.x + el.width && pos.y >= el.y && pos.y <= el.y + el.height
  }
  // 椭圆:把点归一化到椭圆坐标系,x² + y² ≤ 1 即在内部
  const rx = el.width / 2, ry = el.height / 2
  const cx = el.x + rx, cy = el.y + ry
  const nx = (pos.x - cx) / rx
  const ny = (pos.y - cy) / ry
  return nx * nx + ny * ny <= 1
}

为什么不直接用浏览器 ctx.isPointInPath :那个是当前路径 判定,而我们要在已绘制的椭圆上判定------必须自己算。

12 种工具分组

六、导出 PDF:坐标转换 + pdf-lib 写回

这是整个工具的「最后一公里」 ------把 Canvas 上的标注重新画回 PDF 页面:

ts 复制代码
const exportPDF = useCallback(async () => {
  if (!pdfFile) return
  try {
    const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib')
    const bytes = await pdfFile.arrayBuffer()
    const pdfDoc = await PDFDocument.load(bytes)
    const pages = pdfDoc.getPages()
    const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
    const toRgb = (hex: string) => {
      const c = parseHexColor(hex)
      return rgb(c.r, c.g, c.b)
    }

    for (const elementRaw of elements) {
      // 关键:viewport 像素 → PDF 点的转换(÷ scale)
      const s = scale
      const element: EditElement = {
        ...elementRaw,
        x: elementRaw.x / s,
        y: elementRaw.y / s,
        width: elementRaw.width != null ? elementRaw.width / s : elementRaw.width,
        height: elementRaw.height != null ? elementRaw.height / s : elementRaw.height,
        fontSize: elementRaw.fontSize != null ? elementRaw.fontSize / s : elementRaw.fontSize,
        lineWidth: elementRaw.lineWidth != null ? elementRaw.lineWidth / s : elementRaw.lineWidth,
        points: elementRaw.points?.map((p) => ({ x: p.x / s, y: p.y / s })),
      }
      if (element.pageIndex >= pages.length) continue
      const page = pages[element.pageIndex]
      const { height: pH } = page.getSize()
      const elH = element.height ?? 0
      // 关键:Y 轴翻转。Canvas 原点在左上,PDF 原点在左下
      const pdfY = pH - element.y - elH
      const strokeRgb = toRgb(element.color ?? '#000000')
      const lw = element.lineWidth ?? 2

      switch (element.type) {
        case 'text': {
          page.drawText(element.content ?? '', {
            x: element.x,
            y: pH - element.y - (element.fontSize ?? 16),
            size: element.fontSize ?? 16,
            font,
            color: strokeRgb,
          })
          break
        }
        case 'rectangle': {
          const fillRgb = element.fillColor && element.fillColor !== 'transparent' ? toRgb(element.fillColor) : undefined
          page.drawRectangle({ x: element.x, y: pdfY, width: element.width ?? 0, height: elH, borderColor: strokeRgb, borderWidth: lw, color: fillRgb })
          break
        }
        case 'arrow': {
          // 画主线 + 两侧箭头线段
          const ax1 = element.x, ay1 = pH - element.y
          const ax2 = element.x + (element.width ?? 0), ay2 = pH - (element.y + elH)
          const headLen = 14, aAngle = Math.atan2(ay2 - ay1, ax2 - ax1)
          page.drawLine({ start: { x: ax1, y: ay1 }, end: { x: ax2, y: ay2 }, color: strokeRgb, thickness: lw })
          page.drawLine({ start: { x: ax2, y: ay2 }, end: { x: ax2 - headLen * Math.cos(aAngle - Math.PI / 6), y: ay2 - headLen * Math.sin(aAngle - Math.PI / 6) }, color: strokeRgb, thickness: lw })
          page.drawLine({ start: { x: ax2, y: ay2 }, end: { x: ax2 - headLen * Math.cos(aAngle + Math.PI / 6), y: ay2 - headLen * Math.sin(aAngle + Math.PI / 6) }, color: strokeRgb, thickness: lw })
          break
        }
        case 'pen': {
          const pts = element.points ?? []
          for (let i = 1; i < pts.length; i++) {
            page.drawLine({ start: { x: pts[i - 1].x, y: pH - pts[i - 1].y }, end: { x: pts[i].x, y: pH - pts[i].y }, color: strokeRgb, thickness: lw })
          }
          break
        }
        // ... 其它类型
      }
    }

    const savedBytes = await pdfDoc.save()
    const blob = new Blob([new Uint8Array(savedBytes)], { type: 'application/pdf' })
    const url = URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = `${pdfFile.name.replace(/\.pdf$/i, '')}-edited.pdf`
    link.click()
    URL.revokeObjectURL(url)
  } catch (error) {
    console.error('Export error:', error)
    showNotice(t('notices.exportError'))
  }
}, [elements, pdfFile, scale, showNotice, t])

5 个关键决策

  1. viewport 像素 → PDF 点element.x / scale------所有坐标都从「用户视口(scale 倍)」除以 scale 还原为 PDF 点。忘了一步位置全部错位
  2. Y 轴翻转 。Canvas 原点在左上,PDF 原点在左下。pdfY = pH - element.y - elH------少了这一步,矩形会出现在 PDF 顶部而不是原本位置
  3. StandardFonts.Helvetica 内置字体不嵌入 TTF ------pdf-libStandardFonts 是 14 种 PDF 1.7 内置字体(Helvetica / Times / Courier 等),无需嵌入、自动支持 。中文 PDF 标注需要嵌入字体,这个工具目前不支持 ------try { page.drawText(...) } catch { /* ignore font encoding issues */ } 这条 try-catch 就是兜底。
  4. drawLine 画笔 。pdf-lib 没有 drawPath API ------画笔路径必须拆成多段 drawLine 。100 点的画笔就是 99 个 drawLine 调用,效率不高但能用
  5. Y 轴翻转要每个 line 都做y: pH - pts[i - 1].y------路径里所有点都要翻转不是只在 switch 入口翻一次

颜色转换

ts 复制代码
const parseHexColor = (hex: string) => {
  const clean = hex.replace('#', '')
  return {
    r: parseInt(clean.slice(0, 2), 16) / 255,   // [0, 255] → [0, 1]
    g: parseInt(clean.slice(2, 4), 16) / 255,
    b: parseInt(clean.slice(4, 6), 16) / 255,
  }
}

pdf-librgb(r, g, b) 接收 0-1 范围------和 CSS 16 进制(0-255)的转换必须除以 255。

七、缩略图栏:右侧 0.3x 缩略图

右侧缩略图用 0.3 倍渲染 ------比 1x 渲染快 10 倍,视觉上够用

ts 复制代码
useEffect(() => {
  if (!pdfDocument || !isPdfJsLoaded || totalPages === 0) return
  let cancelled = false
  const thumbScale = 0.3
  ;(async () => {
    const urls: string[] = []
    for (let i = 1; i <= totalPages; i++) {
      if (cancelled) break   // 组件卸载或依赖变化时立即终止
      try {
        const page = await pdfDocument.getPage(i)
        const viewport = page.getViewport({ scale: thumbScale })
        const offscreen = document.createElement('canvas')
        offscreen.width = viewport.width
        offscreen.height = viewport.height
        const ctx = offscreen.getContext('2d')!
        await page.render({ canvasContext: ctx, viewport }).promise
        urls.push(offscreen.toDataURL('image/jpeg', 0.7))
      } catch {
        urls.push('')
      }
    }
    if (!cancelled) setThumbnailUrls(urls)
  })()
  return () => { cancelled = true }
}, [pdfDocument, isPdfJsLoaded, totalPages])

cancelled 标志位是必须的 ------useEffect cleanup 时设 cancelled = true循环里下一次迭代检测到立即 break ------避免 setState on unmounted 警告 + 节省无意义的渲染。

缩略图渲染示意

缩略图渲染核心要点

  1. 0.3x 渲染 ------getViewport({ scale: 0.3 })50 页的 PDF 在缩略图侧基本不卡
  2. 离屏 Canvas + toDataURL('image/jpeg', 0.7) ------JPEG 70% 质量 + 离屏,比 PNG 小 80%
  3. cancelled 标志位 (红色块)------这是项目里真实踩过的坑 :用户连续打开 2 个 PDF 时,第 1 个文件的缩略图循环还在 await,第 2 个文件已经触发了新的 useEffect ------不 cancel 就会 setState on unmounted 警告
  4. cleanup 流程 (靛蓝块)------React 18 Strict Mode 下 useEffect 会执行 2 次 ,cleanup 会被频繁调用,cancelled 是唯一的安全网
  5. try/catch 容错 (灰色块)------单页渲染失败不影响其他页,urls.push('') 占位

一句话总结scale = 0.3 + 离屏 Canvas + cancelled 标志位 = 一个能扛住 50 页 PDF 的缩略图条

八、触摸 / 键盘支持

触摸(移动端 / 平板)

ts 复制代码
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
  if (e.touches.length !== 1) return
  e.preventDefault()
  const synth = {
    clientX: e.touches[0].clientX,
    clientY: e.touches[0].clientY,
  } as React.MouseEvent<HTMLCanvasElement>
  handleMouseDown({ ...synth, currentTarget: e.currentTarget, target: e.target } as any)
}, [handleMouseDown])

为什么不直接给 <canvas> 加 onTouchStart 然后写一套独立的触摸处理复用鼠标事件处理逻辑 ------把 TouchEvent 包装成 MouseEvent一份代码两套入口前提是单指操作e.touches.length !== 1 直接 return)------多指缩放手势不在当前范围。

键盘快捷键

工具栏按钮的 title 属性提示快捷键(如 Ctrl+Z / Ctrl+Y),快捷键本身用 useEffect 监听 keydown 事件实现:

  • Ctrl/Cmd + Z → undo
  • Ctrl/Cmd + Shift + ZCtrl/Cmd + Y → redo
  • Delete / Backspace → 删除选中元素
  • Escape → 取消文字输入

九、可访问性 / 响应式 细节

  • 左侧工具栏 在 PC 端是竖排 ,移动端是横排底部 ------order-2 md:order-none + flex-row md:flex-col
  • 顶部操作栏 在 PC 是完整显示 (撤销 / 重做 / 打开 / 导出),移动端简化 (只显示图标 + 短文本)------<span className="hidden sm:inline"> 控制。
  • 所有按钮都是原生 <button>------Tab 键可聚焦、屏幕阅读器可识别。
  • hoveredForDelete 红色虚线 + hoveredForFill 绿色虚线 ------除了视觉提示,对色弱用户不够友好------可考虑加图标。

十、可扩展的方向

  • 中文 PDF 标注 :当前用 StandardFonts.Helvetica中文会乱码 。需要 pdfDoc.embedFont(await fetchFont(...)) 嵌入 TTF/OTF。
  • 公式标注 :用 KaTeX 渲染为 SVG 后转 PDF(pdf-lib 支持 drawSvgPath)。
  • 图章工具 :用户上传 PNG 图章,嵌入为 PDF 图片
  • 协作编辑 :用 Y.js / CRDT 同步多用户的标注状态。
  • PDF 表单字段编辑 :pdf-lib 支持 pdfDoc.getForm()理论上能加文本框/复选框

写在最后

PDF 编辑器 是个「看着高大上,做起来其实分块后每个都不难」的项目。核心就 3 件事:

  1. PDF.js 解析 + 渲染到 Canvas(10 行代码)
  2. Canvas 2D 绘制矢量元素(教科书级 API)
  3. pdf-lib 把元素写回 PDF(坐标转换 + Y 轴翻转)

真正的复杂度集中在「 交互细节**」** :撤销重做、触摸支持、键盘快捷键、缩略图懒加载、错误重试、内存释放......这些不是任何库能帮你做的,只能靠一遍遍踩坑 + 重构。

希望这篇文章能让你在面对「给 PDF 加点东西 」的需求时,少走 1 周弯路

技术栈速览

  • 前端框架:Next.js 14 (App Router)
  • 构建工具:Turbopack
  • 核心库:React 18 + TypeScript 5
  • PDF 渲染:PDF.js(CDN 按需加载 + 重试)
  • PDF 写入 :pdf-lib(按需 import
  • 画布:原生 Canvas 2D(无第三方库)
  • UI 组件:Tailwind CSS, shadcn/ui
  • 状态管理 :React useState / useCallback / useMemo
  • Portal :react-dom createPortal(文字输入浮层)
  • 图标:lucide-react
相关推荐
December3103 个月前
借助哪些软件工具可以实现在PDF上做批注?
pdf·pdf编辑·pdf批注·pdf注释
麻辣翅尖5 个月前
【vue】基于 pdf.js 实现 pdf 文件预览
vue.js·pdf.js
极智-9966 个月前
pdf怎么打开?【图文详解】免费的pdf阅读编辑器?pdf文件转换?
pdf·pdf转换·pdf转word·pdf编辑·pdf文件怎么打开·pdf阅读编辑·pdf文件阅读
sinat_333518877 个月前
如何高效处理日常 PDF 文档?
pdf转换·pdf合并·电子签名·pdf编辑
sinat_333518878 个月前
深入解析如何高效处理PDF?
pdf转换·pdf编辑·文档加密·pdf对比·pdf页面管理
sinat_333518878 个月前
适用于自动化脚本的PDF查看器?
pdf编辑·办公效率·pdf阅读器
程序视点9 个月前
PDF格式转换、PDF编辑全功能解锁,功能图文教程
pdf·pdf压缩·pdf合并·pdf编辑·pdf格式转换
sinat_333518879 个月前
一个真正跨平台可用的免费PDF解决方案
pdf转换·pdf加密·pdf解密·pdf编辑
不老刘10 个月前
解决 pdf.mjs 因 MIME 类型错误导致的模块加载失败问题
es6·pdf.js·mjs