vue+pdf.js预览本地pdf文件(可以复制文本,滚动页码展示)

vue+pdf.js预览本地pdf文件(可以复制文本,滚动页码展示)

一、背景与问题

在现代Web应用中,处理PDF文件是一项常见需求。传统方案通常依赖浏览器内置的PDF查看器,但存在以下问题:

  1. 无法实现文本复制功能
  2. 无法自定义页码滚动逻辑
  3. 无法访问PDF内部结构(如文本层)
  4. 无法在无服务器环境下处理本地文件

为解决这些问题,我们采用pdf.js(Mozilla开发的PDF查看器库)实现本地PDF的深度处理。本文将详细讲解如何在Vue项目中实现PDF预览功能,包括文本复制、页码滚动等高级特性。

二、基本原理

pdf.js的核心原理是通过解析PDF文件的Content Stream,将PDF内容转换为可渲染的HTML元素。其关键机制包括:

  1. PDF文件解析:通过PDF.js的getDocument方法加载PDF文件,解析其内容流(Content Stream)
  2. 文本层提取:使用getOperatorList获取文本内容,构建文本层(Text Layer)
  3. Canvas渲染:通过Canvas元素绘制PDF页面,支持缩放和滚动
  4. 文本选择:通过文本层的坐标信息实现文本复制功能

三、环境准备

  1. 安装依赖:

    npm install pdfjs-dist
  2. 配置Vue项目:

    // main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    import * as pdfjsLib from 'pdfjs-dist'
    
    const app = createApp(App)
    app.config.globalProperties.pdfjsLib = pdfjsLib
    app.mount('#app')
  3. 设置PDF.js配置(建议使用2.x版本):

    // pdf.config.js
    pdfjsLib.GlobalWorkerOptions.workerSrc = 
      'https://unpkg.com/pdfjs-dist@2.x/build/pdf.worker.min.js'

四、核心实现

1. 文件上传与读取

<template>
  <div>
    <input type="file" @change="handleFileUpload" accept="application/pdf" />
    <div ref="pdfContainer" class="pdf-container"></div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      pdfDocument: null,
      currentPageNum: 1,
      scale: 1.5
    }
  },
  methods: {
    handleFileUpload(event) {
      const file = event.target.files[0]
      if (!file) return
      
      const reader = new FileReader()
      reader.onload = this.loadPDF
      reader.readAsArrayBuffer(file)
    },
    loadPDF(event) {
      const arrayBuffer = event.target.result
      const loadingTask = this.pdfjsLib.getDocument({ 
        data: arrayBuffer,
        cMapUrl: 'https://unpkg.com/pdfjs-dist@cmap/2.0.337/cmaps/',
        cMapPacked: true
      })
      
      loadingTask.promise.then((pdf) => {
        this.pdfDocument = pdf
        this.renderPage(this.currentPageNum)
      })
    }
  }
}
</script>

2. 页面渲染与文本层

renderPage(pageNum) {
  if (!this.pdfDocument) return
  
  const page = this.pdfDocument.getPage(pageNum)
  page.then((page) => {
    const viewport = page.getViewport({ scale: this.scale })
    
    // 创建Canvas元素
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    canvas.height = viewport.height
    canvas.width = viewport.width
    
    // 渲染页面
    const renderContext = {
      canvasContext: context,
      viewport: viewport
    }
    page.render(renderContext)
    
    // 创建文本层
    const textLayer = this.createTextLayer(page)
    this.$refs.pdfContainer.innerHTML = ''
    this.$refs.pdfContainer.appendChild(canvas)
    this.$refs.pdfContainer.appendChild(textLayer)
    
    // 添加文本选择事件
    this.addTextSelectEvent(canvas, textLayer)
  })
}

createTextLayer(page) {
  const textLayer = document.createElement('div')
  textLayer.className = 'text-layer'
  
  const textContent = page.getTextContent()
  textContent.promise.then((textContent) => {
    textContent.items.forEach(item => {
      const span = document.createElement('span')
      span.textContent = item.str
      span.style.left = `${item.transform[4]}px`
      span.style.top = `${item.transform[5]}px`
      span.style.position = 'absolute'
      span.style.whiteSpace = 'pre'
      textLayer.appendChild(span)
    })
  })
  
  return textLayer
}

addTextSelectEvent(canvas, textLayer) {
  canvas.addEventListener('mousedown', (e) => {
    const rect = canvas.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    
    // 查找文本层中的选中文本
    const selectedText = this.findSelectedText(x, y, textLayer)
    if (selectedText) {
      navigator.clipboard.writeText(selectedText)
      alert('已复制文本:' + selectedText)
    }
  })
}

findSelectedText(x, y, textLayer) {
  let selectedText = ''
  const spans = textLayer.querySelectorAll('span')
  
  for (const span of spans) {
    const spanRect = span.getBoundingClientRect()
    if (x >= spanRect.left && x <= spanRect.right && 
        y >= spanRect.top && y <= spanRect.bottom) {
      selectedText = span.textContent
      break
    }
  }
  
  return selectedText
}

3. 页码滚动控制

scrollToPage(pageNum) {
  if (this.currentPageNum === pageNum) return
  
  this.currentPageNum = pageNum
  this.renderPage(pageNum)
}

handleScroll(event) {
  const scrollTop = event.target.scrollTop
  const pageHeight = this.$refs.pdfContainer.clientHeight
  const scrollRatio = scrollTop / pageHeight
  
  // 根据滚动位置计算当前页码
  this.currentPageNum = Math.floor(scrollRatio * this.pdfDocument.numPages) + 1
  this.renderPage(this.currentPageNum)
}

五、完整案例

1. 项目结构

src/
├── components/
│   └── PdfViewer.vue
├── App.vue
├── main.js
└── pdf.config.js

2. PdfViewer.vue完整代码

<template>
  <div class="pdf-viewer">
    <input type="file" @change="handleFileUpload" accept="application/pdf" />
    <div ref="pdfContainer" class="pdf-container" @scroll="handleScroll"></div>
    <div class="page-controls">
      <button @click="scrollToPage(1)">首页</button>
      <button @click="scrollToPage(2)">上一页</button>
      <button @click="scrollToPage(3)">下一页</button>
      <button @click="scrollToPage(this.pdfDocument.numPages)">末页</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      pdfDocument: null,
      currentPageNum: 1,
      scale: 1.5
    }
  },
  methods: {
    handleFileUpload(event) {
      const file = event.target.files[0]
      if (!file) return
      
      const reader = new FileReader()
      reader.onload = this.loadPDF
      reader.readAsArrayBuffer(file)
    },
    loadPDF(event) {
      const arrayBuffer = event.target.result
      const loadingTask = this.pdfjsLib.getDocument({ 
        data: arrayBuffer,
        cMapUrl: 'https://unpkg.com/pdfjs-dist@cmap/2.0.337/cmaps/',
        cMapPacked: true
      })
      
      loadingTask.promise.then((pdf) => {
        this.pdfDocument = pdf
        this.renderPage(this.currentPageNum)
      })
    },
    renderPage(pageNum) {
      if (!this.pdfDocument) return
      
      const page = this.pdfDocument.getPage(pageNum)
      page.then((page) => {
        const viewport = page.getViewport({ scale: this.scale })
        
        // 创建Canvas元素
        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')
        canvas.height = viewport.height
        canvas.width = viewport.width
        
        // 渲染页面
        const renderContext = {
          canvasContext: context,
          viewport: viewport
        }
        page.render(renderContext)
        
        // 创建文本层
        const textLayer = this.createTextLayer(page)
        this.$refs.pdfContainer.innerHTML = ''
        this.$refs.pdfContainer.appendChild(canvas)
        this.$refs.pdfContainer.appendChild(textLayer)
        
        // 添加文本选择事件
        this.addTextSelectEvent(canvas, textLayer)
      })
    },
    createTextLayer(page) {
      const textLayer = document.createElement('div')
      textLayer.className = 'text-layer'
      
      const textContent = page.getTextContent()
      textContent.promise.then((textContent) => {
        textContent.items.forEach(item => {
          const span = document.createElement('span')
          span.textContent = item.str
          span.style.left = `${item.transform[4]}px`
          span.style.top = `${item.transform[5]}px`
          span.style.position = 'absolute'
          span.style.whiteSpace = 'pre'
          textLayer.appendChild(span)
        })
      })
      
      return textLayer
    },
    addTextSelectEvent(canvas, textLayer) {
      canvas.addEventListener('mousedown', (e) => {
        const rect = canvas.getBoundingClientRect()
        const x = e.clientX - rect.left
        const y = e.clientY - rect.top
        
        // 查找文本层中的选中文本
        const selectedText = this.findSelectedText(x, y, textLayer)
        if (selectedText) {
          navigator.clipboard.writeText(selectedText)
          alert('已复制文本:' + selectedText)
        }
      })
    },
    findSelectedText(x, y, textLayer) {
      let selectedText = ''
      const spans = textLayer.querySelectorAll('span')
      
      for (const span of spans) {
        const spanRect = span.getBoundingClientRect()
        if (x >= spanRect.left && x <= spanRect.right && 
            y >= spanRect.top && y <= spanRect.bottom) {
          selectedText = span.textContent
          break
        }
      }
      
      return selectedText
    },
    scrollToPage(pageNum) {
      if (this.currentPageNum === pageNum) return
      
      this.currentPageNum = pageNum
      this.renderPage(this.currentPageNum)
    },
    handleScroll(event) {
      const scrollTop = event.target.scrollTop
      const pageHeight = this.$refs.pdfContainer.clientHeight
      const scrollRatio = scrollTop / pageHeight
      
      // 根据滚动位置计算当前页码
      this.currentPageNum = Math.floor(scrollRatio * this.pdfDocument.numPages) + 1
      this.renderPage(this.currentPageNum)
    }
  }
}
</script>

<style scoped>
.pdf-viewer {
  padding: 20px;
  font-family: sans-serif;
}

.pdf-container {
  width: 100%;
  height: 600px;
  overflow: auto;
  border: 1px solid #ccc;
  position: relative;
}

.text-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: auto;
  z-index: 10;
}

.page-controls {
  margin-top: 10px;
}
</style>

六、源码解析

1. PDF解析流程

const loadingTask = this.pdfjsLib.getDocument({ 
  data: arrayBuffer,
  cMapUrl: 'https://unpkg.com/pdfjs-dist@cmap/2.0.337/cmaps/',
  cMapPacked: true
})
  • 使用getDocument方法创建加载任务
  • 指定cMap资源路径(字体映射文件)
  • 通过cMapPacked: true启用压缩字体映射

2. 文本层创建

textContent.items.forEach(item => {
  const span = document.createElement('span')
  span.textContent = item.str
  span.style.left = `${item.transform[4]}px`
  span.style.top = `${item.transform[5]}px`
  span.style.position = 'absolute'
  span.style.whiteSpace = 'pre'
  textLayer.appendChild(span)
})
  • 从getTextContent()获取文本项数组
  • 通过transform数组计算文本位置
  • 使用绝对定位创建文本层,实现文本复制功能

3. 文本选择逻辑

findSelectedText(x, y, textLayer) {
  let selectedText = ''
  const spans = textLayer.querySelectorAll('span')
  
  for (const span of spans) {
    const spanRect = span.getBoundingClientRect()
    if (x >= spanRect.left && x <= spanRect.right && 
        y >= spanRect.top && y <= spanRect.bottom) {
      selectedText = span.textContent
      break
    }
  }
  
  return selectedText
}
  • 通过鼠标坐标定位文本层中的文本
  • 支持选择任意文本区域
  • 使用navigator.clipboard.writeText实现复制功能

七、进阶使用

1. 文本搜索功能

searchText(text) {
  if (!this.pdfDocument) return
  
  const promises = []
  for (let pageNum = 1; pageNum <= this.pdfDocument.numPages; pageNum++) {
    promises.push(new Promise((resolve) => {
      this.pdfDocument.getPage(pageNum).then((page) => {
        page.getTextContent().then((textContent) => {
          const found = this.findTextInPage(text, textContent)
          resolve(found)
        })
      })
    }))
  }
  
  Promise.all(promises).then(results => {
    const matches = results.flat().filter(Boolean)
    if (matches.length) {
      alert('找到匹配文本:' + matches.join(', '))
    } else {
      alert('未找到匹配文本')
    }
  })
}

findTextInPage(text, textContent) {
  let found = []
  
  textContent.items.forEach(item => {
    const match = item.str.match(new RegExp(text, 'gi'))
    if (match) {
      found.push(item.str)
    }
  })
  
  return found
}

2. 打印功能

printPDF() {
  if (!this.pdfDocument) return
  
  const printWindow = window.open('', '_blank')
  printWindow.document.write(`
    <html>
      <head>
        <title>PDF打印</title>
        <style>
          body { margin: 0; }
          canvas { display: block; }
        </style>
      </head>
      <body>
        <div id="pdf-print"></div>
      </body>
    </html>
  `)
  
  const printContainer = printWindow.document.getElementById('pdf-print')
  
  for (let pageNum = 1; pageNum <= this.pdfDocument.numPages; pageNum++) {
    this.renderPageForPrint(pageNum, printContainer)
  }
  
  printWindow.document.close()
  printWindow.print()
}

renderPageForPrint(pageNum, container) {
  const page = this.pdfDocument.getPage(pageNum)
  page.then((page) => {
    const viewport = page.getViewport({ scale: 1.5 })
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    canvas.height = viewport.height
    canvas.width = viewport.width
    
    const renderContext = {
      canvasContext: context,
      viewport: viewport
    }
    page.render(renderContext)
    
    container.appendChild(canvas)
  })
}

八、性能与工程实践

1. 渲染性能优化

  1. Canvas重用:避免频繁创建和销毁Canvas元素
  2. 缩放控制:通过scale参数控制渲染密度
  3. 异步加载:使用Promise处理PDF加载和渲染
  4. 内存管理:在页面切换时及时释放资源

2. 安全风险

  1. 文件类型验证:确保上传文件为PDF格式
  2. 内存限制:处理大PDF文件时注意内存占用
  3. 沙箱环境:考虑在Web Worker中处理PDF解析
  4. XSS防护:对文本内容进行转义处理

3. 性能优化方案

  1. 懒加载:只加载当前可见页面
  2. 分页缓存:缓存最近使用的页面
  3. 压缩渲染:使用pdfjs-dist的canvas渲染模式
  4. 异步渲染:使用render方法的progress回调

九、常见问题与踩坑

1. 常见错误及解决办法

问题描述解决方案
1. PDF无法加载文件路径错误检查workerSrc配置
2. 文本无法复制文本层定位错误确保文本层位置正确
3. 页面不滚动滚动事件未绑定添加@scroll事件监听
4. 渲染不流畅过度重绘使用requestAnimationFrame优化
5. 字体显示异常缺少cMap资源指定正确的cMap路径

2. 常见坑点

  1. 版本兼容性:pdf.js 2.x与3.x API差异
  2. 字体渲染:部分字体可能需要额外配置
  3. 跨域问题:本地文件加载时的跨域限制
  4. 内存泄漏:未正确释放PDF文档资源
  5. 文本定位:文本层坐标计算误差

十、最佳实践

  1. 适用场景:

    • 需要深度处理PDF文件的业务
    • 需要文本复制功能的文档预览
    • 需要自定义PDF渲染逻辑的场景
  2. 注意事项:

    • 避免处理超大PDF文件(建议控制在50MB以内)
    • 对敏感PDF文件进行内容过滤
    • 考虑使用Web Worker处理PDF解析
    • 在移动端优化渲染性能
  3. 推荐方案:

    • 使用pdfjs-dist的Canvas渲染模式
    • 结合text-layer实现文本选择
    • 使用getOperatorList获取文本内容
    • 使用getViewport控制渲染密度

十一、总结

本文深入探讨了在Vue项目中使用pdf.js实现PDF预览的完整方案。通过解析PDF文件、创建文本层、实现文本复制和页码滚动等功能,我们能够实现高度定制的PDF预览功能。需要注意的是,这种方案适用于需要深度处理PDF的场景,但不适合处理超大文件或需要服务器端处理的场景。在实际开发中,应根据具体需求选择合适的PDF处理方案,同时注意性能优化和安全防护。通过合理的设计和实现,我们可以构建出功能完善、性能优越的PDF预览系统。

评论已关闭

推荐阅读

AIGC实战——Transformer模型
2024年12月01日
Socket TCP 和 UDP 编程基础(Python)
2024年11月30日
python , tcp , udp
如何使用 ChatGPT 进行学术润色?你需要这些指令
2024年12月01日
AI
最新 Python 调用 OpenAi 详细教程实现问答、图像合成、图像理解、语音合成、语音识别(详细教程)
2024年11月24日
ChatGPT 和 DALL·E 2 配合生成故事绘本
2024年12月01日
omegaconf,一个超强的 Python 库!
2024年11月24日
【视觉AIGC识别】误差特征、人脸伪造检测、其他类型假图检测
2024年12月01日
[超级详细]如何在深度学习训练模型过程中使用 GPU 加速
2024年11月29日
Python 物理引擎pymunk最完整教程
2024年11月27日
MediaPipe 人体姿态与手指关键点检测教程
2024年11月27日
深入了解 Taipy:Python 打造 Web 应用的全面教程
2024年11月26日
基于Transformer的时间序列预测模型
2024年11月25日
Python在金融大数据分析中的AI应用(股价分析、量化交易)实战
2024年11月25日
AIGC Gradio系列学习教程之Components
2024年12月01日
Python3 `asyncio` — 异步 I/O,事件循环和并发工具
2024年11月30日
llama-factory SFT系列教程:大模型在自定义数据集 LoRA 训练与部署
2024年12月01日
Python 多线程和多进程用法
2024年11月24日
Python socket详解,全网最全教程
2024年11月27日
python之plot()和subplot()画图
2024年11月26日
理解 DALL·E 2、Stable Diffusion 和 Midjourney 工作原理
2024年12月01日