poi-tl(poi template language)是Word模板引擎,基于Microsoft Word模板和数据生成新的文档。

在文档的任何地方做任何事情(Do Anything Anywhere)是poi-tl的星辰大海。

1. Why poi-tl

方案

移植性

功能性

易用性

Poi-tl

Java跨平台

Word模板引擎

基于Apache POI

Apache POI

Java跨平台

Apache项目,功能丰富

文档不全,这里有一个教程:Apache POI Word快速入门

Freemarker

XML跨平台

仅支持文本,很大的局限性

复杂,需要维护XML结构,基本不可迭代

OpenOffice

部署OpenOffice软件,移植性较差

-

复杂,需要了解OpenOffice的API

HTML浏览器导出

依赖浏览器的实现,移植性较差

HTML不能很好的兼容Word的格式

-

Jacob、winlib

Windows平台

-

复杂,不推荐使用

操作Word文档最强大的方式要么是直接操作底层的文档结构,要么借助一个直接操作底层文档结构的类库,Apache POI不仅在上层封装了易用的API(文本、图片、表格、页眉、页脚等),也可以在底层直接操作文档结构。

poi-tl正是一个基于Apache POI的Word模板引擎,并且拥有着让人喜悦的特性。

引擎功能 描述

文本

将标签渲染为文本

图片

将标签渲染为图片

表格

将标签渲染为表格

列表

将标签渲染为列表

If Condition判断

隐藏或者显示某些文档内容(包括文本、段落、图片、表格、列表等)

Foreach Loop循环

循环某些文档内容(包括文本、段落、图片、表格、列表等)

Loop表格行

循环渲染表格的某一行

Loop有序列表

支持有序列表的循环,同时支持多级列表

书签、锚点、超链接

支持设置书签,文档内锚点和超链接功能

图片替换

将原有图片替换成另一张图片

强大的表达式

完全支持SpringEL表达式,可以扩展更多的表达式:OGNL, MVEL…​

标签定制

支持自定义标签前后缀

样式

模板即样式,同时代码也可以设置样式

模板嵌套

模板包含子模板,子模板再包含子模板

Merge合并

Word合并,可以在指定位置进行合并

用户自定义函数(插件)

在文档任何位置执行函数

poi-tl是一个免费开源的Java类库,你可以非常方便的加入到你的Java项目中。

如果你希望打开一个文档或者创建一个简单的文档,那么Apache POI就可能满足你的需求;如果你是一个需要将数据导出成Word文档的开发者,不妨试试poi-tl;如果你是一个搭建Word模板云服务的开发者,不妨试试poi-tl;如果你开发的某个软件支持导出HTML、Markdown,不妨使用poi-tl为你的软件添加导出Word的功能。

2. 软件要求

  • Apache POI4.0.0+

  • JDK1.8+

3. 历史版本

点击下方链接查阅poi-tl历史版本文档,其中v1.5.x是构建在Apache poi3.16+和JDK1.6+上的版本:

4. Getting Started

4.1. Maven

<dependency>
  <groupId>com.deepoove</groupId>
  <artifactId>poi-tl</artifactId>
  <version>1.7.3</version>
</dependency>

4.2. Gradle

compile group: 'com.deepoove', name: 'poi-tl', version: '1.7.3'

4.3. 2min快速入门

新建Word模板template.docx,包含标签 {{title}}

template.docx

{{title}}

代码示例
XWPFTemplate template = XWPFTemplate.compile("template.docx").render(new HashMap<String, Object>(){{ (1) (2)
        put("title", "poi-tl Word模板引擎");
}});
FileOutputStream out = new FileOutputStream("output.docx");
template.write(out); (3)
out.flush();
out.close();
template.close();
1 编译模板
2 渲染数据
3 输出到流

TDO模式:Template + data-model = output

output.docx

poi-tl Word模板引擎

4.4. Template:模板

模板是docx格式的Word文档,你可以使用Microsoft office、WPS Office、Pages等任何你喜欢的软件制作模板。

poi-tl遵循“所见即所得”的设计,模板的样式会被完全保留,标签的样式也会应用在替换后的文本上。

poi-tl是一种 "logic-less" 模板引擎,没有复杂的控制结构和变量赋值,只有标签。所有的标签都是以 {{ 开头,以 }} 结尾,模板标签可以出现在任何非文本框的位置,包括页眉,页脚,表格内部等等。

表格布局可以设计出很多优秀专业的文档,模板文档推荐使用表格,暂不支持文本框。

4.5. Data-model:数据

数据模型类似于哈希或字典。

数据可以是 Map (key是标签名称):

Map<String, Object> data = new HashMap<>();
data.put("name", "Sayi");
data.put("start_time", "2019-08-04");

数据可以是JavaBean(属性名是标签名称,可以通过注解 @Name 设置别名):

public class MyDataModel {

  // {{name}}
  private String name;

  // {{start_time}}
  @Name("start_time")
  private String startTime;

  // {{author.XXX}},XXX是Author的属性名
  private Author author;

}

数据可以是树结构,每级之间用点来分隔开,比如 {{author.name}} 标签对应的数据是author对象的name属性值。

对于常见的文本模板,所有Key映射的Value值可以是简单类型:字符串、数字等,比如通过三个字符串变量设置图片路径、宽和高:

<img src="{{path}}" width="{{width}}" height="{{height}}">

但是Word文档不是由简单的字符串表示,所以在渲染图片、表格等元素时数据结构会稍微复杂点,poi-tl提供了这些数据结构,它们都实现了接口 RenderData

  • 文本数据TextRenderData、HyperLinkTextRenderData

  • 图片数据PictureRenderData

  • 表格数据MiniTableRenderData

  • 列表数据NumbericRenderData

  • 嵌套数据DocxRenderData

4.6. Output:输出

模板引擎以流的方式进行输出:

// 输出流
template.write(OutputStream stream)

// 输出到文件
template.writeToFile(String path)

可以写到任意输出流中,比如文件流FileOutputStream或网络流ServletOutputStream:

response.setContentType("application/octet-stream");
response.setHeader("Content-disposition","attachment;filename=\""+"out_template.docx"+"\"");

// HttpServletResponse response
OutputStream out = response.getOutputStream();
BufferedOutputStream bos = new BufferedOutputStream(out);
template.write(bos);
template.close();
out.flush();
out.close();

最后不要忘记关闭这些流。

5. 标签

标签由前后两个大括号组成, {{title}} 是标签, {{?title}} 也是标签, title 是这个标签的名称, ? 标识了标签类型,接下来我们来看看有哪些默认标签类型(用户可以创建新的标签类型,这属于更高级的话题)。

5.1. 文本

{{var}}

数据模型:

  • String :文本

  • TextRenderData :有样式的文本

  • HyperLinkTextRenderData :超链接文本

  • Object :调用 toString() 方法转化为文本

代码示例
put("name", "Sayi");
put("author", new TextRenderData("000000", "Sayi"));
// 超链接
put("link",
  new HyperLinkTextRenderData("website", "http://deepoove.com"));
// 锚点
put("anchor",
  new HyperLinkTextRenderData("anchortxt", "anchor:appendix1"));

标签的样式会应用到替换后的文本上,也可以通过代码设定文本的样式。

TextRenderData 的结构体
{
  "text": "Sayi",
  "style": {
    "strike": false, (1)
    "bold": true, (2)
    "italic": false, (3)
    "color": "00FF00", (4)
    "underLine": false, (5)
    "fontFamily": "微软雅黑", (6)
    "fontSize": 12, (7)
    "highlightColor": "green" (8)
  }
}
1 删除线
2 粗体
3 斜体
4 颜色
5 下划线
6 字体
7 字号
8 背景高亮色
文本换行使用 \n 字符。

5.2. 图片

图片标签以@开始:{{@var}}

PictureRenderData 数据模型。

代码示例
// 本地图片
put("local", new PictureRenderData(80, 100, "./sayi.png"));

// 图片流
put("localbyte", new PictureRenderData(80, 100, ".png", new FileInputStream("./logo.png")));

// 网络图片
put("urlpicture", new PictureRenderData(50, 50, ".png", BytePictureUtils.getUrlBufferedImage("http://deepoove.com/images/icecream.png")));

// java 图片
put("bufferimage", new PictureRenderData(80, 100, ".png", bufferImage)));

图片支持 BufferedImage,这意味着我们可以利用Java生成图表插入到word文档中。

PictureRenderData 的结构体
{
  "path": "", (1)
  "data": [], (2)
  "altMeta": "图片不存在", (3)
  "width": 100, (4)
  "height": 100 (5)
}
1 图片路径
2 图片也可以是byte[]字节数组
3 当无法获取图片时展示的文字
4 宽度,单位是像素
5 高度,单位是像素
图片标签无法设置环绕版式,如果对环绕版式有更高的要求可以自定义图片插件进行设置,或者采用替换占位图片方式:引用插件

5.3. 表格

表格标签以#开始:{{#var}}

poi-tl默认实现了N行N列的样式(如下图),同时提供了当数据为空时,展示一行空数据的文案(如下图中的No Data Descs),数据模型是 MiniTableRenderData

table0
MiniTableRenderData 的结构体
{
  "rows": [ (1)
    {
      "cells": [ (2)
        {
          "cellText": [TextRenderData],
          "cellStyle": { (3)
            "align": "center",
            "backgroundColor": "ff9800"
          }
        }
      ],
      "rowStyle": { (4)
        "align": "center",
        "backgroundColor": "ff9800"
      }
    }
  ],
  "header": { (5)
    "cells": [
      {
        "cellText": [TextRenderData],
        "cellStyle": {
          "align": "center",
          "backgroundColor": "ff9800"
        }
      }
    ],
    "rowStyle": { (4)
      "align": "center",
      "backgroundColor": "ff9800"
    }
  },
  "noDatadesc": "No Data Desc", (6)
  "style": { (7)
      "align": "center"
    }
  "width": 14.65 (8)
}
1 定义表格行数据
2 定义单元格数据,数据由 TextRenderData 指定
3 单元格样式:对齐方式,背景色
4 行样式:行数据的对齐方式,行背景色
5 定义表格头
6 没有数据的展示文案
7 表格样式:表格居左、居中、居右对齐
8 表格宽度(单位cm),表格的最大宽度 = 页面宽度 - 页边距宽度 * 2,页面宽度为A4(20.99 * 29.6,页边距为3.17 * 2.54)的文档最大表格宽度14.65CM。
代码示例
RowRenderData header = RowRenderData.build(new TextRenderData("FFFFFF", "姓名"), new TextRenderData("FFFFFF", "学历"));

RowRenderData row0 = RowRenderData.build("张三", "研究生");
RowRenderData row1 = RowRenderData.build("李四", "博士");
RowRenderData row2 = RowRenderData.build("王五", "小学生");

put("table", new MiniTableRenderData(header, Arrays.asList(row0, row1, row2)));

现实需求中表格的布局和样式可能很复杂,此时默认表格样式将无法满足,可以尝试其它方案来解决:

  • 方案一: 有时候仅仅希望将集合循环展示成表格若干行而已,参见 行循环插件-HackLoopTableRenderPolicy

  • 方案二: 模板中已经有一个表格,我们只想动态的处理表格的某一部分数据,poi-tl提供了 DynamicTableRenderPolicy 动态表格策略,参见 示例-付款通知书

  • 方案三: 编写插件,完全由自己生成整个表格,这个方案需要你熟悉Apache POI XWPFTable相关API,但是自由度最高:参见 插件-开发一个插件

5.4. 列表

列表标签对应Word的符号列表或者编号列表,以*开始:{{*var}}

NumbericRenderData 数据模型。

代码示例
put("list", new NumbericRenderData(new ArrayList<TextRenderData>() {
  {
    add(new TextRenderData("Plug-in function, define your own function"));
    add(new TextRenderData("Supports word text, header..."));
    add(new TextRenderData("Not just templates, but also style templates"));
  }
}));

列表样式支持罗马字符、有序无序等。参见NumbericRenderData.FMT_*。

FMT_DECIMAL //1. 2. 3.
FMT_DECIMAL_PARENTHESES //1) 2) 3)
FMT_BULLET //● ● ●
FMT_LOWER_LETTER //a. b. c.
FMT_LOWER_ROMAN //i ⅱ ⅲ
FMT_UPPER_LETTER //A. B. C.

如果列表的每一项不是简单的文本,而是包含很多文档内容,或者多级列表该怎么生成? 区块对的循环功能可以很好的循环列表,并且支持编号有序。

5.5. 区块对

区块对由前后两个标签组成,开始标签以?标识,结束标签以/标识:{{?sections}}{{/sections}}

区块对开始和结束标签中间可以包含多个图片、表格、段落、列表等,开始和结束标签可以跨多个段落,也可以在同一个段落,但是如果在表格中使用区块对,开始和结束标签必须在同一个单元格内,因为跨多个单元格的渲染行为是未知的。

区块对在处理一系列文档元素的时候非常有用,位于区块对中的文档元素可以被渲染零次,一次或N次,这取决于区块对的取值。

False或空集合

隐藏区块中的所有文档元素

非False且不是集合

显示区块中的文档元素,渲染一次

非空集合

根据集合的大小,循环渲染区块中的文档元素

集合是根据值的类型是否实现了 Iterable 接口来判断。

5.5.1. False或空集合

如果区块对的值是 nullfalse 或者空的集合,位于区块中的所有文档元素将不会显示,这就等同于if语句的条件为 false

数据:

{
  "announce": false
}

模板:

Made it,Ma!{{?announce}}Top of the world!{{/announce}}

Made it,Ma!

{{?announce}}

Top of the world!🎋

{{/announce}}

输出:

Made it,Ma!

Made it,Ma!

5.5.2. 非False且不是集合

如果区块对的值不为 nullfalse ,且不是集合,位于区块中的所有文档元素会被渲染一次,这就等同于if语句的条件为 true

数据:

{
  "person": { "name": "Sayi" }
}

模板:

{{?person}}

Hi {{name}}!

{{/person}}

输出:

Hi Sayi!

区块对中标签的作用域为当前区块对,当且仅当区块对的值是 boolean 类型且为 true 时,这些标签作用域才不会改变。

5.5.3. 非空集合

如果区块对的值是一个非空集合,区块中的文档元素会被迭代渲染一次或者N次,这取决于集合的大小,类似于foreach语法。

数据:

{
  "songs": [
    { "name": "Memories" },
    { "name": "Sugar" },
    { "name": "Last Dance(伍佰)" }
  ]
}

模板:

{{?songs}}

{{name}}

{{/songs}}

输出:

Memories

Sugar

Last Dance(伍佰)

#this:引用当前对象

在循环中,有一个特殊的变量#this可以直接引用当前迭代的对象。由于#和已有表格标签标识冲突,所以在文本标签中需要使用=号标识来输出文本。

数据:

{
  "produces": [
    "application/json",
    "application/xml"
  ]
}

Word模板:

{{?produces}}
{{=#this}}
{{/produces}}

输出:

application/json
application/xml

5.6. 嵌套

嵌套是又称为导入、包含或者合并,以+标识:{{+var}}

DocxRenderData 数据模型。

代码示例
List<SegmentData> subData = new ArrayList<SegmentData>();
SegmentData s1 = new SegmentData();
s1.setTitle("经常抱怨的自己");
s1.setContent("每个人生活得都不容易。");
subData.add(s1);

SegmentData s2 = new SegmentData();
s2.setTitle("拖拖拉拉的自己");
s2.setContent("能够今天做完的事情,不要拖到明天?");
subData.add(s2);

put("docx_word", new DocxRenderData(new File("~/segment.docx"), subData)); (1) (2)
1 主模板包含嵌套标签{{+docx_word}}
2 segment.docx是一个包含了{{title}}和{{content}}的子模板,使用subData集合渲染后合并到主模板

6. 配置

poi-tl提供了配置类 Configure 来存储常用的设置,配置的使用方式如下:

ConfigureBuilder builder = Configure.newBuilder();
XWPFTemplate.compile("~/template.docx", builder.buid());

6.1. 前后缀

我一直使用 {{}} 的方式来致敬Google CTemplate,如果你更偏爱freemarker ${} 的方式:

builder.buildGramer("${", "}");

6.2. 标签类型

默认的图片标签是以@开始,如果你希望使用%开始作为图片标签:

builder.addPlugin('%', new PictureRenderPolicy());

如果你不是很喜欢默认的标签标识类型,你也可以自由更改:

builder.addPlugin('@', new MiniTableRenderPolicy());
builder.addPlugin('#', new PictureRenderPolicy());

这样{{@var}}就变成了表格标签,{{#var}}变成了图片标签,虽然不建议改变默认标签标识,但是从中可以看到poi-tl插件的灵活度,在插件章节中我们将会看到如何自定义自己的标签。

6.3. 标签正则

标签默认支持中文、字母、数字、下划线的组合,比如 {{客户手机号}} ,我们可以通过正则表达式来配置标签的规则,比如不允许中文:

builder.buildGrammerRegex("[\\w]+(\\.[\\w]+)*");

比如允许除了标签前后缀外的任意字符:

builder.buildGrammerRegex(RegexUtils.createGeneral("{{", "}}"));

6.4. 计算标签值

计算标签值是指如何在数据中寻找标签的值,你可以完全自定义计算的方式。

builder.setRenderDataComputeFactory(new RenderDataComputeFactory());
RenderDataComputeFactory是一个抽象工厂,你可以定义自己的工厂提供标签表达式计算接口 RenderDataCompute 的实现。

我们可以通过此方式支持任何的表达式引擎,Sping表达式正是通过 SpELRenderDataCompute 实现。

6.5. Sping表达式

Spring Expression Language 是一个强大的表达式语言,支持在运行时查询和操作对象图。在使用前需要引入相应的依赖:

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-expression</artifactId>
  <version>4.3.6.RELEASE</version>
</dependency>

为了在模板标签中使用SpEL表达式,需要将标签配置为SpEL模式:

builder.setElMode(ELMode.SPEL_MODE);

6.5.1. 基本使用

关于SpEL的写法可以参见官方文档,下面给出一些典型的示例。

{{name}}
{{name.toUpperCase()}} (1)
{{name == 'poi-tl'}} (2)
{{empty?:'这个字段为空'}}
{{sex ? '男' : '女'}} (3)
{{new java.text.SimpleDateFormat('yyyy-MM-dd HH:mm:ss').format(time)}} (4)
{{price/10000 + '万元'}} (5)
{{dogs[0].name}} (6)
1 方法调用,转大写
2 条件
3 三目运算符
4 方法调用,时间格式化
5 运算符
6 数组列表使用下标访问

6.5.2. SpringEL作为区块对的条件

Spring表达式与区块对结合可以实现更强大的功能。

数据:

{
  "desc": "",
  "summary": "Find A Pet",
  "produces": [
    "application/xml"
  ]
}

模板:

{{?desc == null or desc == ''}}{{summary}}{{/}}

{{?produces == null or produces.size() == 0}}无{{/}}

输出:

Find A Pet

使用SpringEL时区块对的结束标签可以是:{{/}}。

6.6. 错误处理

poi-tl支持在发生错误的时候定制引擎的行为。

6.6.1. 标签无法被计算

标签无法被计算的场景有几种,比如模板中引用了一个不存在的变量,或者级联的前置结果不是一个Hash结果,比如 {{author.name}} 中author的值为null,此时就无法计算name的值。

poi-tl可以在发生这种错误时对计算结果进行配置,默认会认为标签值为 null

// 默认行为,静默模式,标签计算错误的情况下结果置为null
builder.setElMode(ELMode.POI_TL_STANDARD_MODE);

当我们需要严格校验模板是否有人为失误时,可以抛出异常:

// 严格模式,标签计算错误的情况下抛出异常,这种情况下要求表达式必须可被计算
builder.setElMode(ELMode.POI_TL_STICT_MODE);

注意的是,如果使用SpringEL表达式,错误处理会遵循SpringEL的规则抛出异常。

6.6.2. 标签数据类型不合法

我们知道渲染图片、表格等标签时对数据类型是有要求的,如果数据不合法(为空或者是一个错误的数据类型),可以配置模板标签的渲染行为。

poi-tl默认的行为会清空标签:

builder.setValidErrorHandler(new ClearHandler());

如果希望保留标签:

builder.setValidErrorHandler(new DiscardHandler());

如果希望执行严格的校验,可以抛出异常:

builder.setValidErrorHandler(new AbortHandler());

6.7. 模板生成模板

模板引擎不仅仅可以生成文档,也可以生成新的模板,比如我们想构造这样的新模板:把原先的一个模板标签分成两个模板标签:

put("title", "{{title}}\n{{subtitle}}");

6.8. 日志

poi-tl使用slf4j作为日志门面,你可以自由选择日志实现,比如logback、log4j等。我们以logback为例:

首先在项目中添加logback依赖:

<dependency>
  <groupId>ch.qos.logback</groupId>
  <artifactId>logback-core</artifactId>
  <version>1.2.3</version>
</dependency>
<dependency>
  <groupId>ch.qos.logback</groupId>
  <artifactId>logback-classic</artifactId>
  <version>1.2.3</version>
</dependency>

然后配置logback.xml文件,可以配置日志级别和格式:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <logger name="com.deepoove.poi" level="debug" additivity="false">
    <appender-ref ref="STDOUT" />
  </logger>
  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

debug级别的日志会打印解析渲染过程中的信息,有利于程序调试,另外在模板引擎执行结束后会打印耗时信息:

Successfully Render the template file in 13 millis

7. 插件

插件,又称为 自定义函数 ,它允许用户在模板标签位置处执行预先定义好的函数。由于插件机制的存在,我们几乎可以在模板的任何位置执行任何操作。

插件是poi-tl的核心,默认的标签都是通过插件加载。

7.1. 默认插件

poi-tl默认提供了五个策略插件,用来处理文本、图片、列表、表格、文档嵌套等:

  • TextRenderPolicy

  • PictureRenderPolicy

  • NumbericRenderPolicy

  • MiniTableRenderPolicy

  • DocxRenderPolicy

由于这五个插件如此通用,因此将这些插件注册为不同的标签类型,从而搭建了poi-tl的标签体系,也构筑了poi-tl高度自由的插件机制。

7.2. 开发一个插件

插件的实现就是要告诉我们在模板的某个地方用某些数据做某些事情,我们可以通过实现 RenderPolicy 接口开发自己的渲染策略插件:

public interface RenderPolicy {
  void render(ElementTemplate eleTemplate, Object data, XWPFTemplate template); (1) (2) (3)
}
1 ElementTemplate代表当前标签
2 data是数据模型
3 XWPFTemplate代表整个模板

7.2.1. Hello, world

按照行业习俗,我们写一个将标签替换为Hello, world的插件:

public class HelloWorldRenderPolicy implements RenderPolicy {

  @Override
  public void render(ElementTemplate eleTemplate, Object data, XWPFTemplate template) {
    XWPFRun run = ((RunTemplate) eleTemplate).getRun(); (1)
    // String thing = String.valueOf(data);
    String thing = "Hello, world";
    run.setText(thing, 0); (2)
  }

}
1 当前位置XWPFRun
2 渲染文本hello, world

7.2.2. AbstractRenderPolicy

poi-tl提供了抽象模板类 AbstractRenderPolicy ,它定义了一些骨架步骤并且将数据模型的校验和渲染逻辑分开,使用泛型约束数据类型,让插件开发起来更简单,我们再来看看Hello, world插件的写法:

public class HelloWorldRenderPolicy extends AbstractRenderPolicy<String> {

  @Override
  public void doRender(RenderContext<String> context) throws Exception {
    // anywhere delegate (1)
    WhereDelegate where = context.getWhereDelegate();
    // any thing
    //String thing = context.getThing();
    String thing = "Hello, world";
    // do
    where.renderText(thing);
  }

}
1 WhereDelegate对当前位置的委托,封装了操作当前位置的一些便捷方法

接下来我们再写一个更复杂的插件,在模板标签位置完完全全使用代码创建一个表格,这样我们就可以随心所欲的操作表格:

public class CustomTableRenderPolicy extends AbstractRenderPolicy<Object> {

  @Override
  protected void afterRender(RenderContext<Object> context) {
    // 清空标签
    clearPlaceholder(context, true);
  }

  @Override
  public void doRender(RenderContext<Object> context) throws Exception {

    XWPFRun run = context.getRun();
    // 当前位置的容器
    BodyContainer bodyContainer = BodyContainerFactory.getBodyContainer(run);
    // 定义行列
    int row = 10, col = 8;
    // 当前位置插入表格
    XWPFTable table = bodyContainer.insertNewTable(run, row, col);

    // 定义表格宽度、边框和样式
    TableTools.widthTable(table, MiniTableRenderData.WIDTH_A4_FULL, col);
    TableTools.borderTable(table, 4);

    // TODO 调用XWPFTable API操作表格:data对象可以包含任意你想要的数据,包括图片文本等
    // TODO 调用MiniTableRenderPolicy.Helper.renderRow方法快速方便的渲染一行数据
    // TODO 调用TableTools类方法操作表格,比如合并单元格
    // ......
    TableTools.mergeCellsHorizonal(table, 0, 0, 7);
    TableTools.mergeCellsVertically(table, 0, 1, 9);

  }

}

CustomTableRenderPolicy通过 bodyContainer.insertNewTable 在当前标签位置插入表格,使用XWPFTable API来操作表格。

随心所欲的意思是原则上Apache POI支持的操作,都可以在当前标签位置进行渲染,Apache POI不支持的操作也可以通过直接操纵底层XML来实现。

7.3. 使用插件

插件开发好后,为了让插件在某个标签处执行,我们需要将插件与标签绑定。

7.3.1. 将插件应用到标签

当我们有个模板标签为 {{report}},如果希望在这个位置做些不一样或者更复杂的事情,我们可以将插件应用到这个模板标签:

ConfigureBuilder builder = Configure.newBuilder();
builder.bind("report", new CustomTableRenderPolicy());

ConfigureBuilder采用了链式调用的方式,可以一次性设置多个标签的插件:

builder.bind("report", new CustomTableRenderPolicy())
    bind("name", new MyRenderPolicy());

此时,{{report}} 将不再是一个文本标签,而是一个自定义标签。

7.3.2. 将插件注册为新标签类型

当开发的插件具有一定的通用能力就可以将其注册为新的标签类型。比如增加%标识:{{%var}},对应自定义的渲染策略 HelloWorldRenderPolicy

builder.addPlugin('%', new HelloWorldRenderPolicy());

此时,{{%var}} 将成为一种新的标签类型,它的执行函数是 HelloWorldRenderPolicy

7.4. 辅助类Helper

在內建策略插件中,通常会提供一个静态Helper辅助类,在我们实现自己的RenderPolicy时,也可以通过这些辅助类操作文档。

// 某个位置渲染文本
TextRenderPolicy.Helper.renderTextRun(XWPFRun, Object);
// 某个位置渲染图片
PictureRenderPolicy.Helper.renderPicture(XWPFRun, PictureRenderData);
// 某个位置渲染列表
NumbericRenderPolicy.Helper.renderNumberic(XWPFRun, NumbericRenderData);
// 渲染表格的一行数据
MiniTableRenderPolicy.Helper.renderRow(XWPFTable, int, RowRenderData);
// 渲染单元格
MiniTableRenderPolicy.Helper.renderCell(XWPFTableCell, CellRenderData, TableStyle)

7.5. Plugin Example

我想用一个完整的代码示例向你展示 Do Anything Anywhere 的想法,它不使用任何poi-tl的默认插件,完全使用匿名类创建新插件完成。

插件是一个函数,它的入参是anywhere和anything,函数体就是do something。

// where绑定policy
Configure config = Configure.newBuilder().bind("sea", new AbstractRenderPolicy<String>() { (1)
  @Override
  public void doRender(RenderContext<String> context) throws Exception {
    // anywhere
    XWPFRun where = context.getWhere();
    // anything
    String thing = context.getThing();
    // do 文本
    where.setText(thing, 0);
  }
}).bind("sea_img", new AbstractRenderPolicy<String>() { (2)
  @Override
  public void doRender(RenderContext<String> context) throws Exception {
    // anywhere delegate
    WhereDelegate where = context.getWhereDelegate();
    // any thing
    String thing = context.getThing();
    // do 图片
    FileInputStream stream = null;
    try {
      stream = new FileInputStream(thing);
      where.addPicture(stream, XWPFDocument.PICTURE_TYPE_JPEG, 500, 300);
    }
    finally {
      IOUtils.closeQuietly(stream);
    }
    // clear
    clearPlaceholder(context, false);
  }
}).bind("sea_feature", new AbstractRenderPolicy<List<String>>() { (3)
  @Override
  public void doRender(RenderContext<List<String>> context) throws Exception {
    // anywhere delegate
    WhereDelegate where = context.getWhereDelegate();
    // anything
    List<String> thing = context.getThing();
    // do 列表
    where.renderNumberic(NumbericRenderData.build(thing.toArray(new String[] {})));
    // clear
    clearPlaceholder(context, true);
  }
}).build();

// 初始化where的数据
HashMap<String, Object> args = new HashMap<String, Object>();
args.put("sea", "Hello, world!");
args.put("sea_img", "src/test/resources/sea.jpg");
args.put("sea_feature", Arrays.asList("面朝大海春暖花开", "今朝有酒今朝醉"));
args.put("sea_location", Arrays.asList("日落:日落山花红四海", "花海:你想要的都在这里"));

// 一行代码
XWPFTemplate.compile("src/test/resources/sea.docx", config).render(args)
    .writeToFile("out_sea.docx");
1 自定义文本插件
2 自定义图片插件
3 自定义列表插件

7.6. 开发一个引用插件

对于文档中的元素(元素包括不限于表格、图片),很多时候我们只想改变它的一点点属性,比如对于一个模板中布局好的图片我们只想替换图片内容,普通的渲染策略如果做到这一点可能需要重新创建整个图片,然后再设置期望的布局…​

引用渲染策略ReferenceRenderPolicy就这样诞生了,它提供了直接引用文档中的元素句柄的能力,这个重要的特性在我们只想改变文档中某个元素极小一部分样式和属性的时候特别有用,因为其余样式和属性都可以在模板中预置好,真正的所见即所得

public abstract class ReferenceRenderPolicy<T> {

  /**
   * 定位引用对象
   *
   * @param template
   * @return
   */
  protected abstract T locate(XWPFTemplate template);

  /**
   * 操作引用对象
   *
   * @param t
   *      引用对象
   * @param template
   *      模板
   */
  public abstract void doRender(T t, XWPFTemplate template) throws Exception;
}

locate抽象方法是用来定位具体的文档元素的,这个方法的实现充满了想象空间。poi-tl默认提供了两种方式:一种是通过元素在文档的位置,一种是匹配元素的可选文字,推荐使用可选文字引用元素。

我们以poi-tl内置的引用渲染策略插件 ReplaceOptionalTextPictureRefRenderPolicy 为例,演示下如何使用可选文字引用渲染策略替换一个占位图片。

首先在模板中,任意设置图片布局和格式(比如衬于文字下方),可选文字在标题或说明中填写"let’s img"(文字内容没有任何要求,可以输入任何字符)

ref

接下来就可以绑定引用渲染策略替换图片了:

Configure configure = Configure.newBuilder()
    .referencePolicy( (1)
      new ReplaceOptionalTextPictureRefRenderPolicy("let's img", (2)
        new FileInputStream("sayi.png"), (3)
        XWPFDocument.PICTURE_TYPE_PNG))
    .build();

XWPFTemplate template = XWPFTemplate.compile("template.docx", configure)
    .render(new HashMap<>());

template.writeToFile("out.docx");
1 绑定引用渲染策略
2 "let’s img"为匹配文字,优先匹配标题,再匹配说明文字
3 待替换的图片

最终运行的结果是图片布局格式皆不变,只把图片替换成了另一个图片。

7.7. 可选插件

除了五个通用的策略插件外,还内置了一些额外用途的插件。

DynamicTableRenderPolicy

动态表格插件,允许直接操作XWPFTable表格对象

HackLoopTableRenderPolicy

循环表格行,下文会详细介绍

BookmarkRenderPolicy

书签和锚点

TOCRenderPolicy

实验功能:目录,打开文档时需要更新域

IndexRefRenderPolicy

根据元素在文档的位置来渲染

OptionalTextTableRefRenderPolicy

根据表格的可选文字来操作表格

ReplaceOptionalTextPictureRefRenderPolicy

根据图片可选文字来替换占位图片

如果你写了一个不错的插件,欢迎分享。

7.7.1. HackLoopTableRenderPolicy

这是一个特定场景的插件,根据集合数据循环表格行。

template

货物明细和人工费在同一个表格中,货物明细需要展示所有货物,人工费需要展示所有费用。{{goods}} 是个标准的标签,将 {{goods}} 置于循环行的上一行,循环行设置要循环的标签和内容,注意此时的标签应该使用 [] ,以此来区别poi-tl的默认标签语法。同理,{{labors}}置于循环行的上一行

example looptable template
代码示例

{{goods}}{{labors}} 标签对应的数据分别是货物集合和人工费集合,如果集合为空则会删除循环行。

class Goods {
  private int count;
  private String name;
  private String desc;
  private int discount;
  private int tax;
  private int price;
  private int totalPrice;
  // getter setter
}

class Labor {
  private String category;
  private int people;
  private int price;
  private int totalPrice;
  // getter setter
}

List<Labor> labors = new ArrayList<>();
List<Goods> goods = new ArrayList<>();

接下来我们将插件应用到这两个标签。

HackLoopTableRenderPolicy policy = new HackLoopTableRenderPolicy();

Configure config = Configure.newBuilder()
        .bind("goods", policy).bind("labors", policy).build(); (1)

XWPFTemplate template = XWPFTemplate.compile(resource, config).render(
  new HashMap<String, Object>() {{
      put("goods", goods);
      put("labors", labors);
    }}
);
output

最终生成的文档列出了所有货物和人工费。

example looptable output

8. 示例

接下来的示例采取三段式output+template+data-model来说明,首先直接展示生成后的文档,然后一览模板的样子,最后我们对数据模型作个介绍。

8.1. 软件说明文档

output

需要生成这样的一份软件说明书:拥有封面和页眉,正文含有不同样式的文本,还有表格,列表和图片。下载最终生成的文件poi_tl.docx

example poitl output
template

使用poi-tl标签制作模板,可以看到标签可以拥有样式。

example poitl template

这个示例向我们展示了poi-tl最基本的能力,它在模板标签位置,插入基本的数据模型,所见即所得。

源码参见 JUnit XWPFTemplateTest

8.2. 付款通知书

output

需要生成这样的一份流行的通知书:大部分数据是由表格构成的,需要创建一个订单的表格(图中第一个表格),还需要在一个已有表格中,填充货物明细和人工费数据(图中第二个表格)。下载最终生成的文件payment.docx

example payment output
template

使用{{#order}}生成poi-tl提供的默认样式的表格,设置{{detail_table}}为自定义模板渲染策略(继承抽象表格策略DynamicTableRenderPolicy),自定义已有表格中部分单元格的渲染。

example payment template

这个示例向我们展示了poi-tl在表格操作上的一些思考。示例中货物明细和人工费的表格就是一个相当复杂的表格,货物明细是由7列组成,行数不定,人工费是由4列组成,行数不定。

默认表格数据模型(MiniTableRenderData)实现了最基本的样式,当需求中的表格更加复杂的时候,我们完全可以设计好那些固定的部分,将需要动态渲染的部分单元格交给自定义模板渲染策略。

poi-tl提供了抽象表格策略DynamicTableRenderPolicy来实现这样的功能,{{detail_table}}标签可以在表格内的任意单元格内,DynamicTableRenderPolicy会获取XWPFTable对象进而获得操作整个表格的能力。

public abstract class DynamicTableRenderPolicy implements RenderPolicy {
  public abstract void render(XWPFTable table, Object data);
}

首先新建渲染策略DetailTablePolicy,继承于抽象表格策略。

public class DetailTablePolicy extends DynamicTableRenderPolicy {

  // 货品填充数据所在行数
  int goodsStartRow = 2;
  // 人工费填充数据所在行数
  int laborsStartRow = 5;

  @Override
  public void render(XWPFTable table, Object data) {
    if (null == data) return;
    DetailData detailData = (DetailData) data;

    // 人工费循环渲染
    List<RowRenderData> labors = detailData.getLabors();
    if (null != labors) {
      table.removeRow(laborsStartRow);
      // 循环插入行
      for (int i = 0; i < labors.size(); i++) {
        XWPFTableRow insertNewTableRow = table.insertNewTableRow(laborsStartRow);
        for (int j = 0; j < 7; j++) insertNewTableRow.createCell();

        // 合并单元格
        TableTools.mergeCellsHorizonal(table, laborsStartRow, 0, 3);
        // 渲染单行人工费数据
        MiniTableRenderPolicy.Helper.renderRow(table, laborsStartRow, labors.get(i));
      }
    }

    // 货品明细
    List<RowRenderData> goods = detailData.getGoods();
    if (null != goods) {
      table.removeRow(goodsStartRow);
      for (int i = 0; i < goods.size(); i++) {
        XWPFTableRow insertNewTableRow = table.insertNewTableRow(goodsStartRow);
        for (int j = 0; j < 7; j++) insertNewTableRow.createCell();
        // 渲染单行货品明细数据
        MiniTableRenderPolicy.Helper.renderRow(table, goodsStartRow, goods.get(i));
      }
    }
  }
}

然后将模板标签{{detail_table}}设置成此策略。

Configure config = Configure.newBuilder().bind("detail_table", new DetailTablePolicy()).build();

付款通知书是用来展示 DynamicTableRenderPolicy 的用法,示例中货物明细和人工费仅仅是循环渲染表格行,使用HackLoopTableRenderPolicy 插件会更方便。

源码参见 JUnit PaymentExample

8.3. 目标制定

output

需要指定一份OKR目标计划,每个目标使用一个表格呈现,业务目标有多少个不一定,管理目标也可能没有。下载最终生成的文件okr.docx

example okr output
template

将表格放到区块对中,当区块对取值为空集合或者null则不会展示目标表格,当区块对是一个非空集合则循环展示表格。

example okr template

这个示例展示了区块对的循环Foreach功能,它可以对文档内容进行循环渲染。

源码参见 JUnit OKRExample

8.4. 个人简历

output

需要生成这样的一份流行的个人简历:左侧是个人的基本信息,技术栈是个典型的列表,右侧是个人的工作经历,数量不定。下载最终生成的文件resume.docx

example resume output

8.4.1. 方案一:使用区块对标签

template

工作经历是一个循环显示的内容,我们使用区块对标签{{?experiences}}{{/experiences}}。

example iterable resume template

8.4.2. 方案二:使用嵌套标签

template

工作经历可以使用嵌套标签,我们制作两个模板,一套主模板简历.docx(下图左侧),一套为文档模板segment.docx(下图右侧)。

example resume template

看起来很复杂的简历,其实对于模版引擎来说,和普通的Word文档没有什么区别,我们只需要制作好一份简历,将需要替换的内容用模版标签代替。

因为模版即样式,模版引擎无需考虑样式,只关心数据,我们甚至可以制作10种不同样式的简历模板,用同一份数据去渲染。

源码参见 JUnit ResumeExample

8.5. Swagger文档

output

这是一份非常专业的Swagger Word文档,样式优雅且有着清晰完整的文档结构,接口需要循环展示,接口的参数需要循环展示,接口的返回值需要循环展示,数据类型支持锚点到具体的模型,模型支持代码块高亮展示。下载最终生成的文件swagger.docx

example swagger output
example swagger output2
template

使用区块对标签完成所有循环功能,可以完美的支持有序和多级列表;表格使用 HackLoopTableRenderPolicy 插件的约定,可以非常方便的完成参数、返回值和数据模型表格的渲染;使用Spring表达式来支持丰富的条件判断;代码块高亮只需要使用拥有不同样式文本的集合循环展示即可。

example swagger template1
example swagger template2
代码示例
SwaggerParser swaggerParser = new SwaggerParser();
Swagger swagger = swaggerParser.read("https://petstore.swagger.io/v2/swagger.json");
SwaggerView viewData = convert(swagger); (1)

HackLoopTableRenderPolicy hackLoopTableRenderPolicy = new HackLoopTableRenderPolicy();
Configure config = Configure.newBuilder()
        .bind("parameters", hackLoopTableRenderPolicy)
        .bind("responses", hackLoopTableRenderPolicy)
        .bind("properties", hackLoopTableRenderPolicy)
        .addPlugin('>', new BookmarkRenderPolicy())
        .setElMode(ELMode.SPEL_MODE)
        .build(); (2)

XWPFTemplate template = XWPFTemplate.compile("swagger.docx", config).render(viewData); (3)
template.writeToFile("out_example_swagger.docx");
1 解析Swagger.json
2 配置模板引擎
3 Swagger导出Word

没错,一切都是如此简洁:简洁的导出代码 ,简洁的Word模板,甚至生成的Swagger文档都看起来那么简洁,愿一切如你所愿。

9. License

Apache License 2.0

10. 源码

11. 打赏个小费

poi-tl开源的初衷是希望让所有有需要的人享受Word模板引擎的功能,如果你觉得它节省了你的时间,给你带来了方便和灵感,或者认同这个开源项目,可以为我的付出打赏点小费哦。

pay

poi-tl是给你的礼物!

— Sayi

12. 常见问题

  1. 出现NoSuchMethodError 、ClassNotFoundException 、NoClassDefFoundError异常?

    poi-tl依赖的apache-poi版本是4.0.0+,如果你的项目引用了低版本,请升级或删除。

  2. 是否支持文本框?

    不支持,表格布局可以设计出几乎所有优秀专业的文档,请使用表格。

  3. 是否支持Android客户端使用?

    未知,有些朋友尝试成功,但我尚未在Android环境中验证过。

  4. 有没有提供图表、数学公式模板?

    暂不支持,如果是简单的图表,可以考虑通过Java提供的 BufferedImage 类创建图片后插入。

  5. 如何通过标签指定格式化函数?

    Spring表达式,应有尽有。

  6. 如何在一行中显示不同样式的文本?

    可能你需要多个标签;或者使用区块对,区块对的集合数据是拥有不同样式的TextRenderData。

  7. 我不是很熟悉Apache POI,我该怎么编写插件?

    编写插件还是需要熟悉下POI,你可以参考现有插件的源码,或者Google下Apache POI的用法,这里有一个入门教程:Apache POI Word快速入门

  8. Apache POI不支持的功能,我该怎么编写插件?

    Apache POI底层的组件也是直接操作XML的,你可以使用POI背后的组件。

  9. 有没有HTML转Word的插件?

    网上有一些这样的插件,我也很期待有人能Pull Request。

  10. 为什么没有技术群?

    技术群只在你第一次加入的时候有用,后来就会成为恼人的群。