vue+pdf.js预览本地pdf文件(可以复制文本,滚动页码展示)
vue+pdf.js预览本地pdf文件(可以复制文本,滚动页码展示)
一、背景与问题
在现代Web应用中,处理PDF文件是一项常见需求。传统方案通常依赖浏览器内置的PDF查看器,但存在以下问题:
- 无法实现文本复制功能
- 无法自定义页码滚动逻辑
- 无法访问PDF内部结构(如文本层)
- 无法在无服务器环境下处理本地文件
为解决这些问题,我们采用pdf.js(Mozilla开发的PDF查看器库)实现本地PDF的深度处理。本文将详细讲解如何在Vue项目中实现PDF预览功能,包括文本复制、页码滚动等高级特性。
二、基本原理
pdf.js的核心原理是通过解析PDF文件的Content Stream,将PDF内容转换为可渲染的HTML元素。其关键机制包括:
- PDF文件解析:通过PDF.js的
getDocument方法加载PDF文件,解析其内容流(Content Stream) - 文本层提取:使用
getOperatorList获取文本内容,构建文本层(Text Layer) - Canvas渲染:通过Canvas元素绘制PDF页面,支持缩放和滚动
- 文本选择:通过文本层的坐标信息实现文本复制功能
三、环境准备
安装依赖:
npm install pdfjs-dist配置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')设置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.js2. 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. 渲染性能优化
- Canvas重用:避免频繁创建和销毁Canvas元素
- 缩放控制:通过
scale参数控制渲染密度 - 异步加载:使用
Promise处理PDF加载和渲染 - 内存管理:在页面切换时及时释放资源
2. 安全风险
- 文件类型验证:确保上传文件为PDF格式
- 内存限制:处理大PDF文件时注意内存占用
- 沙箱环境:考虑在Web Worker中处理PDF解析
- XSS防护:对文本内容进行转义处理
3. 性能优化方案
- 懒加载:只加载当前可见页面
- 分页缓存:缓存最近使用的页面
- 压缩渲染:使用
pdfjs-dist的canvas渲染模式 - 异步渲染:使用
render方法的progress回调
九、常见问题与踩坑
1. 常见错误及解决办法
| 问题 | 描述 | 解决方案 |
|---|---|---|
| 1. PDF无法加载 | 文件路径错误 | 检查workerSrc配置 |
| 2. 文本无法复制 | 文本层定位错误 | 确保文本层位置正确 |
| 3. 页面不滚动 | 滚动事件未绑定 | 添加@scroll事件监听 |
| 4. 渲染不流畅 | 过度重绘 | 使用requestAnimationFrame优化 |
| 5. 字体显示异常 | 缺少cMap资源 | 指定正确的cMap路径 |
2. 常见坑点
- 版本兼容性:pdf.js 2.x与3.x API差异
- 字体渲染:部分字体可能需要额外配置
- 跨域问题:本地文件加载时的跨域限制
- 内存泄漏:未正确释放PDF文档资源
- 文本定位:文本层坐标计算误差
十、最佳实践
适用场景:
- 需要深度处理PDF文件的业务
- 需要文本复制功能的文档预览
- 需要自定义PDF渲染逻辑的场景
注意事项:
- 避免处理超大PDF文件(建议控制在50MB以内)
- 对敏感PDF文件进行内容过滤
- 考虑使用Web Worker处理PDF解析
- 在移动端优化渲染性能
推荐方案:
- 使用
pdfjs-dist的Canvas渲染模式 - 结合
text-layer实现文本选择 - 使用
getOperatorList获取文本内容 - 使用
getViewport控制渲染密度
- 使用
十一、总结
本文深入探讨了在Vue项目中使用pdf.js实现PDF预览的完整方案。通过解析PDF文件、创建文本层、实现文本复制和页码滚动等功能,我们能够实现高度定制的PDF预览功能。需要注意的是,这种方案适用于需要深度处理PDF的场景,但不适合处理超大文件或需要服务器端处理的场景。在实际开发中,应根据具体需求选择合适的PDF处理方案,同时注意性能优化和安全防护。通过合理的设计和实现,我们可以构建出功能完善、性能优越的PDF预览系统。
评论已关闭