前言
本文主要从全栈角度详解加载pdf优化方案,服务端通过node,nestjs
服务端上传 pdf 文件同时切片 pdf 为图片,再到移动端 h5 优先加载切片图直至原 pdf 文件资源加载完成后显示 pdf 源文件并且支持用户手势缩放,复制。
背景
前端页面常有加载 pdf 的需求,尤其政府事业单位,金融行业等,而 PDF 文件是一个包含多个对象(如文本、图像、字体等)的容器,这些对象在文件中可能以任意顺序存储,需要网络下载整个 pdf 文件后解析和渲染才能正确显示到页面,当一个 pdf 有几十 MB 甚至几百 MB,用户需要等待很长一段时间才能看到 PDF 文件内容,在移动端打开 pdf 文件的等待时间会更漫长
方案对比
-
HTTP Range Requests headers
-
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加载效果,右图为分片方案加载效果
环境与使用到的技术点
- 服务端: node,nestjs,MulterModule,ServeStaticModule,FileInterceptor,postman工具
- 前端:http-server(全局安装),pdfjs,pdfh5
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切图需要的工具包:
pdfjs-dist
操作pdf文件工具包(文档地址),避免新版本的用法不同,本文安装固定包为@2.7.570
canvas
通过画布读取pdf流生成图片@types/pdfjs-dist
为pdfjs-dist
声明文件包
shell
npm install [email protected] 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依赖包...,后期会出一篇优化方案文章,敬请期待!