Java后端使用Freemarker导出word文档的各种细节

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

猜你喜欢

转载自my.oschina.net/u/3737136/blog/2876045