轶哥

📚 Having fun with AI Agent. Always learning.

    适用于嵌入式设备的高性能PDF在线预览方案
    •   更新:2020-11-16 20:05:14
    •   首发:2020-11-12 23:49:23
    •   教程
    •   4944

    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中的代码都“比较粗糙”,需要进一步完善。

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

    👍 👍 👍

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

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

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

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

    2020年11月23日 06:40回复
    鱼弦

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

    我的意思是那个只显示10页的那种优化方案。我看了看代码还是要获取分片。

    2020年11月23日 06:44回复
    轶哥

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

    2020年11月23日 06:47回复
    尚未登陆
    发布
      上一篇 (NuxtJS中Axios的配置说明)
    下一篇 (从Apple备忘录导入到Standard Notes)  

    评论回复提醒