背景

图册业务需求:

  • 用户在后台上传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**/ //构建dataBufferBytedataBuffer = 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

java 后端处理PDF图册相关推荐

  1. java后端生成pdf模板合并单元格表格

    这里只放部分片段的代码 java中使用二维数组生成表格非常方便,但是每一维的数组都需要排好序,而且,在java中所谓的二维数组,三维数组等,其实都是多个一维数组组成的 /*** 添加子女教育规划表.* ...

  2. java itext 导出pdf文件_【Java,PDF】使用Itext实现PDF文件生成

    重要声明:本文章仅仅代表了作者个人对此观点的理解和表述.读者请查阅时持自己的意见进行讨论. 前言 有时候,业务系统要求提供一个PDF文件导出的功能,这时候我们就需要将数据库的对应数据查询出来,然后生成 ...

  3. Java纯后端生成PDF格式报表的三种方案(包含echarts图表)

    最近做了一个奇葩的需求,研究了一下Java纯后端生成PDF报表的方案,顺便将研究的方案做个总结复盘,分享一下. 需求分析:Java后端定时任务统计汇总成报表数据,并生成PDF格式的报表文件,并通过邮件 ...

  4. java word转pdf linux_java实现word转pdf在线预览(前端使用PDF.js;后端使用openoffice、aspose)...

    背景 之前一直是用户点击下载word文件到本地,然后使用office或者wps打开.需求优化,要实现可以直接在线预览,无需下载到本地然后再打开. 随后开始上网找资料,网上资料一大堆,方案也各有不同,大 ...

  5. Java Html转pdf实战

    Java Html转pdf实战 - 简书年尾手头没啥事,干起了打杂工作,最近帮忙解决后端项目里一个html批量转pdf速度慢的问题,项目里用到的转换工具是 wkhtmltopdf ,这货转单个html ...

  6. Java后端开发需要掌握什么

    Java后端开发需要掌握什么? 需要熟悉Apache.NginX.Tomcat.WildFly.Weblogic等Web服务器和应用服务器的使用,熟悉面向对象的设计原则,熟悉基于JSP和Servlet ...

  7. Java后端工程师必备书单(从Java基础到分布式)

    Java开发工程师一般负责后端开发,当然也有专门做Java Web的工程师,但是随着前后端的分离,越来越多的Java工程师需要往大后端方向发展. 今天我们就来介绍一下Java后端开发者的书单. 首先要 ...

  8. Java 后端开发面试总结:25 个技术专题(最全面试攻略)

    另送福利: java 面试准备 准确的说这里又分为两部分: 1.Java 刷题 2.算法刷题 Java 刷题:此份文档详细记录了千道面试题与详解:  !     私信我回复[03]即可免费获取 很多人 ...

  9. 17年毕业,三年跳槽大厂,如今Java后端开发高级岗位,拿下45k月薪

    前言 本内容来源于我17年毕业的学长,先在得物,后美团,如今准备跳槽了,以下内容为他的最近面试经历(以及每次面试前后总结的学习资料分享): 我从美团离职之后在广州呆了个把月,之前已经准备了半个多月,从 ...

  10. 震惊!2022 年秋招 Java 后端开发岗竟然一片红海!算法岗都不香了吗?

    据说,2022 年算法岗遇冷,BAT 暑期实习甚至收不到简历,Java 反而爆炸. 难道,Java 的春天(映射 Spring 全家桶)又要来了吗?作为 Java 领域的优质创作者(见下图),又可以在 ...

最新文章

  1. 使用nat方式解决虚拟机联网问题
  2. linux安装python2和3版本_Windows下安装Python2和Python3双版本
  3. Popupwin结合Timer实现定时弹出消息提示
  4. 收藏!企业数据安全防护5条建议
  5. 数据采集与清洗基础习题(二)Python爬虫常用模块,头歌参考答案
  6. cocos2d-x Lua与OC互相调用
  7. MySQL工作笔记-检索出某一时间段中的数据,并更新
  8. memcache java client_Memcache的客户端连接系列(一) Java
  9. JDBC之数据库的连接步骤(六步)
  10. 计算机一级第103套题,全国计算机等级考试一级试题
  11. html5 乱码解决方案
  12. 函数与导数中常用的函数和不等关系
  13. 关于iptables封禁国外ip的方法
  14. 最强分布式锁工具:Redisson
  15. Redis 客户端工具
  16. 基于matlab的简易诊断系统,基于matlab的图像识别
  17. 南阳oj 28 大数阶乘
  18. Q版京剧脸谱来喽——状元
  19. MySQL在服务里找不到(未卸载)
  20. SQL 身份证获取性别

热门文章

  1. 【消息轰炸】Python消息轰炸
  2. 【模拟IC】闩锁效应的概念,产生原因,工作过程及解决方案
  3. python算法入门
  4. android简单记账软件,简洁记账app
  5. Win11 在线安装QT5.15.2教程
  6. chrome历史版本下载
  7. 关于各操作系统对UVC协议支持的说明
  8. win32com excel转pdf
  9. win 10 linux shell,实用工具:Win10下的bash shell打开教程
  10. 海思烧写工具需要java_HiTool(海思芯片烧录工具)下载