java 后端处理PDF图册
背景
圖冊業務需求:
- 用戶在后臺上傳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/6g8428de9168548f3a9da61a6ee5faaf1?,這是第一頁/張
?/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图册的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: HTTP代理如何使用
- 下一篇: Verilog 7人投票表决器