vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮

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. 高度优化

组件会自动计算高亮层的高度,避免底部溢出问题。

注意事项

  1. 组件依赖 vue-pdf-embed 库,需要确保已安装该依赖
  2. 组件需要在 PDF 的 text-layer 渲染完成后才能进行高亮,因此使用了重试机制
  3. 高亮层使用绝对定位,不会影响 PDF 文本的选择和复制功能
  4. 高亮颜色固定为红色,如需自定义颜色,可以修改组件中的 background: 'red' 部分

依赖安装

bash 复制代码
npm install vue-pdf-embed

bash 复制代码
yarn add vue-pdf-embed
相关推荐
未等与你踏清风17 小时前
Elpis npm 包抽离总结
前端·javascript
代码猎人17 小时前
如何使用for...of遍历对象
前端
秋天的一阵风17 小时前
🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)
前端·开源·全栈
林恒smileZAZ17 小时前
【Vue3】我用 Vue 封装了个 ECharts Hooks
前端·vue.js·echarts
颜酱17 小时前
用填充表格法-继续吃透完全背包及其变形
前端·后端·算法
代码猎人17 小时前
new操作符的实现原理是什么
前端
程序员小李白17 小时前
定位.轮播图详细解析
前端·css·html
前端小菜鸟也有人起17 小时前
浏览器不支持vue router
前端·javascript·vue.js
腥臭腐朽的日子熠熠生辉17 小时前
nest js docker 化全流程
开发语言·javascript·docker