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