FreeMarker 动态生成 Word


FreeMarker 简介

什么是 FreeMarker?

FreeMarker 是一款 模板引擎:即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。

怎样用于动态生成 Word🤔

文字模板

Word 其实是一种特殊格式的文本文档

通过 Microsoft Word 转换成 Xml 文件后,可以清楚的看到,用户所写内容存放在一个个的标签里面

那么我们只需要有一个模板,就可以通过程序,动态地修改指定字符,来实现动态生成 Word

FreeMarker 提供了非常便利的模板指令表达式等结构帮助我们迅速的完成一份模板文件(FTL)

FreeMarker 中文官方参考手册 (foofun.cn)

完成模板的设计后,只需要在代码程序中调用 FreeMarker 相关的API 就可以生成 Word 文档了

图片模板

在 Word 中插入一张图片,并将文档导出为 Xml 后,定位到图片的标签发现是以 Base64 字符串进行存储的

那么在使用模板引擎的时候,只需要将这一串字符改为占位符,再由程序进行替换即可

效果展示

  • 模板(只用来设计样式,具体内容还需在Xml中编写)

  • Xml (主要内容设计,如图片占位符,模板指令标签)

  • 测试数据

  • 最终生成的文档(这里的图片并不是模板中的,模板中的图片在XML 中已经删掉了)

关于文本的样式

这部分可以在百度搜索WordXML格式解析进行相关标签的学习

例如上面每道题的前缀序号是自动生成的,见下图标签

doc 与 docx

通过上面介绍的方法,可以简单的生成.doc后缀的 Word 文档,但是由于兼容性等问题,最好的方案还是生成.docx文档

参考了一些文章(见文末链接),这里简单的介绍一下.doc.docx的区别和解决方案

✨docx 的秘密

docx 其实是一个 ZIP 格式的压缩文件

借助 7Zip 可以直接看到内部的文件,或改后缀名为 zip 进行解压

最重要的部分在word目录下

简单介绍一下这三个部分:

  • document.xml: 等价于我们上面提到的 doc 转换成的 xml,是 word 文档的核心部分,包含了样式,文本等信息,也是我们需要用模板引擎动态修改的一个文件
  • media:里面存放的是媒体数据,如图片、视频等,不过如果想要 word 正确识别这些资源,那就需要有特定的格式
  • _rels:上面两部分分别是内容资源,那么自然需要一个链接了,正如其名,_rels 中存放了链接文件 document.xml.rels

相较于docdocx 最大的不同是把媒体文件单独分离了出来

所以要生成 docx 格式的 word 文档,只需要做好一个模板,然后动态地修改 document.xmldocument.xml.rels 并传入媒体资源到 media

模板制作

doc 生成

  1. 使用 Microsoft Word 等软件制作一个模板
  2. 将文件转换成 Xml 格式
  3. 使用 FreeMarker 语法自定义数据标签,并将文件后缀名改为 .flt
  4. 运行 Java 程序

篇幅原因,这里不做展示,可以参考下面的 docx 生成

docx 生成 🛠

一、制作模板

还是那句话,这张图片只是用来占位的,后续使用中并不存在

表格的数据项生成只需要像上图中添加一行数据即可,在 Xml 中将会改成 list 形式,其他类似的多组数据同理

这一步只是制作一份通用模板,并不需要展示全貌,或者说,把样式调调好就可以了

完成模板的制作后,我们需要保存一份模板文件(docx),并复制一份,用来拿出压缩包中的那两个核心 xml 文件 document.xmldocument.xml.rels

XML 模板的编辑

前言:这些文件都是被“压缩”的,打开来你会看到所有字符挤在一起,需要用一些格式化工具来辅助开发。XML 中各个标签的含义如果需要了解,请在搜索引擎搜索WordXML格式解析

注意:编辑完 XML 之后一定要将后缀改为flt,方便起见,下面的将直接在文件名后面加上这个后缀

document.xml.fltdocument.xml.rels.flt

这里的 XML 与上面模板图片对应,可以对应起来做个参考

  1. 普通文本(document.xml.flt

    可以看到 Word 其实已经把这几个特殊格式的占位符独立出来了

    doc$符号可能会被单独放到一个标签,如果遇到这样的情况,请修改为上图格式

    普通的文本处理较为简单,按需求加入占位符即可

  2. 多组数据,如表格(document.xml.flt

    耐心分析 XML 文件,可以找到你所需的每一组数据的父标签

    上图中每一行表格就是一个w:tr

    若需传入多组数据,可以使用FreeMarker的标签 <#list>

    上图中我们每一个数据对象的名字是 user,所以在 #list 中我们将每一个数据项命名为 user

    这样我们就可以在 Java 程序中,以 List 的形式传入一组数据了(按照 Xml 中的命名,这个 List 对象名称必须是 users

    这个标签其实很像 Vue 中的 v-for

    当然如果有更多需求,可以查看 FreeMarker 中文官方参考手册 (foofun.cn)

  3. 图片(document.xml.fltdocument.xml.rels.fltmedia

    图片的动态链接较为麻烦,因为相关信息同时存在于三块地方

    • 最为直观易懂的,media文件夹中,我们需要动态地放入图片,而这并不简单,由于不同操作系统上 Word 默认编码不同,图片的命名不能含有中文,不能以数字开头,后缀名也必须是jpeg,这就需要做一定的工作

    • 链接_rels

      上面几行标签可以根据 Url 猜测到是 Word 本身一些样式相关的链接,不用我们去修改

      最后一行就是我的模板中那张图片的占位符了,这里是我已经修改过的样子,原本的Targetmedia/image1.jpeg

      简单的分析一下

      每个标签都有一个属性Id,一个类型Type,和具体文件的地址Target,我们需要做的就是动态修改Target

      如果有需求,也可以修改Id

    • 既然_rels用来链接图片与内容,那么在内容(document.xml.flt)中一定存在着“坐标”,结合上面我们看到的属性Id,应该可以很容易地搜索到“坐标”

      这一段就是这张图片的信息了,本身并不包含图片,只保存了相关的样式

      上面的QQ图片20220113111144.jpg几个字,也只是图片的原名,对模板没有多大影响,只需要红色框框的部分与链接文件中对应就可以看到图片了

代码编写

推荐使用 SpringBoot 简化配置与开发

Maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>

配置文件

spring:
  freemarker:
    template-loader-path: classpath:/templates
    cache: false # 开发环境缓存关闭
    suffix: xml
    charset: UTF-8

模板文件需要存放在 配置的template-loader-path 目录下,doc 模板只需要一个 flt 文件,docx 则需要两个 flt

讲是这么讲,但是工具类的原作者写的是用 classLoader 拿路径,所以你只能放在 resource 里面,要么就去改源代码,嫌麻烦的话就按照这个配置文件来

文件名随意

注意!!!,上面的只能在开发环境使用,在生产环境下 File 类拿不到 jar 包中的文件,所以freemarker.docx得放到外面的路径下

工具类

工具类源于文章末尾的参考文章

并在此基础上进行了部分修改与二次封装

有些代码看着就乱,那就不是我写的

import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import sun.misc.BASE64Decoder;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

@Slf4j
@Component
public class WordUtil {

    static {
        init();
    }

    /**
     * 初始化配置
     */
    private static Configuration configuration;

    /**
     * 分隔符
     */
    private static final String SEPARATOR = File.separator;

    /**
     * 文件后缀
     */
    private static final String fileSuffix = ".docx";


    private static Configuration getConfiguration(){
        //创建配置实例
        Configuration configuration = new Configuration(Configuration.VERSION_2_3_28);
        //设置编码
        configuration.setDefaultEncoding("utf-8");
        configuration.setClassForTemplateLoading(WordUtil.class, "/templates");
        return configuration;
    }

    private static void init(){
        configuration = getConfiguration();
    }


    /**
     * 生成doc文件
     *
     * @param ftlFileName 模板ftl文件的名称
     * @param params      动态传入的数据参数
     * @param outFilePath 生成的最终doc文件的保存完整路径
     */
    public static void ftlToDoc(String ftlFileName, Map params, String outFilePath) {
        try {
            // 加载模板文件
            Template template = configuration.getTemplate(ftlFileName);
            // 指定输出word文件的路径
            File docFile = new File(outFilePath);
            FileOutputStream fos = new FileOutputStream(docFile);
            Writer bufferedWriter = new BufferedWriter(new OutputStreamWriter(fos, StandardCharsets.UTF_8), 10240);
            template.process(params, bufferedWriter);
            bufferedWriter.close();
        } catch (TemplateException e) {
            log.error("export doc error",e);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * <p>
     *
     * @author lgz
     * @date 2022/1/14 9:50
     * @param dataMap 传入的数据
     * @param picList 图片列表 每一个map中,name是文件名,code是base64
     * @param docxPath 模板docx路径,在开发环境下可以放在resources目录中(直接传相对路径),但是生产环境无法访问jar包内的文件,所以需要放到别的地方(绝对路径)
     * @param outputPath 文件输出路径
     * @param fileName 文件名
     */
    public static void createDocx(Map<String, Object> dataMap, List<Map<String, String>> picList, String docxPath, String outputPath, String fileName)throws Exception {
        createDocx(dataMap, picList, docxPath, "document.xml.flt", "document.xml.rels.flt", outputPath, fileName);
    }

    /**
     * <p>
     *
     * @author lgz
     * @date 2022/1/14 9:50
     * @param dataMap 传入的数据
     * @param picList 图片列表 每一个map中,name是文件名,code是base64
     * @param docxPath 模板docx路径
     * @param docTemplate docx模板文件名称,如document.xml.flt
     * @param documentXmlRels 图片引用配置文件名,如document.xml.rels.flt
     * @param outputPath 文件输出路径
     * @param fileName 文件名
     */
    public static void createDocx(Map<String, Object> dataMap, List<Map<String, String>> picList, String docxPath, String docTemplate, String documentXmlRels, String outputPath, String fileName) throws Exception {
        if (!StringUtils.hasLength(fileName)) {
            Calendar calendar = Calendar.getInstance();
            fileName = calendar.get(Calendar.HOUR) + ":" + calendar.get(Calendar.MINUTE) + ":" + calendar.get(Calendar.SECOND) + fileSuffix;
        }
        if (!fileName.endsWith(fileSuffix)) {
            fileName = fileName + fileSuffix;
        }
        if (fileName.startsWith(SEPARATOR)) {
            fileName = fileName.substring(1);
        }
        if (!outputPath.endsWith(SEPARATOR)) {
            outputPath += SEPARATOR;
        }
        ZipOutputStream zipOut = null;
        OutputStream outputStream = new FileOutputStream(outputPath + fileName);
        try {
            //图片配置文件模板
            ByteArrayInputStream documentXmlRelsInput =getFreemarkerContentInputStream(dataMap, documentXmlRels);
            //内容模板
            ByteArrayInputStream documentInput = getFreemarkerContentInputStream(dataMap, docTemplate);
            //最初设计的模板
            File docxFile = new File(docxPath);
            ZipFile zipFile = new ZipFile(docxFile);
            Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
            zipOut = new ZipOutputStream(outputStream);
            //开始覆盖文档
            writeZipFile(zipFile, zipEntries, zipOut, documentXmlRelsInput, documentInput);
            //写入图片
            writePicture(picList, zipOut);
        } catch (Exception e) {
            log.error("word导出失败");
            log.error(e.getMessage());
        } finally {
            if (zipOut != null){
                try {
                    zipOut.close();
                } catch (IOException e) {
                    log.error("io异常");
                    log.error(e.getMessage());
                }
            }
            try {
                outputStream.close();
            } catch (IOException e) {
                log.error("io异常");
                log.error(e.getMessage());
            }
        }
    }

    /**
     * <p>
     * 向docx写入文件
     * @author lgz
     * @date 2022/1/14 9:51
     * @param zipFile :
     * @param zipEntries :
     * @param zipOut :
     * @param documentXmlRelsInput :
     * @param documentInput :
     */
    private static void writeZipFile(ZipFile zipFile, Enumeration<? extends ZipEntry> zipEntries, ZipOutputStream zipOut, ByteArrayInputStream documentXmlRelsInput, ByteArrayInputStream documentInput) throws IOException {
        int len;
        byte[] buffer = new byte[1024];
        while (zipEntries.hasMoreElements()) {
            ZipEntry next = zipEntries.nextElement();
            InputStream is = zipFile.getInputStream(next);
            if (!next.toString().contains("media")) {
                zipOut.putNextEntry(new ZipEntry(next.getName()));
                //如果是document.xml.rels由我们输入
                if (next.getName().indexOf("document.xml.rels") > 0) {
                    if (documentXmlRelsInput != null) {
                        while ((len = documentXmlRelsInput.read(buffer)) != -1) {
                            zipOut.write(buffer, 0, len);
                        }
                        documentXmlRelsInput.close();
                    }
                    //如果是word/document.xml由我们输入
                } else if ("word/document.xml".equals(next.getName())) {
                    if (documentInput != null) {
                        while ((len = documentInput.read(buffer)) != -1) {
                            zipOut.write(buffer, 0, len);
                        }
                        documentInput.close();
                    }
                } else {
                    while ((len = is.read(buffer)) != -1) {
                        zipOut.write(buffer, 0, len);
                    }
                    is.close();
                }
            }
        }
    }

    /**
     * <p>
     * 向docx的media目录写入图片
     * 如果要使用ImageBuffer的话可以稍作修改
     * @author lgz
     * @date 2022/1/14 9:48
     * @param picList : 图片Map集合,name表示写入之后图片的名字,code是BASE64字符串
     * @param zipout : zip输出流
     */
    private static void writePicture(List<Map<String, String>> picList, ZipOutputStream zipout) throws IOException {
        int len;
        byte[] buffer = new byte[1024];
        for (Map<String, String> pic : picList) {
            ZipEntry next = new ZipEntry("word" + SEPARATOR + "media" + SEPARATOR + pic.get("name"));
            zipout.putNextEntry(new ZipEntry(next.toString()));
            BASE64Decoder decoder = new BASE64Decoder();
            byte[] bytes = decoder.decodeBuffer(pic.get("code"));
            InputStream in  = new ByteArrayInputStream(bytes);
            while ((len = in.read(buffer)) != -1) {
                zipout.write(buffer, 0, len);
            }
            in.close();
        }
    }

    /**
     * 处理转义字符
     * @param str
     * @return
     */
    public static String transform(String str) {

        if (str.contains("<") || str.contains(">") || str.contains("&")) {
            str = str.replaceAll("&", "&amp;");
            str = str.replaceAll("<", "&lt;");
            str = str.replaceAll(">", "&gt;");
        }
        return str;
    }



    /**
     * base64转inputStream
     * @param base64string
     * @return
     */
    private static InputStream toInputStream(String base64string){
        ByteArrayInputStream stream = null;
        try {
            BASE64Decoder decoder = new BASE64Decoder();
            byte[] bytes1 = decoder.decodeBuffer(base64string);
            stream = new ByteArrayInputStream(bytes1);
        } catch (Exception e) {
            log.error("base64 to inputstream error",e);
        }
        return stream;
    }

    /**
     * 获取模板字符串输入流
     * @param dataMap   参数
     * @param templateName  模板名称
     * @return
     */
    private static ByteArrayInputStream getFreemarkerContentInputStream(Map dataMap, String templateName) {
        ByteArrayInputStream in = null;
        try {
            //获取模板
            Template template = configuration.getTemplate(templateName);
            StringWriter swriter = new StringWriter();
            //生成文件
            template.process(dataMap, swriter);
            in = new ByteArrayInputStream(swriter.toString().getBytes("utf-8"));//这里一定要设置utf-8编码 否则导出的word中中文会是乱码
        } catch (Exception e) {
            log.error("模板生成错误!",e);
        }
        return in;
    }

    /**
     * 删除所有的HTML标签
     * e.g. <div>“3人伪造老干妈印章与腾讯签合同”事件</div> 去掉 div
     *
     * @param source 需要进行除HTML的文本
     * @return
     */
    public static String deleteAllHTMLTag(String source) {
        if(source == null) {
            return "";
        }
        String s = source;
        /** 删除普通标签  */
        s = s.replaceAll("<(S*?)[^>]*>.*?|<.*? />", "");
        /** 删除转义字符 */
//        s = s.replaceAll("&.{2,6}?;", "");
        return s;
    }
}

Web 使用

在 Web 中使用就不能把文件写在本地磁盘上了,需要通过 Http 请求发给前端。可以对工具类进行一点点的修改:

/**
     * <p>
     *
     * @author lgz
     * @date 2022/1/14 9:50
     * @param dataMap 传入的数据
     * @param picList 图片列表 每一个map中,name是文件名,code是base64
     * @param docxPath 模板docx路径,在开发环境下可以放在resources目录中(直接传相对路径),但是生产环境无法访问jar包内的文件,所以需要放到别的地方(绝对路径)
     * @param fileName 文件名
     */
public static void createDocx(Map<String, Object> dataMap, List<Map<String, String>> picList, String docxPath, String fileName, HttpServletResponse response)throws Exception {
    createDocx(dataMap, picList, docxPath, "document.xml.flt", "document.xml.rels.flt", fileName, response);
}

/**
     * <p>
     *
     * @author lgz
     * @date 2022/1/14 9:50
     * @param dataMap 传入的数据
     * @param picList 图片列表 每一个map中,name是文件名,code是base64
     * @param docxPath 模板docx路径
     * @param docTemplate docx模板文件名称,如document.xml.flt
     * @param documentXmlRels 图片引用配置文件名,如document.xml.rels.flt
     * @param fileName 文件名
     */
public static void createDocx(Map<String, Object> dataMap, List<Map<String, String>> picList, String docxPath, String docTemplate, String documentXmlRels, String fileName, HttpServletResponse response) throws Exception {
    if (!StringUtils.hasLength(fileName)) {
        Calendar calendar = Calendar.getInstance();
        fileName = calendar.get(Calendar.HOUR) + ":" + calendar.get(Calendar.MINUTE) + ":" + calendar.get(Calendar.SECOND) + fileSuffix;
    }
    if (!fileName.endsWith(fileSuffix)) {
        fileName = fileName + fileSuffix;
    }
    OutputStream outputStream = response.getOutputStream();
    setResponseHeader(response, fileName);
    ZipOutputStream zipOut = null;
    try {
        //图片配置文件模板
        ByteArrayInputStream documentXmlRelsInput =getFreemarkerContentInputStream(dataMap, documentXmlRels);
        //内容模板
        ByteArrayInputStream documentInput = getFreemarkerContentInputStream(dataMap, docTemplate);
        //最初设计的模板
        File docxFile = new File(docxPath);
        ZipFile zipFile = new ZipFile(docxFile);
        Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
        zipOut = new ZipOutputStream(outputStream);
        //开始覆盖文档
        writeZipFile(zipFile, zipEntries, zipOut, documentXmlRelsInput, documentInput);
        //写入图片
        writePicture(picList, zipOut);
    } catch (Exception e) {
        log.error("word导出失败");
        e.printStackTrace();
    } finally {
        if (zipOut != null){
            try {
                zipOut.close();
            } catch (IOException e) {
                log.error("io异常");
                log.error(e.getMessage());
            }
        }
        try {
            outputStream.close();
        } catch (IOException e) {
            log.error("io异常");
            log.error(e.getMessage());
        }
    }
}
/**
     * 设置浏览器下载响应头
     * @param response response
     * @param fileName 文件名
     */
private static void setResponseHeader(HttpServletResponse response, String fileName) {
    try {
        response.setContentType("application/octet-stream;charset=GBK");
        response.setHeader("Content-Disposition", "attachment;filename="+ URLEncoder.encode(fileName, "UTF-8"));
        response.addHeader("Pargam", "no-cache");
        response.addHeader("Cache-Control", "no-cache");
    } catch (Exception ex) {
        ex.printStackTrace();
    }
}

如何使用工具类

首先需要提到一点,Word 不会将 <>& 这三个字符看做普通字符,所以会导致文件打不开等情况。

那么就需要像 html 里的处理一样,变成 &amp;&lt;&gt; ,在工具类里有个 transform 方法可以帮助我们转换这些字符

工具类提供了两个生成 Word 的方法:ftlToDoccreateDocx,前者用来生成doc文件,只需要传入模板 flt 文件名,数据集 Map, 和输出路径就可以使用。

createDocx 方法的参数比较多,分别是:

  • dataMap 传入的数据
  • picList 图片列表,每一个 Map 中,name 存放写入docx中的文件名,code 是 base64 字符串,如果需要使用 BufferImage 来传递图片,可以对工具类稍作修改
  • docxName 模板 docx 文件名,如上图中的 freemarker.docx
  • docTemplate 我们修改的模板文件名,如上图中的 document.xml.flt
  • documentXmlRels 我们修改的链接文件名,如上图中的 document.xml.rels.flt
  • outputPath 文件输出路径
  • fileName 文件名

通常 docTemplatedocumentXmlRels 文件只需要自己加一个 flt 后缀名,所以我又重载了一个方法来填入这两个默认值

public static void createDocx(Map<String, Object> dataMap, List<Map<String, String>> picList, String docxName, String outputPath, String fileName)throws Exception {
    createDocx(dataMap, picList, docxName, "document.xml.flt", "document.xml.rels.flt", outputPath, fileName);
}

测试代码

还是文章上述内容中的那个模板

Map<String, Object> map = new HashMap<>();
// 那行普通文本
// < > 需要进行转换
map.put("name", WordUtil.transform("<姓名>"));
map.put("sex", "性别");
map.put("birthday", "出生日期");
// 图片列表
List<Map<String, String>> picList = new ArrayList<>();

Map<String, String> map1 = new HashMap<>();
map1.put("name", "image1.jpeg");
map1.put("code", ImageUtil.getImageBase64String("C:\\Users\\11047\\Desktop\\123.jpg"));

picList.add(map1);
// 链接文件的数据
map.put("image2", "image1.jpeg");

// 表格中的数据
User user = new User("user1", "男", "2020-01-01");
map.put("user", user);
WordUtil.createDocx(map, picList, "freemarker.docx", "C:\\Users\\11047\\Desktop\\", "test.docx");

效果展示 👀

docx 模板

生成的最终文件

参考文章


文章作者: ❤纱雾
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 ❤纱雾 !
评论
  目录