轶哥

妄图改变世界的全栈程序员。

适用于嵌入式设备的高性能PDF在线预览方案

desktop.jpeg

对于内存较小的嵌入式设备或者移动设备(例如带屏智能音响,安卓、IOS平台的设备),在线预览PDF功能经常会导致内存溢出、应用程序闪退。

对于非常大的PDF文档,一次性下载整个文档将消耗大量的时间,即使开启分段下载,PDF文档正常下载完成并加载,超大的内存占用也会导致设备运行卡顿。

关于《如何实现高性能的在线 PDF 预览》,原作者(子木)已经描述得非常详细了,这是一篇深度好文,值得认真拜读。我所采用的方案和该文章描述完全一致,甚至前端demo完全采用了这篇文章介绍的方法。对于其中实现原理,我不再赘述,只对方案进行补充说明。

方案目的

提供在线查看PDF文件的JS接口。查看文件时,用户可以对PDF文件的进行旋转、缩放、跳转到指定页码。

在客户端,受限于内存,如果直接预览超大PDF可能内存溢出,即便进行前端分页加载,在首屏加载时也会异常的慢。PDF预览接口需要将PDF的内容交由中间服务进行分片,将一个大的PDF文件分成多个小文件。比如某个PDF有100页,我们按照5页一片,将它切分成20片,每次只下载用户看到的那1-2个分片。然后在用户进行滚动翻页的时候,异步去下载包含对应页码的分片。

PDF文件可能会很大,比如一个1000页的PDF文件。随着用户的滚动浏览,浏览器会一直渲染页码,如果最终同时将所有页面的DOM都加载到页面上,内存占用将会非常多,导致系统卡顿。因此,为了减少内存占用,需要自动清理已渲染但不再需要的内容。

后端主要代码及Demo

后端采用itext7对PDF进行裁剪。代码很简单,就是照抄官网示例。根据实际需求选用splitByPageCount(切割为固定页数的PDF文件)或splitByPageNumbers(切割指定页码),参考https://api.itextpdf.com/iText7/java/7.1.7/com/itextpdf/kernel/utils/PdfSplitter.html#splitByPageNumbers-java.util.List-

private static String splitPDF(String perpage, String url) throws IOException {
        String hash = CryptoUtils.hashKeyForDisk(perpage + "-" + url); // 根据perpage和url获取hash值
        String pageCountFilePath = PUBLIC_PATH + "/" + hash + "/pageCount.txt";
        String filePath = PUBLIC_PATH + "/" + hash + "/" + hash + ".pdf";
        List<String> urlList = new ArrayList<>();
        File file = new File(PUBLIC_PATH + "/" + hash);

        if (!file.exists() && !file.isDirectory()) { // 如果文件不存在
            if (!file.mkdir()) {
                return "";
            }

            FilesUtils.saveUrlAs(url, filePath); // 从URL下载文件

            final int maxPageCount = Integer.parseInt(perpage); // create a new PDF per X pages from the original file
            PdfDocument pdfDocument = new PdfDocument(new PdfReader(new File(filePath)));
            PdfSplitter pdfSplitter = new PdfSplitter(pdfDocument) { // PDF分割
                int partNumber = 1;

                @Override
                protected PdfWriter getNextPdfWriter(PageRange documentPageRange) {
                    try {
                        String filePath = PUBLIC_PATH + "/" + hash + "/" + partNumber++ + ".pdf";
                        urlList.add(filePath.replace(PUBLIC_PATH, HOST));
                        return new PdfWriter(filePath);
                    } catch (final FileNotFoundException ignored) {
                        throw new RuntimeException();
                    }
                }
            };

            pdfSplitter.splitByPageCount(maxPageCount, (pdfDoc, pageRange) -> pdfDoc.close()); // 根据perpage分割
//        pdfSplitter.splitByPageNumbers(maxPageCount); // 根据pageNumber分割
            int pageCount = pdfDocument.getNumberOfPages(); // 总页数

            pdfDocument.close();

            File pageCountFile = new File(pageCountFilePath);
            if (!pageCountFile.exists() && !pageCountFile.createNewFile()) {
                throw new RuntimeException("Can't createNewFile 'pageCount.txt'.");
            }
            FileWriter fw = new FileWriter(pageCountFilePath);
            fw.write(String.valueOf(pageCount));
            fw.close();

            Map<String, Object> out = new HashMap<>();
            out.put("totalPage", pageCount);
            out.put("urlList", urlList);
            return JSON.toJSONString(out);
        } else { // 如果文件已经存在
            int totalPage = 0;
            File pageCountFile = new File(pageCountFilePath);
            if (pageCountFile.exists()) { // 读取总页数记录
                InputStream is = new FileInputStream(pageCountFilePath);
                BufferedReader reader = new BufferedReader(new InputStreamReader(is));
                String line = reader.readLine();
                totalPage = Integer.parseInt(line);
                is.close();
                reader.close();
            }

            File[] fileArray = file.listFiles();
            String fileFirstPath = file.toPath().toString().replace(PUBLIC_PATH, HOST) + "/";
            if (fileArray != null) {
                for (File value : fileArray) { // 还原下载地址
                    if (value.isFile() && !value.getName().equals("pageCount.txt") && !value.getName().equals(hash + ".pdf")) {
                        urlList.add(fileFirstPath + value.getName());
                    }
                }
            }

            urlList.sort(Comparator.comparingInt(str -> Integer.parseInt(str.replace(fileFirstPath, "").replace(".pdf", ""))));

            Map<String, Object> out = new HashMap<>();
            out.put("totalPage", totalPage);
            out.put("urlList", urlList);
            return JSON.toJSONString(out);
        }
    }

完整的Demo代码:https://github.com/yi-ge/pdf-split

跟子木同学实现方案不一样的是,我一次性返回了所有分片的下载地址。

请求参数:

POST https://pdf.ykfz.pw/

Body(Json):

{
    "perpage": 5,
    "url": "https://cdn.wyr.me/files/2020-11-12/demo.pdf"
}

返回值:

{
    "totalPage": 177,
    "urlList": [
        "https://pdf.ykfz.pw/91c8c56d2b56e071ecabeddb8d09cbf1/1.pdf",
        "https://pdf.ykfz.pw/91c8c56d2b56e071ecabeddb8d09cbf1/2.pdf",
        "https://pdf.ykfz.pw/91c8c56d2b56e071ecabeddb8d09cbf1/3.pdf",
        "https://pdf.ykfz.pw/91c8c56d2b56e071ecabeddb8d09cbf1/4.pdf",
        "https://pdf.ykfz.pw/91c8c56d2b56e071ecabeddb8d09cbf1/5.pdf",
        "https://pdf.ykfz.pw/91c8c56d2b56e071ecabeddb8d09cbf1/6.pdf",
        ......
    ]
}

Demo特点:

  • 通过urlperpage计算哈希值,无需依赖数据库,重复请求不会再次下载文件
  • 自动清理n天以前的文件

注意:

  • 这个Demo真的只是一个Demo,速成的,仅供学习,请勿直接用于生产环境
  • 没有进行分布式设计,不支持部署多个Pod运行
  • itext7用于生产环境您可能需要向itext7支付授权费用
  • 支持付费定制,提供Node.js版本

前端Demo

线上Demo地址:https://pdf-demo.ykfz.pw/

完整的Demo代码:https://github.com/yi-ge/pdf-split-demo

Demo基本上照搬了子木同学的代码。感谢!

更多

Q: 为什么不将PDF文件转换为图片进行加载?

A: 在Web平台,此方案和直接预览PDF本质上没有太大的区别,也可以做到高性能。itext7也为PDF转图片功能提供了API。需要根据具体业务场景选择方案,有些PDF带特殊字体,需要针对性做处理。

Q: 为什么说这个Demo不能直接用于生产环境?

A: PDF预览需要考虑的因素较多,例如同一个PDF文件,不同页面的页面大小可能不一样。在高并发环境下,这样需要做大量计算和硬盘IO的任务,是需要进行任务队列限流、操作分布式文件系统或操作对象存储来实现PDF切割的。针对加密PDF文档,需要进行解密;针对有水印的PDF文档,需要还原水印(推荐文章《移动端pdf预览-水印&电子签章问题》);针对有数字签名的PDF,需要进行校检。Demo中的代码都“比较粗糙”,需要进一步完善。

打赏
交流区(4)
小苦瓜一点也不苦

👍 👍 👍

2020年11月13日 12:45回复
鱼弦

hi,轶哥。我想问下PDF.JS的按需加载是不是要基于PDF分片呢?

2020年11月23日 14:39回复
轶哥

不是的哈。自带的按需加载只需要静态资源服务器支持即可。

2020年11月23日 14:40回复
轶哥

嗯嗯,那就用分片的方案呗。

2020年11月23日 14:47回复
尚未登陆
发布
  上一篇
下一篇 (从Apple备忘录导入到Standard Notes)  

评论回复提醒