1. 程式人生 > >Java後端使用Freemarker匯出word文件的各種細節

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