java 后端处理PDF图册

java pdf 专栏收录该内容
1 篇文章 0 订阅

背景

图册业务需求:

  • 用户在后台上传pdf图册文件,前台可以进行pdf浏览,浏览方式为左右翻页模式(默认pdf是从上到下的),还有其他玩法,本质是花样看图(翻页电子书)。
  • 后续又产生了付费需求:可以预览前5页,后面图册浏览需要付费查阅。

选型与过程

基于上述业务需求,我们简单进行需求拆解。

第一,pdf文件大小:需考量文件上传速度及下载速度;第二,浏览方式:需考量灵活性,图片化。

基于上述考量,以及交互方式,我们选定了第一种方案

  • 文件存储采用阿里云oss存储,前端服务直接跟oss存储交互,实现前端上传与下载,效率最大化(没有中间商赚差价)
  • 技术上选择pdf.js + canvas;上传时,前端解析pdf文件后,按页读流,利用canvas转化为图片后上传;浏览时,直接对每页的图片进行读取并呈现;

这里中间出了些插曲,技术选择没错,但执行时,顺序反了:pdf文件直接上传oss;浏览时将pdf下载再利用canvas切图后呈现。

结局已经预料:pdf大时,下载时间长,加载缓慢,再加上下载后再切图渲染,更是无法想象。

那回归第一种方案,会有问题么。还是有些问题的,主要是时间不允许。

后面的变化也是确实促使我们变更了方案,基于以下几点:

  • 前端的工作量大,在经历插曲后的变更,时间上更是不足。
  • 技术落地实践曲折,上传过程陆续经历了几次问题,时间愈发不宽裕。
  • 更深入思考技术细节:切图后的清晰度问题、图片压缩问题、图片命名规则问题、网络某个图片上传失败问题、大文件OOM问题、其他问题。

基于以上问题,我们进行了方案改进,可以归为第二种方案

  • 前端直接将pdf进行分片上传至oss; (保留了原pdf,后续即便出现未知pdf故障也可以脚本处理;(如默认分辨率不满意))
  • 后端新增pdf处理服务,从oss获取pdf后处理切图后,再将图片上传oss
  • 前端根据规则获取图片信息并呈现

这样做的好处是:

  • 前端只需要专注于呈现,屏蔽了一些处理细节。

也有个缺点

  • 用户上传pdf后立即预览,可能出现图片获取不到情况。(因为此时后端才开始pdf处理,有时延)

当然了,最后考虑到使用场景,图册pdf制作需要时间,更新频率不会太高;我们保证其最终可见性,目前是足以支撑业务的。

设计原则:管理后台功能优先,前台体验优先

pdfBox

pdf技术选择

java实现pdf处理的技术现有技术大概有几种:pdfbox、PDFRenderer、jpedal、itext、ICEPDF。

pdfbox:是appach出品,开源、免费、今年还在更新。

PDFRenderer:sum出品,只有一个2012年版本0.9.1-patched,不大行的样子

jpedal:收费

itext:AGPL / 商业软件的双重许可。AGPL是免费/开源软件许可证。这并不意味着该软件是免费的

ICEPDF:切图后质量不大行,有水印的pdf,切图后水印会特别清晰。

基于以上调研,最终选择了pdfbox。

pdf处理中遇到的问题

  • java.awt.AWTError: Assistive Technology not found: org.GNOME.Accessibility.AtkWrapper
  • 现象:本地正常,无此问题,pass部署后第一次调用pdf处理时报error错误。
  • 排查:
  • 根据报错信息初步判断,这应该是某个类不存在。(大意是说该辅助技术不存在)
    • 其初始化采用单例模式,如果有配置Assistive Technology(辅助技术),则会实例化该辅助技术。
    • 追溯内部代码,pdf处理后生成图片使用java.awt.toolkit工具包。
  • 原因:
  • toolkit类内部会基于spi机制加载辅助技术 assistive_technologies,该辅助技术非必须。
    • 所以,这是一起由jdk版本不同/环境不同、引发的问题
    • pass上基础镜像jdk为: java-8-openjdk,其内部配置assistive_technologies,却无引入具体类,导致第一次初始化时异常。
    • 本地是jdk为jdk1.8.0_221,无配置assistive_technologies,无加载问题
    • 该配置文件在jdk/accessibility.properties 中。
  • 解决:
  • 第一种:修改jdk/accessibility.properties 配置: 注释assistive_technologies
    • 第二种:因为内部初始化为单例模式,初始化后toolkit对象存在则不在初始化,预先初始化。
  • java.lang.OutOfMemoryError: Java heap space
  • 现象: 上传一个188M pdf文件时,在某几页的处理会出现 OOM 堆内存溢出

造成OutOfMemoryError原因一般有2种:

  • 内存泄露,对象已经死了,无法通过垃圾收集器进行自动回收,通过找出泄露的代码位置和原因,才好确定解决方案;
  • 内存溢出,内存中的对象都还必须存活着,这说明Java堆分配空间不足,检查堆设置大小(-Xmx与-Xms),检查代码是否存在对象生命周期太长、持有状态时间过长的情况。
  • 排查:
  • 启动加入参数:-XX:+HeapDumpOnOutOfMemoryError, 进行对OOM日志dump
    • OOM后进行日志分析,其占用空间为2部分:
  • 第一部分:原pdf所需内存。
    • 第二部分:每一页的pdf转图片过程需要的内存。(主要内存占用在此部分)
  • 针对第一部分,官方倒是有一个配置:MemoryUsageSetting.setupTempFileOnly();
  • 即原pdf暂存在外存中,而非内存,减轻主内存暂用。
  • 针对第二部分
  • 基本流程
  • 取某一页的pdf流,进行解析;解析后的像素数据写入BufferedImage中,在调用原生java.awt.image 画图生成。
  • 内部涉及pdf的解析、渲染+渲染算法、是否允许下采样等等。

oom问题源码解析

此部分基于OOM问题引出,目的是为了了解为什么需要那么多的内存;进行源码追踪下:  

  /**1**/  //先将pdf文件load进pdf结构 PdfDocument中,本质是内部的ScratchFile(暂存文件)存储
 PDDocument load = PDDocument.load(new File("D:\\pdfToImg\\test3\\28.pdf"));
 //实例pdf渲染器进行pdf转图片
 new PDFRenderer(load).renderImageWithDPI(0, 100);
 ...
    //绘制页面
  drawer.drawPage(g, page.getCropBox());
    //初始化并处理流的内容
    processPage(getPage());
        //处理pdf内容流
        processStream(page);
        //处理内容流的运算符。
            processStreamOperators(contentStream);
            /**2**/
            PDFStreamParser parser = new PDFStreamParser(contentStream);
          /**3**/
          while (token != null) {
            ...
            //处理操作
            processOperator((Operator) token, arguments);
                //具体操作者:策略模式,不同类型不同操作者
                processor.process(operator, operands);
                    //第一类:font,解析pdf文字、含字体、格式、大小、位置等
                    //创建一个新的inputStream,读取的是解码后的流数据 
                    COSInputStream.create(getFilterList(), this, input, scratchFile, options);
                    
                  //第二类:PDImageXObject 图像对象
                   context.drawImage(image);
                   
                   /**4**/  //是否允许下采样
                   if (subsamplingAllowed) {...}else{drawBufferedImage(pdImage.getImage(), at);}
                      //默认获取rgb图像
                        SampledImageReader.getRGBImage(this, region, subsampling, getColorKeyMask());
                        //非彩色8位图像绘制图像
                        from8bit(pdImage, raster, clipped, subsampling, width, height);
                            pdImage.createInputStream(options);
                            getStream().createInputStream(options);
                                stream.createInputStream(options)
                                COSInputStream.create(getFilterList(), this, input, scratchFile, options);
                                    /**5**/
                                  for (int i = 0; i < filters.size(); i++){DecodeResult result = filters.get(i).decode(input, new RandomAccessOutputStream(buffer), parameters, i, options)}
                                    ...
                                    imageType.createBufferedImage(destWidth, destHeight);
                                                        ...
                                    /**6**/ //构建dataBufferByte
                                    dataBuffer = new DataBufferByte(size, numBanks);
                                    
            token = parser.parseNextToken;
                    }

大致代码流程如上,我们重点关注注释如:/**1**/ 格式的;其中

1,2,6代表了内存分配;

3,5是循环分支,6在其内,意味着会不断进行内存分配;

4  是否允许下采样:如果允许,其会计算图像像素与绘制像素的比例,当计算出比例越大时,占用内存会越少。

下采样:对于一幅图像I尺寸为M*N,对其进行s倍下采样,即得到(M/s)*(N/s)尺寸的得分辨率图像

目的:1.使得图像符合显示区域的大小。2.生成对应图像的缩略图。

最终定位到6内,部分token解析后绘制成图所需的内存巨大,pdf越是精致,越是巨大。

这个跟图像的着色、轮廓、纹理、像素点、边缘锯齿、抖动等相关。

这里水有点深,概念上就有分辨率、容量、清晰度、像素、矢量图、位图、栅格化、插值算法。

也是头大,但不是我们关注的点。

总之,一套流程下来,我们发现某些pdf的转化确实需要巨大的内存,典型的空间复杂度高。

空间复杂度:表现在内存占用大小

所以,这是个正常内存溢出,并非某些流或对象未及时关闭,本质上还是需要扩大虚拟机堆内存。

那就真的无法优化么?有的,但作用微末;接下来说明。

oom问题优化

经测试,某24M的单页pdf图,转化成图片大约需要800M内存。(就是这么夸张!)

优化总结

  • PDDocument.load(file, MemoryUsageSetting.setupTempFileOnly())
  • 将pdf暂存在本地磁盘,即省出了内存空间;像100M的pdf就能省100M内存呢
  • PDFRenderer.renderImageWithDPI(i,72);
  • 降低dpi,减少dpi比例,也可以一定程度上优化,但在呈现上跟原图比会有所缩放。

DPI(Dot Per Inch) 表示打印分辨率,指每英寸长度上的点数

  • PDFRenderer.setSubsamplingAllowed(true);
  • 允许下采样,下采样可以在更快、更小的内存密集型情况下使用,但它也可能导致质量的损失,尤其是针对高空间频率的图像
  • 通过-Xmx增加最大堆内存
  • 终极大法,扩大内存

pdfbox官方也有oom问题的处理建议,如下:

I'm getting an OutOfMemoryError. What can I do?

The memory footprint depends on the PDF itself and on the resolution you use for rendering. Some possible options:

  • increase the -Xmx value when starting java
  • use a scratch file by loading files with this code PDDocument.load(file, MemoryUsageSetting.setupTempFileOnly())
  • be careful not to hold your images after rendering them, e.g. avoid putting all images of a PDF into a List
  • don't forgot to close your PDDocument objects
  • decrease the scale when calling PDFRenderer.renderImage(), or the dpi value when calling PDFRenderer.renderImageWithDPI()
  • disable the cache for PDImageXObject objects by calling PDDocument.setResourceCache() with a cache object that is derived from DefaultResourceCache and whose call public void put(COSObject indirect, PDXObject xobject) does nothing. Be aware that this will slow down rendering for PDF files that have an identical image in several pages (e.g. a company logo or a background). More about this can be read in PDFBOX-3700.

更多细节参考:pdfbox官方答疑

图册文件加密设计

一个pdf,可能含200+的页码,切成图片后分开存放,即产生200+记录。

如果存储在库里,有点浪费空间,同时还是能通过接口规则获取数据。

如果单纯的通过统一路径后加1、2、3、4,也是很容易的推导后续的数据。

所以需要制定内部加密规则。

加密 的基本过程,就是对原来为 明文 的文件或数据按 某种算法 进行处理,使其成为 不可读的一段代码,通常称为 “密文”。通过这样的途径,来达到 保护数据 不被 非法人窃取、阅读的目的。

基本流程

明文  + 规则(密钥)  -> 密文   (典型的对称加密的加密段)

明文为uuid:如数据库存放格式:/fileUrl/68428de9168548f3a9da61a6ee5faaf3  ,  黑体部分即明文

规则: 即密钥:rule = "......" ;

密文: 为具体的oss文件名:/fileUrl/6g8428de9168548f3a9da61a6ee5faaf,这是第一页/张

 /fileUrl/68z428de9168548f3a9da61a6ee5faaf2  ,  这是第二页/张

#加密规则:具体看相关代码,含java版,js版

java代码如下

public class PdfHandler {
    //读取配置文件
    private static final String BUCKET_NAME = SwjConfig.get("bucketName");
    private static final String ENDPOINT = SwjConfig.get("endpoint");
    private static final String ACCESS_KEY_ID = SwjConfig.get("access_key_id");
    private static final String ACCESS_KEY_SECRET = SwjConfig.get("access_key_secret");

    public Integer pdfHandle(String pdfUrl) {
       return this.pdfHandle(pdfUrl, initOssClient(), BUCKET_NAME);
    }

    public Integer pdfHandle(String pdfUrl, OSS ossClient, String bucketName) {
        log.info("pdf处理开始:{}", pdfUrl);
        if (pdfNotExist(pdfUrl, ossClient, bucketName)) {
            return null;
        }

        try (OSSObject object = ossClient.getObject(bucketName, pdfUrl);
             PDDocument document = PDDocument.load(object.getObjectContent(), MemoryUsageSetting.setupTempFileOnly())) {
            log.info("pdfDocument生成完成");

            initToolkit();

            String uuid = pdfUrl.substring(pdfUrl.lastIndexOf("/") + 1);
            String prefix = pdfUrl.substring(0, pdfUrl.lastIndexOf("/") + 1);
            PDFRenderer pdfRenderer = new PDFRenderer(document);
            BufferedImage image;
            //切图并压缩
            for (int i = 0; i < document.getNumberOfPages(); i++) {
                pdfRenderer.setSubsamplingAllowed(true);
                image = pdfRenderer.renderImageWithDPI(i, 160, ImageType.RGB);
                try (InputStream inputStream = compressImage(image)) {
                    if (i % 10 == 0) {
                        log.info("当前处理页:{}", i + 1);
                    }
                    //上传
                    String key = prefix.concat(PdfHelper.uuidBuilder(uuid, i + 1));
                    ossClient.putObject(bucketName, key, inputStream);
                }
            }
            log.info("pdf处理结束");
            return document.getNumberOfPages();
        } catch (OSSException oe) {
            log.error("ossException: " + oe.getErrorMessage());
            throw oe;
        } catch (ClientException ce) {
            log.error("clientException: " + ce.getErrorMessage());
            throw ce;
        } catch (IOException e) {
            log.error("ioeException: " + e.getMessage());
            throw new ServiceException(e.getMessage());
        } finally {
            ossClient.shutdown();
        }
    }


    /**
     * 初始化ossClient
     *
     * @return oss
     */
    private OSS initOssClient() {
        return new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET, getClientConfiguration());
    }

    /**
     * 压缩图片
     *
     * @param image image
     * @return InputStream
     * @throws IOException IOException
     */
    private InputStream compressImage(BufferedImage image) throws IOException {
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
            Thumbnails.of(image)
                    .scale(1)
                    .outputFormat("jpg")
                    .outputQuality(0.9f)
                    .toOutputStream(byteArrayOutputStream);
            return new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        }
    }

    /**
     * 判断pdf 是否存在
     *
     * @param pdfUrl     pdfUrl
     * @param ossClient  ossClient
     * @param bucketName bucketName
     * @return true: 不存在  false:存在
     */
    private boolean pdfNotExist(String pdfUrl, OSS ossClient, String bucketName) {
        if (!ossClient.doesObjectExist(bucketName, pdfUrl)) {
            log.info("pdf不存在: {}", pdfUrl);
            return true;
        }
        return false;
    }


    /**
     * 初始化toolkit  java-8-openjdk
     * toolkit内部会基于spi机制加载辅助技术 assistive_technologies,非必须
     * jdk1.8.0_221中无配置assistive_technologies,无加载问题
     * 但在java-8-openjdk中会配置assistive_technologies,却无引入具体类,会报异常
     * <p>
     * 解决方案:
     * 第一种:修改jdk/accessibility.properties 配置: 注释assistive_technologies
     * <p>
     * 第二种:因为内部初始化为单例模式,初始化后toolkit对象存在则不在初始化
     * <p>
     * 这里采用粗暴的第二种,因为第一种需要修改docker镜像配置,不属于管辖内;
     */
    private void initToolkit() {
        try {
            Toolkit.getDefaultToolkit();
        } catch (AWTError e) {
            log.info("error: {}", e.getMessage());
        }
    }

    public ClientBuilderConfiguration getClientConfiguration() {
        // 创建ClientConfiguration。ClientConfiguration是OSSClient的配置类,可配置代理、连接超时、最大连接数等参数。
        ClientBuilderConfiguration conf = new ClientBuilderConfiguration();
        // 设置OSSClient允许打开的最大HTTP连接数,默认为1024个。
        conf.setMaxConnections(2048);
        // 设置Socket层传输数据的超时时间,默认为50000毫秒。
        conf.setSocketTimeout(20000);
        // 设置建立连接的超时时间,默认为50000毫秒。
        conf.setConnectionTimeout(20000);
        // 设置从连接池中获取连接的超时时间(单位:毫秒),默认不超时。
        conf.setConnectionRequestTimeout(5000);
        // 设置连接空闲超时时间。超时则关闭连接,默认为60000毫秒。
        conf.setIdleConnectionTime(10000);
        // 设置失败请求重试次数,默认为3次。
        conf.setMaxErrorRetry(5);
        return conf;
    }
}
public class PdfHelper {
    /**
     * uuid规则构造器
     * 原理:去除最后一位字符,再取剩下最后一位字符为起始值,经过规则转换后,插入第i个位置;
     * 规则:ruleMark
     * 如ABCD,1 -> C ABC 1
     * 如ABCD,2 -> D ABC 2
     *
     * @param sourceUuid 源id
     * @param pageNum    页码 第n页
     * @return 规则后的uuid
     */
    public static String uuidBuilder(String sourceUuid, int pageNum) {
        String splitUuid = sourceUuid.substring(0, sourceUuid.length() - 1);
        String publicMark = splitUuid.substring(splitUuid.length() - 1);
        String ruleMark = ruleMark(publicMark, pageNum);
        int index = pageNum;
        while (index > splitUuid.length()) {
            index = index - splitUuid.length();
        }
        return splitUuid.substring(0, index) + ruleMark + splitUuid.substring(index) + pageNum;
    }

    public static String ruleMark(String mark, int pageNum) {
        String rule = "abcdefghijklnmopqrstuvwxyz1234567890";
        int index = rule.indexOf(mark) + pageNum;
        while (index > rule.length() - 1) {
            index = index - rule.length();
        }
        char c = rule.charAt(index);
        return String.valueOf(c);
    }

}

js代码如下

 

/**
* uuid规则构造器
* 原理:去除最后一位字符,再取剩下最后一位字符为起始值,经过规则转换后,插入第i个位置;
* 规则:ruleMark
* 如ABCD,1 -> C ABC 1
* 如ABCD,2 -> D ABC 2
*
* @param sourceUuid 源id
* @param pageNum 页码 第n页
* @return string 规则后的uuid
*/

function uuidBuilder(sourceUuid, pageNum) {
const ruleMark = (mark, pageNum) => {
const rule = 'abcdefghijklnmopqrstuvwxyz1234567890'
let index = rule.indexOf(mark) + pageNum
while (index > rule.length - 1) {
index = index - rule.length
}
const c = rule.charAt(index)
return c
}
const splitUuid = sourceUuid.substring(0, sourceUuid.length - 1)
const publicMark = splitUuid.substring(splitUuid.length - 1)
const ruleMarkV = ruleMark(publicMark, pageNum)
let index = pageNum
while (index > splitUuid.length) {
index = index - splitUuid.length
}
return splitUuid.substring(0, index) + ruleMarkV + splitUuid.substring(index) + pageNum
}

export default uuidBuilder 

  • 1
    点赞
  • 2
    评论
  • 1
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值