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
相较于doc
,docx
最大的不同是把媒体文件单独分离了出来
所以要生成 docx 格式的 word 文档,只需要做好一个模板,然后动态地修改 document.xml 和 document.xml.rels 并传入媒体资源到 media
模板制作
doc 生成
- 使用 Microsoft Word 等软件制作一个模板
- 将文件转换成 Xml 格式
- 使用 FreeMarker 语法自定义数据标签,并将文件后缀名改为
.flt
- 运行 Java 程序
篇幅原因,这里不做展示,可以参考下面的 docx 生成
docx 生成 🛠
一、制作模板
还是那句话,这张图片只是用来占位的,后续使用中并不存在
表格的数据项生成只需要像上图中添加一行数据即可,在 Xml 中将会改成 list 形式,其他类似的多组数据同理
这一步只是制作一份通用模板,并不需要展示全貌,或者说,把样式调调好就可以了
完成模板的制作后,我们需要保存一份模板文件(docx),并复制一份,用来拿出压缩包中的那两个核心 xml 文件 document.xml 和 document.xml.rels
XML 模板的编辑
前言:这些文件都是被“压缩”的,打开来你会看到所有字符挤在一起,需要用一些格式化工具来辅助开发。XML 中各个标签的含义如果需要了解,请在搜索引擎搜索WordXML格式解析
注意:编辑完 XML 之后一定要将后缀改为flt
,方便起见,下面的将直接在文件名后面加上这个后缀
document.xml.flt 和 document.xml.rels.flt
这里的 XML 与上面模板图片对应,可以对应起来做个参考
普通文本(document.xml.flt)
可以看到 Word 其实已经把这几个特殊格式的占位符独立出来了
在
doc
中$
符号可能会被单独放到一个标签,如果遇到这样的情况,请修改为上图格式普通的文本处理较为简单,按需求加入占位符即可
多组数据,如表格(document.xml.flt)
耐心分析 XML 文件,可以找到你所需的每一组数据的父标签
上图中每一行表格就是一个
w:tr
若需传入多组数据,可以使用FreeMarker的标签
<#list>
上图中我们每一个数据对象的名字是
user
,所以在#list
中我们将每一个数据项命名为user
这样我们就可以在 Java 程序中,以 List 的形式传入一组数据了(按照 Xml 中的命名,这个 List 对象名称必须是 users)
这个标签其实很像 Vue 中的 v-for
当然如果有更多需求,可以查看 FreeMarker 中文官方参考手册 (foofun.cn)
图片(document.xml.flt、document.xml.rels.flt、media)
图片的动态链接较为麻烦,因为相关信息同时存在于三块地方
最为直观易懂的,
media
文件夹中,我们需要动态地放入图片,而这并不简单,由于不同操作系统上 Word 默认编码不同,图片的命名不能含有中文,不能以数字开头,后缀名也必须是jpeg,这就需要做一定的工作链接_rels
上面几行标签可以根据 Url 猜测到是 Word 本身一些样式相关的链接,不用我们去修改
最后一行就是我的模板中那张图片的占位符了,这里是我已经修改过的样子,原本的Target是
media/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("&", "&");
str = str.replaceAll("<", "<");
str = str.replaceAll(">", ">");
}
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 里的处理一样,变成 &
、<
、>
,在工具类里有个 transform 方法可以帮助我们转换这些字符
工具类提供了两个生成 Word 的方法:ftlToDoc
和 createDocx
,前者用来生成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 文件名
通常 docTemplate 和 documentXmlRels 文件只需要自己加一个 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 模板
生成的最终文件
参考文章
上面这个链接里的看看原理就行,他的代码纯NT,照搬原作者的不注明就算了,还改得全是bug,下面这个链接就是代码的原作者