java 后端处理PDF图册
背景
圖冊(cè)業(yè)務(wù)需求:
- 用戶在后臺(tái)上傳pdf圖冊(cè)文件,前臺(tái)可以進(jìn)行pdf瀏覽,瀏覽方式為左右翻頁模式(默認(rèn)pdf是從上到下的),還有其他玩法,本質(zhì)是花樣看圖(翻頁電子書)。
- 后續(xù)又產(chǎn)生了付費(fèi)需求:可以預(yù)覽前5頁,后面圖冊(cè)瀏覽需要付費(fèi)查閱。
選型與過程
基于上述業(yè)務(wù)需求,我們簡(jiǎn)單進(jìn)行需求拆解。
第一,pdf文件大小:需考量文件上傳速度及下載速度;第二,瀏覽方式:需考量靈活性,圖片化。
基于上述考量,以及交互方式,我們選定了第一種方案:
- 文件存儲(chǔ)采用阿里云oss存儲(chǔ),前端服務(wù)直接跟oss存儲(chǔ)交互,實(shí)現(xiàn)前端上傳與下載,效率最大化(沒有中間商賺差價(jià))
- 技術(shù)上選擇pdf.js + canvas;上傳時(shí),前端解析pdf文件后,按頁讀流,利用canvas轉(zhuǎn)化為圖片后上傳;瀏覽時(shí),直接對(duì)每頁的圖片進(jìn)行讀取并呈現(xiàn);
這里中間出了些插曲,技術(shù)選擇沒錯(cuò),但執(zhí)行時(shí),順序反了:pdf文件直接上傳oss;瀏覽時(shí)將pdf下載再利用canvas切圖后呈現(xiàn)。
結(jié)局已經(jīng)預(yù)料:pdf大時(shí),下載時(shí)間長(zhǎng),加載緩慢,再加上下載后再切圖渲染,更是無法想象。
那回歸第一種方案,會(huì)有問題么。還是有些問題的,主要是時(shí)間不允許。
后面的變化也是確實(shí)促使我們變更了方案,基于以下幾點(diǎn):
- 前端的工作量大,在經(jīng)歷插曲后的變更,時(shí)間上更是不足。
- 技術(shù)落地實(shí)踐曲折,上傳過程陸續(xù)經(jīng)歷了幾次問題,時(shí)間愈發(fā)不寬裕。
- 更深入思考技術(shù)細(xì)節(jié):切圖后的清晰度問題、圖片壓縮問題、圖片命名規(guī)則問題、網(wǎng)絡(luò)某個(gè)圖片上傳失敗問題、大文件OOM問題、其他問題。
基于以上問題,我們進(jìn)行了方案改進(jìn),可以歸為第二種方案:
- 前端直接將pdf進(jìn)行分片上傳至oss; (保留了原pdf,后續(xù)即便出現(xiàn)未知pdf故障也可以腳本處理;(如默認(rèn)分辨率不滿意))
- 后端新增pdf處理服務(wù),從oss獲取pdf后處理切圖后,再將圖片上傳oss
- 前端根據(jù)規(guī)則獲取圖片信息并呈現(xiàn)
這樣做的好處是:
- 前端只需要專注于呈現(xiàn),屏蔽了一些處理細(xì)節(jié)。
也有個(gè)缺點(diǎn):
- 用戶上傳pdf后立即預(yù)覽,可能出現(xiàn)圖片獲取不到情況。(因?yàn)榇藭r(shí)后端才開始pdf處理,有時(shí)延)
當(dāng)然了,最后考慮到使用場(chǎng)景,圖冊(cè)pdf制作需要時(shí)間,更新頻率不會(huì)太高;我們保證其最終可見性,目前是足以支撐業(yè)務(wù)的。
設(shè)計(jì)原則:管理后臺(tái)功能優(yōu)先,前臺(tái)體驗(yàn)優(yōu)先
pdfBox
pdf技術(shù)選擇
java實(shí)現(xiàn)pdf處理的技術(shù)現(xiàn)有技術(shù)大概有幾種:pdfbox、PDFRenderer、jpedal、itext、ICEPDF。
pdfbox:是appach出品,開源、免費(fèi)、今年還在更新。
PDFRenderer:sum出品,只有一個(gè)2012年版本0.9.1-patched,不大行的樣子
jpedal:收費(fèi)
itext:AGPL?/?商業(yè)軟件的雙重許可。AGPL是免費(fèi)/開源軟件許可證。這并不意味著該軟件是免費(fèi)的!
ICEPDF:切圖后質(zhì)量不大行,有水印的pdf,切圖后水印會(huì)特別清晰。
基于以上調(diào)研,最終選擇了pdfbox。
pdf處理中遇到的問題
- java.awt.AWTError: Assistive Technology not found: org.GNOME.Accessibility.AtkWrapper
- 現(xiàn)象:本地正常,無此問題,pass部署后第一次調(diào)用pdf處理時(shí)報(bào)error錯(cuò)誤。
- 排查:
- 根據(jù)報(bào)錯(cuò)信息初步判斷,這應(yīng)該是某個(gè)類不存在。(大意是說該輔助技術(shù)不存在)
- 其初始化采用單例模式,如果有配置Assistive Technology(輔助技術(shù)),則會(huì)實(shí)例化該輔助技術(shù)。
- 追溯內(nèi)部代碼,pdf處理后生成圖片使用java.awt.toolkit工具包。
- 原因:
- toolkit類內(nèi)部會(huì)基于spi機(jī)制加載輔助技術(shù) assistive_technologies,該輔助技術(shù)非必須。
- 所以,這是一起由jdk版本不同/環(huán)境不同、引發(fā)的問題。
- pass上基礎(chǔ)鏡像jdk為:?java-8-openjdk,其內(nèi)部配置assistive_technologies,卻無引入具體類,導(dǎo)致第一次初始化時(shí)異常。
- 本地是jdk為jdk1.8.0_221,無配置assistive_technologies,無加載問題
- 該配置文件在jdk/accessibility.properties 中。
- 解決:
- 第一種:修改jdk/accessibility.properties 配置: 注釋assistive_technologies
- 第二種:因?yàn)閮?nèi)部初始化為單例模式,初始化后toolkit對(duì)象存在則不在初始化,預(yù)先初始化。
- java.lang.OutOfMemoryError: Java heap space
- 現(xiàn)象:?上傳一個(gè)188M pdf文件時(shí),在某幾頁的處理會(huì)出現(xiàn) OOM 堆內(nèi)存溢出
造成OutOfMemoryError原因一般有2種:
- 內(nèi)存泄露,對(duì)象已經(jīng)死了,無法通過垃圾收集器進(jìn)行自動(dòng)回收,通過找出泄露的代碼位置和原因,才好確定解決方案;
- 內(nèi)存溢出,內(nèi)存中的對(duì)象都還必須存活著,這說明Java堆分配空間不足,檢查堆設(shè)置大小(-Xmx與-Xms),檢查代碼是否存在對(duì)象生命周期太長(zhǎng)、持有狀態(tài)時(shí)間過長(zhǎng)的情況。
- 排查:
- 啟動(dòng)加入?yún)?shù):-XX:+HeapDumpOnOutOfMemoryError, 進(jìn)行對(duì)OOM日志dump
- OOM后進(jìn)行日志分析,其占用空間為2部分:
- 第一部分:原pdf所需內(nèi)存。
- 第二部分:每一頁的pdf轉(zhuǎn)圖片過程需要的內(nèi)存。(主要內(nèi)存占用在此部分)
- 針對(duì)第一部分,官方倒是有一個(gè)配置:MemoryUsageSetting.setupTempFileOnly();
- 即原pdf暫存在外存中,而非內(nèi)存,減輕主內(nèi)存暫用。
- 針對(duì)第二部分
- 基本流程
- 取某一頁的pdf流,進(jìn)行解析;解析后的像素?cái)?shù)據(jù)寫入BufferedImage中,在調(diào)用原生java.awt.image 畫圖生成。
- 內(nèi)部涉及pdf的解析、渲染+渲染算法、是否允許下采樣等等。
oom問題源碼解析
此部分基于OOM問題引出,目的是為了了解為什么需要那么多的內(nèi)存;進(jìn)行源碼追蹤下: ?
/**1**/ //先將pdf文件load進(jìn)pdf結(jié)構(gòu) PdfDocument中,本質(zhì)是內(nèi)部的ScratchFile(暫存文件)存儲(chǔ)PDDocument load = PDDocument.load(new File("D:\\pdfToImg\\test3\\28.pdf"));//實(shí)例pdf渲染器進(jìn)行pdf轉(zhuǎn)圖片new PDFRenderer(load).renderImageWithDPI(0, 100);...//繪制頁面drawer.drawPage(g, page.getCropBox());//初始化并處理流的內(nèi)容processPage(getPage());//處理pdf內(nèi)容流processStream(page);//處理內(nèi)容流的運(yùn)算符。processStreamOperators(contentStream);/**2**/PDFStreamParser parser = new PDFStreamParser(contentStream);/**3**/while (token != null) {...//處理操作processOperator((Operator) token, arguments);//具體操作者:策略模式,不同類型不同操作者processor.process(operator, operands);//第一類:font,解析pdf文字、含字體、格式、大小、位置等//創(chuàng)建一個(gè)新的inputStream,讀取的是解碼后的流數(shù)據(jù) COSInputStream.create(getFilterList(), this, input, scratchFile, options);//第二類:PDImageXObject 圖像對(duì)象context.drawImage(image);/**4**/ //是否允許下采樣if (subsamplingAllowed) {...}else{drawBufferedImage(pdImage.getImage(), at);}//默認(rèn)獲取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**/ //構(gòu)建dataBufferBytedataBuffer = new DataBufferByte(size, numBanks);token = parser.parseNextToken;}大致代碼流程如上,我們重點(diǎn)關(guān)注注釋如:/**1**/ 格式的;其中
1,2,6代表了內(nèi)存分配;
3,5是循環(huán)分支,6在其內(nèi),意味著會(huì)不斷進(jìn)行內(nèi)存分配;
4 ?是否允許下采樣:如果允許,其會(huì)計(jì)算圖像像素與繪制像素的比例,當(dāng)計(jì)算出比例越大時(shí),占用內(nèi)存會(huì)越少。
下采樣:對(duì)于一幅圖像I尺寸為M*N,對(duì)其進(jìn)行s倍下采樣,即得到(M/s)*(N/s)尺寸的得分辨率圖像
目的:1.使得圖像符合顯示區(qū)域的大小。2.生成對(duì)應(yīng)圖像的縮略圖。
最終定位到6內(nèi),部分token解析后繪制成圖所需的內(nèi)存巨大,pdf越是精致,越是巨大。
這個(gè)跟圖像的著色、輪廓、紋理、像素點(diǎn)、邊緣鋸齒、抖動(dòng)等相關(guān)。
這里水有點(diǎn)深,概念上就有分辨率、容量、清晰度、像素、矢量圖、位圖、柵格化、插值算法。
也是頭大,但不是我們關(guān)注的點(diǎn)。
總之,一套流程下來,我們發(fā)現(xiàn)某些pdf的轉(zhuǎn)化確實(shí)需要巨大的內(nèi)存,典型的空間復(fù)雜度高。
空間復(fù)雜度:表現(xiàn)在內(nèi)存占用大小
所以,這是個(gè)正常內(nèi)存溢出,并非某些流或?qū)ο笪醇皶r(shí)關(guān)閉,本質(zhì)上還是需要擴(kuò)大虛擬機(jī)堆內(nèi)存。
那就真的無法優(yōu)化么?有的,但作用微末;接下來說明。
oom問題優(yōu)化
經(jīng)測(cè)試,某24M的單頁pdf圖,轉(zhuǎn)化成圖片大約需要800M內(nèi)存。(就是這么夸張!)
優(yōu)化總結(jié):
- PDDocument.load(file, MemoryUsageSetting.setupTempFileOnly())
- 將pdf暫存在本地磁盤,即省出了內(nèi)存空間;像100M的pdf就能省100M內(nèi)存呢
- PDFRenderer.renderImageWithDPI(i,72);
- 降低dpi,減少dpi比例,也可以一定程度上優(yōu)化,但在呈現(xiàn)上跟原圖比會(huì)有所縮放。
DPI(Dot Per Inch) 表示打印分辨率,指每英寸長(zhǎng)度上的點(diǎn)數(shù)
- PDFRenderer.setSubsamplingAllowed(true);
- 允許下采樣,下采樣可以在更快、更小的內(nèi)存密集型情況下使用,但它也可能導(dǎo)致質(zhì)量的損失,尤其是針對(duì)高空間頻率的圖像
- 通過-Xmx增加最大堆內(nèi)存
- 終極大法,擴(kuò)大內(nèi)存
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.
更多細(xì)節(jié)參考:pdfbox官方答疑
圖冊(cè)文件加密設(shè)計(jì)
一個(gè)pdf,可能含200+的頁碼,切成圖片后分開存放,即產(chǎn)生200+記錄。
如果存儲(chǔ)在庫里,有點(diǎn)浪費(fèi)空間,同時(shí)還是能通過接口規(guī)則獲取數(shù)據(jù)。
如果單純的通過統(tǒng)一路徑后加1、2、3、4,也是很容易的推導(dǎo)后續(xù)的數(shù)據(jù)。
所以需要制定內(nèi)部加密規(guī)則。
加密?的基本過程,就是對(duì)原來為?明文?的文件或數(shù)據(jù)按?某種算法?進(jìn)行處理,使其成為?不可讀的一段代碼,通常稱為?“密文”。通過這樣的途徑,來達(dá)到?保護(hù)數(shù)據(jù)?不被?非法人竊取、閱讀的目的。
基本流程:
明文 ?+ 規(guī)則(密鑰) ?-> 密文 ? (典型的對(duì)稱加密的加密段)
明文為uuid:如數(shù)據(jù)庫存放格式:/fileUrl/68428de9168548f3a9da61a6ee5faaf3 ?, ?黑體部分即明文
規(guī)則: 即密鑰:rule?= "......" ;
密文: 為具體的oss文件名:/fileUrl/6g8428de9168548f3a9da61a6ee5faaf1?,這是第一頁/張
?/fileUrl/68z428de9168548f3a9da61a6ee5faaf2 ?, ?這是第二頁/張
#加密規(guī)則:具體看相關(guān)代碼,含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("當(dāng)前處理頁:{}", i + 1);}//上傳String key = prefix.concat(PdfHelper.uuidBuilder(uuid, i + 1));ossClient.putObject(bucketName, key, inputStream);}}log.info("pdf處理結(jié)束");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內(nèi)部會(huì)基于spi機(jī)制加載輔助技術(shù) assistive_technologies,非必須* jdk1.8.0_221中無配置assistive_technologies,無加載問題* 但在java-8-openjdk中會(huì)配置assistive_technologies,卻無引入具體類,會(huì)報(bào)異常* <p>* 解決方案:* 第一種:修改jdk/accessibility.properties 配置: 注釋assistive_technologies* <p>* 第二種:因?yàn)閮?nèi)部初始化為單例模式,初始化后toolkit對(duì)象存在則不在初始化* <p>* 這里采用粗暴的第二種,因?yàn)榈谝环N需要修改docker鏡像配置,不屬于管轄內(nèi);*/private void initToolkit() {try {Toolkit.getDefaultToolkit();} catch (AWTError e) {log.info("error: {}", e.getMessage());}}public ClientBuilderConfiguration getClientConfiguration() {// 創(chuàng)建ClientConfiguration。ClientConfiguration是OSSClient的配置類,可配置代理、連接超時(shí)、最大連接數(shù)等參數(shù)。ClientBuilderConfiguration conf = new ClientBuilderConfiguration();// 設(shè)置OSSClient允許打開的最大HTTP連接數(shù),默認(rèn)為1024個(gè)。conf.setMaxConnections(2048);// 設(shè)置Socket層傳輸數(shù)據(jù)的超時(shí)時(shí)間,默認(rèn)為50000毫秒。conf.setSocketTimeout(20000);// 設(shè)置建立連接的超時(shí)時(shí)間,默認(rèn)為50000毫秒。conf.setConnectionTimeout(20000);// 設(shè)置從連接池中獲取連接的超時(shí)時(shí)間(單位:毫秒),默認(rèn)不超時(shí)。conf.setConnectionRequestTimeout(5000);// 設(shè)置連接空閑超時(shí)時(shí)間。超時(shí)則關(guān)閉連接,默認(rèn)為60000毫秒。conf.setIdleConnectionTime(10000);// 設(shè)置失敗請(qǐng)求重試次數(shù),默認(rèn)為3次。conf.setMaxErrorRetry(5);return conf;} } public class PdfHelper {/*** uuid規(guī)則構(gòu)造器* 原理:去除最后一位字符,再取剩下最后一位字符為起始值,經(jīng)過規(guī)則轉(zhuǎn)換后,插入第i個(gè)位置;* 規(guī)則:ruleMark* 如ABCD,1 -> C ABC 1* 如ABCD,2 -> D ABC 2** @param sourceUuid 源id* @param pageNum 頁碼 第n頁* @return 規(guī)則后的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規(guī)則構(gòu)造器
* 原理:去除最后一位字符,再取剩下最后一位字符為起始值,經(jīng)過規(guī)則轉(zhuǎn)換后,插入第i個(gè)位置;
* 規(guī)則:ruleMark
* 如ABCD,1 -> C ABC 1
* 如ABCD,2 -> D ABC 2
*
* @param sourceUuid 源id
* @param pageNum 頁碼 第n頁
* @return string 規(guī)則后的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?
總結(jié)
以上是生活随笔為你收集整理的java 后端处理PDF图册的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: HTTP代理如何使用
- 下一篇: Verilog 7人投票表决器