1、背景

为某单位开发的一款项目申报审批系统,用户需求在申报阶段填写的信息资料能够导出PDF。且项目申报的报告正文为用户上传,所以需要合并导出。

2、问题

在项目初期阶段使用的是PDF的预设模板导出,因为以前使用过,比较熟悉。所以优先选择此方法,但项目测试阶段发现问题,因为某些项目的某些资料是动态的,不能确定有多少,PDF预设模板方式不够灵活,而且某些表格内容长度也是不确定的,导出效果很差。

3、解决

总体解决思路为导出word,因为有许多开源方法支持,且导出内容更灵活。满足用户数据内容长度不确定的要求。再将word转换PDF与用户上传的报告正文合并导出。

一、easypoi导出word

第一想法是想到easypoi导出word的方式。easypoi是对poi的二次封装,使得poi的多数功能得以简单实现,让许多没有接触过poi的开发者也能实现对Excel,word的导出。通过简单的注解和表达式语法实现导出功能。
引入依赖
GItHub地址
教程地址

引入依赖
<!-- easyPOi--><dependency><groupId>cn.afterturn</groupId><artifactId>easypoi-spring-boot-starter</artifactId><version>4.2.0</version></dependency>
使用表达式创建模板
指令作用
{{test ? obj:obj2}}三元运算
n:表示 这个cell是数值类型 {{n:}}
le:代表长度{{le:()}} 在if/else 运用{{le:() > 8 ? obj1 : obj2}}
fd:格式化时间 {{fd:(obj;yyyy-MM-dd)}}
fn:格式化数字 {{fn:(obj;###.00)}}
fe:遍历数据,创建row
!fe:遍历数据不创建row
$fe:下移插入,把当前行,下面的行全部下移.size()行,然后插入
#fe:横向遍历
v_fe:横向遍历值
!if:删除当前列 {{!if:(test)}}
‘’单引号表示常量值 ‘’ 比如’1’ 那么输出的就是 1
&NULL&空格
]]换行符 多行遍历导出
sum:统计数据

创建模板
注意模板中合租单位信息栏处,此处有坑,后续会讲到

模板上传与代码编写

模板设置好后,将文件放在项目能访问点的地方,可以是resources文件夹下。如下

我系统内是将文件上传oss文件服务,通过系统配置获取文件访问URL的方式,避免修改一次导出模板就得打包一次项目。

代码编写
1、数据准备
dataMap.put("birth", PdfUtils.getDateMonth(vo.getSysUser().getBirthday()));//申请者出生年月dataMap.put("applicationPhone", vo.getSysUser().getPhone() == null " />"" : vo.getSysUser().getPhone());//申请者手机号码dataMap.put("applicationEmail", vo.getSysUser().getEmail() == null ? "" : vo.getSysUser().getEmail());//申请者电子邮件dataMap.put("companyName", vo.getDepart().getDepartName() == null ? "" : vo.getDepart().getDepartName());//申请单位--名称dataMap.put("companyPeople", vo.getDepart().getPeople() == null ? "" : vo.getDepart().getPeople());//申请单位-联系人dataMap.put("companyPhone", vo.getDepart().getMobile() == null ? "" : vo.getDepart().getMobile());//申请单位--手机dataMap.put("companyEmail", vo.getDepart().getEmail() == null ? "" : vo.getDepart().getEmail());//申请单位--电子邮件if (vo.getUnits().size() > 0) { dataMap.put("coorList",vo.getUnits());}
2、代码实现

这里的word生成是用了一个临时文件夹进行保存。使用easypoi工具类下的方法exportWord07()实现word的数据填充,方法前一个参数为模板URL,后一个为map的数据内容。因为我这里使用的oss文件存储,直接使用URL会获取不到文件。所以使用了工具类通过URL获取File。代码贴下面。

//临时文件夹路径String filename = (String) dataMap.get("fileName");//word导出模板String url = wordUrl;File templateFile = UrlFilesToZip.Url2File(url);//获取模板文档//File templateFile = new File(url);//2.映射为模板XWPFDocument xwpfDocument = null;xwpfDocument = WordExportUtil.exportWord07(templateFile.getPath(), dataMap);//删除File file = new File(filename);for (File listFile : file.listFiles()) {listFile.delete();}ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();FileOutputStream outputStream = new FileOutputStream(filename + "/test.docx");xwpfDocument.write(outputStream);
通过URL获取网络文件
public static File Url2File(String url) throws Exception {//对本地文件命名,可以从链接截取,可以自己手写,看需求String fileName = "fileName";// String fileName = url.substring(url.lastIndexOf("."));File file = null;URL urlfile;InputStream inStream = null;OutputStream os = null;file = File.createTempFile("net_url", fileName);//下载urlfile = new URL(url);inStream = urlfile.openStream();os = new FileOutputStream(file);int bytesRead = 0;byte[] buffer = new byte[8192];while ((bytesRead = inStream.read(buffer, 0, 8192)) != -1) {os.write(buffer, 0, bytesRead);}return file;}
方法不足

这里还没进行word转PDF的代码编写,就发现此方法的不足之处。在上面提到过合作单位信息栏。如果表格的第一列是固定的不进行遍历,而在第二列开始遍历插入。easypoi的原始方法是不满足需求的。查询后有方式可以通过修改源码实现,但没尝试,感觉麻烦,且easypoi对于富文本内容导出的处理不够完美,只能通过自己找方法实现处理。尝试过后仍然有部分样式无法保留。所以弃用此方式。

二、poi-tl导出word

通过查找,发现poi-tl的开源类库,也是基于poi开发,且对word导出的支持更好,对于导出的方式与easypoi相同,减少了关于数据准备阶段的代码修改。使用方法在参考文档中也有很多例子。

开发参考文档

依赖引入

注意版本对应,不然会出问题

<!-- POI --><dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId><version>4.1.2</version></dependency><dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>4.1.2</version></dependency><dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml-schemas</artifactId><version>4.1.2</version></dependency><!-- poi-tl --><dependency><groupId>com.deepoove</groupId><artifactId>poi-tl</artifactId><version>1.10.0</version></dependency><!--poi-tl 富文本插件--><dependency> <groupId>io.github.draco1023</groupId> <artifactId>poi-tl-ext</artifactId> <version>0.4.2</version></dependency>
创建模板

与easypoi类似,只是在列表遍历处不一样

代码编写
1、数据准备

此处的数据格式与之前的数据格式一致,无需修改

2、代码编写
public byte[] exportWordByPOi_tl(Map dataMap, String wordPath)throws Exception{long start = System.currentTimeMillis();//wordPath = "D:/Test/poi-tl/青年科学基金项目申请书导出模板.docx";//wordPath = "https://guizhou-keyan-oss.oss-cn-hangzhou.aliyuncs.com/temp/博士基金项目申请书导出模板_1676885802664.docx";//绑定行循环LoopRowTableRenderPolicy policy=new LoopRowTableRenderPolicy();//富文本插件HtmlRenderPolicy htmlRenderPolicy = new HtmlRenderPolicy();Configure configure= Configure.builder().bind("coorList", policy).bind("peopleList", policy).bind("peopleSingList", policy).bind("fileList", policy).bind("allotList",policy).bind("equipmentList",policy).bind("budget_state",htmlRenderPolicy).build();//通过url获取网络文件输入流InputStream inputStream = POICacheManager.getFile(wordPath);XWPFTemplate render = XWPFTemplate.compile(inputStream,configure).render(dataMap);ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();render.write(byteArrayOutputStream);log.info("导出word消耗时间" + (System.currentTimeMillis() - start) + "毫秒");render.close();byteArrayOutputStream.close();return byteArrayOutputStream.toByteArray();}

行循环与富文本内容处理都使用了插件,所以代码中添加了二者的写法。poi-tl类库中方法XWPFTemplate.compile不支持模板的网络url使用,所以使用了easypoi中获取网络文件输入流方法,也可以自己写一个工具实现。此处便于下一步的word转PDF,所以返回了byte数组。如果不需要下一步的处理,也可以像后文直接进行输出流返回给前端。

三、word转PDF的合并导出

此处先使用了poi的word转PDF,虽然此方法使用率很高,但是效果实在不怎么样。后来看到一个横向对比的文章
Java开发中Word转PDF文件5种方案横向评测
对比中aspose与spire的转换效果最好,本着互联网精神,最后选择aspose的方式。

依赖引入
<!--aspose 破解 word转pdf--><dependency><groupId>com.aspose</groupId><artifactId>aspose-words</artifactId><version>0.0.1-SNAPSHOT</version><scope>system</scope><systemPath>${project.basedir}/src/main/resources/lib/aspose-words-16.8.0-jdk16.jar</systemPath></dependency>

这里的jar包是下载后放置在系统resources/lib文件夹,因为做一些其他操作,所以仓库是没有这个jar包。

链接:https://pan.baidu.com/s/1k4qEQBHf-t8rco6PSWwpiQ
提取码:1446

代码编写
1、word转PDF

使用aspose转换需要进行验证

public byte[] asposeWord2Pdf(InputStream inputStream) {if (!getLicense()) { // 验证License 若不验证则转化出的pdf文档会有水印产生return null;}if (inputStream.equals(null)) {log.info("word为null");return null;}try {long old = System.currentTimeMillis();ByteArrayOutputStream os = new ByteArrayOutputStream();com.aspose.words.Document doc = new com.aspose.words.Document(inputStream); //Address是将要被转化的word文档doc.save(os, SaveFormat.PDF);//全面支持DOC, DOCX, OOXML, RTF HTML, OpenDocument, PDF, EPUB, XPS, SWF 相互转换os.close();log.info("word2pdf消耗" + (System.currentTimeMillis() - old) + "毫秒");return os.toByteArray();} catch (Exception e) {log.info("doc转pdf文件失败,", e);return null;}}

license验证

public boolean getLicense() {boolean result = false;try {InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("wordPath/license.xml");License aposeLic = new License();aposeLic.setLicense(resourceAsStream);result = true;} catch (Exception e) {e.printStackTrace();}return result;}

license内容,放置于resources/wordPath

<License><Data><Products><Product>Aspose.Total for Java</Product><Product>Aspose.Words for Java</Product></Products><EditionType>Enterprise</EditionType><SubscriptionExpiry>20991231</SubscriptionExpiry><LicenseExpiry>20991231</LicenseExpiry><SerialNumber>8bfe198c-7f0c-4ef8-8ff0-acc3237bf0d7</SerialNumber></Data><Signature>sNLLKGMUdF0r8O1kKilWAGdgfs2BvJb/2Xp8p5iuDVfZXmhppo+d0Ran1P9TKdjV4ABwAgKXxJ3jcQTqE/2IRfqwnPf8itN8aFZlV3TJPYeD3yWE7IT55Gz6EijUpC7aKeoohTb4w2fpox58wWoF3SNp6sK6jDfiAUGEHYJ9pjU=</Signature></License>
2、查找位置合并PDF

这里的合并我理解的是一页一页的拼接在上一页后面,不知道还有没有其他方式,至于PDF查找那一块现在我有点困,不想写了。还有俩小时下班,有人需要再补吧。合并后通过Response给前端。

/** * 合并pdf 关键词查找合并的位置 * @Author CoCo * @Date 2023/2/16 * @params * @return */public void mergePdf(HttpServletResponse response, byte[] pdfByte, String textUrl,String keyWord) {response.reset();response.setContentType("application/pdf");//response.setContentType("content-type:octet-stream;charset=UTF-8");try {response.setHeader("Content-Disposition", "attachment;filename=" + new String(("filename").getBytes(), "iso-8859-1"));} catch (UnsupportedEncodingException e) {e.printStackTrace();}PdfUtils pdfUtils=new PdfUtils();com.itextpdf.text.Document document = null;PdfCopy copy = null;OutputStream os = null;boolean empty = textUrl != null;try {os = response.getOutputStream();//File file = new File("D:/Test/aspose.pdf"); //新建一个pdf文档//FileOutputStream oss = new FileOutputStream(file);document = new com.itextpdf.text.Document(new PdfReader(pdfByte).getPageSize(1));copy = new PdfCopy(document, os);document.open();PdfReader pdfReader = new PdfReader(pdfByte);PdfReader textReader = null;for (int i = 1; i <= pdfReader.getNumberOfPages(); i++) {PdfImportedPage pdfPage = copy.getImportedPage(pdfReader, i);//根据关键字查询页数及其他信息List list = pdfUtils.matchPage(pdfReader, i, keyWord);//如果PDF关键字查找有返回数据且正文url不为空,拼接正文pdfif (!list.isEmpty()&&empty){textReader = new PdfReader(UrlFilesToZip.getFileFromURL(textUrl));for (int k = 1; k <= textReader.getNumberOfPages(); k++) {PdfImportedPage textPage = copy.getImportedPage(textReader,k);copy.addPage(textPage);}}copy.addPage(pdfPage);}} catch (Exception e) {e.printStackTrace();log.info("合并失败");}finally {if (copy != null) {try {copy.close();} catch (Exception ex) {/* ignore */}}if (document != null) {try {document.close();} catch (Exception ex) {/* ignore */}}if (os != null) {try {os.close();} catch (Exception ex) {/* ignore */}}}}