调研

优秀的 PDF 阅读器开源库主要有两个:

  1. pdf.js:是 Firefox 浏览器内置的 PDF 引擎,由 Mozilla 提供支持,目标是创建一个基于 Web 标准的通用 PDF 解析和渲染平台。基于 HTML5,由 JavaScript 编写,可以直接在前端技术中使用
  2. pdfium:是 Chromium 浏览器内置的 PDF 引擎,由 C/C++ 编写,前端使用比较复杂,需要编译为 WebAssembly 使用(未实践,可行性未知)

所以在前端想要做 PDF 阅读器的需求,那 pdf.js 基本可以算是唯一的选择

虽然 pdf.js 很强大、功能很丰富,但是其作为一个底层通用库,对实用功能封装比较少,而且其 API 文档比较简陋,在摸索上手阶段需要耗费不少的功夫,实际使用也需要进行大量开发工作以支持业务

一些 pdf.js 有用的资料

所以在实际项目中,都会对 pdf.js 进行封装或二次开发,当然也有一些封装的比较好的项目:

  • PDF.js Express
    • 做的很优秀,添加了很多开箱即用的功能
      • 丰富完善的文档和使用示例,以及官方技术支持
    • 缺点:完整的内容需要购买商业许可,且价格较为昂贵
  • ZoteroReader
    • 是 Zotero 的 PDF/EPUB/HTML reader and annotator,饱受市场检验
    • 不仅支持 PDF,还支持 EPUB 和 HTML snapshot
    • 完全开源,团队维护积极(现在每天都有 commit)
      • 缺点:无文档、无使用示例,只能看源码知道如何使用
      • 但是有一些 AI 总结的文档 写的也不错,还可以进行问答

背景

笔者所在公司现在使用 PDF.js Express,但由于 PDF.js Express 收费昂贵且闭源,现调研转向 ZoteroReader 的可能性和迁移成本

常用功能对比

PDF 阅读

都是基于 pdf.js,差别不大

PDF.js Express

ZoteroReader

PDF 标注

PDF.js Express

提供丰富的标注工具:

  • 高亮、下划线、删除线、波浪线、荧光笔、文本、path 路径
  • 丰富的图形:矩形、圆形、直线、弧线、箭头…
  • 插入:附件、图片

ZoteroReader

有限的标注工具,仅有以下工具:

  • 高亮、下划线、文本、path 路径、note 标签、矩形

标注的存储与加载

PDF.js Express

const debouncedSave = debounce(() => {
  const annotationManager = webViewerCore?.annotationManager as any
  annotationManager
    .exportAnnotations({
      links: false,
      widgets: false,
    })
    .then((xfdfData: any) => {
      if (!libraryId) return
      ApiSavePDFNote({
        libraryId,
        note: xfdfData,
      }).catch(() => {
        message.error(trans(I18N.libraryV2.saveNoteFailed))
      })
    })
}, 800)
 
// 监听笔记修改
useEffect(() => {
  if (webViewerCore) {
    const { documentViewer, annotationManager } = webViewerCore
    // 监听文档加载
    documentViewer.addEventListener(
      webViewerCore.DocumentViewer.Events.DOCUMENT_LOADED,
      () => {
        annotationManager.addEventListener('annotationChanged', (annotations, action, { imported }) => {
          if (['delete', 'modify', 'add'].includes(action) && !imported) {
            if (libraryId) {
              debouncedSave()
            } else {
              bookmark(false)
              annotationManager.deleteAnnotations(annotations)
            }
          }
        })
      },
      { once: true },
    )
  }
}, [webViewerCore, libraryId])
 
// 笔记还原
useEffect(() => {
  if (webViewerCore && note) {
    const { documentViewer, annotationManager } = webViewerCore
    // 监听文档加载
    documentViewer.addEventListener(
      webViewerCore.DocumentViewer.Events.DOCUMENT_LOADED,
      () => {
        annotationManager
          .importAnnotations(note?.trim())
          .then(() => {
            message.success({ content: trans(I18N.libraryV2.loadNoteSuccess) })
          })
          .catch(() => {
            message.error({ content: trans(I18N.libraryV2.loadNoteFailed) })
          })
      },
      { once: true },
    )
  }
}, [webViewerCore, note])

ZoteroReader

源码

const reader = iframeWindow.createReader({
  // 监听笔记修改
  onSaveAnnotations: async function (annotations) {
    console.log('Save annotations', annotations)
  },
  onDeleteAnnotations: function (ids) {
    console.log('Delete annotations', JSON.stringify(ids))
  },
  // 其他配置...
})
 
// 笔记还原
reader.setAnnotations(annotations)

I18N

PDF.js Express

支持

instance.UI.setLanguage(isChinese ? 'zh_cn' : 'en')

ZoteroReader

支持,不过官网只有英文,需要开发者编写中文文案并加载

编写 ftl 文件(中文文案),进行加载。源码

划词弹窗自定义功能

PDF.js Express

// 划词 popup 添加自定义按钮
instance.UI.textPopup.add([
  // 翻译
  {
    type: 'customElement',
    render: () => <CustomPopupButton buttonType={EPopupButton.TRANSLATE} instance={instance} onButtonClick={onTranslate} />,
  },
  // AI 分析
  {
    type: 'customElement',
    render: () => <CustomPopupButton buttonType={EPopupButton.AIANALYSIS} instance={instance} onButtonClick={onAIAnalysis} />,
  },
])

ZoteroReader

也支持自定义

iframeWindow.addEventListener('customEvent', (event: any) => {
  if (event?.detail?.type !== 'renderTextSelectionPopup') {
    return
  }
  const { append } = event.detail
  append('2342423432')
})

跳转到指定位置并高亮区域

PDF.js Express

instance.Core.documentViewer.addEventListener('annotationsLoaded', () => {
  const { pos, unit } = qs.parse(location.search) as unknown as {
    pos: [number, [number, number, number, number]][]
    unit: string
  }
 
  if (pos?.[0] && unit === 'percent') {
    const [page, range] = pos[0]
    const newPage = Number(page)
    const newRange = range?.map(item => Number(item))
 
    const pageInfo = instance.Core.documentViewer.getDocument().getPageInfo(newPage) || {
      width: 0,
      height: 0,
    }
 
    const x1 = newRange[0] * pageInfo.width
    const y1 = newRange[1] * pageInfo.height
    const x2 = newRange[2] * pageInfo.width
    const y2 = newRange[3] * pageInfo.height
 
    onNavigate({
      tl: [x1, y1],
      br: [x2, y2],
      page: newPage - 1,
    })
  }
 
  if (pos?.[0] && unit !== 'percent') {
    const [page, range] = pos[0]
    onNavigate({
      tl: [Number(range[0]), Number(range[1])],
      br: [Number(range[2]), Number(range[3])],
      page: Number(page),
    })
  }
})

ZoteroReader

实际就是 PDF 跳转到指定位置 + 在该区域添加一个标注,ZoteroReader 都支持

其他

PDF 内链接跳转、快捷键(如 ctrl+z)、目录预览、搜索、缩放等其他功能都支持

总结

  • PDF.js Express 现在使用到的功能,ZoteroReader 都能实现,迁移可行性没问题
  • 迁移成本上:
    • ZoteroReader 少很多标注工具(如波浪线、图片,详见上文),需要开发,成本:⭐️⭐️⭐️⭐️⭐️
    • ZoteroReader 和 PDF.js Express 的标注数据格式不同,需要迁移老数据,成本:⭐️⭐️⭐️
    • ZoteroReader 仅有英文,中文需要开发添加,成本:⭐️
    • 跳转到指定位置并高亮区域未具体实践,需要看下,成本:⭐️⭐️