惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
H
Help Net Security
腾讯CDC
宝玉的分享
宝玉的分享
H
Hackread – Cybersecurity News, Data Breaches, AI and More
L
LangChain Blog
爱范儿
爱范儿
T
The Blog of Author Tim Ferriss
J
Java Code Geeks
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
MyScale Blog
MyScale Blog
Engineering at Meta
Engineering at Meta
N
Netflix TechBlog - Medium
D
Docker
V
V2EX
Last Week in AI
Last Week in AI
G
Google Developers Blog
IT之家
IT之家
C
Check Point Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
人人都是产品经理
人人都是产品经理
博客园 - 叶小钗
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 聂微东

轶哥博客

blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog blog
blog
2020-11-12 · via 轶哥博客

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