前端解析PDF文件目录以及点击目录实现对应内容预览

整体功能概述

这个 PDF 查看器是一个使用 Vue2 框架开发的单页应用,它允许用户上传 PDF 文件并在浏览器中查看。应用提供了基本的 PDF 浏览功能,包括文件选择、页面导航、目录浏览以及根据窗口大小自动调整显示比例。

主要组件结构

  1. 文件选择器:顶部区域的文件输入控件,用于选择本地 PDF 文件

  2. 主内容区

    • 左侧目录面板:显示 PDF 文档的目录结构 (如果有)
    • 右侧内容区:显示当前 PDF 页面的渲染结果
  3. 页脚导航区:显示当前页码并提供上一页 / 下一页按钮

核心功能模块

1. PDF 加载与解析

  • 用户通过文件选择器选择 PDF 文件后,触发loadPdf方法
  • 该方法使用 FileReader 读取文件内容为 ArrayBuffer
  • 调用 pdf.js 库的getDocument方法解析 PDF 内容
  • 解析成功后获取总页数并尝试提取文档目录结构 (TOC)
  • 最后渲染第一页内容

2. 目录处理与渲染

  • processOutline方法递归处理 PDF 大纲结构
  • 为每个目录项生成唯一 ID 和层级关系
  • 异步获取每个目录项对应的页码信息
  • 在左侧面板渲染目录列表,点击时跳转到对应页面

3. 页面渲染机制

  • renderPage方法负责渲染指定页码的 PDF 内容
  • 创建 canvas 元素并设置尺寸与 PDF 页面匹配
  • 使用 pdf.js 的渲染 API 将页面内容绘制到 canvas 上
  • 每次渲染前清空现有内容,确保只显示当前页

4. 页面导航控制

  • prevPagenextPage方法分别处理前后翻页
  • goToPage方法根据目录项或页码导航到指定页面
  • 按钮状态根据当前页码和总页数动态禁用 / 启用
  • 页脚显示当前页码和总页数信息

5. 响应式布局处理

  • 监听窗口大小变化事件
  • 根据容器宽度动态计算最佳缩放比例
  • 窗口大小改变时重新渲染当前页面以适应新尺寸
  • 使用平滑滚动确保用户体验一致性

6. 状态管理

  • 使用 Vue2 的响应式数据管理 PDF 加载状态
  • 包括 loading 状态、当前页码、总页数等信息
  • 数据变化自动触发 DOM 更新

关键技术实现细节

  1. PDF.js 集成

    • 正确配置 worker 源以处理 PDF 解析任务
    • 使用 Promise 方式处理异步解析过程
    • 处理可能出现的解析错误
  2. 动态 DOM 操作

    • 使用 ref 属性访问 DOM 元素
    • 在 mounted 生命周期钩子中添加事件监听
    • 在组件销毁前正确移除事件监听
  3. 异步操作处理

    • 使用 async/await 处理 PDF 解析的多个异步步骤
    • 适当的错误处理机制确保应用稳定性
    • 加载状态管理提供良好用户体验
css 复制代码
npm i pdfjs-dist

"pdfjs-dist": "^2.16.105"

Vue3风格

ini 复制代码
<template>
  <div class="flex flex-col h-screen bg-gray-50">
    <!-- 文件选择器 -->
    <div class="p-4 bg-white shadow-sm">
      <input type="file" @change="loadPdf" accept=".pdf" class="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
    </div>
    
    <!-- PDF查看器主体 -->
    <div class="flex-1 flex overflow-hidden">
      <!-- 左侧目录 -->
      <div class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
        <div class="p-4 font-medium text-gray-700 border-b border-gray-200">目录</div>
        <ul class="p-2">
          <li v-for="item in toc" :key="item.id" class="mb-1">
            <a 
              href="#" 
              @click.prevent="goToPage(item.pageNum)" 
              class="block p-2 rounded hover:bg-gray-100 text-sm"
              :class="{ 'font-medium': currentPage === item.pageNum }"
            >
              {{ item.title }}
            </a>
          </li>
        </ul>
      </div>
      
      <!-- 右侧内容 -->
      <div class="flex-1 overflow-auto bg-gray-100 p-4" ref="pdfContainer">
        <div v-if="loading" class="flex items-center justify-center h-full">
          <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
        </div>
        <div v-else ref="pagesContainer">
          <!-- PDF页面将在这里动态渲染 -->
        </div>
      </div>
    </div>
    
    <!-- 页脚 -->
    <div class="p-4 bg-white border-t border-gray-200 flex justify-between items-center">
      <div class="text-sm text-gray-600">
        当前页: {{ currentPage || 0 }} / {{ totalPages || 0 }}
      </div>
      <div class="flex space-x-2">
        <button 
          @click="prevPage" 
          :disabled="!currentPage || currentPage <= 1"
          class="px-4 py-1.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          上一页
        </button>
        <button 
          @click="nextPage" 
          :disabled="!currentPage || currentPage >= totalPages"
          class="px-4 py-1.5 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          下一页
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import * as pdfjsLib from 'pdfjs-dist';
import 'pdfjs-dist/web/pdf_viewer.css';

// 修复:从项目中导入Worker而不是使用CDN
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;

// 引用DOM元素
const pdfContainer = ref(null);
const pagesContainer = ref(null);

// 状态变量
const loading = ref(true);
const pdfDoc = ref(null);
const toc = ref([]);
const currentPage = ref(1);
const totalPages = ref(0);
const scale = ref(1.0);

// 加载PDF文件
const loadPdf = async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  
  loading.value = true;
  toc.value = [];
  currentPage.value = 1;
  
  try {
    const fileReader = new FileReader();
    
    fileReader.onload = async (e) => {
      const typedArray = new Uint8Array(e.target.result);
      
      // 加载PDF文档
      pdfDoc.value = await pdfjsLib.getDocument(typedArray).promise;
      totalPages.value = pdfDoc.value.numPages;
      
      // 尝试获取目录
      try {
        const outline = await pdfDoc.value.getOutline();
        if (outline && outline.length) {
          processOutline(outline);
        }
      } catch (err) {
        console.warn('获取PDF目录失败:', err);
      }
      
      // 渲染第一页
      await renderPage(currentPage.value);
      loading.value = false;
    };
    
    fileReader.readAsArrayBuffer(file);
  } catch (err) {
    console.error('加载PDF失败:', err);
    loading.value = false;
  }
};

// 处理PDF目录结构
const processOutline = (outlineItems, parentId = null, level = 1) => {
  outlineItems.forEach((item, index) => {
    const id = parentId ? `${parentId}-${index}` : `item-${index}`;
    const pageRef = item.dest ? item.dest[0] : (item.pageRef || null);
    
    let pageNum = null;
    if (pageRef) {
      pdfDoc.value.getPageIndex(pageRef).then(num => {
        const pageItem = toc.value.find(i => i.id === id);
        if (pageItem) {
          pageItem.pageNum = num + 1; // PDF.js索引从0开始,页面从1开始
        }
      }).catch(err => {
        console.warn('获取页面索引失败:', err);
      });
    }
    
    toc.value.push({
      id,
      title: item.title,
      pageNum: pageNum || null,
      level
    });
    
    if (item.items && item.items.length) {
      processOutline(item.items, id, level + 1);
    }
  });
};

// 渲染指定页面
const renderPage = async (num) => {
  if (!pdfDoc.value) return;
  
  try {
    // 清除现有页面
    if (pagesContainer.value) {
      pagesContainer.value.innerHTML = '';
    }
    
    // 获取并渲染指定页面
    const page = await pdfDoc.value.getPage(num);
    currentPage.value = num;
    
    const viewport = page.getViewport({ scale: scale.value });
    
    // 创建canvas元素
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.height = viewport.height;
    canvas.width = viewport.width;
    canvas.className = 'mb-6 shadow-md bg-white';
    
    // 添加到容器
    if (pagesContainer.value) {
      pagesContainer.value.appendChild(canvas);
    }
    
    // 渲染页面到canvas
    const renderContext = {
      canvasContext: context,
      viewport: viewport
    };
    
    await page.render(renderContext).promise;
    
    // 平滑滚动到顶部
    if (pdfContainer.value) {
      pdfContainer.value.scrollTop = 0;
    }
  } catch (err) {
    console.error('渲染页面失败:', err);
  }
};

// 导航到指定页面
const goToPage = (pageNum) => {
  if (pageNum && pageNum !== currentPage.value) {
    renderPage(pageNum);
  }
};

// 上一页
const prevPage = () => {
  if (currentPage.value > 1) {
    renderPage(currentPage.value - 1);
  }
};

// 下一页
const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    renderPage(currentPage.value + 1);
  }
};

// 页面加载完成后初始化
onMounted(() => {
  // 监听窗口大小变化,调整PDF缩放比例
  const handleResize = () => {
    if (pdfContainer.value && pdfDoc.value) {
      // 计算适合容器的缩放比例
      const containerWidth = pdfContainer.value.clientWidth - 40; // 减去内边距
      if (containerWidth > 0) {
        pdfDoc.value.getPage(1).then(page => {
          const viewport = page.getViewport({ scale: 1.0 });
          scale.value = containerWidth / viewport.width;
          if (currentPage.value) {
            renderPage(currentPage.value);
          }
        });
      }
    }
  };
  
  window.addEventListener('resize', handleResize);
  
  // 初始调整
  nextTick(() => {
    handleResize();
  });
  
  // 清理函数
  onUnmounted(() => {
    window.removeEventListener('resize', handleResize);
    pdfDoc.value = null;
  });
});
</script>

<style scoped>
/* 自定义样式 */
.pdf-page {
  margin-bottom: 20px;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
</style>    

Vue2风格

xml 复制代码
<template>
  <div class="flex flex-col h-screen bg-gray-50">
    <!-- 文件选择器 -->
    <div class="p-4 bg-white shadow-sm">
      <input type="file" @change="loadPdf" accept=".pdf" class="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
    </div>
    
    <!-- PDF查看器主体 -->
    <div class="flex-1 flex overflow-hidden">
      <!-- 左侧目录 -->
      <div class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
        <div class="p-4 font-medium text-gray-700 border-b border-gray-200">目录</div>
        <ul class="p-2">
          <li v-for="item in toc" :key="item.id" class="mb-1">
            <a 
              href="#" 
              @click.prevent="goToPage(item.pageNum)" 
              class="block p-2 rounded hover:bg-gray-100 text-sm"
              :class="{ 'font-medium': currentPage === item.pageNum }"
            >
              {{ item.title }}
            </a>
          </li>
        </ul>
      </div>
      
      <!-- 右侧内容 -->
      <div class="flex-1 overflow-auto bg-gray-100 p-4" ref="pdfContainer">
        <div v-if="loading" class="flex items-center justify-center h-full">
          <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
        </div>
        <div v-else ref="pagesContainer">
          <!-- PDF页面将在这里动态渲染 -->
        </div>
      </div>
    </div>
    
    <!-- 页脚 -->
    <div class="p-4 bg-white border-t border-gray-200 flex justify-between items-center">
      <div class="text-sm text-gray-600">
        当前页: {{ currentPage || 0 }} / {{ totalPages || 0 }}
      </div>
      <div class="flex space-x-2">
        <button 
          @click="prevPage" 
          :disabled="!currentPage || currentPage <= 1"
          class="px-4 py-1.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          上一页
        </button>
        <button 
          @click="nextPage" 
          :disabled="!currentPage || currentPage >= totalPages"
          class="px-4 py-1.5 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          下一页
        </button>
      </div>
    </div>
  </div>
</template>

<script>
import * as pdfjsLib from 'pdfjs-dist';
import 'pdfjs-dist/web/pdf_viewer.css';

// 修复:从项目中导入Worker而不是使用CDN
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;

export default {
  data() {
    return {
      loading: true,
      pdfDoc: null,
      toc: [],
      currentPage: 1,
      totalPages: 0,
      scale: 1.0
    }
  },
  mounted() {
    // 监听窗口大小变化,调整PDF缩放比例
    const handleResize = () => {
      if (this.$refs.pdfContainer && this.pdfDoc) {
        // 计算适合容器的缩放比例
        const containerWidth = this.$refs.pdfContainer.clientWidth - 40; // 减去内边距
        if (containerWidth > 0) {
          this.pdfDoc.getPage(1).then(page => {
            const viewport = page.getViewport({ scale: 1.0 });
            this.scale = containerWidth / viewport.width;
            if (this.currentPage) {
              this.renderPage(this.currentPage);
            }
          });
        }
      }
    };
    
    window.addEventListener('resize', handleResize);
    
    // 初始调整
    this.$nextTick(() => {
      handleResize();
    });
    
    // 保存resize处理函数引用,以便在销毁时移除监听
    this.handleResize = handleResize;
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.handleResize);
    this.pdfDoc = null;
  },
  methods: {
    // 加载PDF文件
    async loadPdf(event) {
      const file = event.target.files[0];
      if (!file) return;
      
      this.loading = true;
      this.toc = [];
      this.currentPage = 1;
      
      try {
        const fileReader = new FileReader();
        
        fileReader.onload = async (e) => {
          const typedArray = new Uint8Array(e.target.result);
          
          // 加载PDF文档
          this.pdfDoc = await pdfjsLib.getDocument(typedArray).promise;
          this.totalPages = this.pdfDoc.numPages;
          
          // 尝试获取目录
          try {
            const outline = await this.pdfDoc.getOutline();
            if (outline && outline.length) {
              this.processOutline(outline);
            }
          } catch (err) {
            console.warn('获取PDF目录失败:', err);
          }
          
          // 渲染第一页
          await this.renderPage(this.currentPage);
          this.loading = false;
        };
        
        fileReader.readAsArrayBuffer(file);
      } catch (err) {
        console.error('加载PDF失败:', err);
        this.loading = false;
      }
    },
    
    // 处理PDF目录结构
    processOutline(outlineItems, parentId = null, level = 1) {
      outlineItems.forEach((item, index) => {
        const id = parentId ? `${parentId}-${index}` : `item-${index}`;
        const pageRef = item.dest ? item.dest[0] : (item.pageRef || null);
        
        let pageNum = null;
        if (pageRef) {
          this.pdfDoc.getPageIndex(pageRef).then(num => {
            const pageItem = this.toc.find(i => i.id === id);
            if (pageItem) {
              pageItem.pageNum = num + 1; // PDF.js索引从0开始,页面从1开始
            }
          }).catch(err => {
            console.warn('获取页面索引失败:', err);
          });
        }
        
        this.toc.push({
          id,
          title: item.title,
          pageNum: pageNum || null,
          level
        });
        
        if (item.items && item.items.length) {
          this.processOutline(item.items, id, level + 1);
        }
      });
    },
    
    // 渲染指定页面
    async renderPage(num) {
      if (!this.pdfDoc) return;
      
      try {
        // 清除现有页面
        if (this.$refs.pagesContainer) {
          this.$refs.pagesContainer.innerHTML = '';
        }
        
        // 获取并渲染指定页面
        const page = await this.pdfDoc.getPage(num);
        this.currentPage = num;
        
        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;
        canvas.className = 'mb-6 shadow-md bg-white';
        
        // 添加到容器
        if (this.$refs.pagesContainer) {
          this.$refs.pagesContainer.appendChild(canvas);
        }
        
        // 渲染页面到canvas
        const renderContext = {
          canvasContext: context,
          viewport: viewport
        };
        
        await page.render(renderContext).promise;
        
        // 平滑滚动到顶部
        if (this.$refs.pdfContainer) {
          this.$refs.pdfContainer.scrollTop = 0;
        }
      } catch (err) {
        console.error('渲染页面失败:', err);
      }
    },
    
    // 导航到指定页面
    goToPage(pageNum) {
      if (pageNum && pageNum !== this.currentPage) {
        this.renderPage(pageNum);
      }
    },
    
    // 上一页
    prevPage() {
      if (this.currentPage > 1) {
        this.renderPage(this.currentPage - 1);
      }
    },
    
    // 下一页
    nextPage() {
      if (this.currentPage < this.totalPages) {
        this.renderPage(this.currentPage + 1);
      }
    }
  }
}
</script>

<style scoped>
/* 自定义样式 */
.pdf-page {
  margin-bottom: 20px;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
</style>

实现效果

相关推荐
Hilaku16 分钟前
用好了 watchEffect 才算会用 Vue3 —— 那些让人误解的响应式陷阱
前端·javascript·vue.js
GISer_Jing20 分钟前
Zustand 状态管理库:极简而强大的解决方案
前端·javascript·react.js
zacksleo20 分钟前
鸿蒙Flutter实战:25-混合开发详解-5-跳转Flutter页面
前端·flutter·harmonyos
zacksleo26 分钟前
鸿蒙Flutter实战:23-混合开发详解-3-源码模式引入
前端·flutter·harmonyos
三年三月27 分钟前
018-场景遍历和世界坐标系
前端·three.js
zacksleo29 分钟前
鸿蒙Flutter实战:22-混合开发详解-2-Har包模式引入
前端·flutter·harmonyos
doubleZ31 分钟前
使用Trae从零开发一个跳转顶部的Chrome插件
前端·javascript
RR133532 分钟前
图标统计页面的设计与控件 Apache echarts
前端·apache·echarts
用户25191624271134 分钟前
ES6之类:构造函数的语法糖
javascript·ecmascript 6
怀予36 分钟前
JavaScript 对象拯救计划:从"对象恐惧症"到"对象操纵大师"!
前端