高性能加载 pdf 全栈方案

前言

本文主要从全栈角度详解加载pdf优化方案,服务端通过node,nestjs 服务端上传 pdf 文件同时切片 pdf 为图片,再到移动端 h5 优先加载切片图直至原 pdf 文件资源加载完成后显示 pdf 源文件并且支持用户手势缩放,复制。

背景

前端页面常有加载 pdf 的需求,尤其政府事业单位,金融行业等,而 PDF 文件是一个包含多个对象(如文本、图像、字体等)的容器,这些对象在文件中可能以任意顺序存储,需要网络下载整个 pdf 文件后解析和渲染才能正确显示到页面,当一个 pdf 有几十 MB 甚至几百 MB,用户需要等待很长一段时间才能看到 PDF 文件内容,在移动端打开 pdf 文件的等待时间会更漫长

方案对比

  1. HTTP Range Requests headers

  2. pdf 分片切图

    方案一:为前端对 pdf 文件进行网络分页请求,network 并行请求达到pdf page资源加速下载过程,需要修改服务端 支持Content-Range,但这种方式有个问题:可视范围内的pdf page优先加载不可控。

    方案二:为本章节主要介绍的内容,优点是能优先加载出可视范围内的 pdf 内容,能大大提高首屏渲染pdf内容时间,但缺点是分片显示的 pdf 图片不支持缩放复制,直至原 pdf 文件加载完成后才支持手势缩放,复制。

pdf分片切图方案效果对比

运行环境:在Chrome浏览器,模拟移动端加载pdf,network设置为4G

加载的pdf文件:NVIDIA2024第一季度财报pdf文件,页数在183,文件大小:34.8MB

左图为原pdf加载效果,右图为分片方案加载效果

环境与使用到的技术点

  1. 服务端: node,nestjs,MulterModule,ServeStaticModule,FileInterceptor,postman工具
  2. 前端:http-server(全局安装),pdfjspdfh5

node服务端

服务端主要负责将上传pdf资源按照页码将pdf切成多个图片,和请求加载pdf api,接口返回pdf资源地址和切片生成的图片资源地址,使用nestjs框架

1. nestjs 创建项目和安装依赖

首先快速创建nestjs项目和upload资源模块,如需要了解细节请务必看完:nestjs入门实战(二):上传图片, 修改upload.module允许pdf格式上传

diff 复制代码
  // src/upload/upload.module.ts
  
  fileFilter: (req, file, cb) => { 
     if ( 
       file.mimetype === 'image/jpeg' || 
       file.mimetype === 'image/png' || 
+       file.mimetype === 'application/pdf' ) { 
         cb(null, true); 
       } else { 
         cb(new Error('Only images (JPEG, PNG) and PDF files are allowed...'), false);
         } 
       } 

安装pdf切图需要的工具包:

  1. pdfjs-dist 操作pdf文件工具包(文档地址),避免新版本的用法不同,本文安装固定包为@2.7.570
  2. canvas 通过画布读取pdf流生成图片
  3. @types/pdfjs-distpdfjs-dist声明文件包
shell 复制代码
npm install pdfjs-dist@2.7.570 canvas @types/pdfjs-dist --save

2. pdf文件每个page切成图片

按照pdf文件页码切为图片,每个page对应一张图片,切出来的图片存储在根目录下uploads/images/,我们需要再启动文件main.ts中检查uploads/images/是否存在,不存在则需要创建

diff 复制代码
// src/main.ts

async function bootstrap() {
...
+ const imagesDir = join(process.cwd(), 'uploads/images');
+  if (!existsSync(imagesDir)) {
+    mkdirSync(imagesDir);
+  }
...
}

创建把pdf流转换为图片的Canvas

ts 复制代码
// src/upload/node-canvas-factory.ts

import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas';

export class NodeCanvasFactory {
    create(width: number, height: number) {
        const canvas = createCanvas(width, height);
        const context = canvas.getContext('2d');
        return {
            canvas,
            context,
        };
    }

    reset(canvasAndContext: { canvas: Canvas; context: CanvasRenderingContext2D }, width: number, height: number) {
        canvasAndContext.canvas.width = width;
        canvasAndContext.canvas.height = height;
    }

    destroy(canvasAndContext: { canvas: Canvas; context: CanvasRenderingContext2D }) {
        canvasAndContext.canvas.width = 0;
        canvasAndContext.canvas.height = 0;
        canvasAndContext.canvas = null;
        canvasAndContext.context = null;
    }
}

创建pdf读取并生成图片的基础类upload.service

ts 复制代码
// src/upload/upload.service.ts

import { Injectable } from '@nestjs/common';
import { promises as fs } from 'fs';
import * as path from 'path';
import { NodeCanvasFactory } from './node-canvas-factory';

// 使用 require 语句导入 pdfjs-dist
const pdfjsLib = require("pdfjs-dist/es5/build/pdf.js");

@Injectable()
export class UploadService {
    async convertPdfToImages(pdfPath: string, outputDir: string): Promise<string[]> {
        const pdfBuffer = await fs.readFile(pdfPath);
        const pdfDocument = await pdfjsLib.getDocument({ data: pdfBuffer }).promise;
        const numPages = pdfDocument.numPages;
        const imageUrls = [];
        for (let pageNum = 1; pageNum <= numPages; pageNum++) {
            const imageUrl = await this.processPage(pdfDocument, pageNum, outputDir);
            imageUrls.push(imageUrl);
        }
        return imageUrls;
    }

    private async processPage(pdfDocument, pageNumber: number, outputDir: string): Promise<string> {
        const page = await pdfDocument.getPage(pageNumber);
        const viewport = page.getViewport({ scale: 1.8 });
        const canvasFactory = new NodeCanvasFactory();
        const canvasAndContext = canvasFactory.create(viewport.width, viewport.height);

        const renderContext = {
            canvasContext: canvasAndContext.context,
            viewport: viewport,
            canvasFactory: canvasFactory,
        };

        const renderTask = page.render(renderContext);
        await renderTask.promise;

        const imageBuffer = canvasAndContext.canvas.toBuffer();
        const outputFileName = path.join(outputDir, `output_page_${pageNumber}.png`);
        await fs.writeFile(outputFileName, imageBuffer);

        return `http://localhost:3000/uploads/images/output_page_${pageNumber}.png`;
    }
}

其中convertPdfToImages为读取pdf文件,并返回切片生成的图片地址,processPage函数则是将pdf 每页转换为图片。

现在需要在upload.controller.ts中添加对应上传pdf资源接口:uploadPdf

diff 复制代码
// src/upload/upload.controller.ts

...
export class UploadController {
    ...
    
+    @Post('/uploadPdf')
+    @UseInterceptors(FileInterceptor('file'))
+    async uploadPdf(@UploadedFile() file) {
+       const outputDir = join(process.cwd(), 'uploads/images');
+        const filePath = join(process.cwd(), 'uploads', file.filename);
+        const imageUrls = await this.uploadService.convertPdfToImages(filePath, outputDir);
+        return { urls: imageUrls };
+    }

}

现在通过postman提交pdf文件到接口:localhost:3000/upload/uploadPdf,使用NVIDIA2024第一季度财报为上传的pdf文件,页数在183,文件大小:34.8MB,上传过程中pdf切片的过程:

3.创建获取pdf和对应切片图地址的 api接口

upload.controller.ts中创建 Get方法获取 pdf和对应切片图地址,为了简单这里指定步骤2生成的静态资源地址即可

diff 复制代码
// src/upload/upload.controller.ts

import {
    Controller,
    Post,
    UploadedFile,
    UseInterceptors,
+    Get
} from '@nestjs/common';
...
export class UploadController {
    ...
    
+ @Get()
+ async getPdf() {
+        const baseUrl = 'http://localhost:3000/uploads/'
+        const pdf = `${baseUrl}1718437748872.pdf`;
+        const images = [] // 存储切片图
+       // 有187张图片
+        for (let i = 0; i < 187; i++) {
+            images.push(`${baseUrl}images/output_page_${i}.png`)
+       }
+        return { pdf, images };
+  }
+}

Postman Get请求: localhost:3000/upload 返回格式:

json 复制代码
{
   "pdf": "http://localhost:3000/uploads/1718437748872.pdf",
   "images": [ 
        "http://localhost:3000/uploads/images/output_page_0.png",
        "http://localhost:3000/uploads/images/output_page_1.png",
        "http://localhost:3000/uploads/images/output_page_2.png",
        .....
   ] 
}

4.服务端启动 CORS 允许跨域访问接口

主要为后文介绍前端项目服务为8080端口,与服务3000端口存在跨域问题,这里需要设置下 main.ts主入口文件,允许跨域访问 upload接口:

diff 复制代码
// src/main.ts

+  app.use('/uploads', (req, res, next) => {
+   res.header("Access-Control-Allow-Origin", "*");
+   res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
+    next();
+  });

  app.use('/uploads', express.static(join(process.cwd(), 'uploads')));

  // 启用 CORS
+  app.enableCors({
+    origin: '*', // 允许的前端地址
+    methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
+    credentials: true,
+ });
  

最终服务端成功切图存储到静态资源目录下,接下来介绍前端分片渲染pdf切片图

前端

本文主要介绍pdf在H5移动端加载速度优化方案,所以H5端显示pdf文件使用到开源框架pdfh5,本文使用html运行pdfh5,如果需要运行在React,Vue可点击参考作者对应框架Example

1. 创建前端项目web&&开启本地服务

创建前端项目web和创建index.html

html 复制代码
// index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>H5 performance loading PDF Viewer</title>
  </head>
  <body>
    <h2>H5 performance loading PDF Viewer</h2>
  </body>
</html>

由于前端是纯Html页面,需要本地开启服务,这里使用http-server,首先先全局安装

shell 复制代码
 npm i http-server -g

切换到 web目录,启动服务

shell 复制代码
 cd web
 http-server ./

浏览器访问localhost:8080显示:

2.pdfh5整合到web项目中

下载pdfh5项目

shell 复制代码
git clone https://github.com/gjTool/pdfh5.git

拷贝css,js文件到前端web项目中和修改index.html代码,请求之前写好的服务端接口localhost:3000/upload

这里直接上index.html代码吧

xml 复制代码
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
    <meta name="format-detection" content="telephone=no" />
    <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
    <meta http-equiv="pragma" content="no-cache" />
    <meta http-equiv="cache-control" content="no-cache" />
    <meta http-equiv="expires" content="0" />
    <title>PDFH5</title>
    <link rel="stylesheet" href="css/pdfh5.css" />
    <style>
      html, body {
        width: 100%;
        height: 100%;
        padding: 0;
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div id="demo"></div>
    <script src="js/pdf.js"></script>
    <script src="js/pdf.worker.js"></script>
    <script src="js/jquery-3.6.0.min.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/pdfh5.js" type="text/javascript" charset="utf-8"></script>
    <script type="text/javascript">
      var pdfh5;
      $(function () {
        // Perform AJAX GET request to retrieve PDF URL
        $.get('http://localhost:3000/upload', function(response) {
          if (response && response.pdf) {
            var pdfUrl = response.pdf;
            pdfh5 = new Pdfh5("#demo", {
              pdfurl: pdfUrl,
              pageNum: false,
              URIenable: false, //关闭浏览器地址栏file参数获取
              lazy: false,
            });
 
            //监听pdf渲染成功
            pdfh5.on("success", function (time) {
              time = time / 1000;
              console.log("pdf渲染完成,总耗时" + time + "秒");
    
            });

     
          } else {
            console.error('Failed to retrieve PDF URL.');
          }
        }).fail(function() {
          console.error('Failed to perform GET request.');
        });
      });
    </script>
  </body>
</html>

浏览器调成h5模式访问http://127.0.0.1:8080/,成功加载接口返回的pdf地址:

3.优先渲染pdf切片图

由于pdf显示需要network层下载完成后才能渲染,当pdf文件较大时用户需要等待很长一段时间,这样对用户不友好,可以在这等待时间时候先渲染切片图,当network层下载完成了pdf文件再remove切片图,显示pdf原文件,支持复制,放大等交互

添加切片图容器 .image-list并创建Image节点 src为服务接口返回的images数组中获取,优先加载第一张图,加载成功后顺序加载切片图

diff 复制代码
// index.html

...
<style>
+ .image-list {
+       padding: 12px 8px;
+       z-index: 999;
+     }
+     .image-list img {
+       box-sizing: border-box;
+       border: none;
+       padding: 0;
+       margin: 0;
+       width: 100%;
+       margin-bottom: 8px;
+       box-shadow: darkgrey 0px 1px 3px 0px;
+      }
</style>
...
+  <div class="image-list" ></div>
   <div id="demo"></div>

...
 <script type="text/javascript">
+ const imageList = $(".image-list");

    $.get("http://localhost:3000/upload", function (response) {
          if (response && response.pdf) {
            var pdfUrl = response.pdf;
+            var images = response.images;
+            loadImageSequentially(images);
...


+function loadImageSequentially(images) {
+        let index = 1;
+        function loadNextImage() {
+          if (index < images.length + 1) {
+            const img = new Image();
+           img.src = images[index];
+           img.onload = function () {
+              index++;
+             loadNextImage(); // Load next image
+            };
+            img.onerror = function (error) {
+             console.error("Failed to load image:", img.src, error);
+             index++;
+              loadNextImage(); // Skip to next image
+            };
+           imageList.append(img);
+         }
+        }
+        loadNextImage();
+      }
      

注意:.image-list节点一定要在 #demo节点前面,这样才能首屏优先看到的为pdf切片的图片资源 loadImageSequentially 函数为递归加载切片图片资源

当图片加载完毕后需要 remove.image-list节点

diff 复制代码
// index.html

  pdfh5.on("success", function (time) {
+	      imageList.remove();
              time = time / 1000;
              console.log("pdf渲染完成,总耗时" + time + "秒");
  });
            

效果:

由效果我们可以看出,当pdf加载成功后去除切片图会回调顶部,这样的交互非常不友好,这个切换的过程还需要保存滚动条位置不变:

diff 复制代码
// index.html

  pdfh5.on("success", function (time) {
+             const scrollTop = $(window).scrollTop();
	           imageList.remove();
+             document.querySelector(".viewerContainer")
                .scrollTo(0, scrollTop);
              time = time / 1000;
              console.log("pdf渲染完成,总耗时" + time + "秒");
  });
            

最终效果:

最终看到效果,当pdf源文件替换pdf切片图过程很丝滑,用户几乎感受不到其中的切换。至此高性能加载pdf全栈方案初步介绍告一段落。

总结

pdf切片图方案的文章有很多,但很少有从全栈角度详细解读,故写此文。需要读者对node,nestjs有简单的了解(我有开个专栏介绍nestjs可以点击了解),本文主要解说大致的前后端方案与联调步骤,当前方案许多细节可以进一步优化比如:切片图的压缩,获取pdf总宽高预置页面高度,图片虚拟列表,app native端本地load,pdfh5依赖包...,后期会出一篇优化方案文章,敬请期待!

源码地址

相关推荐
学习使我快乐013 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19953 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈4 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水5 小时前
简洁之道 - React Hook Form
前端
正小安7 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch9 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光9 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   9 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   9 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d