【預覽PDF】前端預覽pdf

通過pdfjs-dist預覽

注:需要在public中放入對應的pdf.worker.min.mjs文件

<script setup lang="ts">
import { ref, onMounted, defineProps } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api'

// Worker 必須存在 public/pdf.worker.min.mjs
pdfjsLib.GlobalWorkerOptions.workerSrc = window.location.origin + '/pdf.worker.min.mjs'

const props = defineProps<{
  src: string
  scale?: number
}>()

// 每頁 canvas 的 ref 列表
const canvasRefs = ref<HTMLCanvasElement[]>([])
// pdfDoc 用普通變量
let pdfDoc: PDFDocumentProxy | null = null
// 當前加載頁
let currentPage = 1
let totalPages = 0

// 用 IntersectionObserver 懶加載下一頁
let observer: IntersectionObserver

const renderPage = async (pageNum: number) => {
  if (!pdfDoc) return
  const page = await pdfDoc.getPage(pageNum)
  // const viewport = page.getViewport({ scale: props.scale ?? 1 })
  const viewport = page.getViewport({ scale: props.scale ?? 1.1 })

  // 創建 canvas 元素
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d')
  if (!context) return

  canvas.width = viewport.width
  canvas.height = viewport.height
  canvas.style.width = '100%' // 自適應父容器寬度
  canvas.style.height = 'auto'
  canvas.style.display = 'block'
  canvas.style.marginBottom = '16px'

  const renderContext = {
    canvas,
    canvasContext: context,
    viewport,
    renderInteractiveForms: true,
    textLayer: true
  }
  await page.render(renderContext).promise

  // 添加到 DOM
  canvasRefs.value.push(canvas)
  containerRef.value?.appendChild(canvas)

  // 觀察最後一頁,滾動到可見時加載下一頁
  if (pageNum === currentPage && currentPage < totalPages) {
    observer.observe(canvas)
  }
}

const loadPdf = async () => {
  const loadingTask = pdfjsLib.getDocument({
    url: props.src,
    cMapUrl: window.location.origin + '/pdf/cmaps/',
    cMapPacked: true,
    enableXfa: true,
    // 添加更多參數以提高兼容性
    disableFontFace: false,
    rangeChunkSize: 65536,
    maxImageSize: -1
    // nativeImageDecoderSupport: true
  })
  try {
    pdfDoc = await loadingTask.promise
    pdfDoc.getData().then(data => {
      console.log('🚀 ~ loadPdf ~ data:', data)
    })
    totalPages = pdfDoc.numPages
    currentPage = 1
    canvasRefs.value = []
    containerRef.value!.innerHTML = '' // 清空之前內容
    await renderPage(currentPage)
  } catch (err) {
    console.error('🚀 ~ loadPdf ~ err:', err)
  }
}

const containerRef = ref<HTMLDivElement | null>(null)

onMounted(() => {
  observer = new IntersectionObserver(
    async entries => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          observer.unobserve(entry.target) // 防止重複觸發
          currentPage++
          if (currentPage <= totalPages) {
            await renderPage(currentPage)
          }
        }
      }
    },
    {
      root: containerRef.value,
      rootMargin: '0px',
      threshold: 0.1
    }
  )

  loadPdf()
})
</script>

<template>
  <div ref="containerRef" class="w-full overflow-auto" style="max-height: 80vh">
    <!-- canvas 會動態加入這裏 -->
  </div>
</template>

<style scoped>
canvas {
  background-color: #fff;
  color: #000;
}
</style>

通過@embedpdf/pdfium預覽

注:需要下載對應的pdfium.wasm

<script setup lang="ts">
import { ref, onMounted, defineProps } from 'vue'
import { init } from '@embedpdf/pdfium'

const props = defineProps<{
  src: string
  scale?: number
}>()

const containerRef = ref<HTMLDivElement | null>(null)
const loadingRef = ref<boolean>(true)
const errorRef = ref<string | null>(null)

const loadPdf = async () => {
  loadingRef.value = true
  errorRef.value = null

  // 清空容器
  if (containerRef.value) {
    containerRef.value.innerHTML = ''
  }

  try {
    console.log('開始加載PDFium WebAssembly...')
    // 從CDN加載WebAssembly文件
    const pdfiumWasmUrl = 'https://cdn.jsdelivr.net/npm/@embedpdf/pdfium/dist/pdfium.wasm'
    const response = await fetch(pdfiumWasmUrl)
    const wasmBinary = await response.arrayBuffer()

    // 初始化PDFium
    const pdfium = await init({ wasmBinary })
    console.log('PDFium WebAssembly加載成功')

    // 初始化PDFium擴展庫
    pdfium.PDFiumExt_Init()
    console.log('PDFium擴展庫初始化成功')

    // 加載PDF文件
    console.log('開始加載PDF文件:', props.src)
    const pdfResponse = await fetch(props.src)
    const pdfBuffer = await pdfResponse.arrayBuffer()
    const pdfData = new Uint8Array(pdfBuffer)
    console.log(`PDF文件加載成功,大小: ${pdfData.length} 字節`)

    // 分配內存並加載文檔
    const filePtr = pdfium.pdfium.wasmExports.malloc(pdfData.length)
    pdfium.pdfium.HEAPU8.set(pdfData, filePtr)

    // 加載PDF文檔
    const docPtr = pdfium.FPDF_LoadMemDocument(filePtr, pdfData.length, '')
    if (!docPtr) {
      const errorCode = pdfium.FPDF_GetLastError()
      throw new Error(`PDF文檔加載失敗,錯誤碼: ${errorCode}`)
    }
    console.log('PDF文檔加載成功')

    // 獲取頁數
    const pageCount = pdfium.FPDF_GetPageCount(docPtr)
    console.log(`PDF共有 ${pageCount} 頁`)

    if (pageCount === 0) {
      throw new Error('PDF沒有任何頁面')
    }

    // 添加加載指示器
    const loadingIndicator = document.createElement('div')
    loadingIndicator.style.padding = '20px'
    loadingIndicator.style.textAlign = 'center'
    loadingIndicator.textContent = 'PDF加載中...'
    if (containerRef.value) {
      containerRef.value.appendChild(loadingIndicator)
    }

    // 記錄一下pdfium對象的可用方法,幫助我們找出正確的API
    console.log(
      'PDFium可用方法:',
      Object.keys(pdfium).filter(key => typeof pdfium[key] === 'function')
    )

    // 逐頁渲染
    for (let i = 0; i < pageCount; i++) {
      try {
        console.log(`開始渲染第 ${i + 1} 頁...`)

        // 加載頁面
        const pagePtr = pdfium.FPDF_LoadPage(docPtr, i)
        if (!pagePtr) {
          throw new Error(`無法加載第 ${i + 1} 頁`)
        }

        // 獲取頁面尺寸
        const origWidth = pdfium.FPDF_GetPageWidth(pagePtr)
        const origHeight = pdfium.FPDF_GetPageHeight(pagePtr)
        console.log(`頁面原始尺寸: ${origWidth} x ${origHeight}`)

        // 使用固定的縮放比例或用户提供的縮放比例
        const scale = props.scale || 1.5

        // 計算實際渲染尺寸
        const renderWidth = Math.floor(origWidth * scale)
        const renderHeight = Math.floor(origHeight * scale)
        console.log(`渲染尺寸: ${renderWidth} x ${renderHeight}`)

        // 創建canvas元素
        const canvas = document.createElement('canvas')
        canvas.width = renderWidth
        canvas.height = renderHeight
        canvas.style.width = '100%'
        canvas.style.height = 'auto'
        canvas.style.display = 'block'
        canvas.style.marginBottom = '20px'
        canvas.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)'

        // 獲取canvas上下文
        const ctx = canvas.getContext('2d')
        if (!ctx) {
          throw new Error('無法獲取canvas上下文')
        }

        // 設置白色背景
        ctx.fillStyle = '#FFFFFF'
        ctx.fillRect(0, 0, renderWidth, renderHeight)

        // 檢查FPDFBitmap_Create是否可用(替代PDFiumExt_CreateBitmap)
        if (typeof pdfium.FPDFBitmap_Create === 'function') {
          console.log('使用FPDFBitmap_Create創建位圖')

          // 創建位圖
          const bitmapPtr = pdfium.FPDFBitmap_Create(renderWidth, renderHeight, 1) // 1 for BGRA format
          if (!bitmapPtr) {
            throw new Error('無法創建位圖')
          }

          // 填充位圖背景為白色 (0xFFFFFFFF 為白色,BGRA格式)
          pdfium.FPDFBitmap_FillRect(bitmapPtr, 0, 0, renderWidth, renderHeight, 0xffffffff)

          // 渲染PDF頁面到位圖
          pdfium.FPDF_RenderPageBitmap(
            bitmapPtr,
            pagePtr,
            0,
            0,
            renderWidth,
            renderHeight,
            0, // 旋轉角度
            pdfium.FPDF_RENDER_ANNOT // 渲染標誌
          )

          // 獲取位圖緩衝區
          const scanline = pdfium.FPDFBitmap_GetStride(bitmapPtr)
          const bufferPtr = pdfium.FPDFBitmap_GetBuffer(bitmapPtr)

          // 創建ImageData
          const buffer = new Uint8ClampedArray(pdfium.pdfium.HEAPU8.buffer, bufferPtr, scanline * renderHeight)

          // 處理BGRA到RGBA的轉換(如果需要)
          const imageData = new ImageData(renderWidth, renderHeight)
          for (let y = 0; y < renderHeight; y++) {
            for (let x = 0; x < renderWidth; x++) {
              const srcIdx = y * scanline + x * 4
              const dstIdx = (y * renderWidth + x) * 4

              imageData.data[dstIdx] = buffer[srcIdx + 2] // R <- B
              imageData.data[dstIdx + 1] = buffer[srcIdx + 1] // G <- G
              imageData.data[dstIdx + 2] = buffer[srcIdx] // B <- R
              imageData.data[dstIdx + 3] = buffer[srcIdx + 3] // A <- A
            }
          }

          ctx.putImageData(imageData, 0, 0)

          // 釋放位圖
          pdfium.FPDFBitmap_Destroy(bitmapPtr)
        } else {
          // 備選方案:使用原生canvas渲染(如果可用)
          console.log('嘗試使用FPDF_RenderPageToDC進行canvas渲染')

          // 創建一個臨時的img元素,用於存放渲染結果
          const img = new Image()

          // 使用FPDF_RenderPage直接渲染到canvas(如果API支持)
          if (typeof pdfium.FPDF_RenderPage === 'function') {
            console.log('使用FPDF_RenderPage直接渲染')
            pdfium.FPDF_RenderPage(ctx, pagePtr, 0, 0, renderWidth, renderHeight, 0, 0)
          } else {
            throw new Error('無法找到適合的渲染API,請檢查PDFium庫版本')
          }
        }

        console.log(`第 ${i + 1} 頁渲染完成`)

        // 添加到DOM
        if (containerRef.value) {
          if (i === 0) {
            // 如果是第一頁,移除加載指示器
            containerRef.value.innerHTML = ''
          }
          containerRef.value.appendChild(canvas)
        }

        // 關閉頁面
        pdfium.FPDF_ClosePage(pagePtr)
      } catch (pageError) {
        console.error(`渲染第 ${i + 1} 頁時出錯:`, pageError)

        // 創建錯誤提示元素
        const errorDiv = document.createElement('div')
        errorDiv.style.width = '100%'
        errorDiv.style.padding = '15px'
        errorDiv.style.marginBottom = '20px'
        errorDiv.style.backgroundColor = '#f8d7da'
        errorDiv.style.border = '1px solid #f5c6cb'
        errorDiv.style.color = '#721c24'
        errorDiv.style.borderRadius = '4px'
        errorDiv.textContent = `無法渲染第 ${i + 1} 頁: ${pageError.message}`

        if (containerRef.value) {
          if (i === 0) {
            // 如果是第一頁出錯,清空容器
            containerRef.value.innerHTML = ''
          }
          containerRef.value.appendChild(errorDiv)
        }
      }
    }

    console.log('所有頁面渲染完成')

    // 清理資源
    pdfium.FPDF_CloseDocument(docPtr)
    pdfium.pdfium.wasmExports.free(filePtr)

    loadingRef.value = false
  } catch (error) {
    console.error('PDF處理失敗:', error)
    errorRef.value = error.message
    loadingRef.value = false

    // 顯示錯誤信息
    if (containerRef.value) {
      const errorContainer = document.createElement('div')
      errorContainer.style.width = '100%'
      errorContainer.style.padding = '20px'
      errorContainer.style.backgroundColor = '#f8d7da'
      errorContainer.style.border = '1px solid #f5c6cb'
      errorContainer.style.color = '#721c24'
      errorContainer.style.borderRadius = '4px'
      errorContainer.textContent = `PDF加載或渲染失敗: ${error.message}`

      containerRef.value.innerHTML = ''
      containerRef.value.appendChild(errorContainer)
    }
  }
}

onMounted(() => {
  loadPdf()
})
</script>

<template>
  <div class="pdf-viewer">
    <div v-if="loadingRef && !errorRef" class="pdf-loading">加載中...</div>
    <div v-if="errorRef" class="pdf-error">
      {{ errorRef }}
    </div>
    <div ref="containerRef" class="pdf-container">
      <!-- PDF頁面將被渲染到這裏 -->
    </div>
  </div>
</template>

<style scoped>
.pdf-viewer {
  width: 100%;
  position: relative;
}

.pdf-container {
  width: 100%;
  overflow-y: auto;
  max-height: 80vh;
  background-color: #f5f5f5;
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.pdf-loading,
.pdf-error {
  padding: 20px;
  text-align: center;
}

.pdf-error {
  color: #721c24;
  background-color: #f8d7da;
  border: 1px solid #f5c6cb;
  border-radius: 4px;
  margin-bottom: 15px;
}
</style>