1.前言
最近在项目中,因客户要求,需要做一个导出成word的功能(比如月度报表等),技术选型也考虑过几种,比如easypoi,itext,但发现这两种在实现起来有困难,所以最终还是选Freemarker模板进行导出,灵活性比较好。
2.实现步骤
1.准备好标准文档的word,标题格式间距什么的先设计好,这是减少后面修改模板文很重要一步;
2.打开word原件把需要动态修改的内容替换成***,如果有图片,尽量选择较小的图片几十K左右,并调整好位置;
3.另存为,选择保存类型Word 2003 XML 文档(*.xml)【这里说一下为什么用Microsoft Office Word打开且要保存为Word 2003XML,本人亲测,用WPS找不到Word 2003XML选项,如果保存为Word XML,会有兼容问题,避免出现导出的word文档不能用Word 2003打开的问题】,还有保存的文件名尽量不要是中文;
4.用NotePad打开文件,notepad预先装好xml的插件,然后格式化,当然也可以用Firstobject free XML editor打开文件,选择Tools下的Indent【或者按快捷键F8】格式化文件内容。看个人喜欢;
notepad xml插件下载地址:https://sourceforge.net/projects/npp-plugins/files/XML%20Tools/
5. 将文档内容中需要动态修改内容的地方,换成freemarker的标识。其实就是Map<String, Object>中key,如${userName};
6.在加入了图片占位的地方,会看到一片base64编码后的代码,把base64替换成${image},也就是Map<String, Object>中key,值必须要处理成base64;
代码如:<w:binData w:name="wordml://自定义.png" xml:space="preserve">${image}</w:binData>
注意:
(1)“>${image}<”这尖括号中间不能加任何其他的诸如空格,tab,换行等符号。
(2)如果是多张图片需要循环图片 w:name 和v:imagedata 的src需要变化的
(3)如果图片的宽高最好是在后端自定义(我这里是固定宽然后高比例变化),不至于图片很宽导出的word图片变形
完整实例如下
<w:binData w:name="${"wordml://03000001"+ins_index+1+".jpg"}" xml:space="preserve">${ins.insHealthImg.code}</w:binData>
<v:shape id="图片 10" o:spid="_x0000_i1032" type="#_x0000_t75" style="width:${ins.insHealthImg.width}pt;height:${ins.insHealthImg.height}pt;visibility:visible;mso-wrap-style:square">
<v:imagedata src="${"wordml://03000001"+ins_index+1+".jpg"}" o:title=""/>
</v:shape>
7. 标识替换完之后,模板就弄完了,另存为.ftl后缀文件即可。注意:一定不要用word打开ftl模板文件,否则xml内容会发生变化,导致前面的工作白做了。
3.代码实现
引入依赖
<dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.28</version> </dependency>
导出的工具类FreemarkerBase
import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.util.Map; /** * @author lpf * @create 2018-11-03 17:27 **/ public class FreemarkerBase { protected final Logger logger = LoggerFactory.getLogger(getClass()); private Configuration configuration = null; /** * 获取freemarker的配置. freemarker本身支持classpath,目录和从ServletContext获取. */ protected Configuration getConfiguration() { if (null == configuration) { configuration = new Configuration(Configuration.VERSION_2_3_28); configuration.setDefaultEncoding("utf-8"); //ftl是放在classpath下的一个目录 configuration.setClassForTemplateLoading(this.getClass(), "/template/"); } return configuration; } /** * 导出word * * @param response * @param templateName * @param dataMap */ public void downLoad(HttpServletResponse response, String templateName, Map<String, Object> dataMap) throws IOException { OutputStream os = response.getOutputStream(); Writer writer = new OutputStreamWriter(os, "utf-8"); Template template = null; try { template = getConfiguration().getTemplate(templateName, "utf-8"); template.process(dataMap,writer); os.flush(); writer.close(); os.close(); } catch (TemplateException e) { logger.error("模板文件异常,请检查模板文件路径和文件名:" + e.getMessage()); } catch (IOException e) { logger.error("IO异常,导出到浏览器出错:" + e.getMessage()); } } }
这里因为是浏览器导出,使用输出流用的response,而网上一般的教程都是先生存临时文件在读取文件流输出,然后删除临时文件,我任务是多余的步骤;
导出代码
@RequestMapping(value = "/download") public void downWord(HttpServletRequest request, HttpServletResponse response) throws IOException { Map<String, Object> dataMap = this.getWordData(request);//封装数据的方法 FreemarkerBase freemarkerBase = new FreemarkerBase(); String fileName = "XXXXX.doc"; response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment;filename=" + new String(fileName.getBytes("gb2312"), "ISO8859-1")); freemarkerBase.downLoad(response, "templete_min.ftl", dataMap); }
核心代码就上面这些,当然一个比较复杂的word导出在封装数据的时候肯定会碰到问题
4.遇到的问题
1.图片数据来源
如果插入图片是本地已经存在的图片那很好办,读取图片转成base64即可,但是在项目中图片本地并没有而是在前端页面用echart生成的图片。
我的思路是利用phantomjs模拟浏览器请求前端页面利用echart生成图片将生成图片的base64传入后端
代码逻辑
前端请求下载word
@RequestMapping(value = "/download") public void download(HttpServletRequest request,HttpServletResponse response) throws IOException { String rptId = request.getParameter("rptId"); User userInfo = (User) request.getSession().getAttribute("user"); Long startTime= System.currentTimeMillis(); Long currentTime = null; WordWrite.Domain(rptId);//模拟浏览器请求生成图片 while (true){// if(WordWrite.imgsMap.get(rptId)!=null){//监听图片是否已经生成好 reportWordService.downWord(request,response); WordWrite.imgsMap.remove(rptId); break; }else{ currentTime = System.currentTimeMillis(); if((currentTime-startTime)/1000>60){//添加下载超时的判断避免死循环 break; } } } }
模拟浏览器请求方法
生成图片工具类
public static void Domain(String rptId) throws IOException { ReportService reportService = SpringContextHolder.getBean("reportService"); List<Map<String, Object>> instanceList = reportService.getRelationInstanceByReportId(rptId); StringBuffer sb = new StringBuffer(); for(int i =0;i<instanceList.size();i++){ String _uid = (String)instanceList.get(i).get("target_id"); sb.append(_uid+","); } String uids = sb.substring(0,sb.length()-1); String paramStr = "target_ids="+uids+";rptId="+rptId; paramStr = URLEncoder.encode(paramStr ,"UTF-8"); propPath = WordWrite.class.getResource("/").toString(); String[] ps = propPath.split("file:/")[1].split("/"); String[] newPaths = Arrays.copyOfRange(ps, 0, ps.length-6); propPath = StringUtils.join(newPaths, "/") + "/conf"; if(propPath.indexOf(":") == -1){ propPath = "/"+propPath; System.out.println("propPath linux"); }else if(propPath.indexOf(":") != -1){ System.out.println("propPath windows"); } System.out.println("phantomjs.properties文件所在目录:"+propPath+"/phantomjs.properties"); FileInputStream in = new FileInputStream(propPath+"/phantomjs.properties"); String[] _path = Arrays.copyOfRange(ps,0,ps.length-2); WordWritePath = StringUtils.join(_path, "/")+"/jsp/pages/"; if(WordWritePath.indexOf(":") == -1){ WordWritePath = "/"+WordWritePath; System.out.println("WordWritePath linux"); }else if(WordWritePath.indexOf(":") != -1){ System.out.println("WordWritePath windows"); } System.out.println("截图时需要用到的js路径:"+WordWritePath); proper = new Properties(); proper.load(in); in.close(); // 生成月报图片 dopng(proper,"month",paramStr); }
/** * 保存网页中的图片 * @return * @throws IOException */ public static String dopng(Properties pro,String type, String jsParam) throws IOException{ String jspUrl = pro.getProperty("jsp"); //"http://localhost:8080/RtManageCon/jsp/pages/nobrowserpages/chartsByNoBrowser.jsp"; if(jsParam != null){ jspUrl = jspUrl+"?"+jsParam; } String jsurl = ""; switch (type) { case "day": jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("dayjs")+" "; break; case "week": jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("weekjs")+" "; break; case "month": jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("monthjs")+" "; break; default: jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("monthjs")+" "; break; } return downloadImage(jsurl,jspUrl); }
public static String downloadImage(String jsurl,String url) throws IOException { String cmdStr = PHANTOM_PATH + jsurl + url; //String cmdStr = "C:/develop/phantomjs-2.1.1-windows/bin/phantomjs.exe " + jsurl + url; System.out.println("命令行字符串:"+cmdStr); Runtime rt = Runtime.getRuntime(); try { rt.exec(cmdStr); } catch (IOException e) { System.out.println("执行phantomjs的指令失败!请检查是否安装有PhantomJs的环境或配置path路径!"); } return cmdStr; }
public static final ConcurrentMap<String,Object> imgsMap = new ConcurrentHashMap<>();用来接收图片的base64编码
//接收图片base64编码 public static void doExecutoer(Map<String,Object> map){ imgsMap.putAll(map); /*原子操作,如果期望值是false时,则执行赋值 if(exists.compareAndSet(false,true)){ imgsMap.clear(); imgsMap = map; }*/ }
前端js
var system = require('system'); var page = require('webpage').create(); // 如果是windows,设置编码为gbk,防止中文乱码,Linux本身是UTF-8 var osName = system.os.name; console.log('os name:' + osName); if ('windows' === osName.toLowerCase()) { phantom.outputEncoding="gbk"; } // 获取第二个参数(即请求地址url). var url = system.args[1]; console.log('url:' + url); // 显示控制台日志. page.onConsoleMessage = function(msg, lineNum, sourceId) { console.log('CONSOLE: ' + msg + ' (from line #' + lineNum + ' in "' + sourceId + '")'); }; //打开给定url的页面. var start = new Date().getTime(); // 页面大小 ------------------------------------------------------------------------------ page.viewportSize={width:650,height:400}; // ----------------------------------------------------------------------------------------- page.open(url, function(status) { if (status == 'success') { console.log('echarts页面加载完成,加载耗时:' + (new Date().getTime() - start) + ' ms'); page.evaluate(function() { console.log("月报js"); getAjaxRequest("month");//改方法去实现生成图片并传入后端 }); } else { console.log("页面加载失败 Page failed to load!"); } // 5秒后再关闭浏览器. setTimeout(function() { phantom.exit(); }, 15*1000); });
有不熟悉phantomjs的可以查找下资料大概了解就行。
2.导出的word比较大
用模版导出的方式,这个问题不可避免,因为模版是XML,本身带有大量的标签,注意在XML里写循环的时候注意 不要生成不必要的 标签,另外XML模版弄好后压缩一下,然后导出的word大小就减少很多啦。
3.由于下载时间长,避免重复下载,客户希望在前端有一个加载等待框
利用iframe实现下载等待,用iframe实现下载等待的原理是把下载的路径给iframe的src,然后监听iframe的onload事件,当后台处理完成并返回文件时,会触发iframe的onload事件。
这里有一个帖子的详细说明:https://blog.csdn.net/fgx_123456/article/details/79603455
但是我在项目中总是无法监听到onload事件。浏览器给的提示是请求一直没完成。后面也一直没找到原因,没有找到解决办法,不知道谁遇到过着个问题没。
后面没办法用了框架中的WebSocket主动向前端相应下载完成,等待加载结束。在上面下载接口的代码上改造如下
@RequestMapping(value = "/download") public void download(HttpServletRequest request,HttpServletResponse response) throws IOException { String rptId = request.getParameter("rptId"); User userInfo = (User) request.getSession().getAttribute("user"); Long startTime= System.currentTimeMillis(); Long currentTime = null; WordWrite.Domain(rptId); while (true){ if(WordWrite.imgsMap.get(rptId)!=null){ reportWordService.downWord(request,response); for(WebSocketForJSP item: WebSocketForJSP.webSocketSet){ if(userInfo.getUsername().equals(item.userName)){ JSONObject resultObj = new JSONObject(); resultObj.put("reportCode", 0); resultObj.put("msg", "月报表导出成功"); item.sendMessage(resultObj.toJSONString()); } } WordWrite.imgsMap.remove(rptId); break; }else{ currentTime = System.currentTimeMillis(); if((currentTime-startTime)/1000>60){ for(WebSocketForJSP item: WebSocketForJSP.webSocketSet){ if(userInfo.getUsername().equals(item.userName)){ JSONObject resultObj = new JSONObject(); resultObj.put("reportCode", -1); resultObj.put("msg", "月报表导出超时"); item.sendMessage(resultObj.toJSONString()); } } break; } } } }
WebSocket的一些实现代码就没贴了,有需要欢迎留言。
5.结束语
如果对Freemarker标签不熟的,可以在网上先学习下,了解文档结构,模板需要足够的耐心和仔细。
Firstobject free XML editor下载地址:http://www.firstobject.com/dn_editor.htm
freemarker 官网:http://freemarker.org/
phantomjs下载 http://phantomjs.org/download.html
来源:oschina
链接:https://my.oschina.net/u/3737136/blog/2876045