PdfPage 组件使用说明
组件概述
PdfPage 是一个用于渲染 PDF 页面并支持关键字高亮的 Vue 组件。它基于 vue-pdf-embed 库,提供了跨 span 的关键字高亮功能。
组件特性
- ✅ PDF 页面渲染
- ✅ 关键字高亮(支持跨 span)
- ✅ 同一页中多个匹配的高亮
- ✅ 自动处理高度溢出问题
- ✅ 响应式更新:关键字变化时自动重新高亮
组件源代码
PdfPage.vue
vue
<template>
<div class="pdf-page-container" :data-page-key="pageKey">
<VuePdfEmbed
:source="source"
:page="page"
text-layer
class="pdf-page"
@rendered="onPageRendered"
/>
</div>
</template>
<script setup>
import { watch, nextTick } from 'vue'
import VuePdfEmbed from 'vue-pdf-embed'
const props = defineProps({
source: {
type: String,
required: true
},
page: {
type: Number,
required: true
},
pageKey: {
type: String,
required: true
},
keyword: {
type: String,
default: ''
}
})
// PDF页面渲染完成事件
const onPageRendered = (event) => {
// DOM 渲染完再高亮,使用重试机制确保text-layer已渲染
retryHighlight(5)
}
// 重试高亮,直到找到textLayer
const retryHighlight = (maxRetries = 5, delay = 200) => {
let retries = 0
const tryHighlight = () => {
const pageContainer = document.querySelector(`[data-page-key="${props.pageKey}"]`)
if (!pageContainer) {
if (retries < maxRetries) {
retries++
setTimeout(tryHighlight, delay)
}
return
}
// 直接查找textLayer
const textLayer = pageContainer.querySelector('.textLayer')
if (textLayer && textLayer.querySelectorAll('span').length > 0) {
highlightTextInLayer(textLayer)
} else {
if (retries < maxRetries) {
retries++
setTimeout(tryHighlight, delay)
}
}
}
setTimeout(tryHighlight, delay)
}
// 在textLayer中高亮文字
const highlightTextInLayer = (textLayer) => {
if (!props.keyword) {
// 如果没有关键字,清除所有高亮
textLayer.querySelectorAll('.pdf-keyword-highlight').forEach((el) => el.remove())
textLayer.querySelectorAll('span').forEach((span) => {
if (span.style.background === 'red') {
span.style.background = ''
}
})
return
}
// 先清除之前的高亮层
textLayer.querySelectorAll('.pdf-keyword-highlight').forEach((el) => el.remove())
// 清除之前设置的背景色
textLayer.querySelectorAll('span').forEach((span) => {
if (span.style.background === 'red') {
span.style.background = ''
}
})
// 查找所有span元素
const spans = Array.from(textLayer.querySelectorAll('span'))
// 处理跨span的关键字高亮
highlightKeywordAcrossSpans(spans, props.keyword, textLayer)
}
// 跨span高亮关键字
const highlightKeywordAcrossSpans = (spans, keyword, textLayer) => {
// 收集所有span的文本和位置信息
const spanInfos = spans.map((span, index) => {
const text = span.textContent || ''
return {
span,
text,
index,
start: 0, // 在整个文本中的起始位置(字符索引)
end: 0 // 在整个文本中的结束位置
}
})
// 计算每个span在整个文本中的位置
let currentPos = 0
spanInfos.forEach((info) => {
info.start = currentPos
currentPos += info.text.length
info.end = currentPos
})
// 将所有文本连接起来
const fullText = spanInfos.map((info) => info.text).join('')
// 查找所有匹配的关键字位置
const keywordMatches = findAllMatches(fullText, keyword)
if (keywordMatches.length === 0) {
// 如果关键字不在完整文本中,尝试在每个span中单独查找
spans.forEach((span) => {
const text = span.textContent || ''
if (text === keyword) {
span.style.background = 'red'
} else if (text.includes(keyword)) {
highlightKeywordInSpan(span, keyword, textLayer)
}
})
return
}
// 为每个匹配的关键字创建高亮
keywordMatches.forEach((match) => {
const keywordIndex = match.index
const keywordEnd = keywordIndex + keyword.length
// 找到关键字跨越的span
const affectedSpans = spanInfos.filter(
(info) => keywordIndex < info.end && keywordEnd > info.start
)
// 为每个受影响的span创建高亮
affectedSpans.forEach((info, idx) => {
const spanStartInKeyword = Math.max(0, keywordIndex - info.start)
const spanEndInKeyword = Math.min(info.text.length, keywordEnd - info.start)
const spanKeywordText = info.text.substring(spanStartInKeyword, spanEndInKeyword)
if (spanKeywordText.length > 0) {
highlightKeywordPartInSpan(info.span, spanKeywordText, spanStartInKeyword, textLayer)
}
})
})
}
// 查找所有匹配的关键字位置
const findAllMatches = (text, keyword) => {
const matches = []
let searchIndex = 0
while (true) {
const index = text.indexOf(keyword, searchIndex)
if (index === -1) break
matches.push({ index, keyword })
searchIndex = index + 1 // 继续查找下一个匹配
}
return matches
}
// 在span中高亮关键字的特定部分
const highlightKeywordPartInSpan = (span, keywordPart, startIndex, textLayer) => {
const text = span.textContent || ''
if (startIndex < 0 || startIndex + keywordPart.length > text.length) return
// 获取span的样式信息
const spanStyle = window.getComputedStyle(span)
// 获取字体大小和字体族
const fontSize = parseFloat(spanStyle.fontSize) || parseFloat(span.style.fontSize) || 12
const fontFamily = spanStyle.fontFamily || span.style.fontFamily || 'sans-serif'
// 获取span的left和top(从style属性中获取)
const spanLeft = parseFloat(span.style.left) || 0
const spanTop = parseFloat(span.style.top) || 0
// 考虑transform scaleX的影响
const transform = spanStyle.transform
let scaleX = 1
if (transform && transform.includes('scaleX')) {
const match = transform.match(/scaleX\(([^)]+)\)/)
if (match) {
scaleX = parseFloat(match[1])
}
}
// 创建一个临时的span来测量文本宽度
const tempSpan = span.cloneNode(true)
tempSpan.style.visibility = 'hidden'
tempSpan.style.position = 'absolute'
tempSpan.style.left = '-9999px'
document.body.appendChild(tempSpan)
// 计算关键字部分前面的文本宽度
const beforeText = text.substring(0, startIndex)
const beforeWidth = getTextWidth(beforeText, fontFamily, fontSize, tempSpan)
// 计算关键字部分的宽度
const keywordWidth = getTextWidth(keywordPart, fontFamily, fontSize, tempSpan)
document.body.removeChild(tempSpan)
// 获取span的实际高度
const spanRect = span.getBoundingClientRect()
const spanHeight = spanRect.height || fontSize
// 使用稍微小一点的高度,避免底部溢出
const highlightHeight = Math.min(spanHeight, fontSize * 1.1)
// 创建高亮覆盖层
const highlight = document.createElement('div')
highlight.className = 'pdf-keyword-highlight'
highlight.style.position = 'absolute'
highlight.style.left = `${spanLeft + beforeWidth * scaleX}px`
highlight.style.top = `${spanTop}px`
highlight.style.width = `${keywordWidth * scaleX}px`
highlight.style.height = `${highlightHeight}px`
highlight.style.background = 'red'
highlight.style.pointerEvents = 'none'
highlight.style.zIndex = '1'
textLayer.appendChild(highlight)
}
// 在span中精确高亮关键字
const highlightKeywordInSpan = (span, keyword, textLayer) => {
const text = span.textContent || ''
const keywordIndex = text.indexOf(keyword)
if (keywordIndex === -1) return
// 获取span的样式信息
const spanStyle = window.getComputedStyle(span)
// 获取字体大小和字体族
const fontSize = parseFloat(spanStyle.fontSize) || parseFloat(span.style.fontSize) || 12
const fontFamily = spanStyle.fontFamily || span.style.fontFamily || 'sans-serif'
// 获取span的left和top(从style属性中获取)
const spanLeft = parseFloat(span.style.left) || 0
const spanTop = parseFloat(span.style.top) || 0
// 考虑transform scaleX的影响
const transform = spanStyle.transform
let scaleX = 1
if (transform && transform.includes('scaleX')) {
const match = transform.match(/scaleX\(([^)]+)\)/)
if (match) {
scaleX = parseFloat(match[1])
}
}
// 如果span没有文本节点子元素,说明文本直接在span中
const textNode = span.firstChild
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
// 获取span的实际高度
const spanRect = span.getBoundingClientRect()
const spanHeight = spanRect.height || fontSize
// 使用稍微小一点的高度,避免底部溢出
const highlightHeight = Math.min(spanHeight, fontSize * 1.1)
// 创建一个临时的文本节点来测量
const tempSpan = span.cloneNode(true)
tempSpan.style.visibility = 'hidden'
tempSpan.style.position = 'absolute'
tempSpan.style.left = '-9999px'
document.body.appendChild(tempSpan)
// 计算关键字前面的文本宽度
const beforeText = text.substring(0, keywordIndex)
const beforeWidth = getTextWidth(beforeText, fontFamily, fontSize, tempSpan)
// 计算关键字的宽度
const keywordWidth = getTextWidth(keyword, fontFamily, fontSize, tempSpan)
document.body.removeChild(tempSpan)
// 创建高亮覆盖层
const highlight = document.createElement('div')
highlight.className = 'pdf-keyword-highlight'
highlight.style.position = 'absolute'
highlight.style.left = `${spanLeft + beforeWidth * scaleX}px`
highlight.style.top = `${spanTop}px`
highlight.style.width = `${keywordWidth * scaleX}px`
highlight.style.height = `${highlightHeight}px`
highlight.style.background = 'red'
highlight.style.pointerEvents = 'none'
highlight.style.zIndex = '1'
textLayer.appendChild(highlight)
return
}
// 使用Range API获取精确位置
try {
const range = document.createRange()
// 设置range为关键字前面的文本
range.setStart(textNode, 0)
range.setEnd(textNode, keywordIndex)
const beforeRect = range.getBoundingClientRect()
// 设置range为关键字文本
range.setStart(textNode, keywordIndex)
range.setEnd(textNode, keywordIndex + keyword.length)
const keywordRect = range.getBoundingClientRect()
// 计算相对于textLayer的位置
const beforeWidth = beforeRect.width
const keywordWidth = keywordRect.width
// 使用实际测量的高度,优先使用keywordRect的高度,确保不溢出
const spanRect = span.getBoundingClientRect()
const keywordHeight = keywordRect.height || spanRect.height || fontSize
// 使用稍微小一点的高度,避免底部溢出
const highlightHeight = Math.min(keywordHeight, fontSize * 1.1)
// 创建高亮覆盖层
const highlight = document.createElement('div')
highlight.className = 'pdf-keyword-highlight'
highlight.style.position = 'absolute'
highlight.style.left = `${spanLeft + beforeWidth * scaleX}px`
highlight.style.top = `${spanTop}px`
highlight.style.width = `${keywordWidth * scaleX}px`
highlight.style.height = `${highlightHeight}px`
highlight.style.background = 'red'
highlight.style.pointerEvents = 'none'
highlight.style.zIndex = '1'
textLayer.appendChild(highlight)
} catch (error) {
console.error('高亮关键字失败:', error)
}
}
// 计算文本宽度(使用临时元素)
const getTextWidth = (text, fontFamily, fontSize, referenceSpan) => {
if (referenceSpan) {
// 使用参考span的样式
const tempSpan = referenceSpan.cloneNode(false)
tempSpan.textContent = text
tempSpan.style.visibility = 'hidden'
tempSpan.style.position = 'absolute'
tempSpan.style.left = '-9999px'
document.body.appendChild(tempSpan)
const width = tempSpan.offsetWidth
document.body.removeChild(tempSpan)
return width
}
// 备用方法:使用canvas
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
context.font = `${fontSize}px ${fontFamily || 'sans-serif'}`
return context.measureText(text).width
}
// 监听关键字变化,重新高亮
watch(
() => props.keyword,
() => {
nextTick(() => {
const pageContainer = document.querySelector(`[data-page-key="${props.pageKey}"]`)
if (pageContainer) {
const textLayer = pageContainer.querySelector('.textLayer')
if (textLayer) {
highlightTextInLayer(textLayer)
}
}
})
}
)
</script>
<style scoped>
.pdf-page-container {
width: 100%;
display: flex;
justify-content: center;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: 10px;
}
.pdf-page {
width: 100%;
max-width: 100%;
}
:deep(canvas) {
max-width: 100%;
height: auto;
}
</style>
使用示例
index.vue 中的使用部分
1. 导入组件
vue
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import PdfPage from './components/PdfPage.vue'
// ... 其他导入和代码
</script>
2. 定义相关变量
javascript
// PDF 页面缓存:key 为 "pageNum",value 为 { source, page }
const pdfPageCache = ref(new Map())
// 搜索关键字
const searchKeyword = ref('中国')
3. 在模板中使用组件
vue
<template>
<!-- PDF预览 -->
<div v-if="taskInfo.file && currentPageData" class="pdf-viewer-wrapper">
<!-- 渲染所有已缓存的页面,使用 v-show 控制显示/隐藏 -->
<PdfPage
v-for="[pageKey, cachedPage] in Array.from(pdfPageCache)"
:key="pageKey"
v-show="isPageVisible(pageKey)"
:source="cachedPage.source"
:page="cachedPage.page"
:page-key="pageKey"
:keyword="searchKeyword"
/>
<!-- 加载状态 -->
<div v-if="pdfLoading" class="loading-overlay">
<div class="loading-text">加载中...</div>
</div>
</div>
<div v-else-if="taskInfo.file && !currentPageData" class="loading-placeholder">
请选择一项查看
</div>
<div v-else class="loading-placeholder">
<div class="loading-text">加载中...</div>
</div>
</template>
Props 说明
| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| source | String | 是 | - | PDF 文件的 URL 或路径 |
| page | Number | 是 | - | 要渲染的页码(从1开始) |
| pageKey | String | 是 | - | 页面的唯一标识符,用于定位页面容器 |
| keyword | String | 否 | '' | 要高亮的关键字,为空时清除所有高亮 |
功能说明
1. 跨 span 高亮
组件支持高亮跨多个 span 的关键字。例如,如果关键字"评估"被拆分到两个 span 中("系统风险评"和"估报告"),组件会自动识别并高亮两个部分。
2. 多匹配高亮
组件会在同一页中查找所有匹配的关键字并全部高亮。
3. 响应式更新
当 keyword prop 发生变化时,组件会自动重新高亮,无需手动调用任何方法。
4. 高度优化
组件会自动计算高亮层的高度,避免底部溢出问题。
注意事项
- 组件依赖
vue-pdf-embed库,需要确保已安装该依赖 - 组件需要在 PDF 的 text-layer 渲染完成后才能进行高亮,因此使用了重试机制
- 高亮层使用绝对定位,不会影响 PDF 文本的选择和复制功能
- 高亮颜色固定为红色,如需自定义颜色,可以修改组件中的
background: 'red'部分
依赖安装
bash
npm install vue-pdf-embed
或
bash
yarn add vue-pdf-embed