Pdfjs Viewer 实战指南

574 阅读5分钟

pdfjs 的文档奇少无比,基本使用只能靠看源码。对于想要快速上手的开发者而言是灾难性的存在。笔者趟过了这趟浑水,故分享经验,促进碳中和(bushi)

pdfjs预览有很多种方式实现,可以通过渲染canvas实现预览,再渲染一层样式相同的文本层,实现右键复制,缺点是需要大量操作dom

另一种方式是使用pdfjs封装好的viewer,这是官方更推荐的做法

基本原理

  • PDF数据层 从url或者bufferArray中(fetch)获取PDF,放入 web worker 中处理,处理出来的数据结构不详
  • PDF视图层 将数据层处理的内容传入给canva context , 渲染图片层和文字层
  • 操作层 操作层通过eventBus与pdf交互,实际上是js主线程和webworker线程的交互

快速访问:总览未完成功能避雷

第三方封装示例

GitHub - LiuSandy/react-pdf-render:

源地址

GitHub - mozilla/pdf.js: PDF Reader in JavaScript

创建Viewer

四个主要viewer构造函数

  1. EventBus 事件总线

  2. PDFLinkService 将上面两项连接起来的桥梁

  3. PDFFindController 查找文本控件 接受一个对象

    1. 接受一个LinkService实例
    2. 接受一个EventBus实例
  4. PDFViewer 预览控件 (接受一个对象参数)| PDFSiglePageViewer

    1. container 元素实例(div)
    2. linkService 实例化的LinkService
    3. useOnlyCssZoom false | true
    4. textLayerMode 0 | 1
    5. renderer 'svg' | 'canvas'
    6. findController 实例化的findController
    7. eventBus 事件总线
    8. maxCanvasPixels 最大canvas像素
    9. enablePrintAutoRotate 启用打印旋转

container元素使用类名:pdfViewer 可以直接使用pdfjs提供的css样式

EventBus事件总线

事件总线令PDFViewer的各部分之间可以传递消息

EventBus 类型的对象有一个 dispatch 方法,可以用来触发事件,以及一个 on 方法,可以用来监听事件。

  • PDFFindContorller / PDFViewer 的实例化都需传入EventBus实例
eventBus.on('pagesinit', () => {
    console.log('All pages are initialized.')
})

eventBus.dispatch('scalechange', { scale: 2 })

原型方法

PDFViewer实例上有一些原型方法和属性

  • pagesCount 总页数
  • pageViewsReady 是否渲染完成,通过这个属性判断是不是所有的页面都渲染完成,因为pdf渲染是异步的,有一些事件监听是需要渲染完成后再进行
  • currnetPageNumber 当前页码,翻页使用
  • currentScale 缩放比率,设置页面缩放需要用currentScaleValue
  • pagesRotation 页面旋转
  • isPageVisible(pageNumber) 传入页码判断页面是否不可见

流程

  1. 配置Woker

  1. 创建四个重要实例

  1. 加载PDF

  1. 初始化PDF

  1. 绑定事件

功能实现

  1. 翻页和缩放

  • 翻页: newViewer.currentPageNumber (newViewer是viewer实例)

  • 缩放: newViewer.currentScaleValue

    • 缩放的大小可也是任何数字
    • 或者: auto | page-actual | page-fit | page-width
  1. 文字搜索

    const handleSearch = (value: string) => {
        eventBus?. dispatch('find', {
            type: '',
            query: value,
            phraseSearch: searchParams.phraseSearch,
            caseSensitive: searchParams.caseSensitive,
            highlightAll: searchParams.highlightAll,
            findPrevious: searchParams.findPrevious,
        })
        if (viewer?.currentPageNumber) {
            setcurrentPage(viewer.currentPageNumber)
            console.log(viewer?.currentPageNumber)
        }
    }

在旧版本的pdf中使用viewer.findController.executeCommand('findagain', searcher),但是executeCommand已被废弃

image.png

踩雷逼坑

web worker 配置

报错:No "GlobalWorkerOptions.workerSrc" specified.

解决:pdfjs的worker导入方式千奇百怪,找到当前版本和环境适配的即可

import 'pdfjs-dist/build/pdf.worker.mjs'
pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/build/pdf.worker.mjs'

container下无子元素

报错:pdf_viewer.js:251 Uncaught TypeError: Cannot read properties of null (reading 'firstElementChild')

Invalid container and/or viewer option.

解决:虽然文档里没有说,但是

const pdfViewer = new PDFViewer({
    container: containerRef.current as HTMLDivElement,
    eventBus: eventBus,
    linkService,
    textLayerMode: 1,
    findController,
})
  • 这里的containerRef的目标元素下一定要有一个id为viewer的元素
  • 而且container必须是绝对定位

重复渲染

问题描述: 使用PDFViewer 或者 PDFSinglepageViewer的时候,无法渲染指定页面,而且渲染了两次页面

解决:这不是js的问题,而是css的问题,使用viewer的时候,所有页面都是堆叠在一起的,container和viewer都必须设置绝对定位absolute

<>
{/* contianer外面的盒子对pdf无影响  */}
<div className=' w-full h-full relative '>
{/* container 的宽度和高度是可视区域的宽高*/}
    <div
    ref={containerRef}
    className=' absolute bg-slate-600  h-5/6  aspect-[7/10]  overflow-auto'>
        <div id='viewer' className='absolute m-auto '></div>
    </div>
</div>
</>

无可滚动父元素

问题报错:PdfRender.tsx:141 offsetParent is not set -- cannot scroll

这个问题一般出现在通过state保存和获取currentPageNumber/currentScale等属性的时候

  • currentPageNumber/currentScale/currentScaleValue 直接使用点方法访问,而不是使用state

    • 问题:无法通过currentPageNumber动态计算一些属性,比如disable
Button
    disabled={//无法使用viewer?.currentPageNumber动态计算}
    onClick={() => {
        console.log(viewer?.currentPageNumber)
        viewer && (viewer.currentPageNumber -= 1)
    }}>
</Button>

实际上👆这个也是不完全的修复,不建议使用,治标不治本

  • 从effect / layoutEffect中移除 currentPage ,scale,在更新参数值的时候不应当重新初始化url,故下面的这个effect /layoutEffect只在更换pdf或者更换pdf预览方式的时候执行
    useEffect(() => {
        initPdfViewer(pdfUrl)
    }, [pdfUrl]) //pdfUrl变化时重新渲染pdf

worker线程失效

警告:Warning Setup Fake Worker

原因:Worker线程配置不正确,浏览器为了保证程序运行,设定了一个模拟worker,实际上没有在后台线程中运行。fake worker 会影响程序的性能,虽然是个warning还是建议不要出现

  • pdfjs.version配置问题
  • 本地worker引入问题

其实我也没找出本地引入的问题在哪,使用以下的cdn连接,以导入正确的worker文件

import * as pdfjs from 'pdfjs-dist'
pdfjs.GlobalWorkerOptions.workerSrc =`https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.mjs`

被动渲染 passive render

错误:(无报错)在使用React事件:onScroll/onWheel 到时候,希望阻止默认事件 e.preventDefault ,此时你会发现无法成功,无论如何默认事件还是会被触发

原因:passive 属性最初是为了提高触摸和滚动事件的性能而设计的。在移动设备上,滚动性能尤其重要,因为用户期望页面能够快速响应触摸滚动。在没有 passive 属性之前,浏览器必须等待事件处理函数执行完毕,以确 定是否调用了 event.preventDefault(),这可能会导致滚动延迟。

解决:

  • 目前,浏览器whell/scroll事件默认开启 passive,需要手动关闭
  • React事件暂时未能支持passive属性的设置,所以需要通过ref获取真实dom,然后添加监听
useEffect(() => {
    //为viewer窗口绑定滚动事件
    if (viewerRef.current) {
            viewerRef.current.addEventListener('wheel', handleWheel)
    }
    }
    return () => {
    }
})
useEffect(() => {
    //为viewer窗口绑定滚动事件
    if (viewerRef.current) {
            viewerRef.current.addEventListener('wheel', handleWheel, {
                    passive: false,
            })
    }
    }
    return () => {
    }
})

STATUS_BREAKPOINT

浏览器报错: STATUS_BREAKPOINT

原因:模块导入的顺序不当,必须先导入pdfjs-dist再从pdfjs-dist/web/pdf_viewer.mjs 和 pdf_viewer.css文件,遵循从大到小的原则

解决:

import {
    EventBus,
    PDFViewer,
    PDFSinglePageViewer,
    PDFLinkService,
    PDFFindController,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import 'pdfjs-dist/web/pdf_viewer.css'
import * as pdfjs from 'pdfjs-dist'=
import * as pdfjs from 'pdfjs-dist'
import {
    EventBus,
    PDFViewer,
    PDFSinglePageViewer,
    PDFLinkService,
    PDFFindController,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import 'pdfjs-dist/web/pdf_viewer.css'