anyline-office 从零开始(重点:aol 标签)
面向没有写过 Java 模板引擎、也没见过 OOXML 的同学:从「什么是模板引擎」讲到「写出一个能跑的报表」,
再逐个拆解每一个 aol 标签。
0. 导读:怎么用这份文档
0.1 适合谁看
- 你会用 Word / Excel 排版,但没接触过「模板引擎」;
- 公司项目里有一堆
.docx模板,里面写着${}和<aol:...>,你想弄明白它们是什么意思; - 你想自己做一个「数据 → 自动出报表」的功能。
0.2 需要的前置知识
| 知识 | 要求程度 | 不会怎么办 |
|---|---|---|
| Java 基础语法 | 能看懂类、方法、变量就行 | 先补 Java 入门,本文不会讲语法 |
| Maven | 知道 pom.xml 是加依赖的地方 | 看第 2 节,直接抄 |
| Word / Excel | 会打字、会插表格 | —— |
| JSON | 知道 [{"name":"张三"}] 长什么样 | 看第 1 节术语表 |
0.3 30 分钟速通路线
- 第 1 节:把 8 个术语看懂(10 分钟);
- 第 3 节:把「Hello World」例子的代码复制运行,看到结果(10 分钟);
- 第 5 节 + 9.1
for:搞懂${}和循环,这两样能解决 80% 的需求(10 分钟); - 剩下的章节当成字典,用到时按目录查。
.docx / .xlsx 当模板,
把里面的 ${占位符} 和 <aol:标签> 换成数据,生成新文档。
字体、表格边框、页眉页脚这些排版,全部由你在 Word 里设计好,引擎不负责画样式,只负责填内容。
1. 一句话原理与术语表
1.1 它到底怎么工作的
你在 Word 里做的模板(tpl.docx) 你在 Java 里准备的数据
┌──────────────────────────┐ ┌──────────────────────────┐
│ 员工:${name} │ │ name = "张三" │
│ ┌────┬──────┬────────┐ │ │ list = [ {name:张三,…}, │
│ │序号│ 姓名 │ 部门 │ │ │ {name:李四,…} ] │
│ ├────┼──────┼────────┤ │ └──────────────────────────┘
│ │<aol:for …> │ │
│ └────┴──────┴────────┘ │ │
└──────────────────────────┘ │
└──────────► 引擎渲染 ◄───────────────┘
│
▼
员工:张三
┌────┬──────┬────────┐
│ 1 │ 张三 │ 研发部 │
│ 2 │ 李四 │ 市场部 │
└────┴──────┴────────┘
结果 out.docx
1.2 术语表(看不懂的名词都在这)
- docx / xlsx
- 本质都是 zip 压缩包,里面装着一堆 XML 文件。Word 文档的正文在包里的
word/document.xml,文字内容放在一层层段落、运行块、文本块里。引擎就是解压 → 改 XML → 压缩回去。 - 占位符
${...} - 写在模板里的「填空位」,例如
${name}。渲染时被替换成数据里的name值。不带aol前缀,纯粹是取值。 - 标签 /
aol标签 - 写在模板里的「指令」,长得像 XML 标签:
<aol:for ...></aol:for>。它不做取值,而是控制结构——循环、判断、合并、插图。aol是默认命名空间(namespace),可以改(见第 15 节)。 - 上下文(Context)
- 一个「变量袋子」。你用
doc.variable("name", "张三")放进去,模板里就能用${name}取出来。循环时每一轮会克隆一份子上下文,这也是「循环里${name}表示当前行」的原因。 - DataSet / DataRow
- anyline 的数据容器。
DataSet≈ 一张表(很多行),DataRow≈ 一行记录。你把 JSON 或数据库查询结果转成它,<aol:for>就能一行行遍历。 - OGNL 表达式
${}里可以写一点「小表达式」,如${score >= 60}、${list.size-1}、${item.name}。规则和 Java 的点号调用很像,但容错更高。表达式写错不会报错到页面上,只打日志(见第 12 节)。- JSON
- 一种文本格式的数据。
{"name":"张三"}是一个对象,[{...},{...}]是一个数组(多行)。引擎可以直接把这种字符串变成DataSet。 w:t/ 「被拆散的文本」- Word 会把你输入的一句连续文字,偷偷切成好几个文本块(
w:t),因为中间可能夹了拼写检查、语言标记等。这是新手最容易踩的坑:你看到的${name}在文件里可能是${na+me}两块。引擎有自动合并(recombine),但跨段落就救不回来了。 - Imager / Uploader / Downloader
- 三个「外挂接口」,由你自己实现后注入:
Imager用来生成二维码/条形码图片;Downloader用来把网上的图片下载到本地;Uploader用于文档转 HTML 时把图片上传到你的文件服务器。 不注入,相关标签就静默不渲染。
2. 环境准备(从零开始)
2.1 需要的东西
- JDK 8 或更高(项目
pom.xml里source/target = 8); - Maven(或者 Gradle);
- 一个能打开的 Word / WPS,用来做模板。
2.2 加依赖
在 pom.xml 里加(版本跟随 anyline 主版本,示例为 17.x):
<dependency>
<groupId>org.anyline</groupId>
<artifactId>anyline-office</artifactId>
<version>17.7.6-e-SNAPSHOT</version>
</dependency>
这个包依赖 anyline-core(会自动带进来)。如果从中央仓库拉不到快照版,需要加上官方仓库:
<repositories>
<repository>
<id>mdm</id>
<url>http://maven.anyline.org/repository/maven-public/</url>
</repository>
<repository>
<id>ossrh</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
2.3 建议的目录结构
D:/demo/
├── tpl.docx // 你在 Word 里做的模板,只读,永不直接渲染
├── out.docx // 程序生成的输出(先复制 tpl.docx 再渲染)
└── src/main/java/HelloWord.java
3. 第一个能跑起来的例子
目标:模板里写 员工姓名:${name},程序跑完打开文档看到 员工姓名:张三。
1做模板
新建一个 Word 文档,随便敲一行字,用英文输入法敲:
员工姓名:${name} 所属部门:${dept}
另存为 D:/demo/tpl.docx(必须是 .docx,不要用老的 .doc)。
“ ” 转成直引号,所以中文输入法敲的也能识别。但属性值里的中文标点容易引起歧义,建议全程用英文半角,能少一半的怪问题。
2写代码
import org.anyline.office.docx.entity.WDocument;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
public class HelloWord {
public static void main(String[] args) throws Exception {
File tpl = new File("D:/demo/tpl.docx"); // 原模板:只读
File out = new File("D:/demo/out.docx"); // 输出文件
// ① 先复制一份:渲染改的是这个副本,不是原模板
Files.copy(tpl.toPath(), out.toPath(), StandardCopyOption.REPLACE_EXISTING);
// ② 打开副本
WDocument doc = new WDocument(out);
// ③ 往上下文里放数据(key 就是模板里 ${} 中的名字)
doc.variable("name", "张三");
doc.variable("dept", "研发部");
// ④ 渲染并保存(结果就地写回 out.docx)
doc.save();
System.out.println("已生成:" + out.getAbsolutePath());
}
}
3运行 & 验收
运行 main,控制台打印出路径,打开 out.docx,应该看到 员工姓名:张三 所属部门:研发部,字体和排版跟模板一模一样。
save() 会就地改写你传给 WDocument 的那个文件(内部是 ZipUtil.replace(file, ...),把改好的 XML 覆盖回原 zip)。
所以:永远不要把原始模板直接交给 WDocument,一定先 Files.copy 出一份来渲染。
不小心传了模板本身,${name} 就永久变成「张三」了——记得用 Git 或备份救回来。
3.1 save() 内部到底做了 6 件事
知道这个顺序,后面排错会快很多(docx 侧):
| 步骤 | 做什么 | 它导致的"现象" |
|---|---|---|
1. load() | 解压 docx,解析 word/document.xml、页眉页脚、图表 XML | 文件路径写错 / 文件被占用 → 日志报「加载失败」 |
2. predefine() | 登记内置变量(如 ${aov:now})与 <aol:pre> 片段 | —— |
3. recombine() | 把被 Word 拆散的 ${} 和标签文本合并回一个 w:t | 只有这一步救不了「跨段落」的标签 |
4. runTag() | 执行 <aol:for>、<aol:if> 等标签 | 循环/条件的结果在这时产生 |
4.1 executeMerges() | 执行延迟的合并单元格(merge) | 合并必须在 for 之后,所以 merge 要写在模板末尾 |
| 5. 替换占位符 | 页眉 → 页脚 → 图表 → 正文,逐个把 ${} 换成数据 | 页眉页脚里写 ${} 也能生效 |
| 6. 写回 zip | ZipUtil.replace(file, ...) 覆盖原文件 | 模板被就地改写(见上面的红框) |
4. 三条数据通道
给模板送数据有三种方式,长得像但其实不一样,混用会迷惑,这里说清区别。
| 通道 | 写法 | 特点 / 什么时候用 |
|---|---|---|
| 变量(最常用) | doc.variable("name", "张三")doc.variable("list", dataSet)doc.variable(map) |
放对象:字符串、数字、Map、JavaBean、DataSet 都行。${name}、${user.name}、<aol:for items="${list}"> 都从这取。循环/聚合/图表数据源必须走这里。 |
| HTML 替换 | doc.replace("content", "<b>标题</b>") |
值当成 HTML 解析后插进文档(支持 b/i/u/del/sup/sub/br/ul/ol/li/table/img 等)。要「加粗、换行、插表格、插图片」就用它。 |
| 纯文本替换 | doc.replace(false, "raw", "原样<b>不解析</b>") |
第一参数 false = 不解析 HTML,原样塞进去。用户填的内容里可能有 <,用这个防破坏格式。 |
| 插入另一个 Word | doc.replace("word", new File("a.docx"))doc.replace(false, "word", a, b) |
把别的 docx 内容合并到占位符位置(保留原来的样式),常用于「合同附件」「公章文件」拼接。 |
variable() 优先于 replace();replace() 的值再分「HTML 段」和「纯文本段」。
行内取值统一用 ${},结构变化(循环/判断/合并/图片/图表)交给 aol 标签——这是本项目最省心的用法。
// 完整示例:一个变量、一段 HTML、一段纯文本
WDocument doc = new WDocument(out);
doc.variable("name", "张三");
doc.replace("content", "<b>标题</b>"); // 解析 HTML
doc.replace(false, "raw", "原样<b>不解析</b>"); // 不解析 HTML
doc.replace("word", new File("a.docx")); // 插入另一个 word 文档内容
doc.save(); // 输出结果
xlsx 同套路:XWorkBook 也有 variable() / replace() / save(),模板单元格里直接写 ${列} 和 <aol:...> 标签(详见第 11 节)。
5. ${} 占位符完全指南
你可以先把 ${} 理解成「填空」。下面从最简单的开始,逐条加难度。
5.1 全部写法一览
| 写法 | 说明 | 例子 |
|---|---|---|
${NM} | 普通取值(当前上下文;循环内就是「当前这一行」) | doc.variable("NM","张三") |
${NM:NAME:昵称} | 多键顺位取第一个非空。列名不确定时特别好用 | 数据里有 NAME 或 昵称 都能取到 |
${list[0].NM}${list[list.size-1].NM} | OGNL 表达式:按下标取,或取最后一条 | 取集合第一行、最后一行 |
${aov:now}${aov:now:yyyy-MM-dd}${aov:date}${aov:time}${aov:datetime} | 内置常量(aov: / alv: 前缀),不用你赋值就能用 | 当前时间,可带格式(详见 15.2) |
${name} | 配合 <aol:for flatten="true"> 可直接用列名,等效 ${item.name} | 见 9.1 |
${aov:...}:以源码注册表为准
aov: / alv: 后面的名字必须能在内置「表达式执行器」注册表里查到才会生效。
按当前源码实际情况,可以放心使用的是时间系列:
| 写法 | 结果 | 说明 |
|---|---|---|
${aov:now} / ${aov:date} / ${aov:time} / ${aov:datetime} | 当前时间 | 前两个等价;date/time/datetime 是同一实现的多个别名 |
${aov:now:yyyy-MM-dd HH:mm} | 指定格式的当前时间 | 第 2 个冒号后的内容就是 SimpleDateFormat 格式,格式里含 : 会解析失败 |
而 ${aov:uuid} / ${aov:string:8} / ${aov:random:8} 在源码里并不存在对应的注册名,
写了取不到值(保持原样或输出空);${aov:number:8}、${aov:timestamp:13} 存在注册名重复的问题,
最终由哪个实现接管取决于容器注入顺序,不要在生产模板里依赖它们。
要随机数/唯一号,请在 Java 侧算好再用 doc.variable() 传入。
5.2 三个容易忽略的细节
- 空值兜底:取不到值时输出
context.getPlaceholderDefault(),可以用doc.setPlaceholderDefault("—")换成短横线或空串。 - 属性值里的
${}也会被解析:fetchAttributeString支持多段${}拼接,所以format="${fmt}"、items="${myList}"这类「动态属性」是成立的。意思是:你先改变量,标签的属性值跟着变。 - Word 拆字问题:Word 会把
${x}拆到多个w:t里,引擎有自动合并(save()第 3 步recombine)。但跨段落、手动换行、书签、域里仍然会失败。
${} 没被替换,先做两件事:
① 在 Word 里打开「显示编辑标记 / 显示格式符号」,看这个占位符是不是被你无意中拆到了两行;
② 把 ${} 连同前后一个字选中后删除,重新手工敲一遍(输入法跨中英文很容易敲进不可见字符)。
6. aol 标签基础
6.1 标签和 ${} 到底怎么分工
| 写法 | 本质 | 能做什么 | 不能做什么 |
|---|---|---|---|
${name} |
取值 | 把数据里某个 key 的值塞到当前位置(含简单表达式) | 不能增加/删除段落、不能重复一段内容 |
<aol:for> 等 |
指令 | 改变文档结构:循环复制、条件删除、合并单元格、插图、画图表 | 不负责「排版样式」,样式靠你在 Word 里设计 |
${} 管「填什么」,aol 标签管「要不要、几次、长什么样」。
90% 的模板只需要这两样组合。
6.2 三种书写形式
<!-- ① 自闭合:不需要标签体 -->
<aol:if test="${score >= 60}" value="及格" else="不及格"/>
<!-- ② 成对:标签体里的内容会受到标签控制 -->
<aol:for items="${list}" var="item">${item.NAME},</aol:for>
<!-- ③ 用标签体当数据源(value 属性没写时读 body) -->
<aol:money>${amount}</aol:money>
引擎按「标签名」分发(TagUtil 静态块里的注册表),标签名不区分大小写(内部统一 toLowerCase() 查表),
但属性名区分大小写,建议一律小写。
6.3 命名空间:aol 是可以改的
aol 只是默认前缀(WDocument 里 namespace = "aol")。如果模板里 aol: 和你的其它系统标签冲突,可以整体换掉:
WDocument doc = new WDocument(out);
doc.namespace("anyline"); // 之后模板里写 <anyline:for ...>
<aol:for> → <anyline:for>),
而且 <aol:pre> 定义、属性名不变。不建议为了「好看」去改,只在冲突时改。
6.4 一个标签的生命周期
理解这 4 步,就知道为什么有些标签「不生效却也不报错」:
| 阶段 | 做什么 | 你可以从这里看出什么 |
|---|---|---|
init() | 把外层上下文 克隆一份给标签自己用 | 标签内部改变量不会污染外层(除非显式往 var 写) |
prepare() | 解析属性:var / property / valueKey / textKey,并把标签体切成 head / body / foot | 属性写错名字 → 这一步读到 null,后面静默走默认分支 |
run() | 改文档结构(docx 专用) | 只实现 run() 的标签,在 Excel 单元格里不生效(见 11.3) |
parse() | 产出纯文本(不碰文档结构) | 基类默认返回空串 "";没覆写它的标签在「取值型」场景会输出空 |
6.5 属性值的三种写法
<!-- ① 字面量:直接写死 -->
<aol:number value="1234.5" format="#,##0.00"/>
<!-- ② 动态:属性值里写 ${},运行时先求值 -->
<aol:for items="${订单列表}" var="o">...</aol:for>
<aol:date format="${日期格式}" value="${@now}"/>
<!-- ③ 别名:同一个属性有多个名字,按优先级取第一个出现的 -->
<aol:sum data="${明细}" p="金额"></aol:sum> <!-- p 就是 property -->
AbstractTag.fetchAttributeString() 拿到属性值后,如果发现里面含 ${...},
会用 DocxUtil.splitKey() 切成多段并逐段求值再拼起来,所以
style="width:${w}px;height:${h}px;"、items="${myList}" 这类写法是成立的。
含 ${} 的属性走的是「数据取值」而不是原样字符串。
6.6 属性默认值:标签没写这个属性时怎么取值
每个标签属性都可以配一个「保底值」。标签显式写了就用标签上写的值,没写才按这里的默认值取。
配置项写成 标签名.属性名(大小写不敏感),值可以是 EL 表达式、普通字符串,也可以直接是一个对象。
// ① 全局:影响所有文件(建议在应用启动时配一次)
TagDefault.set("for.items", "${set}");
// ② 单个文件:只影响这个文档(context 就是文件的隔离边界)
XWorkBook book = new XWorkBook(out);
TagDefault.set(book.context(), "for.items", "${sales}");
// ③ 值也可以直接给对象,不用再包 ${}
TagDefault.set(book.context(), "for.items", set);
TagDefault.set(batch); // 批量:Map<String,Object>
TagDefault.set(book.context(), batch); // 批量:Map<String,Object>
// 删除 / 清空
TagDefault.remove("for.items");
TagDefault.remove(book.context(), "for.items");
TagDefault.clear();
TagDefault.clear(book.context());
| 作用域 | 怎么设置 | 作用范围 |
|---|---|---|
| 文件级 | set(Context context, String attribute, Object value) | 只影响当前 WDocument / XWorkBook。借文档的 Context 传递,标签运行时的 context 是它的 clone,所以文件之间天然隔离 |
| 全局级 | set(String attribute, Object value) | 影响所有文件 |
| 内置 | — | for.items = ${set},优先级最低,可被前两档覆盖 |
别名会依次匹配:
for 的数据属性内部按 data → d → items → is 的顺序查默认值,先查到的生效。所以配了 for.items、又没配 for.data / for.d 时,模板里一个数据属性都不写就会走到 items。标签名可通配:
*.var 对所有标签生效;精确 key(for.var)优先于通配 key。注意:内置默认值里只登记了
for.items。如果把 for.data 也登记上,它会抢在 items 前面命中,你配的 for.items 就失效了。「没写」和「写了没值」是两回事:只有标签上根本没出现这个属性时才用默认值。写了
items="${list}" 而 list 不存在,结果是空,不会被默认数据源顶替;同理别名也是先出现的生效(写了 data= 就不再看 items=)。
6.7 把标签定义成可复用片段:pre / ref
模板里同一段标签要写很多遍时,可以先「注册」再「引用」:
<!-- ① 用 <aol:pre id="xxx"> 注册一个片段(这段内容渲染后会被移除,只当定义) -->
<aol:pre id="金额栏"><aol:number value="${amount}" format="#,##0.00"/></aol:pre>
<!-- ② 用 <aol:金额栏/> 直接调用(以定义名当标签名) -->
合计:<aol:金额栏/>
<!-- ③ 也可以用 ref="名称" 引用,同时覆盖部分属性 -->
<aol:number ref="金额栏" value="${otherAmount}"/>
<!-- ④ 带 pre="名称" 的标签同样会被注册成片段,自身不输出 -->
<aol:date pre="日期" format="yyyy-MM-dd"/>
pre="xxx"(或 <aol:pre id="xxx">)的这段不会渲染出内容,只是把标签文本存进上下文。
引用时 ref 里没写的属性会从定义里继承,ref 里写的以引用处为准。
7. 标签清单
下面是注册表(TagUtil 静态块)里的全部 25 个标签。
「适用」一列:docx 表示正文可用;
仅 run 表示只实现了 run(),因此在 Excel 单元格中不生效。
| 标签 | 作用 | 关键属性 | 适用 | 详见 |
|---|---|---|---|---|
<aol:for> | 循环复制(集合遍历 / 计数循环) | items var status scope begin/end/step fill flatten | docx xlsx 仅行循环 | 9.1 |
<aol:if> | 条件判断 / 二选一输出 | test value else var remove scope | 仅 run | 9.2 |
<aol:sum> | 求和 | data property format scale nvl min/max | 仅 run | 9.3 |
<aol:avg> | 平均值 | data property scale round | 仅 run | 9.3 |
<aol:max> <aol:min> | 取最大 / 最小的那一行 | data property var | 仅 run | 9.3 |
<aol:agg> | 万能聚合(sum/avg/max/min/count/median/stdev/var/first/last) | type data property scale format empty | 仅 run | 9.3 |
<aol:number> | 数字格式化 | value format | docx xlsx 可用 | 9.4 |
<aol:money> | 金额转中文大写 | value | 仅 run | 9.4 |
<aol:date> | 日期格式化 | value format property evl | docx xlsx 可用 | 9.4 |
<aol:concat> | 把一列值拼成一个字符串 | data property split | 仅 run | 9.5 |
<aol:group> | 按列分组 | data by var extract | 仅 run | 9.5 |
<aol:pivot> | 行转列(长表转宽表),对应 DataSet.pivot | data pk class value var extract sep classVar | 仅 run | 9.5 |
<aol:grid> | 检测项网格:按 组/检测项/轴/值类型 把行数据透视成表格(列式可选 layout=item 项沿行每项K行 / layout=block 轴值横向块) | data dir layout axis typeField vol types ph miss empty scope order merge split | 仅 run | 9.12 |
<aol:set> | 给变量赋值(含类型推断) | var value data | 仅 run | 9.5 |
<aol:select> | 数据集筛选(预留,未实现) | — | — | 9.11 |
<aol:checkbox> | 勾选项(☑/☐ 或纯文本) | data value type vol rely valueKey/textKey | 仅 run | 9.6 |
<aol:merge> | 表格单元格合并(延迟执行) | scope by ignore | 仅 run | 9.7 |
<aol:img> | 插入图片 | src style | 仅 run xlsx 可用 | 9.8 |
<aol:html> | 把一段 HTML 渲染进文档 | 标签体 | 仅 run | 9.8 |
<aol:qr> | 二维码 | value style width/height | 仅 run(需 Imager) | 9.8 |
<aol:bar> | 条形码 | value style width/height | 仅 run(需 Imager) | 9.8 |
<aol:page> | 插入分页符 | position | 仅 run | 9.9 |
<aol:mark> | 插入 Word 书签 | name | 仅 run | 9.11 |
<aol:chart> | 原生图表(OOXML DrawingML) | type data title categoryKey/valueKey … | 仅 run xlsx 可用 | 9.10 |
<aol:sheet>:写在模板工作表的任意单元格里,引擎按数据把整张表复制 N 份并各自命名,
用于「一个订单 N 个商品、每个商品一张表」这类表数量由数据决定的场景,详见
aol:sheet 多工作表说明。
org.anyline.office.tag.Include(从远程 URL 拉内容插入文档),但它没有出现在注册表里,
因此模板里写 <aol:include> 不会被执行。要用的话需要自己 TagUtil.reg("include", Include.class) 注册。
8. 通用属性
这些属性不是某一个标签专属,而是由基类 AbstractTag 统一解析的。
也就是说:凡是从集合取数的标签(for / sum / avg / max / min / agg / concat / group / checkbox / chart),都认下面这套「数据源 + 切片」属性。
8.1 数据源与切片
| 属性 | 别名 | 作用 |
|---|---|---|
data | d items is | 数据源。写 ${变量} 时按上下文取值;直接写字面量时:[{...}] 解析为 DataSet,a,b,c 解析为列表 |
property | p | 要处理的列名(sum/avg/concat/group by 等用它)。可写多列:p="A,B" |
begin | start b | 起始下标(默认 0) |
end | e | 结束下标(含)。不写则到末尾 |
qty | q | 只取多少条 |
index | i | 只取指定下标的那一条 |
selector | st | 只保留指定列(BeanUtil.selects),减少无关字段 |
distinct | ds | 按指定列去重(多列逗号分隔) |
formatDate | fd | 把数据源里 property 指定字段先按该格式转成字符串(仅 DataSet) |
formatDate → selector → index(有则只取一条)→ begin/end/qty 切片 → distinct 去重。
所以「index 和 begin 一起写」时只有 index 生效。
8.2 输出控制
| 属性 | 别名 | 作用 |
|---|---|---|
var | — | 把结果写进变量而不是直接输出文本。常用于 set、group、max/min、if。写 var 时标签自身不产生可见文本 |
valueKey | vk | 「选项的值」取哪个字段(默认主键列)。checkbox、chart 用它 |
textKey | tk | 「显示文本」取哪个字段(默认 NM)。checkbox、chart 用它 |
pre | — | 把本标签注册成可复用片段(见 6.6) |
ref | — | 引用一个已注册的片段,同名属性以本处为准 |
var 的两个坑
① var 有值时标签不输出文本,同时会把标签所在的第一个 t 元素删掉;
② 多一层上下文时结果会同时写到当前层和 parent 层,退出循环后仍可能读到,别用同一个变量名跨循环复用。
8.3 一个「全属性」的例子
<!-- 取「明细」里 金额 列,跳过前 2 条,最多 5 条,按 客户 去重,求和 -->
<aol:sum data="${明细}" b="2" q="5" ds="客户" p="金额"
format="#,##0.00" scale="2" nvl="--"/>
sum、avg、max、min、agg 的差别只是「怎么算」,
「从哪些数据里算」完全共享上面这套属性,学会一个就会了全部。
9. 逐标签详解
每个标签一张卡片,统一按「干什么 → 属性表 → 最小示例 → 坑」来写。 属性表里的「别名」写法可以互换,「默认」列写了就说明不填时的行为。
${...} 都是 Java 侧 doc.variable("名字", 值) 放进去的;
标签体里的 \\n 是排版示意,实际模板里就是一个段落或多个段落。
9.1 for(循环)
<aol:for>
干什么:把标签体(或整个表格行 / 整个表格)复制 N 份,每份里 ${} 取当前这一轮的数据。
它是绝大多数报表真正干活的标签。
属性表
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
items | data d is | 无 | 数据源,见 8.1 |
var | — | 无 | 迭代变量名。绑「当前行」;计数循环时绑「当前下标数字」 |
status | s | 无 | 循环状态变量名,值为 index / count / prev / next |
scope | sp | body | 复制范围:body 标签体 / tc(td)整列 / tr 整行 / table 整表 |
begin | start b | 0 | 起始下标(越界自动纠正到 0) |
end | e | 数据末尾 | 结束下标(含)。如果没有 items 只有 end,就变成计数循环 |
step | — | 1 | 步长 |
fill | — | 无 | 数据不足时补空行:把 end 撑到 fill(不是「补到 fill 行」)。实际行数 = fill − begin + 1,所以 begin=0、step=1 时 fill="8" 出来的是 9 行(已实测),想要 8 行要写 fill="7" |
vol / align | — | 无 | 当前版本已解析但未参与任何计算(写了等于没写)。列循环的排布请靠 scope="tc" 自身的列复制,或改用表格行 |
remove | — | true | 没有数据时是否删掉模板。想保留空行占位就写 remove="false"。注意:只要有数据(items 非空),源码会把 remove 强制改回 true,此时写 false 无效 |
flatten | flat fl | false | 把当前行的列「平铺」进上下文,模板里可直接写 ${列名} |
四种 scope
scope | 标签写在哪 | 复制什么 | 典型用途 |
|---|---|---|---|
body(默认) | 段落里 | 标签体之间的内容 | 一段文字重复、小票条目行 |
tr | 表格某一行(模板行)的单元格内 | 整行(含格式) | 明细表按行展开(最常用) |
tc / td | 模板列的单元格内 | 整列 | 横向展开(如 1~12 月横着排) |
table | 表格内 | 整张表 | 一个客户一张表 |
w:tr / w:tc 本身没有可见文本框,所以你只能把标签写在单元格(td)里,
引擎会自动向上找到所在的行 / 列。标签和 ${} 写在同一单元格时才属于这一行模板。
最小示例
<!-- ① 表格按行展开(模板行里写标签 + 占位符) -->
<aol:for items="${明细}" var="r" scope="tr" status="s">
序号 ${s.count} · 名称 ${r.NAME} · 金额 ${r.AMOUNT}
</aol:for>
<!-- ② 计数循环:没有 items,只有 end -->
<!-- ⚠ 实测:begin=1 end=5 会输出 5 份「副本」+ 1 份未替换的模板体(多一份),-->
<!-- 固定份数建议写成 begin=1 end=4,或干脆用 items 传一个空集合 -->
<aol:for var="i" begin="1" end="5">第 ${i} 页 </aol:for>
<!-- ③ 固定 8 行,不够补空行(fill 是「把 end 撑到 N」,所以 8 行要写 7) -->
<aol:for items="${明细}" var="r" scope="tr" fill="7">${r.NAME}</aol:for>
<!-- ④ flatten:直接写列名 -->
<aol:for items="${明细}" var="r" scope="tr" flatten="true">${NAME} / ${AMOUNT}</aol:for>
<!-- ⑤ 嵌套:内层可以引用外层变量 -->
<aol:for items="${部门}" var="d">
<aol:for items="${d.员工}" var="e">${d.名称} - ${e.姓名} </aol:for>
</aol:for>
WDocument doc = new WDocument(out);
doc.variable("明细", dataSet); // DataSet / List<Map> / List<Bean> / [{...}] JSON 字符串都可以
doc.save();
坑
- 不写
var就没有当前行变量:Context.variable(null, item)会被忽略,循环体里只能用status。 index是绝对下标:配begin="2"时第一条的index是 2 而不是 0,序号请用count(从 1 开始)。prev/next会越界为空:首条没有prev,末条没有next,取值前建议判空。- JSON 字符串只认
[{...}]:"[a,b,c]"这种不会被解析,日志会提示「数据源仅支持集合形式」。 - 数据源必须是集合:单个 Map / 单个 Bean 不行(会被当字符串处理),请包成 List。
fill补出来的是空行:只补行数,不会给占位符任何值(会走placeholderDefault)。fill="N"出来的是N+1行(begin=0、step=1时):源码做的是end = max(end, fill),所以fill是结束下标而不是行数。已用最小模板实测确认 2 条数据 +fill="4"得到 5 行。- 循环里绝对不要写
i(index):i是「只取第 N 条」的通用切片属性,会把数据源变成单条记录,for随即报「数据源仅支持集合形式」并且整段标签体被清空(已实测:模板段落里什么都不剩)。序号请用status的count/index。 qty(q)对for无效:源码里for取数据时走的是「不切片」的模式,已实测qty="1"仍然把两条数据全渲染出来。要限量请用end,或干脆在 Java 侧裁好集合。- 计数循环会残留一份模板体:不写
items、只用begin/end时,源码在循环里把remove置成了false,因此模板本身不会被删除。实测scope="tr"、begin="1" end="3"得到 4 行(3 行ROW1/2/3+ 1 行未替换的ROW),scope=body同样多出一份。写「每页 5 行」这类需求时务必把end减 1,并注意多出来的那份里${i}是空的。 vol/align是「死属性」:源码里解析了但没有任何地方使用,写上去不会报错也不会生效,别照着属性名猜效果。- flatten 不会覆盖控制变量:
index/count/prev/next与var/status同名时前者优先;其它同名列会遮蔽外层变量,退出本轮自动恢复。 - 循环里不要重复用同一个
var名:内层会遮蔽外层,建议内层用不同名字。
想更细地了解 var / status / flatten,可看专门文档:aol:for 的 var 属性使用说明。
9.2 if(判断)
<aol:if>
干什么:算一个布尔值,然后决定「保留标签体 / 换成一段文字 / 整段删掉」。
一句话概括它的优先级:有 value 就用 value,没有 value 才去执行标签体。
属性表
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
test | — | 无 | 判断表达式。可以是 true/false 字面量,也可以是 OGNL 表达式。写成 ${...} 也能识别(会自动去掉外壳) |
value | v | 无 | 为真时输出的文本。写了就不再执行标签体 |
else | — | "" | 为假时输出的文本。不写则整段删除 |
var | — | 无 | 把布尔结果写进变量,标签体整段删除、不输出任何文字 |
remove | rm | false | 为假时,是否连所在单元格 / 所在行一起删掉。⚠ 当前版本该功能已损坏(见下方 callout),不要依赖 |
scope | — | tc | 配合 remove:tc/td 删单元格,tr 删整行。⚠ 同上,当前会抛 NPE |
remove + scope 目前是坏的(已实测)
运行 <aol:if test="1 == 2" remove="true" scope="tr">…</aol:if> 会抛
NullPointerException(If.java:92,tr is null),
日志里能看到堆栈,但因为异常被 TagUtil 兜住,页面不报错,只是「整行没被删掉、只留下一个空行」。
原因是源码先执行 box.remove() 把标签内容从文档树上摘掉,之后才去用
DocxUtil.getParent(contents.get(0), "tr") 反查父级 —— 元素已经脱离文档树,父级为 null。
- 在 Java 侧过滤数据(推荐):先把不满足条件的记录从集合里剔掉,再交给
aol:for; - 用
forEach思路包一层:外层<aol:for items="${过滤后的明细}" scope="tr">; - 如果只是「这一行里所有文字都不要」,
<aol:if test="…">…</aol:if>不写remove即可:内容会被删掉,只剩一个空行(空行能否接受取决于版式)。
四种用法
<!-- ① 二选一文字输出(不需要标签体) -->
成绩:<aol:if test="${score >= 60}" value="及格" else="不及格"/>
<!-- ② 保留/删除标签体(标签体里可以有段落、表格、其它标签) -->
<aol:if test="${showDetail}">
<h3>明细</h3>
<aol:for items="${明细}" var="r" scope="tr">${r.NAME}</aol:for>
</aol:if>
<!-- ③ 把结果存成变量,后面 ${} 用 -->
<aol:if test="${score >= 60}" var="合格"/>
是否合格:${合格}
<!-- ④ 不满足条件时删掉内容(⚠ remove+scope 当前版本会抛 NPE 且行删不掉,见上方) -->
<aol:if test="${r.QTY > 0}">${r.NAME}</aol:if>
<!-- 想真正「整行消失」,请改为在 Java 侧过滤数据后交给 aol:for -->
坑
- 表达式写错只是打日志:OGNL 抛异常时
chk保持false,表现为「一直走 else 分支」,页面上看不出错误。先用${}把值打印出来确认。 value和else都会被当成模板文本输出:里面写${}是支持的,但不要写标签。var会「吃掉」标签体:写了var就不要指望标签体被渲染出来(源码里box.remove(true))。remove/scope当前不可用:源码先摘子树再反查父级,必然取到null并抛NullPointerException(异常被TagUtil捕获打日志,所以「静默地不生效」)。所以「不满足条件就整行不显示」这个常见需求,暂时只能靠 Java 侧先把数据过滤掉,再交给aol:for。- 不写
else时,条件为假会把标签体删掉、段落/行本身留下:表现为「多了个空行」,这不是 bug,是设计如此。 - 比较字符串要加引号:
test="type == 'A'",别写test="type == A"(A会被当变量)。 - 真假判断看
isBoolean:test="true"/test="false"直接当布尔;其它一律走 OGNL 求值,结果必须是Boolean。
9.3 数值类:sum / avg / max / min / agg
这五个是「算一个数出来」。数据源怎么写见 8.1,这里只讲各自的差异。
<aol:sum> 求和
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
property | p | 无 | 要累加的字段名。不写就累加每个元素本身(元素必须是数字) |
format | f | 无 | 输出格式,如 #,##0.00。写法同 9.4 number |
nvl | n | 无 | 取不到值时输出的兜底文本(只有 n 一个别名) |
min | — | 无 | 下限,结果小于它时按它输出(没有别名,别写 mn) |
max | — | 无 | 上限(没有别名) |
scale | s | 无 | 保留小数位(与 format 二选一) |
round | r | 无 | 舍入模式(BigDecimal 常量,4 = 四舍五入)。只有配 scale 时才起作用 |
def | — | 无 | 累加初始值。源码里只解析、从不使用(注释写着「预留」),写了没有任何效果 |
合计:<aol:sum data="${明细}" p="金额" format="#,##0.00"/>
数量:<aol:sum data="${明细}" p="数量" nvl="0"/>
纯数字列表:<aol:sum data="${数字列表}"/>
<aol:avg> 平均值
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
data | d | 无 | 必须是 DataSet(List<Map> 也可以,其它集合不行) |
property | p | 无 | 要平均的字段名(不写则取不到值,输出为空) |
scale | s | 2 | 小数位 |
round | r | 4 | 舍入模式(BigDecimal 常量,4 = 四舍五入) |
平均单价:<aol:avg data="${明细}" p="单价" scale="2"/>
avg 里虽然读了 format,但没有把它赋给成员变量,
所以 <aol:avg format="#,##0.00"/> 是不生效的。要格式化请套一层:
<aol:number value="<!--平均值-->" format="#,##0.00"/>,或改用它下面的
<aol:agg type="avg" format="#,##0.00"/>。
<aol:max> / <aol:min> 取最大 / 最小的那一行
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
data | d | 无 | 必须是 DataSet |
property | p | 无 | 按哪个字段比大小 |
var | — | 无 | 把整行存进变量(之后可以 ${变量.字段} 取任意列);不写 var 则只输出该字段的值 |
<!-- 只输出金额最大值 -->
最大金额:<aol:max data="${明细}" p="金额"/>
<!-- 取最大金额那一整行,再展开它的几个字段 -->
<aol:max data="${明细}" p="金额" var="最大单"/>
最大单客户:${最大单.客户} · 金额:${最大单.金额}
var 只想拿值
直接用 <aol:max data="${明细}" p="金额"/> 即可;
但如果字段不是数字而是字符串,会按 compareTo 比较,中文排序结果可能和你预期不同。
<aol:agg> 万能聚合
agg 是 sum/avg/max/min 的超集:
一个标签通过 type 切换算法,而且数据源支持任意 Collection(不局限于 DataSet),
并且 format / scale 都真实生效。
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
type | t | sum | 算法,见下表。大小写不敏感,且会自动去掉 _DECIMAL/_DOUBLE 之类后缀 |
property | p | 无 | 参与计算的字段;不写则把元素本身当数字 |
format | f | 无 | 输出格式(同 number) |
scale | s | 见说明 | 小数位。sum/max/min/count 不写则保持原精度;avg/median/stdev/var 不写则按 10 位输出 |
round | r | 四舍五入 | 舍入模式(BigDecimal 常量,4 = 四舍五入) |
nvl | n | 无 | 兜底文本(无数据 / 结果为 null 时输出) |
min / max | — | 无 | 对计算结果做上下限截断(不是用来选最大/最小值的,那要用 type="max") |
empty | in | false | 空值是否当 0 参与运算。为 true 时:count 直接把集合长度当结果、avg 的分母用总条数、其它算法把 null 当 0 |
data 系列 | 见 8.1 | begin/end/qty/distinct/selector 等同样支持 | |
type | 含义 | 返回 |
|---|---|---|
sum | 求和(默认类型) | 数值 |
avg | 平均值 | 数值 |
max / min | 最大值 / 最小值 | 数值 |
count | 计数(配 property 时只数该字段非空的记录;加 empty="true" 则直接返回集合长度) | 整数 |
first / last | 第一个 / 最后一个非空的 字段值 | 字符串 |
median | 中位数 | 数值 |
stdev / stdevp | 样本标准差(÷N-1) / 总体标准差(÷N) | 数值 |
var / varp | 样本方差 / 总体方差 | 数值 |
明细条数:<aol:agg type="count" data="${明细}" p="金额"/>
金额中位数:<aol:agg type="median" data="${明细}" p="金额" scale="2"/>
标准差:<aol:agg type="stdev" data="${明细}" p="金额" scale="2"/>
行数(任意集合):<aol:agg type="count" data="${名单}" empty="true"/>
均值并格式化:<aol:agg type="avg" data="${明细}" p="金额" scale="2" format="#,##0.00"/>
5 个标签怎么选(速查)
| 需求 | 推荐 | 原因 |
|---|---|---|
| 明细金额合计 / 数量合计 | sum | 最直观,支持 format / nvl / 上下限 |
| 平均、最大、最小 | agg | avg/max/min 只认 DataSet 且不生效 format |
| 要拿到「整行」去取别的列 | max / min + var | agg 只返回值 |
| 计数 / 中位数 / 标准差 / 方差 | agg | 只有它支持 |
数据源是 List<数字> 或 List<Bean> | agg / sum | avg/max/min 会静默输出空 |
坑
avg/max/min只认DataSet:数据源是别的集合类型时不报错、直接输出空,很容易误判为「数据没传进去」。- 字段名要和数据里的 key 完全一致:
DataSet默认做了大小写归一(通常大写),如果p="amount"取不到就试p="AMOUNT"。 sum遇到脏数据会抛异常,不是当 0:源码是new BigDecimal(val.toString()),所以"1,234.00"、"-"、"12元"这类值会直接抛NumberFormatException(已实测,堆栈定位到Sum.java:82)。因为异常被TagUtil捕获,现象是「这个合计格子是空的,同时日志里有堆栈」,nvl="0"也不会生效(异常发生在兜底之前)。对比:agg type="sum"容错——它把解析失败的值当null跳过(empty="true"时当 0),不会炸。所以脏数据列建议用agg。avg/median/stdev/var默认 10 位小数:不写scale时结果形如34.3333333333,并且会去掉末尾多余的 0(实测median输出2而不是2.0000000000)。报表上基本都要显式写scale或format。number/date/money的value没有v别名:只有value一个写法(有v别名的是qr/bar/checkbox)。写成v="..."会取不到值,输出空。- 空数据的表现要显式兜底:
sum无数据且没写nvl时输出空串,报表上就是一个空洞,建议统一写nvl="0"。 - 先切数据再算:
begin/end/qty/distinct会真正影响计算结果(切片在计算之前完成),做「小计」时很实用,但别误以为只影响显示。
9.4 格式化类:number / money / date
这三个的共同点:它们不改变文档结构,只把一个值变成一段好看的文字,
所以也是少有的「在 Excel 单元格里也能用」的标签(number / date)。
<aol:number> 数字格式化
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
format | f | 无 | DecimalFormat 模式。不写就原样输出输入值 |
value | 见备注 | 标签体 | 要格式化的值;不写 value 时取标签体文本 |
<aol:number value="${amount}" format="#,##0.00"/>
<aol:number value="${rate}" format="0.00%"/>
<aol:number format="#,##0">${qty}</aol:number> <!-- 用标签体当输入 -->
#,##0 千分位整数 · #,##0.00 千分位两位小数 ·
0.00% 百分比 · ¥#,##0.00 带币种符号 ·
0.00E00 科学计数。
<aol:money> 金额转中文大写
| 属性 | 默认 | 说明 |
|---|---|---|
value | 标签体 | 要转换的金额。没有别名(v 不生效) |
人民币(大写):<aol:money value="${amount}"/>
人民币(大写):<aol:money>${amount}</aol:money>
BasicUtil.parseDouble(v, 0) 处理,
所以 "1,234.00" 这类带千分位的字符串会被解析成 1。
请传裸数字或先去掉分隔符。
<aol:date> 日期格式化
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
format | f | 无 | SimpleDateFormat 模式,如 yyyy-MM-dd |
value | 无 | 标签体 | 日期值/日期字符串;不写时取标签体。没有 v 别名(写 v= 会取不到值) |
evl | — | false | 为 true 且值为空时,用当前时间(做「生成日期」很方便) |
property | p | 无 | 对传入的行/数据集对象,就地格式化其某个日期字段 |
签章日期:<aol:date evl="true" format="yyyy年MM月dd日"/>
开票时间:<aol:date value="${createTime}" format="yyyy-MM-dd HH:mm"/>
<aol:date format="yyyy-MM-dd">${某个日期字符串}</aol:date>
property 的现状
用 property 时,源码会调用 format.date(format, property) 去改对象内部字段,
但最终输出的仍是对象本身(toString())而不是格式化后的值。
所以「立刻输出一个格式化日期」请用 value 写法,不要用 property。
9.5 集合处理:set / select / concat / group
<aol:set> 赋值 / 类型转换
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
var | — | 无 | 变量名。不写就什么都不做(直接 return) |
value | v | 标签体 | 要赋的值;不写时取标签体文本 |
data 系列 | 见 8.1 | 也可以直接给一个集合 | |
它会做一件事:推断类型。值形如 true/false 会转成 Boolean,
能解析成数字的会转成 BigDecimal,其余保持字符串。
<!-- 普通赋值(类型自动推断:这里会变成 BigDecimal) -->
<aol:set var="税率" value="0.13"/>
<aol:set var="是否含税" value="true"/>
<aol:set var="客户名">${customer.name}</aol:set>
<!-- 之后就能当变量用(含参与表达式计算) -->
含税金额:<aol:number value="${金额 * (1 + 税率)}" format="#,##0.00"/>
客户:${客户名}
' 开头就砍掉首尾字符」,'abc' 会变成 abc,但 'abc 会变成 ab,别依赖这个特性;
② set 写在哪个位置就会把那个段落/单元格里的文本清掉,建议单独放一行或一个单元格;
③ 不要在值里手写单引号:字符串直接写原值即可(类型推断只认「像不像数字/布尔」,不会把 abc 当变量)。
已用最小模板实测:value="'abc"(只写前引号)会让该段落输出异常、内容整段消失;value="'abc'" 才等于 abc。
想「强制按字符串处理」不要用引号,直接用不带数字/布尔特征的文本即可;
④ set 结束时会把这个标签的内容元素整体摘除,如果该段落里除标签外没有别的内容,整段会被级联删掉(这就是「set 那一行不见了」的原因,属于实现现状)。
<aol:concat> 把一列拼成一个字符串
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
property | p | 无 | 取哪一列 |
split | s | , | 连接符 |
data | d | 无 | 必须是 DataSet |
客户:<aol:concat data="${明细}" p="客户" split="、"></aol:concat>
concat 结果是替换到标签所在的段落(删掉标签体、写回文本),
所以放在表格单元格里就能得到「一列合并成一个格」的效果。
<aol:group> 按列分组
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
var | — | 无 | 结果存到哪个变量(不写就没法用) |
by | — | 无 | 分组字段,多个用逗号:by="部门,岗位" |
extract | — | false | 布尔值。为 true 时每组的成员会从原集合中移除(做「先处理一组、再处理下一组」的分批场景用) |
data | d | 无 | 必须是 DataSet |
<aol:group data="${订单}" var="按客户" by="客户"/>
<aol:group data="${订单}" var="按部门岗位" by="部门,岗位" extract="true"/>
group 自己不输出可见文本,只是把 DataSet.group(...) 的结果存进变量:
结果本身还是一个 DataSet——每组一行,组内明细挂在嵌套字段里,
配合 for 就能做「一个客户一段」的渲染。
<aol:pivot> 行转列(透视)
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
data | d items is | 无 | 必须是 DataSet(长表) |
pk | pks primary | 无 | 唯一标识 key(如 姓名),多个用逗号:pk="编号,姓名" |
class | classKey classKeys ck by | 无 | 分类 key(如 科目),透视后作为新列名,多个用逗号 |
value | valueKey valueKeys vk | 无 | 取值 key(如 分数);不写时把整行作为值 |
var | — | 无 | 结果存到哪个变量(不写则什么都不做) |
extract | — | true | 是否删除 pk 之外的其他列 |
sep | separator | - | 新列名分隔符。DataSet.pivot 固定用 - 拼接(如 2010-数学),用 sep="_" 可改成 2010_数学 便于占位符访问 |
classVar | colsVar cv | 无 | 把透视出来的列名列表(不含 pk 列)存到该变量,配合 for 遍历动态列 |
它等价于调用 DataSet.pivot(extract, pks, classKeys, valueKeys):
每个 pk 组合一行,每个 class 组合一列,值取自 value。
<!-- 输入(姓名,科目,分数) -->
张三 数学 100
张三 物理 90
李四 数学 80
<!-- 一行搞定透视 -->
<aol:pivot data="${明细}" pk="姓名" class="科目" value="分数" var="宽表"/>
<!-- 宽表里就能这样取 -->
<aol:for items="${宽表}" var="row">
${row.姓名}:数学 ${row.数学},物理 ${row.物理}
</aol:for>
<!-- 多个 value 会生成复合列名(2010-数学-分数 / 2010-数学-等级) -->
<aol:pivot data="${明细}" pk="编号,姓名" class="年度,科目" value="分数,等级" var="宽表" sep="_"/>
DataSet,普通 List/数组不会执行透视;
② pk、class 必填,缺一个只打 warn 不做事;
③ extract="false" 时透视会直接改写原集合里匹配到的行(row.put),会污染源数据,一般保持默认 true;
④ 结果列名里的 - 在某些占位符写法里不好用,建议用 sep 换成 _ 之类。
<aol:select> 数据集筛选
Select 的 run() 是空方法,写 <aol:select> 不会有任何效果,
也不会报错。当前版本要做筛选/切片请在 Java 侧准备数据,或用 for 的
begin/end/qty/distinct 做粗粒度切片。
9.6 checkbox(勾选项)
<aol:checkbox>
干什么:给一组「选项数据」+ 一组「已选中的值」,自动渲染成
☑ 选项A / ☐ 选项B 或纯文本。
常用于调查表、审批单、合同勾选项。
属性表
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
data | d is | 无 | 选项数据集,必须是 Collection<Map>(如 DataSet) |
value | v | 无 | 已选中的值。可以是单个值,也可以是集合(多选) |
rely | — | 依次回退到 property → valueKey | 不参与「值比对」,只用于两件事:① 判断该字段的值是否为字符串 "true"/"checked"(或空串);② 当 value 是「Map 的集合」时,从每个 Map 里取哪个字段来组成待比对的值列表 |
valueKey | vk | 主键列名 | 选项的值取哪个字段,真正和 value 比对的就是这一列 |
textKey | tk | NM | 选项显示的文本取哪个字段。支持 {字段名} 运行时模板,如 tk="{NAME}({CODE})" |
type | t | checkbox | checkbox 输出 ☑/☐;text 只输出文本 |
vol | — | 0 | 每行显示几个选项,0 表示一行到底 |
split | s | 空 | 选项之间的分隔符(type="text" 时最常用) |
示例
<!-- 单选:value 是一个值 -->
性别:<aol:checkbox data="${性别选项}" value="${user.SEX}" rely="CODE" valueKey="CODE" textKey="NM"/>
<!-- 多选:value 是集合 -->
爱好:<aol:checkbox data="${爱好选项}" value="${user.HOBBIES}" vk="CODE" tk="NM" vol="4"/>
<!-- 只要文本,不要方框 -->
状态:<aol:checkbox data="${状态选项}" value="${order.STATUS}" type="text" split=" / "/>
doc.variable("性别选项", DataSet.parseJson(KEY_CASE.SRC,
"[{\"CODE\":\"M\",\"NM\":\"男\"},{\"CODE\":\"F\",\"NM\":\"女\"}]"));
doc.variable("user", DataRow.parseJson(KEY_CASE.SRC, "{\"SEX\":\"M\",\"HOBBIES\":[\"A\"]}"));
选中判定规则(按源码顺序)
对每个选项行依次判断,只要满足下面任意一条就显示 ☑:
rely字段(不写rely时回退到valueKey)的值为空字符串;rely字段的值是字符串"true"或"checked"(不受value影响,可用于「数据里直接带勾选状态」的场景);- 该选项
valueKey列的值equalsvalue;多选时value是集合,则判断集合里是否包含它。
valueKey,不是 rely
<!-- 正确:用 CODE 做值比对 -->
<aol:checkbox data="${选项}" value="${user.SEX}" vk="CODE" tk="NM"/>
<!-- 错误:把值比对字段写成了 NM,rely 是 CODE 也没用 -->
<aol:checkbox data="${选项}" value="${user.SEX}" vk="NM" tk="NM" rely="CODE"/>
已用最小模板实测:选项 [{CODE:A,NM:甲},{CODE:B,NM:乙}]、value="B" 时,
vk="CODE" 得到 ☐甲 ☑乙;换成 vk="NM" rely="CODE" 则两个都不勾。
所以「用哪一列和 value 比」永远由 valueKey 决定。
坑
data不是集合就直接返回:传单个DataRow/Map时什么都不输出,也不报错。- 值比对字段是
valueKey,不是rely:不写vk时默认拿「主键列」去和value比;选项数据里主键列名不是默认主键时,务必显式写vk="CODE"(很多模板只写了rely,于是永远勾不上)。 rely字段值为空串会「白送」一个勾:源码第一条判断就是"".equals(rely字段值),所以选项行里被用来做rely的那一列如果是空值,该项会被判为选中。- 源码里还有
label/headText/headValue字段,但当前没有从模板读取,所以「自定义选项 HTML」和「头部默认选项」暂时用不了,只能靠textKey的{字段}模板来定制文本。 vol只是插<br/>:换行靠 HTML 解析成段落,效果受模板段落样式影响,想要严格网格请用表格。
9.7 merge(单元格合并)
<aol:merge>
干什么:把「值相同的相邻单元格」合并成一个。 这是报表里最容易做错、也最容易被这个标签救回来的需求。
它什么时候执行:这是它最关键的设计。merge 在
run() 阶段只登记「哪个表格、哪一列、模板行在哪」,
真正的合并放到所有标签(尤其是 for)跑完之后,
由 WDocument.executeMerges() 统一执行。
所以你不需要操心「数据还没填完就合并了」这个问题。
属性表
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
scope | s | tc | tc/td = 纵向合并(同一列内相邻行同值合并);tr = 横向合并(同一行内相邻列同值合并) |
by | — | 无 | 附加约束:只有当参考列在合并范围内也全部相同时才合并。可写列下标 by="0",也可写文本让引擎按首行内容匹配,如 by="${分组键}" |
ignore | — | 无 | 忽略值(逗号分隔),如 ignore="null,-",这些值不参与合并比较 |
示例
<!-- 明细表:把「客户」这一列的相邻同值单元格纵向合并 -->
<aol:for items="${明细}" var="r" scope="tr">
${r.客户} | ${r.商品} | <aol:merge scope="tc"/>${r.金额}
</aol:for>
<!-- 只有当「客户」也相同时才合并「订单号」列 -->
<aol:merge scope="tc" by="客户" ignore="null,-"/>
<!-- 横向合并(一行内相邻列同值) -->
<aol:merge scope="tr"/>
for 会移除模板行、在原位置插入数据行)。因此:
merge 要写在被循环的模板行里,不要写在表头行;
表头行不参与合并(合并扫描从模板行的上一行开始)。
坑
- 合并后的空单元格样式会「继承第一个」:Word 只保留第一个单元格的边框/底纹,跨页显示时建议手动检查。
- 空值默认不合并:值为空(
null或空串)时会被跳过,所以「连续几个空值合并成一个大格」不会发生;想合并请把它们统一成某个标记值,再靠ignore排除真正不该合并的。 by用文本匹配时只看首行:匹配失败(找不到列)时不报错,等价于没有约束,会「多合并」。- 横向合并从右往左扫描:这是为了避免删单元格造成下标错位,理解这点有助于排查「合并结果少了一格」。
- 必须写在表格里:标签不在
<table>内(或不在tc里)时直接return,静默失效。
9.8 富文本与图形:img / html / qr / bar
<aol:img> 插入图片
干什么:把标签本身(连同 src、style)当作一段 HTML 的
<img> 交给 HTML 渲染器,插入到当前位置。
| 属性 | 说明 |
|---|---|
src | 图片地址。支持 file: 本地路径、url:/http(s): 远程地址,也支持 ${} 占位符 |
style | CSS 样式,常用 width:150px;height:100px;。支持 ${},可以动态控制尺寸 |
type | 图片后缀提示(如 png)。不写时从文件名后缀推断 |
<aol:img src="${logoUrl}" style="width:${logoW}px;height:${logoH}px;"/>
<aol:img src="file:D:/tpl/logo.png" style="width:120px;"/>
doc.setDownloader(...) 时远程图片会下载失败
(日志:[图片文件下载失败][未提供downloader]),而且不报错、不插图。
本地 file: 路径不走下载器。
<aol:html> 插入一段 HTML
干什么:把标签体先当模板跑一遍(内部还能写别的标签、${}),
再把结果当 HTML 渲染进文档,最后把标签体整段移除。
<aol:html>
<div style="font-size:14px;color:#333;">
<b>${title}</b><br/>
<table><tr><td>${a}</td><td>${b}</td></tr></table>
</div>
</aol:html>
它支持哪些 HTML 标签、哪些 CSS 属性,见 10.7 HTML 对照表。
<aol:qr> / <aol:bar> 二维码 / 条形码
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
value | v | 标签体 | 要编码的内容(网址、单号、文本都行) |
style | — | 无 | CSS,从中读 width/height(px 会被去掉) |
width | w | 100 | 像素宽 |
height | h | 100 | 像素高 |
<aol:qr value="https://anyline.org" style="width:120px;height:120px;"/>
<aol:bar value="${order.NO}" width="200" height="60"/>
- 必须注入
Imager:doc.setImager(...)。 它是本库的一个接口(qr(content,w,h)/bar(content,w,h)), 本库不提供默认实现;没注入时标签被静默移除,不留任何图。 - 生成的是临时图片文件,插入后立即删除,所以不要依赖那个文件路径。
- 条形码内容建议纯数字/大写字母;不同
Imager实现对字符集限制不同。
9.9 page(分页符)
<aol:page>
| 属性 | 默认 | 说明 |
|---|---|---|
position | after | 分页符位置。当前只有 after 真正生效 |
<aol:page/> <!-- 在本标签后分页 -->
<aol:page position="after"/>
<div style="page-break-{position}"> 交给 HTML 渲染器,
而渲染器目前只识别 page-break-after。
所以 position="before" 会被渲染成一个没有分页效果的空 div(不报错)。
想在段落前分页,把 <aol:page/> 放到上一个段落的末尾即可。
9.10 chart(原生图表)
<aol:chart>
干什么:在文档里插入真正的 OOXML 图表(不是图片)。
生成的是 word/charts/chartN.xml + 关系引用,所以打开 Word 后还能改类型、改配色、看数据。
重要前提:图表标签必须写在一个段落里(能找到 w:p),
建议单独一行放标签。它不依赖 POI,也不依赖 Imager。
Excel 也能用:xlsx 里写在单元格中(同一个单元格内),引擎会生成
xl/charts/chartN.xml + 隐藏数据表并锚定到该单元格,详见 11.6。
属性表
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
type | t chartType | pie | 图表类型,见下表。不做校验,未知类型会静默降级成饼图 |
data | d items is | 无 | 数据源。必须是「整个属性就是一个 ${变量}」才会被求值,否则当字符串处理 |
title | t name | 无 | 图表标题 |
categoryKey | ck catKey | name | 分类(X 轴 / 扇区标签)取哪个字段 |
valueKey | vk valKey | value | 数值取哪个字段 |
textKey | tk | name | 文本字段(源码中已解析,当前未参与取数) |
sizeKey | sk bubbleKey | 无 | 气泡大小取哪个字段(仅气泡图) |
holeSize | hs | 50 | 圆环图中心孔洞百分比(仅 doughnut) |
width | w | 约 13.9cm | 宽,单位是 EMU(1px ≈ 9525 EMU)。默认 5274310 |
height | h | 约 8.1cm | 高,单位 EMU。默认 3076575 |
dimension | dim | 2D | 2D / 3D,大小写不敏感 |
shape | s | box | 3D 柱形形状:box / cone / cylinder / pyramid(仅 3D 柱状/条形生效) |
wireframe | wf | false | 曲面图是否画线框(仅 2D 曲面图) |
复合饼图(type="ofPie" 或饼图 + split)专用 | |||
split-type | — | 无 | 非空时才启用复合饼图(pos / val / percent / custom) |
by | st | pos | 分割依据 |
split-pos | sp | 3d | 分割阈值 |
split-second-size | sps | 75 | 第二张子图大小百分比 |
split-gap | gw | 150 | 主图与子图间距 |
上面这些 split 属性也可以写成子标签:
<aol:split type="val" pos="10" secondSize="70" gap="120"/>(放在 chart 标签体里)。
t 同时是 type 和 title 的别名 —— 写 t="pie" 会让标题也变成 pie;
② d 同时是 data 和 dimension 的别名 —— 写 d="3D" 会被当成数据源。
建议:type 和 title、data 和 dimension 一律写全名。
支持的图表类型
type | 图表 | 3D | 说明 |
|---|---|---|---|
pie | 饼图 | ✅ dimension="3D" | 默认类型。配 split 时自动变成复合饼图 |
ofPie | 复合饼图 | — | 需配 split-type / by / split-pos 等 |
doughnut | 圆环图 | — | 用 holeSize 控孔洞 |
line | 折线图 | ✅ | 默认不显示数据点标记 |
area | 面积图 | ✅ | — |
column | 柱状图 | ✅(可配 shape) | 纵向 |
bar | 条形图 | ✅(可配 shape) | 横向 |
radar | 雷达图 | — | 带标记点 |
surface | 曲面图 | 默认 3D,dimension="2D" 出平面图 | 唯一「默认 3D」的类型 |
scatter | 散点图 | — | 用 xVal/yVal |
bubble | 气泡图 | — | 配 sizeKey |
stock | 股价图 | — | 系列 ≥4 时按 OHLC 画涨跌柱 |
三种数据写法
<!-- ① 单个 ${变量}(推荐):Java 侧塞 DataSet / List<Map> -->
<aol:chart type="pie" title="销售分布" data="${sales}"
categoryKey="name" valueKey="value" width="5274310" height="3076575"/>
<!-- ② 直接把 JSON 写在属性里 -->
<aol:chart type="column" data='[{"name":"A","value":30},{"name":"B","value":70}]'/>
<!-- ③ 交错模式:ChartData 对象(支持多系列) -->
<aol:chart data="${chart}" dimension="3D" shape="cylinder"/>
// ① 单系列
DataSet sales = DataSet.parseJson(KEY_CASE.SRC,
"[{\"name\":\"产品A\",\"value\":30},{\"name\":\"产品B\",\"value\":70}]");
doc.variable("sales", sales);
// ③ 多系列:ChartData
ChartData chart = ChartData.build()
.title("两年对比").type("column").dimension("3D").shape("cylinder")
.width("600px").height("380px"); // 带 px 会自动换算成 EMU
chart.addSeries("2024").add("华东", 120).add("华南", 90).add("华北", 60);
chart.addSeries("2025").add("华东", 150).add("华南", 110).add("华北", 80);
doc.variable("chart", chart);
doc.save();
ChartData 上的 title(仅当标签 title 为空时)、
type、width、height、dimension、
shape、split、wireframe 会覆盖标签上的同名设置。
想「模板说了算」就别在 ChartData 上设这些。
坑
- 数据不是集合就静默删掉标签:传单个
DataRow/Map时,图表标签被移除,不报错。必须包成List/DataSet。 data属性必须是「完整的一个${}」:data="前缀${x}"不会被求值。- 宽度高度单位是 EMU,不是像素:写
width="600"会得到一张几乎看不见的图。要么写全 EMU(600*9525),要么用ChartData.width("600px")。Excel 里相反:width/height就是 px(1px = 9525 EMU),写width="400"即 400px。 - 未知
type静默变饼图:拼错类型不会报错,只会发现「画出来是饼图」。 - 重复
save()会累积图表部件:每次save()都会追加新的chartN.xml与关系项,反复保存同一个模板文件会越滚越大。 - Excel 里也能用:写在单元格里即可,引擎会生成
xl/charts/chartN.xml并额外建一个隐藏工作表承载数据(图表与单元格联动),详见 11.6。 - 无法自定义图例位置/坐标轴标题:图例固定在右侧,轴标题、数据标签、配色等都取默认值。
9.11 mark 与「写了没用」的标签
<aol:mark> 插入书签
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
name | n | 无 | 书签名称 |
<aol:mark name="合同金额">${amount}</aol:mark>
它会在标签体内容前后插入 bookmarkStart / bookmarkEnd,
书签 id 自动取「文档中已有书签 id 的最大值 + 1」。
用途:给后续的「按书签替换内容」「按书签定位表格」提供锚点。
<aol:mark name="x"/>(空标签体)只会留下一个没有内容范围的书签,
排版上可能不稳定。建议总是带上标签体。
写了没用 / 需要自己注册的标签
| 标签 | 状态 | 说明 |
|---|---|---|
<aol:select> | 空实现 | 已注册但 run() 是空方法:标签不会被移除,那段 <aol:select .../> 文本会原样出现在生成的文档里,所以千万不要留在正式模板中 |
<aol:include> | 未注册 | 类存在(Include)但不在注册表里,写 <aol:include> 同样会原样输出文本;要启用需 TagUtil.reg("include", Include.class) |
doc.namespace() 一致;
③ 标签是否写在了该标签支持的位置(如 scope="tr" 必须写在单元格里)。
想自己写一个标签?
public class MyTag extends AbstractTag implements Tag {
public void run() throws Exception {
String v = fetchAttributeString("value", "v"); // 属性(支持 ${} 求值)
Object d = data(); // 数据源(含切片)
output("结果文本"); // 写回文档 / 写入 var
}
}
TagUtil.reg("mytag", MyTag.class); // 注册后模板里写 <aol:mytag/>
可用的工具:fetchAttributeString(names...)、fetchAttributeData(names...)、
data()、output(result)、body(text, "标签名")、
doc(WDocument)、context、tops/contents/box。
9.12 grid:检测项网格(四维透视表)
把「长表」数据按 组 × 检测项 × 轴值 × 值类型 四维铺进一个 Word 表格。 典型场景:检测报告里行是样品(轴值)、列是检测项、每个检测项下再分「标准值 / 检测值」(值类型)。
什么时候用它
数据是长表(一条记录一个值):
{组:一班, 检测项:身高, 样品:张三, 类型:标准值, 结果:170},
而最终表格要二维铺开。for 只能一维循环,pivot 只能做行转列,
这种「行、列、还要再分类型」的网格要用 grid。
行式模板长什么样(dir="h",默认)
┌────────┬──────────────┬──────────────┬──────────────┐
│ │ ${指标} │ ${指标} │ ${指标} │ ← 名称行(标签写在这一行的任意格子里)
│ ${组} ├──────┬───────┼──────┬───────┼──────┬───────┤
│ │ 标准值│ 检测值 │ 标准值│ 检测值 │ 标准值│ 检测值 │ ← 表头行(可选,固定文本=值类型)
├────────┼──────┼───────┼──────┼───────┼──────┼───────┤
│ │${样品}│ ${结果}│ ${结果}│ ${结果}│ ${结果}│ ${结果}│ ← 数据行(样板,按轴值克隆)
└────────┴──────┴───────┴──────┴───────┴──────┴───────┘
<aol:grid items="${rows}"/>
- 名称行:第 1 格写
${组}(不需要分组可以留空),之后每格写一个${指标}。 各项名格要按gridSpan=K画好(K = 值类型数,上图 K=2,即每个检测项占 2 列)。 - 表头行(可选):写值类型的固定文本。可以只写前 K 个格,也可以按项重复写满整行—— 引擎会按名称行项名格的宽度取前 K 个,多余的重复自动忽略。
- 数据行:轴值格写
${样品},值格统一写${结果}。 值格里的占位符名决定从记录里取哪个字段;值格不写占位符就不会被填充(保持模板原样)。 - 标签:写在名称行里,
run阶段执行,所以只认表格里的位置。
列式两种版式(dir="v")
<!-- layout="item"(默认):轴值沿列展开,每个检测项占 K 行 -->
行1(轴头行): [${组}] [${样品}] [${样品}] …
行2(项首行): [${指标}] [${结果}] [${结果}] … ← 项名格 vMerge 跨 K 行
行3(类型行): [标准值] [${结果}] [${结果}] … ← 类型文本写在标签列
<!-- layout="block":轴值横向成块 -->
行1(轴头行): [${组}] [${样品}] [${样品}] …
行2(项名行): [ ] [${指标}] [${指标}] [${指标}] [${指标}] ← 项名格 gridSpan=K
行3..K+2: [标准值] [${结果}] [${结果}] [${结果}] [${结果}] ← K 个值类型行
列式的 K = 值类型行的行数(不是表头文本),由模板直接决定;
vol 在列式里表示「每行最多几个轴块」,默认 1(不写会有 warn)。
属性
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
data | d items is | 无(必填) | 数据源,必须是集合(DataSet / List / JSON 数组串) |
dir | direction | h | h 行式;v(也认 col / 转置 / 对调)列式 |
layout | mode | item | 仅列式:item 项沿行每项 K 行 / block(也认 blk / axis / 块)轴值横向块 |
axis | — | 自动推断 | 轴字段名,对应数据行(列式是轴头行)里的 ${样品}。不写则取首个含占位符的格;都不写按「虚拟单轴」处理 |
typeField | kindField kind | 无 | 值类型字段名,逗号分隔可写多列。不写按「单值类型」处理 |
vol | v | 行式:项名格数;列式:1 | 行式=每块放几个检测项(超出会分块,组名格纵向合并);列式=每行最多几个轴块 |
types | type t | 无 | 值类型序列(逗号分隔),表头行文本拿不到类型时用它 |
ph | placeholder | (无检测项) | 块内项位不足时的占位文本 |
miss | missing | 空串 | 该(项, 轴, 类型)没有记录时的填充文本 |
empty | — | show | hide 时整行无值就跳过(仅行式支持,列式会 warn 后按 show) |
scope | — | 全量并集 | group 时每组只用自己的轴值(仅行式) |
order | — | none | 轴值排序 none/asc/desc,自然比较("10月"排在"9月"之后) |
merge | span | true | 组名格纵向合并(仅行式且「组名列 + 轴值列」双列布局生效) |
split | — | true | false 时给生成的行加 cantSplit(不允许跨页断行) |
数据规则
- 检测项为空的记录跳过并 warn;轴值 / 值类型为空时,已声明对应字段的记录跳过并 warn,未声明的按单轴 / 单类型处理。
- 同名(组+项+轴+类型)重复的记录,后值覆盖前值并 warn。
- 数据为空时整个模板区(名称行 / 表头行 / 数据行)会被删除。
- 值类型数 K 拿不到 = 直接不生成:表头行文本、
types属性、数据里的类型顺序三者都取不到时,只打 error 就返回,表格原样不动。 - 值格没写占位符就不填:值区每个格必须写
${结果}这类占位符,漏写的格保持模板原样(不会报错)。 - 值区宽度不够:数据行值区宽度 < K 时直接 error 返回;
vol超出模板容量会被下调并 warn。 - 名称行与数据行列结构要对齐:不一致时会 warn「名称行项区跨列总数与数据行值区宽度不一致」,项名和值会错位。
- 模板区不能嵌套其它标签:模板区里再写
<aol:xxx>不会被解析(只 warn 一次)。 - 数据行第 1 格就是轴值(没有单独的组名列)时,组名纵向合并不可用,
merge被忽略并 warn。
9.13 全标签属性总表(一站式速查)
前面的卡片是「按需求讲」,这一节是「按属性查」—— 把源码里 真正被解析的每一个属性都列出来,包括官方一路没写进文档、 以及解析了却不生效的那几个。查表规则:
- 「别名」栏:写哪个都行,按
fetchAttributeString里列出的先后顺序取第一个出现的; - 「必填」栏:是 = 不写就完全不工作;否 = 有默认行为;
- 「生效」栏:生效 / 不生效, 「不生效」的别浪费时间试。
valueKey 和 valuekey 是两个属性;
② 下表「不生效」的属性不会报错,写了就像没写,最容易被当成「引擎有 bug」。
共有的「取数 + 切片」属性
下面这些由基类 AbstractTag 统一解析,凡是从集合取数的标签都认(for / sum / avg / max / min / agg / concat / group / checkbox / chart):
| 属性 | 别名 | 默认 | 说明 |
|---|---|---|---|
data | d items is | 无 | 数据源。${变量} 取值;[{...}] 解析成 DataSet;a,b,c 解析成列表 |
property | p | 无 | 处理的列名,可用逗号写多列 p="A,B" |
begin | start b | 0 | 起始下标 |
end | e | 末尾 | 结束下标(含) |
qty | q | 无 | 最多取几条 |
index | i | 无 | 只取指定下标的一条(变成单条记录,所以 for 里绝不能用) |
selector | st | 无 | 只保留指定列 |
distinct | ds | 无 | 按指定列去重(逗号分隔) |
formatDate | fd | 无 | 先按该格式把 property 字段转成字符串(仅 DataSet) |
var | — | 无 | 把结果写进变量而非直接输出 |
valueKey | vk | 主键列 | 「值」取哪一列(checkbox / chart) |
textKey | tk | NM | 「显示文本」取哪一列(checkbox) |
pre / ref | — | 无 | 片段注册 / 引用(见 6.6) |
各标签的「专属属性」逐条清单
| 标签 | 属性 | 别名 | 必填 | 默认 | 含义 / 生效情况 |
|---|---|---|---|---|---|
for |
var | — | 否 | 无 | 迭代变量名(不写则循环体里没有「当前行」变量) |
status | s | 否 | 无 | 状态变量名,提供 index/count/prev/next | |
scope | sp | 否 | body | body/tc/td/tr/table | |
begin | start b | 否 | 0 | 起始下标 | |
end | e | 否 | 末尾 | 结束下标(含);无 items 时变成计数循环 | |
step | — | 否 | 1 | 步长 | |
fill | — | 否 | 无 | 把 end 撑到该值(是结束下标,不是行数) | |
flatten | flat fl | 否 | false | 把当前行列平铺进上下文 | |
for(无效) |
vol | — | 否 | 无 | 不生效 已解析,无任何使用 |
align | — | 否 | false | 不生效 同上 | |
for |
remove | — | 否 | true | 无数据时是否删模板;有数据时源码强制改回 true |
if |
test | — | 是 | 无 | 判断表达式,可写 true/false 或 OGNL,也可带 ${} 外壳 |
value | v | 否 | 无 | 为真时输出;写了就不再执行标签体 | |
else | — | 否 | "" | 为假时输出;不写则删掉标签体 | |
var | — | 否 | 无 | 把布尔结果存成变量,不输出文字 | |
remove | rm | 否 | false | 不生效 当前会抛 NPE(见 9.2) | |
scope | — | 否 | tc | 不生效 同上 | |
sum |
property | p | 否 | 无 | 累加列;不写则累加元素本身(元素必须是数字) |
format | f | 否 | 无 | 输出格式 | |
nvl | n | 否 | 无 | 无值兜底(无 nv 别名) | |
min | — | 否 | 无 | 结果下限(无别名) | |
max | — | 否 | 无 | 结果上限(无别名) | |
scale | s | 否 | 无 | 小数位 | |
round | r | 否 | 无 | 舍入模式,需先有 scale | |
def | — | 否 | 无 | 不生效 源码注释即「预留」 | |
avg |
property | p | 是 | 无 | 要平均的列(不写取不到值) |
scale | s | 否 | 2 | 小数位 | |
round | r | 否 | 4 | 舍入模式 | |
format | — | 否 | 无 | 不生效 读了但没赋给成员变量 | |
max / min |
property | p | 是 | 无 | 按哪一列比大小 |
var | — | 否 | 无 | 写:存「整行」;不写:只输出该列值 | |
无 format / scale / nvl,且数据源必须是 DataSet,否则输出空 | |||||
agg |
type | t | 否 | sum | 算法,大小写不敏感,自动去 _DECIMAL 等后缀 |
property | p | 否 | 无 | 参与计算的列;不写则元素本身当数字 | |
format | f | 否 | 无 | 输出格式(真的生效) | |
scale | s | 否 | 见说明 | sum/max/min/count 默认保原精度;avg/median/stdev/var 默认 10 位 | |
round | r | 否 | 四舍五入 | 舍入模式 | |
nvl | n | 否 | 无 | 无数据 / 结果为 null 时的兜底 | |
min | — | 否 | 无 | 对计算结果下限截断(不是选最小值) | |
max | — | 否 | 无 | 对计算结果上限截断 | |
empty | in | 否 | false | 空值是否当 0;count 时直接把集合长度当结果 | |
number |
format | f | 否 | 无 | 不写则原样输出(看起来像「没格式化」)。值用 value(无别名)或标签体 |
money |
value | 无 | 否 | 标签体 | 金额转大写。只有 value,无 v;内部 parseDouble,带千分位会截断 |
date |
format | f | 否 | 无 | SimpleDateFormat 模式 |
value | 无 | 否 | 标签体 | 无 v 别名(写 v= 取不到) | |
evl | — | 否 | false | 为 true 且值为空时用当前时间 | |
property | p | 否 | 无 | 半生效 会就地改对象字段,但最终输出的仍是对象本身,别指望它输出格式化后的日期 | |
set |
var | — | 是 | 无 | 不写就什么都不做 |
value | v | 否 | 标签体 | 类型自动推断(布尔 / 数字 / 字符串) | |
select |
空实现 无任何属性生效,标签文本会原样留在文档里 | ||||
concat |
split | s | 否 | , | 连接符 |
取数列用通用属性 property(p),数据源必须是 DataSet | |||||
group |
by | — | 是 | 无 | 分组列,逗号写多列 |
var | — | 是 | 无 | 结果变量名(不写就没法用) | |
extract | — | 否 | false | 为 true 时组内成员从原集合移除 | |
checkbox |
data | d is | 是 | 无 | 选项集合,必须是 Collection<Map> |
value | v | 否 | 无 | 已选值,单值或集合 | |
rely | — | 否 | 回退 property→valueKey | 不参与值比对,只用于「是否为 true/checked/空串」判断 | |
valueKey | vk | 否 | 主键列 | 真正和 value 比对的列 | |
textKey | tk | 否 | NM | 显示文本列,支持 {列名} 模板 | |
type | t | 否 | checkbox | checkbox / text | |
vol / split | s(仅 split) | 否 | 0 / 空 | 每行几个 / 分隔符 | |
merge |
scope | s | 否 | tc | tc/td 纵向 · tr 横向 |
by | — | 否 | 无 | 参考列(下标或首行文本) | |
ignore | — | 否 | 无 | 忽略值,逗号分隔 | |
img |
src / style / type | — | src 是 | 无 | 整段当 HTML 的 <img> 交给渲染器,三个属性都支持 ${} |
html |
无属性,内容写在标签体里;先跑一遍标签/占位符,再当 HTML 插入 | ||||
qr / bar |
value | v | 否 | 标签体 | 编码内容 |
style / width/w / height/h | — | 否 | 100/100 | 尺寸,style 里的 px 会被去掉 | |
page |
position | — | 否 | after | 仅 after 生效,before 渲染成空 div |
mark |
name | n | 是 | 无 | 书签名称 |
chart |
type | t chartType | 否 | pie | 图表类型,拼错静默降级成饼图 |
title | t name | 否 | 无 | 标题(t 与 type 冲突,别用别名) | |
data | d items is | 否 | 无 | 必须是「完整的 ${变量}」或 JSON 字面量 | |
categoryKey | ck catKey | 否 | name | 分类字段 | |
valueKey | vk valKey | 否 | value | 数值字段 | |
textKey | tk | 否 | name | 不生效 已解析但未参与取数 | |
sizeKey | sk bubbleKey | 否 | 无 | 气泡大小字段(仅气泡图) | |
holeSize | hs | 否 | 50 | 圆环孔洞百分比 | |
width | w | 否 | 5274310 | 宽,单位 EMU(1px≈9525) | |
height | h | 否 | 3076575 | 高,单位 EMU | |
dimension | dim d | 否 | 2D | 2D/3D(d 与 data 冲突) | |
shape | s | 否 | box | 3D 柱形形状 | |
wireframe | wf | 否 | false | 曲面图线框 | |
split-type | — | 否 | 无 | 非空才启用复合饼图 | |
by / split-pos | st / sp | 否 | pos / 3d | 分割依据 / 阈值 | |
split-second-size | sps | 否 | 75 | 第二子图大小 | |
split-gap | gw | 否 | 150 | 主图与子图间距 | |
子标签:<aol:split>
复合饼图的参数也可以写成 chart 标签体里的子标签,属性与上表后半段一一对应:
| 子标签属性 | 对应父属性 | 说明 |
|---|---|---|
type | split-type | pos / val / percent / custom |
by | by | 分割依据 |
pos | split-pos | 阈值 |
secondSize | split-second-size | 第二子图大小 |
gap | split-gap | 间距 |
split-* 属性;只有父标签没写、且标签体里出现了 <aol:split> 时,才去解析子标签。
两者同时写时父属性优先。
「解析了却不生效」清单(照这个避坑)
| 属性 | 所属标签 | 实际情况 |
|---|---|---|
vol / align | for | 解析后无任何引用,写了等于没写 |
def | sum | 源码注释就是「预留」,从不参与计算 |
format | avg | 读了但没赋值给成员变量,输出不带格式 |
remove / scope | if | 执行顺序问题导致必然抛 NullPointerException(被吞) |
position(除 after) | page | HTML 渲染器只识别 page-break-after |
textKey | chart | 解析了,但取数时未使用 |
property | date | 会改对象字段,但输出的是对象本身,不是格式化结果 |
agg 和 number:
agg 的数据源、format、scale 全部真实生效,
number 是唯一「不写 format 也能原样输出」的格式化标签。
10. Word 模板制作规范
前 9 章讲「怎么写标签」,这一章讲「模板本身怎么设计才不会踩坑」。模板没做对,标签写得再对也出不来结果。
10.1 第一条铁律:模板文件先复制
ZipUtil.replace(file, ...))。
所以每次生成前都要把「母版」复制成一个新的临时文件再交给引擎:
// 正确做法
File tmp = File.createTempFile("rpt", ".docx");
Files.copy(Paths.get("D:/tpl/invoice.docx"), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
WDocument doc = new WDocument(tmp);
doc.variable("...", ...);
doc.save(); // 改写的是 tmp,母版 safe
// 错误做法:直接 new WDocument(new File("D:/tpl/invoice.docx"))
// 第二次运行就会变成一个「已经被填过内容」的模板
10.2 ${} 被 Word 拆散这件事
Word 保存时会把一段文字拆成多个 w:r/w:t(因为字体/拼写检查/输入历史等原因),
于是 ${name} 可能变成 ${na + me}。
引擎在启动时会做一次 recombine() 把同一个段落里被拆开的
${} 和 <aol:...> 合并回来,绝大多数情况能自动救回来。
但它救不了下面这些情况,请从源头避免:
- 占位符跨段落:
${在上一段、}在下一段 —— 无解,请保证一个${}在一个段落内。 - 标签跨段落:同理,
<aol:for ...>的尖括号也必须在同一段落内(标签体可以跨段落,标签本身的<>不行)。 - 占位符里有换行/软回车:写
${a+Shift+Enter+b}会解析失败。 - 中文输入法中途切换:容易把
$输成全角。请确认是半角${}。 - 把
${}拆开后再改字体:改字体是最容易造成拆分的操作,建议先写占位符、最后统一改格式,或者干脆用「全选 → 清除格式」重来。
.zip,解压后用文本编辑器打开 word/document.xml,
搜索 ${。如果看到 ${na 和 me} 被 </w:t><w:t> 隔开,就是被拆了 —— 这种通常能被自动修好;
如果被 </w:p> 隔开,就必须手工改模板。
10.3 表格模板的规范
| 要点 | 做法 | 原因 |
|---|---|---|
| 表头与数据行分开 | 表头写普通文字,数据行才是模板行 | 循环复制的是模板行,表头被复制会重复出现 |
| 循环标签写在单元格里 | <aol:for scope="tr"> 写在模板行的任意一个单元格内 | 标签必须落在 w:tc 里才能向上定位到行 |
| 同一行的占位符写全 | 模板行每个单元格都写上 ${r.字段} | 行复制时整行一起复制,占位符在各自单元格里替换 |
| 不要在模板行里放「固定小计」 | 小计放到循环后面的独立行 | 放在模板行里会跟着复制,变成每行都有小计 |
| 合并要写在模板行里 | 见 9.7 | 合并任务登记时需要知道模板行位置 |
| 表格别设「允许跨页断行」的奇怪属性 | 保持 Word 默认 | 合并单元格 + 跨页组合问题最多 |
10.4 段落、换行与空白
- 不能在标签里写
\n:Word 里的换行是段落,不是字符。要换行就写真的回车(在标签体内换行),或者用<br/>(经 html 渲染)。 - 标签体内的「空段落」也是内容:写
<aol:for>时前后各空一行,会真的多出一行空白,建议紧贴内容写。 - 空白占位符的默认值:取不到值时输出什么,由
doc.setPlaceholderDefault(...)控制(默认空串),见 12.4。 - 文件末尾的段落:Word 文档必须有结尾段落,删除操作(
remove)把它删空后会被引擎补回一个空段落,属正常现象。
10.5 页眉 / 页脚 / 图表
- 页眉页脚里的
${}也会被替换(引擎按 页眉 → 页脚 → 图表 → 正文 的顺序处理)。 - 页眉页脚里不要写
aol标签:标签渲染只针对正文document.xml,写在页眉里的标签会原样留在文档中。 - 页码用 Word 自己的「插入 → 页码」功能,不要试图用
${}实现。 - 图表(Excel 图表/Word 图表)里的文字占位符也支持替换,但图表标签
<aol:chart>只在正文生效。
10.6 样式全部交给 Word
10.7 HTML 对照表(<aol:html> / <aol:img> 用)
引擎内部把 HTML 子集翻译成 OOXML。下面是实际实现了的标签与属性:
| HTML 标签 | 转换结果 | 备注 |
|---|---|---|
<table> <tr> <td> <th> | Word 表格 | 在单元格内写 <table> 会生成嵌套表格 |
<div> | 块级元素 | display:inline/inline-block 时按行内处理 |
<span> | 行内元素 | display:block 时按块级处理 |
<img> | 图片 | 读 src、width、height、style |
<word> | 插入另一个 docx 的内容 | 本库专有标签,高级用法见 15.4 |
<ol><li> / <ul><li> | 列表 | li 会生成带编号的列表段落 |
<br/> | 换行(w:br) | 不是新段落,是行内换行 |
<b> | 加粗 | 注意:<strong> 不生效 |
<i> | 斜体 | <em> 不生效 |
<u> | 下划线 | —— |
<del> | 删除线 | <s> 不生效 |
<sup> / <sub> | 上标 / 下标 | —— |
p / h1~h6 / a / font / center / strong / em … | 不做特殊处理 | 只是把子内容按当前样式递归输出:不会产生标题样式,也不会生成超链接 |
| 其它未列出的标签 | 同上(递归输出文本) | 标签被忽略,内容保留 |
| CSS 属性 | 作用 |
|---|---|
display:none | 整段不输出(唯一的「隐藏」手段) |
page-break-after | 分页(<aol:page/> 走的就是它) |
text-align | 段落对齐 |
margin-left/right、padding-left/right | 段落左右缩进 |
margin-top/bottom、padding-top/bottom | 段前 / 段后间距 |
text-indent | 首行缩进 |
line-height | 行距 |
color | 字色(# 会被去掉) |
background-color | 高亮底色 |
vertical-align | superscript / subscript |
font-weight / font-family / font-size 等 | 字体相关(由 StyleUtil.font 处理) |
border-* | 边框(由 StyleUtil.border 处理) |
list-style-type / list-lvl / numFmt | 列表样式与层级 |
page-size-orient | 横向 / 纵向(landscape 之外都算纵向) |
flex / grid / float / position / box-shadow 这些能生效。
复杂排版请在 Word 里做好表格,用 ${} 填数;HTML 只用于动态生成的结构(比如勾选项、动态条数的小表)。
10.8 模板自查清单
- 模板是「只读母版 + 临时副本」的用法,不是直接改母版;
- 每个
${}和每个标签的尖括号都在同一个段落内; ${}是半角字符;- 循环标签写在模板行的单元格里,表头行没有循环标签;
- 没有
<aol:select>/<aol:include>这类不会生效的标签; - 需要图片/二维码的地方已确认 Java 侧注入了
Downloader/Imager; - 取不到值的地方想好了兜底(
setPlaceholderDefault或标签的nvl)。
11. Excel(xlsx)使用说明
Excel 模板简单得多,也受限得多。这一章先说清楚「能做什么」,再说「为什么不能」,避免你在错误的路上耗时间。
11.1 基本用法
// 同样是「复制母版 → 打开 → 填数 → 保存回同一个文件」
File tmp = File.createTempFile("rpt", ".xlsx");
Files.copy(Paths.get("D:/tpl/report.xlsx"), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
XWorkBook book = new XWorkBook(tmp);
book.variable("title", "2026年销售报表");
book.variable("明细", detailDataSet);
book.save();
save() 内部顺序(顺序很重要,排错时按它对照):
| 步骤 | 做什么 | 现象 |
|---|---|---|
1. load()(懒加载) | 解压 xlsx,解析 workbook.xml / sharedStrings.xml / 各 sheet / 绘图 | 路径写错 → 加载异常 |
2. mergePlaceholder() | 把被 Excel 富文本拆到多个 <r><t> 的 ${...} 合并回一个 <t> | 只有「跨 r」能修;跨单元格无解 |
3. reloadShares() | 刷新共享字符串索引 | —— |
4. parseTag() | 逐 sheet 逐行逐单元格解析标签(单元格标签、行循环、合并登记) | 循环/合并在这一步完成 |
5. executeMerges() | 执行延迟的合并 | 合并放在循环之后才正确 |
6. executeImages() | 插入 <aol:img>(行循环后行号才确定,所以延迟执行) | 锚定到标签所在单元格 |
7. executeCharts() | 插入 <aol:chart>:生成 xl/charts/chartN.xml + 隐藏数据表 | 同上,图表锚定到标签所在单元格 |
8. replace() | 替换 ${}(sheet、sharedStrings、drawing、图表都替换) | —— |
| 9. 写回 zip | 覆盖原文件 | 模板被就地改写,务必用副本 |
11.2 哪些单元格会被处理
| 单元格类型 | 是否处理 | 说明 |
|---|---|---|
文本型(t="s",共享字符串) | ✅ | 标签和 ${} 都写在这里面 |
公式(<f>) | ❌ | 公式单元格里的文字不参与替换 |
| 数字 / 布尔 / 日期 | ❌ | 只替换文本;要把数字格式化请在模板里用 Excel 的「设置单元格格式」 |
| 绘图(文本框、形状)里的文字 | ✅ | xl/drawings/*.xml 里的文字也参与替换 |
| 批注 / 条件格式公式 / 数据验证 | ❌ | 不处理 |
${} 不会被拆散
Excel 的富文本虽然也会把一段文字拆成多个 <r>,但引擎有专门的
mergePlaceholder() 自动合并,所以 Excel 模板比 Word 模板省心。
唯一要求:${} 要写在同一个单元格里。
11.3 单元格里能用的标签
TagUtil.parse()(产出纯文本),
而大部分标签只实现了 run()(改文档结构),所以在单元格里写了不会生效,
甚至不会报错,你只会看到「数据没出来」。
| 标签 | xlsx 单元格里 | 说明 |
|---|---|---|
<aol:number> | ✅ 可用 | 产出格式化后的文本,最适合 Excel |
<aol:date> | ✅ 可用 | 同上 |
<aol:for> | ⚠️ 特殊支持 | 只支持「行循环」一种模式,见 11.4 |
<aol:merge> | ⚠️ 特殊支持 | 登记延迟合并,见 11.5 |
<aol:img> | ⚠️ 特殊支持 | 由单元格解析(不走 TagUtil.parse()),延迟到行号确定后插入,见 11.6 |
<aol:chart> | ⚠️ 特殊支持 | 同上,生成 xl/charts/chartN.xml 并锚定到单元格,见 11.6 |
sum / avg / max / min / agg / concat / group / set / if / money / checkbox / qr / bar / html / page / mark | ❌ 不生效 | 只有 run(),单元格式解析模式下不执行 |
那 Excel 里怎么求和?两个办法:
① 用 Excel 自己的公式(模板里写 =SUM(D2:D100)),这也是最自然的方式;
② 在 Java 侧用 agg/sum 算好,再用 book.variable("合计", value) 传进来填。
11.4 行循环(xlsx 版的 for)
<!-- 在第 2 行(模板行)的任意单元格里写: -->
<aol:for items="${明细}" var="r">${r.NAME}</aol:for>
| 要点 | 说明 |
|---|---|
items | 必填,且必须是非空的集合(List / DataSet);不是集合或为空 → 循环不执行,模板行保持原样 |
var | 必填,循环体里用 ${var.字段} 取数 |
| 复制范围 | 固定是「所在整行」,不需要也不支持 scope 属性 |
| 状态变量 | 引擎固定用变量名 s 提供 index/count 等信息,即 ${s.count} |
| 标签位置 | 写在模板行任意单元格里;标签所在的单元格文本(除了标签本身)也会参与替换 |
| 不支持 | scope、begin/end/step/fill/status/flatten 等 docx 侧的高级属性全部无效 |
var 名字和 ${} 里写的不一致;
「模板行还在,数据在后面」→ 模板行本身也会被复制成第一行数据(这是符合预期的,模板行就是第一份副本)。
11.5 合并单元格(xlsx 版的 merge)
<!-- 写在模板行的某个单元格里 -->
<aol:merge scope="tc"/> <!-- 纵向:相邻行同值合并 -->
<aol:merge scope="tr"/> <!-- 横向:相邻列同值合并 -->
<aol:merge scope="tc" by="客户" ignore="null,-"/>
和 docx 一样是延迟执行:parseTag() 阶段只登记,
所有行循环跑完后由 executeMerges() 统一合并,所以不用担心「数据还没填完就合并了」。
| 属性 | 默认 | 说明 |
|---|---|---|
scope | tc | tc/td = 纵向(同列),tr = 横向(同行) |
by | 无 | 参考列:只有当该列的值在合并范围内也相同时才合并 |
ignore | 无 | 忽略值(逗号分隔),这些值不参与比较 |
11.6 图表(xlsx 版 chart)
<!-- 写在单元格里(和 ${} 一样,必须写在同一个单元格内) -->
<aol:chart type="column" title="销售分布" data="${sales}"
categoryKey="name" valueKey="value" width="400" height="300"/>
引擎按顺序做四件事:
- 生成图表部件
xl/charts/chartN.xml(序号与模板里已有的图表连续,不会覆盖); - 新建一个隐藏工作表(
ChartData1、ChartData2…)把图表数据写进去; - 在工作表关联的
xl/drawings/drawingN.xml里加xdr:graphicFrame,锚定到标签所在单元格; - 补齐
[Content_Types].xml与各级.rels(没有 drawing 时会一起新建)。
| 要点 | 说明 |
|---|---|
| 与单元格联动 | 图表的 c:f 指向隐藏数据表(数据与缓存一致),所以在 Excel 里可以右键「选择数据」继续编辑数据源 |
| 尺寸 | width/height 单位是 px(1px = 9525 EMU),与 <aol:img> 一致;只给一边时按 4:3 等比换算,都不给时默认 400×300 |
| 插入时机 | 和图片一样延迟到行循环之后执行,所以 for 插入行后图表仍然锚在正确的行上 |
| 标签位置 | 写在共享字符串型单元格(t="s")里;标签本身会从单元格文本中删除 |
| 数据为空 | 不出图、不报错,标签直接消失(日志 xlsx 图表数据为空) |
| 不支持 | 标签体形式的 <aol:split> 子标签(改用 split-type 等属性);图表配色 / 图例位置取默认值 |
wp:inline,尺寸单位是 EMU;
xlsx 里图表是 drawing 里的 xdr:graphicFrame,尺寸单位是 px,
并且会额外生成一个隐藏数据表来承载数据。支持的图表类型与属性(type / data / categoryKey …)两边完全一致,见 9.10。
11.7 docx 与 xlsx 差异对照
| 能力 | docx(WDocument) | xlsx(XWorkBook) |
|---|---|---|
${} 占位符 | ✅ 正文 + 页眉页脚 + 图表 | ✅ 单元格 + 绘图文字 |
${aov:...} 内置变量 | ✅ | ✅(同一个 Context) |
for 循环 | ✅ 四种 scope + 全套切片属性 | ⚠️ 只有整行循环 |
if 条件 | ✅ | ❌ |
| 数值/日期格式化 | ✅ number date money sum… | ⚠️ 只有 number date |
| 合并单元格 | ✅ merge | ✅ merge |
图片 img | ✅(需 Downloader) | ✅ 锚定到单元格,尺寸按 px(需 Downloader) |
二维码 / 条形码 qr bar | ✅(需 Imager) | ❌ |
图表 chart | ✅ 原生 OOXML 图表(word/charts/) | ✅ 原生 OOXML 图表(xl/charts/ + 隐藏数据表) |
| 分页 / 书签 / HTML | ✅ | ❌ |
| 命名空间可改 | ✅ doc.namespace() | ✅ book.namespace() |
12. 调试与排错
12.1 先把日志打开
引擎内部用的是 anyline 自己的日志门面 LogProxy,会桥接到你项目里已有的日志实现。
把它调成 DEBUG / INFO,就能看到标签的执行轨迹与报错信息:
// logback 示例:只放开 office 包
<logger name="org.anyline.office" level="DEBUG"/>
[标签不支持][xxx]—— 标签名或命名空间写错了;[数据源仅支持集合形式]—— 把单个对象当集合传了;[图片文件下载失败][未提供downloader]—— 忘了setDownloader;[数据格式错误]/ OGNL 异常堆栈 —— 表达式写错了(if会因此静默走 else)。
还有一类「没有日志的失败」:标签「只实现 run() 却写在 Excel 单元格里」、
「avg 的数据源不是 DataSet」—— 这些是直接 return,
不打日志、不报错,只能对照本文档判断。
12.2 症状 → 原因 → 处理
| 症状 | 最可能的原因 | 怎么办 |
|---|---|---|
生成的文档里还有 ${xxx} | 变量名不匹配(大小写/key 不存在);或跨段落被拆开 | 先用 ${aov:now} 确认模板能被替换;再确认 key 完全一致 |
文档里出现 <aol:xxx> 原文 | 标签名不在注册表、命名空间不一致、或该标签是空实现(select) | 对照 第 7 章清单;检查 doc.namespace() |
| 表格循环「只出来一行」 | 数据源不是集合、或集合为空 | 传 List/DataSet;空数据用 fill="N-1"(fill 是结束下标,N 行要写 N-1)+ remove="false" |
| 循环出来的行数对但内容都一样 | var 名与 ${} 里不一致;或占位符在标签体外 | 检查 ${var.字段} 的 var 与属性的 var 相同 |
| 明明要给固定 N 行,结果多了 1 行 | fill="N" / 计数循环 begin..end 都是「含端点」;且计数循环会再残留一份模板体 | 行数写 N-1;计数循环把 end 减 1;不够就换成 items + fill |
循环里写了 i="0" 后整段内容消失 | i(index)会把数据源变成单条记录,for 直接 return | 去掉 i,序号用 status 的 count |
| 合计是 0 / 空 | sum 的字段名错了;或值不是数字——sum 会抛 NumberFormatException(异常被吞,格子留空) | 先看日志有没有 Sum.java 的堆栈;脏数据列改用 agg type="sum"(容错跳过) |
| 勾选项一个都不勾 | 把 vk 写成了非值字段(比如用 rely 指定了列,但 vk 仍是默认主键) | 显式写 vk="CODE":比对只认 valueKey |
avg/max/min 什么都不输出 | 数据源不是 DataSet | 换 agg,或把数据转成 DataSet |
if 永远走 else | 表达式写错(被异常吞掉) | 打日志看堆栈;字符串比较加引号 |
| 图片/二维码位置是空的 | 没注入 Downloader / Imager | 实现接口并 setXxx() |
| 图表没出现或很小 | 数据不是集合;或宽高写成了像素值 | 用 EMU 或用 ChartData.width("600px") |
| 合并结果不对 | by 匹配失败;或 merge 没写在模板行里 | 简化 by,或用下标指定 |
| 第二次运行结果错乱 | 直接改了母版文件 | 每次都复制副本(见 10.1) |
| Excel 单元格里的标签没反应 | 该标签在 xlsx 不生效(见 11.3) | 改用 number/date,或 Excel 原生公式 |
| Excel 里数字变成文本、公式不重算 | 引擎不重算公式;替换后仍是字符串 | 公式请在模板里保留,让 Excel 打开时自己算 |
12.3 二分法定位:先让数据可见
出问题时不要在脑子里猜,按「数据 → 标签 → 排版」的顺序逐段排除:
<!-- 第 1 步:把数据原样打在模板上,确认真的进来了 -->
明细条数:${明细} <!-- DataSet 会整体 toString,能看到条数 -->
<!-- 第 2 步:确认取值路径对(循环里逐字段打印) -->
<aol:for items="${明细}" var="r" scope="tr">
name=${r.NAME} | amount=${r.AMOUNT}
</aol:for>
<!-- 第 3 步:确认标签语法对(先用最简单的属性) -->
<aol:if test="${1 == 1}" value="IF-OK" else="IF-FAILED"/>
12.4 取不到值时输出什么:setPlaceholderDefault
WDocument doc = new WDocument(tmp);
doc.setPlaceholderDefault("-"); // 所有找不到的 ${key} 都输出 "-"
// 默认值是空串(也就是留下空白)
这是唯一能全局控制「空值显示」的地方;nvl 这类属性是标签自己的局部兜底,
两者不冲突:标签的 nvl 优先。
12.5 实例复用与重复 save()
save()内部会先load(),每次 save 都会重新解析文件;同一个实例连续 save 两次,图表部件会累积(每次都会追加新的chartN.xml与关系项)。- 因此推荐一个实例只 save 一次:要生成 N 份就 N 次「复制母版 + new + save」。
- 多线程并发生成时,每个线程操作自己的副本文件,不要共享
File。
13. 常见坑速查
把这一节当交付前的检查清单,逐条打勾。
13.1 模板侧
- 母版文件永远被复制后再交给引擎(否则第二次运行全乱);
- 每个
${}、每个标签的<>都在同一个段落 / 同一个单元格内; - 没有全角
${}、没有中文标点混入; - 循环标签写在模板行(
tr)的单元格里,表头行不放循环标签; - 页眉页脚只写
${},不写aol标签; - 模板里没有
<aol:select>、<aol:include>这类无效标签残留; - Excel 模板里没有「只在 docx 生效」的标签。
13.2 标签侧
for:var写了吗?序号用count而不是index了吗?scope和标签位置匹配吗?if:字符串比较加引号了吗?remove配scope了吗?- 数值类:字段名大小写对吗?数据源是
DataSet吗(avg/max/min)? number/date:format真的写了吗(不写就原样输出、看起来像「没生效」)?money:传的是裸数字吗(带千分位会被截断成 1)?checkbox:data是List<Map>吗?rely/valueKey和选项数据的列名一致吗?merge:写在模板行里了吗?只写在表格内了吗?chart:data是一个完整的${}吗?宽高单位是 EMU 吗?qr/bar/img:Java 侧注入了Imager/Downloader吗?
13.3 数据侧
- 集合不是单个对象(
for/chart/checkbox都要求集合); - 字段名和模板里的
${}完全一致(含大小写); - 数值列里没有
"1,234.00"、"—"之类的脏值; - 日期列传的是
Date或可解析的字符串; - 用
DataSet时注意它的默认列名归一化规则(一般是大写)。
13.4 运行侧
- 文件没被 Word / Excel 打开占用(否则写回失败);
- 先复制再生成,生成完再决定是否改名/移动;
- 临时文件记得清理(
File.deleteOnExit()或 finally 里删); - 生成完最好用 Word/Excel 打开一次,确认没有「文档结构损坏」提示(合并单元格 + 空表格最容易触发)。
14. 能力边界
知道「它不做什么」,比知道「它能做什么」更能省时间。
14.1 不负责排版
引擎不生成样式、不做自动布局、不自动调列宽行高。 所有视觉结果都来自你的模板。所以「生成的文档丑」不是 bug,是模板问题。
14.2 不支持的场景(明确说明)
| 场景 | 结论 | 替代方案 |
|---|---|---|
.doc / .xls 老二进制格式 | ❌ 不支持 | 先在 Office 里另存为 .docx / .xlsx |
| 密码保护 / 加密的文档 | ❌ 不支持 | 先去保护再当模板 |
| 直接导出 PDF | ❌ 不支持 | 生成 docx 后用 LibreOffice / Office 转换 |
Word 宏(.docm) | ❌ 不处理 | —— |
| Excel 公式重算 | ❌ 不重算 | 保留公式,打开时由 Excel 计算 |
| Word 域 / 交叉引用自动更新 | ❌ 不更新 | —— |
| 条件删除整张表格 | ⚠️ 可用 if 包住表格实现 | 把 <aol:if test="..."/> 放在表格外侧段落 |
| 自定义图表配色 / 图例位置 | ❌ 取默认值 | 生成后用 Excel 改图表样式 |
| 页眉页脚里执行标签 | ❌ 只支持 ${} | 把内容放到正文 |
| 加密 / 数字签名 | ❌ 不支持 | —— |
| 超大文档(几百 MB) | ⚠️ 整份 XML 载入内存 | 拆分模板、分批生成 |
14.3 已知的「静默失败」(最需要小心)
- 标签名/命名空间写错 → 标签文本原样留在文档里;
avg/max/min的数据源不是DataSet→ 输出空;- 只实现
run()的标签写在 Excel 单元格里 → 无任何效果; if表达式异常 → 走 else 分支;chart的data不是集合 /type拼错 → 标签被移除或降级成饼图;merge不在表格内 /by匹配失败 → 不合并;img/qr/bar缺Downloader/Imager→ 什么都不插。
对付它们的办法只有一个:交付前把生成的文档完整看一遍(尤其是页数、合计、图表、图片)。
15. 进阶技巧
15.1 用 pre / ref 消灭重复
同一段标签写很多遍时,先注册、再引用,模板会干净很多(机制见 6.6):
<!-- 定义(这段不渲染) -->
<aol:pre id="金额格"><aol:number value="${v}" format="#,##0.00"/></aol:pre>
<!-- 引用 -->
含税:<aol:金额格 v="${amount}"/>
不含税:<aol:金额格 v="${amount / 1.13}"/>
ref 也可以用来「继承 + 覆盖」:
<aol:number ref="金额格" v="${x}"/> 会把定义里的 format 一起带过来。
15.2 内置变量 ${aov:...} / ${alv:...}
aov: 与 alv: 两个命名空间都指向同一批「表达式执行器」,
运行时按 名字 查表,把结果填进占位符。
| 写法 | 结果 | 可用性 |
|---|---|---|
${aov:now} | 当前时间 | 可用 |
${aov:date} / ${aov:time} / ${aov:datetime} | 同上(同一实现的别名) | 可用 |
${aov:now:yyyy-MM-dd HH:mm} | 格式化后的当前时间 | 可用(格式里不要含 :) |
${aov:uuid} | —— | 无对应注册名 |
${aov:string:8} / ${aov:random:8} | —— | 无对应注册名 |
${aov:number:8} / ${aov:timestamp:13} | 随机数 / 时间戳 | 注册名重复,实现不确定,不要依赖 |
doc.variable() 传入。
只有「生成时间」可以用 ${aov:now},或者直接用
<aol:date evl="true" format="yyyy年MM月dd日"/>(更可控,因为它走的是标签而不是内置变量)。
15.3 文档拼接与书签定位
需要把多个模板拼成一份文档、或者往书签位置插内容时,用这些 API:
| 方法 | 作用 |
|---|---|
doc.insert(Element prev, File file) | 把另一个 docx 文件的段落和表格插到 prev 之前 |
doc.insert(Element parent, String html) | 把一段 HTML 插入到指定元素下 |
doc.insert(int index, Element parent, Element element) | 按下标插入已有元素 |
doc.before(Element point, WTable table) | 在指定元素之前插入一个表格 |
doc.after(Element point, Element element) | 在指定元素之后插入 |
doc.table(Bookmark bookmark) | 按书签定位表格(配合 <aol:mark> 使用) |
doc.tables() / tables(boolean recursion) | 拿到文档里全部表格 |
doc.tables(String content) | 按内容定位表格 |
// 组合思路:模板 A 尾部插入模板 B 的全部内容
WDocument doc = new WDocument(tmpA);
doc.load();
Element tail = /* 模板 A 里某个已存在的元素 */;
doc.insert(tail, new File("D:/tpl/B.docx"));
doc.save();
在 HTML 里也可以直接拼:<word src="D:/tpl/B.docx"/>,
渲染时会把 B 的 p 与 tbl 就地展开。
15.4 把生成结果导出成 HTML
WDocument doc = new WDocument(tmp);
doc.load();
String html = doc.html(uploader, 0); // uploader 用来处理图片上传,可传 null
适合做「预览」:先生成 docx,再把同一份文档转成 HTML 给浏览器看。 注意这是结构级转换,复杂排版(浮动表格、文本框)不会完美还原。
15.5 组合拳:一个综合报表的套路
<!-- 1) 标题用 if 做分支 -->
<aol:if test="${type == 'A'}" value="A类销售报表" else="通用销售报表"/>
<!-- 2) 明细表格:模板行循环 + 序号 + 金额格式化 -->
<aol:for items="${明细}" var="r" scope="tr" status="st">
${st.count} | ${r.客户} | <aol:number value="${r.金额}" format="#,##0.00"/>
</aol:for>
<!-- 3) 合计行:放在循环外的独立行 -->
合计:<aol:sum data="${明细}" p="金额" format="#,##0.00" nvl="0"/>
<!-- 4) 空数据也要有交代 -->
<aol:if test="${明细} == null">(本期无数据)</aol:if>
<!-- 5) 生成时间 + 签章位 -->
打印时间:<aol:date evl="true" format="yyyy-MM-dd HH:mm"/>
<aol:mark name="签章"/>
<!-- 6) 图 -->
<aol:chart type="pie" title="销售分布" data="${图表数据}" categoryKey="name" valueKey="value"/>
顺序建议:标签的执行顺序是「按文档中出现的位置,从上到下」。
所以「先复制行、再算合计」天然成立;但如果你把 sum 放在 for 之前,
它算的还是原始数据(结果一样,因为 sum 不依赖循环结果)。
唯一要注意顺序的是 merge「登记 vs 执行」——它自动延迟,不用你管。
16. API 速查表
16.1 Word:org.anyline.office.docx.entity.WDocument
| 分类 | 方法 | 说明 |
|---|---|---|
| 构造 | new WDocument(File file) | 最常用。file 必须存在 |
new WDocument(String fileName) | 路径字符串 | |
new WDocument(File file, String charset) | 指定编码 | |
new WDocument(String fileName, String charset) | 指定编码 | |
| 数据 | variable(String key, Object value) | 放入占位符变量(${key} 用) |
variable(Object value) | 放入当前行/作用域(循环内用) | |
variable(String key, Object value, boolean cache) | 带缓存开关 | |
replace(String key, String value) | 直接替换文本 | |
replace(String key, List<File> words) | 用文件内容替换 | |
setPlaceholderDefault(String def) / getPlaceholderDefault() | 占位符取不到值时的默认输出 | |
| 配置 | namespace() / namespace(String) | 标签命名空间,默认 aol |
getImager() / setImager(Imager) | 二维码/条形码实现 | |
getDownloader() / setDownloader(Downloader) | 远程文件下载 | |
getUploader() / setUploader(Uploader) | 导出 HTML 时的图片上传 | |
| 执行 | load() | 解析文档结构 |
recombine(boolean save) | 修复被 Word 拆散的 ${} / 标签 | |
runTag() | 执行全部 aol 标签 | |
format() / format(Charset) | 替换占位符 | |
save() / save(Charset) | 把结果写回原文件(内部会先 load 并重跑一遍流程) | |
| 插入 | insert(Element prev, File file) | 插入另一个 docx |
insert(Element parent, String html) | 插入 HTML | |
insert(int index, Element parent, Element element) | 按下标插入 | |
before(Element point, Element/String/WTable) | 在元素前插入 | |
after(Element point, Element/String/WTable) | 在元素后插入 | |
table(Element box, Element after, Element src) | 由 w:tbl 构造 WTable | |
| 查询 | table(Bookmark bookmark) | 按书签定位表格 |
tables() / tables(boolean recursion) | 全部表格 | |
tables(String content) | 按内容找表格 | |
html(Uploader uploader, int lvl) | 整篇导出 HTML |
16.2 Excel:org.anyline.office.xlsx.entity.XWorkBook
| 方法 | 说明 |
|---|---|
new XWorkBook(File file) / new XWorkBook(String file) | 构造 |
variable(String key, Object value) | 放入占位符变量 |
replace(String key, String value) | 直接替换文本 |
namespace() / namespace(String) | 标签命名空间 |
reload() | 重新解析工作簿(load() 为私有懒加载,首次访问时自动触发) |
sheets() / sheet(int index) | 拿到全部 / 指定工作表 |
drawings() | 拿到绘图(文本框等) |
shares() / ts() | 共享字符串相关 |
save() / save(Charset) | 执行「合并占位符 → 解析标签 → 替换 → 写回」并保存 |
context() | 拿到上下文(可直接读写变量) |
16.3 常用标签属性一览(按用途记)
| 我想… | 写这个 |
|---|---|
| 表格按行展开 | <aol:for items="${集合}" var="r" scope="tr"> |
| 固定打印 N 行(不够补空) | <aol:for items="${集合}" var="r" scope="tr" fill="N"> |
| 序号 | ${st.count}(status="st") |
| 二选一文字 | <aol:if test="条件" value="是" else="否"/> |
| 不满足就删整行 | <aol:if test="条件" remove="true" scope="tr"> |
| 金额千分位 | <aol:number value="${x}" format="#,##0.00"/> |
| 金额大写 | <aol:money value="${x}"/> |
| 生成日期 | <aol:date evl="true" format="yyyy-MM-dd"/> |
| 合计 | <aol:sum data="${集合}" p="金额" format="#,##0.00" nvl="0"/> |
| 条数 / 中位数 / 标准差 | <aol:agg type="count" data="${集合}"/> |
| 同值单元格合并 | <aol:merge scope="tc"/> |
| 勾选项 | <aol:checkbox data="${选项}" value="${选中值}" vk="CODE" tk="NM"/> |
| 二维码 | <aol:qr value="${url}" style="width:120px;height:120px;"/> |
| 分页 | <aol:page/> |
| 图表 | <aol:chart type="pie" data="${数据}" categoryKey="name" valueKey="value"/> |
| 书签锚点 | <aol:mark name="锚点名">内容</aol:mark> |
| 当前时间 | ${aov:now:yyyy-MM-dd HH:mm:ss} |
16.4 依赖与扩展点
| 扩展点 | 接口 | 用途 | 不实现会怎样 |
|---|---|---|---|
doc.setImager(...) | 需提供二维码/条形码生成能力 | qr / bar 标签 | 标签被移除,不插图 |
doc.setDownloader(...) | 文件下载 | 远程 img、远程 docx 模板 | 下载失败,不插图 |
doc.setUploader(...) | 文件上传 | 导出 HTML 时把图片换成 URL | 导出 HTML 时图片可能缺失 |
TagUtil.reg(name, Class) | 自定义标签 | 扩展新标签 | —— |
16.5 最后:一段完整的可运行骨架
public void generate(Map<String,Object> data) throws Exception {
// 1. 复制母版
File tmp = File.createTempFile("rpt", ".docx");
Files.copy(Paths.get("D:/tpl/report.docx"), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
try {
// 2. 打开 + 配置
WDocument doc = new WDocument(tmp);
doc.setPlaceholderDefault("");
doc.setImager(imager); // 有二维码/条形码才需要
doc.setDownloader(downloader); // 有远程图片才需要
// 3. 填数据
for (Map.Entry<String,Object> e : data.entrySet()) {
doc.variable(e.getKey(), e.getValue());
}
// 4. 生成(写回 tmp)
doc.save();
// 5. 交付
Files.copy(tmp.toPath(), Paths.get("D:/out/report.docx"),
StandardCopyOption.REPLACE_EXISTING);
} finally {
tmp.delete(); // 清理临时文件
}
}
${} → 第 6~8 章搞懂标签机制 → 需要哪个标签再查第 9 章 → 做模板时对照第 10 章 → 出问题查第 12~14 章。
17. 容易误解的点(误解 → 事实)
这一节把最常被「想当然」的地方单独列出来。左边是大多数人第一反应会写的、右边是引擎的真实行为。 每一条都能在源码里找到依据,所以不要试图「试出来」——直接按事实写。
17.1 标签语法与机制
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
| 标签名和属性名都不区分大小写 | 标签名不区分;属性名区分 | 属性名一律小写,多词属性写成 valueKey 这种驼峰 |
| 标签「没反应」是引擎坏了 | 九成是「没注册 / 命名空间不对 / 位置不对」三种原因之一 | 用 9.11 的三检查法 |
| 属性写错名字会报错 | 读不到就是 null,静默走默认分支 | 属性名对照 9.12 总表 |
| 标签可以跨段落写 | 标签的 <、> 必须在同一段落内 | 把标签写在同一个段落里,不要手动换行 |
<aol:pre> 定义的那段会渲染出来 | 它只登记片段,不渲染 | 别把正文内容放进去当普通段落用 |
改了 namespace,${} 写法也要改 | ${} 与命名空间无关 | 只改 <aol:for> 这类标签前缀 |
写 var 时标签也会输出文本 | var 有值就不输出,还会删掉标签所在的首个 t | 要「既存又输出」就写两处 |
17.2 ${} 占位符
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
${aov:uuid} / ${aov:string:8} 能用 | 源码注册表里没有这些名字,取不到值 | 唯一可靠的是时间系列 ${aov:now},其余在 Java 侧算好传入 |
${aov:now:HH:mm:ss} 可以带冒号格式 | 格式里含 : 会解析失败 | 要带时分的动态时间用 <aol:date evl="true" format="HH:mm:ss"/> |
| 取不到值会报错 | 输出 placeholderDefault(默认空串) | doc.setPlaceholderDefault("-") 让空值可见 |
属性值里的 ${} 是原样字符串 | 会被求值,属性值因此可以「动态」 | 当字面量用时不要写 ${} |
| 表达式写错会在页面上看到报错 | 只打日志,页面静默 | 先把值 ${x} 打出来确认,再看日志堆栈 |
17.3 for 循环
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
i / index 是「第几条」的序号属性 | 它是切片:只取第 N 条,会把数据源变成单条记录 | 序号用 status 的 count(从 1)或 index(从 0) |
status 的 index 从 0 开始 count 从 1 开始,所以序号直接用 count | 对,但 begin="2" 时 index 也是绝对下标(从 2 起) | 序号一律用 count |
fill="8" 出 8 行 | fill 是结束下标,begin=0 时出 9 行 | 要 N 行写 fill="N-1" |
不写 items 用 begin/end 精确出 N 份 | 会多一份未替换的模板体(源码把 remove 置成了 false) | end 减 1,或改用 items 传空集合 + fill |
qty="1" 能只输出一条 | for 取数时不走 qty 切片,会全量输出 | 用 end 限量,或在 Java 侧裁集合 |
| 数据源传单个对象(Map/Bean)也行 | 会被当字符串处理并报「仅支持集合形式」 | 包成 List / DataSet |
JSON 字符串 "[a,b,c]" 能循环 | 只认 [{...}] 形式 | 补全成对象数组 |
remove="false" 能保留空模板行 | 只要有数据,源码会把它强制改回 true | 只有「无数据」时 remove="false" 才有效 |
scope="tr" 标签写在表格外面的段落也能用 | 必须写在单元格里,引擎向上找所在行 | 标签和 ${} 写在同一个 td 内 |
内层循环可以用和外层相同的 var 名 | 内层会遮蔽外层 | 内外层用不同变量名 |
17.4 if 判断
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
remove="true" scope="tr" 能删掉不满足条件的整行 | 当前版本必然抛 NullPointerException(被吞,行删不掉) | 在 Java 侧过滤数据后交给 aol:for |
不写 else 时条件为假会「什么都没发生」 | 标签体内容被删,段落/行留下 → 多个空行 | 接受空行,或把判断上移到数据侧 |
test="type == A" 是字符串比较 | A 会被当变量名 | 写成 test="type == 'A'" |
写了 value 还会执行标签体 | 有 value 就直接输出 value、不再跑标签体 | 二选一:要么 value/else,要么标签体 |
var 会顺便把标签体渲染出来 | 写 var 时标签体被整段移除 | 不要混用 |
17.5 数值与聚合
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
avg/max/min 对任何集合都能算 | 只认 DataSet,其它集合静默输出空 | 改用 agg,或先把数据转成 DataSet |
avg 的 format 生效 | 读了但没赋值,不生效 | 用 <aol:agg type="avg" format="#,##0.00"/> |
sum 遇到脏数据("1,234"、"-")当 0 | 直接 new BigDecimal(...) 抛 NumberFormatException,格子留空 | 脏数据列用 agg type="sum"(容错跳过) |
sum 的 min/max 是「选最小/最大行」 | 是对求和结果做上下限截断 | 要选行用 aol:max/aol:min |
agg 的 min/max 是「算法类型」 | 同样是结果截断;算法要用 type="max" | 分清 type 和 min/max |
不写 scale 的平均数很好看 | avg/median/stdev/var 默认 10 位小数 | 显式写 scale 或 format |
sum 的 def 是累加初值 | 源码只解析不使用 | 无视它 |
p="amount" 应该能取到 AMOUNT | DataSet 列名做了归一(通常大写) | 字段名与数据里的 key 完全一致,或试大写 |
| 先算合计再切数据,结果一样 | 切片在计算之前完成,会改变结果 | 做「小计」时故意利用这一点,做「总计」时别加切片 |
17.6 格式化标签
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
number 不写 format 也会自动加千分位 | 不写就原样输出 | 明确写 format="#,##0.00" |
value 可以用 v 简写 | number/money/date 的 value 没有别名 | 写全 value=(有 v 的只有 qr/bar/checkbox/if/set) |
money 能正确转 "1,234.00" | 走 parseDouble,结果被截成 1 | 传裸数字,或先去分隔符 |
date 的 property 能输出格式化结果 | 它改对象字段,但输出的是对象本身 | 用 value="${日期}" |
date evl="true" 在值非空时也覆盖成当前时间 | 只有值为空时才用当前时间 | 要固定用当前时间就别传 value |
17.7 集合处理与勾选/合并
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
set 那一行会保留在文档里 | 它会把所在段落/单元格的文字清掉,孤立段落还会被级联删除 | 单独放一行,或在旁边写点别的文字 |
set 字符串值要加单引号 | 只认「去引号」的畸形逻辑,写半个引号会整段消失 | 字符串直接写原值,不加引号 |
concat 要写在循环里 | 它自己遍历整列,放一格里就行 | 把 concat 放在要显示结果的单元格 |
group 会直接输出分组结果 | 它不输出任何文本,只把结果存进 var | 必须写 var,再用 for 渲染 |
group extract="true" 只影响副本 | 会真的从原集合移除该组成员 | 需要保留原数据就传副本 |
select 能筛选数据 | run() 是空实现,标签文本会原样留在文档里 | 在 Java 侧筛选,或用 for 的切片属性 |
checkbox 的 rely 决定用哪列比值 | 比值只认 valueKey;rely 只管 true/checked/空串判断 | 显式写 vk="CODE" |
选项行里 rely 列为空不会影响结果 | 空串会被判为「选中」,白送一个勾 | 保证 rely 列有值,或别乱写 rely |
checkbox 可以传单个 Map 当选项 | 不是集合就直接返回,什么都不输出 | 包成 List<Map> |
merge 写在表头行就行 | 扫描从模板行的上一行开始,表头不参与 | merge 写在被循环的模板行里 |
相邻空值也会被 merge 合并 | 空值默认跳过,不合并 | 统一成同一标记值再靠 ignore 排除 |
merge 用 by 文本匹配失败会报错 | 静默等价于无约束,会「多合并」 | 能用下标就用下标 by="0" |
17.8 图片 / 码 / 图表 / 分页
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
| 本库自带二维码/条形码实现 | Imager 是接口,没有默认实现,不注入就静默不插图 | 自己实现 qr/bar 并 doc.setImager() |
| 远程图片能直接用 | 没有 Downloader 时下载失败且不报错 | doc.setDownloader();本地 file: 不走下载器 |
| 二维码生成的文件可以留下来复用 | 是临时文件,插入后立即删除 | 不要依赖该路径 |
chart 的宽高是像素 | 单位是 EMU,写 600 会几乎看不见 | 写 600*9525 或用 ChartData.width("600px") |
chart 的 type 拼错会报错 | 静默降级成饼图 | 对照 9.10 的类型表 |
chart data="前缀${x}" 能求值 | 只有「整个属性就是一个 ${}」才求值 | 把数据先在 Java 侧拼好 |
chart 的 -t/-d 别名能随便用 | t 同时是 type/title,d 同时是 data/dimension | type、title、data、dimension 一律写全名 |
chart 能用在 Excel 里 | 图表是 docx 专有能力 | Excel 用原生图表 |
page position="before" 能在段前分页 | 渲染器只识别 page-break-after,等于空 div | 把 <aol:page/> 放到上一段末尾 |
17.9 Excel 专属误解
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
| docx 里能用的标签,Excel 单元格里也能用 | 只有 number/date 真正可用,for/merge 是特殊支持,其余全部无效 | 见 11.3 的清单 |
Excel 的 for 也认 scope 和各种切片属性 | 固定「整行循环」,scope/begin/end/step/fill/flatten 全无效 | 把数据裁好再传 |
| Excel 的循环状态变量名可以自定 | 固定叫 s,即 ${s.count} | 不要写 status 属性 |
| 公式单元格里的文字也会被替换 | 公式、数字、布尔、日期单元格一律不处理 | 只把标签写在文本单元格里 |
| 引擎会重算公式 | 不重算,替换后原样保留 | 让 Excel 打开时自己算 |
${} 会被 Excel 拆散 | 有 mergePlaceholder() 自动合并,比 Word 省心 | 只要保证写在同一个单元格里 |
17.10 文件与运行
| 你可能以为 | 实际上是 | 怎么避坑 |
|---|---|---|
save() 会另存成一个新文件 | 就地改写你传进去的那个文件 | 永远先 Files.copy 出副本再渲染 |
同一个实例可以反复 save() | 每次 save 都重新解析并追加部件,图表会越滚越多 | 一个实例只 save 一次 |
页眉页脚里也能写 aol 标签 | 页眉页脚只做 ${} 替换,不执行标签 | 把标签内容放正文 |
merge 要放在模板最后一行 | 不用,它自动延迟到所有 for 之后执行 | 写在模板行里即可 |
| 生成的文档结构一定没问题 | 合并单元格 + 空表格最容易触发「文档损坏」 | 生成后用 Word/Excel 打开一次验收 |
18. 可复制示例集(模板 + Java 成对)
前面各章讲「知识点」,这一章讲「拿来就跑」。每个示例都由三块组成: ① 模板怎么摆(在 Word/Excel 里哪一行、哪个单元格写什么)→ ② Java 怎么准备数据 → ③ 结果长什么样 + 这一例最容易踩的坑。 字段名故意用中文,方便你对照自己的模板。
18.1 公共骨架(所有示例都从这里开始)
先复制这个通用方法,后面每个示例只管准备 data 和写模板:
import org.anyline.office.docx.entity.WDocument;
import org.anyline.entity.DataSet;
import org.anyline.entity.DataRow;
import java.io.File;
import java.nio.file.*;
import java.util.*;
/** 渲染入口:复制模板 → 灌数据 → 写回副本 */
public File render(File tpl, Map<String,Object> data) throws Exception {
// 1) save() 会就地改写文件,必须先复制一份,绝不能在原始模板上渲染
File tmp = File.createTempFile("rpt", ".docx");
Files.copy(tpl.toPath(), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
// 2) 打开、配置、灌数据
WDocument doc = new WDocument(tmp);
doc.setPlaceholderDefault(""); // 取不到值时输出空串,而不是把 ${} 留在文档里
for (Map.Entry<String,Object> e : data.entrySet()) {
doc.variable(e.getKey(), e.getValue()); // key 就是模板里 ${key} 的 key
}
// 3) 渲染(内部会跑标签 + 替换占位符)并写回 tmp
doc.save();
return tmp; // 调用方把 tmp 拷到最终路径即可
}
数据有三种准备方式,按需要选:
| 方式 | 写法 | 什么时候用 |
|---|---|---|
List<Map> | List<Map<String,Object>> rows = ... | 只用 for / sum / agg / checkbox / chart 时最简单 |
DataSet | DataSet rows = new DataSet(list); | max / min / avg / group / concat 必须用它 |
| JSON 字符串 | 直接写在模板属性里 items="[{'a':1}]" | 临时调试;正式还是走 ${变量} |
再给一个省事的「造行」工具,后面示例都用它:
/** 快速造一行:map("name","张三","amount",1234.5) */
static Map<String,Object> map(Object... kv) {
Map<String,Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
m.put(String.valueOf(kv[i]), kv[i+1]);
}
return m;
}
/** 造一个 DataRow(给 group / max / min / concat 用) */
static DataRow row(Object... kv) {
DataRow r = new DataRow();
for (int i = 0; i < kv.length; i += 2) {
r.put(String.valueOf(kv[i]), kv[i+1]);
}
return r;
}
${r.字段},r 就是 <aol:for var="r"> 指定的循环变量;
② 所有集合类标签(for / sum / agg / checkbox / chart)的数据源统一写 ${变量名},
变量名就是你 doc.variable("变量名", 集合) 用的那个名字。
18.2 示例 1:明细表(for + 序号 + 金额格式化)
模板——Word 里建一个 3 列表格:
【表格第 1 行 = 表头】 序号 | 客户 | 金额
【表格第 2 行 = 模板行,三个单元格分别写:】
第 1 格: <aol:for items="${rows}" var="r" scope="tr" status="st">${st.count}
第 2 格: ${r.customer}
第 3 格: <aol:number value="${r.amount}" format="#,##0.00"/></aol:for>
Java:
List<Map<String,Object>> rows = new ArrayList<>();
rows.add(map("customer","张三", "amount", 1234.5));
rows.add(map("customer","李四", "amount", 890));
data.put("rows", rows);
// File out = render(new File("D:/tpl/report.docx"), data);
结果:
序号 | 客户 | 金额
1 | 张三 | 1,234.50
2 | 李四 | 890.00
- 序号用
${st.count}(从 1);${st.index}是下标(从 0)。 ${r.amount}直接写只会输出1234.5,千分位必须过aol:number。scope="tr"时,<aol:for>开关标签要落在同一行的不同单元格里。
18.3 示例 2:合计 / 条数 / 平均值 / 最大值
模板——写在普通段落里即可:
条数:<aol:agg type="count" data="${rows}"/>
合计:<aol:sum data="${rows}" p="amount" format="#,##0.00" nvl="0"/>
前两条合计:<aol:sum data="${rows}" p="amount" end="1" format="#,##0.00" nvl="0"/>
客单价:<aol:agg type="avg" data="${rows}" p="amount" scale="2"/>
最大值:<aol:max data="${rows}" p="amount"/>
Java(max 需要 DataSet,这里统一用 DataSet):
DataSet rows = new DataSet();
rows.add(row("customer","张三", "amount", 1234.5));
rows.add(row("customer","李四", "amount", 890));
rows.add(row("customer","王五", "amount", 2100));
data.put("rows", rows);
结果:条数 3;合计 4,224.50;前两条 2,124.50;客单价 1,408.17;最大值 2100。
max/min/avg(标签)只认DataSet,传List会整格空白 → 用agg,或先把数据转成DataSet。sum遇到"1,234"、"-"这类脏数据会抛异常、整格空白 → 脏数据列改用<aol:agg type="sum" .../>(内部容错跳过)。end="1"表示「下标 0~1」,即前两条;切片在求和之前生效,这正是做「小计」的手法(做「总计」时不要加切片)。sum的nvl只有n一个别名;min/max没有别名。
18.4 示例 3:条件文字与空数据提示
模板(写在循环体里或独立段落):
等级:<aol:if test="${r.amount >= 1000}" value="大额" else="普通"/>
统计:<aol:if test="${rows.size} == 0" value="本期无数据" else="共 ${rows.size} 条"/>
Java:同示例 1 的 rows,允许为空列表。
结果:等级:大额 / 统计:共 2 条;空列表时输出 统计:本期无数据。
- 想「不满足就删整行」的
remove="true" scope="tr"在当前版本必然失败(内部先移除标签再取父节点,空指针被吞掉),请在 Java 侧过滤数据。 - 不写
else时条件为假会留下空行 / 空段落,模板里要预留。 - 字符串比较必须写引号:
test="${r.type == 'A'}";写成== A会把A当变量名。
18.5 示例 4:固定 8 行(数据不足补空)
模板——和示例 1 同一个表格,模板行加 fill:
第 1 格: <aol:for items="${rows}" var="r" scope="tr" status="st" fill="7">${st.count}
第 2 格: ${r.customer}
第 3 格: <aol:number value="${r.amount}" format="#,##0.00"/></aol:for>
Java:rows 只有 2 条(同上)。
结果:固定打印 8 行(2 条数据 + 6 行空白),模板行本身被移除。
fill是结束下标(从 0 计),要补到 8 行就写fill="7"。- 不要用「不写
items的计数循环」来凑固定行数:源码在计数循环里把remove置成false,会多残留一份未替换的模板行。要 N 行优先用items+fill="N-1"。 - 补出来的空行里
${r.xxx}会是空串;行高/边框由模板行样式决定。
18.6 示例 5:纵向合并相同值(merge)
模板——3 列表格(部门 | 姓名 | 金额),模板行:
第 1 格: <aol:for items="${rows}" var="r" scope="tr"><aol:merge scope="tc"/>${r.dept}
第 2 格: ${r.name}
第 3 格: ${r.amount}</aol:for>
Java:
List<Map<String,Object>> rows = new ArrayList<>();
rows.add(map("dept","研发部", "name","张三", "amount", 1000));
rows.add(map("dept","研发部", "name","李四", "amount", 2000));
rows.add(map("dept","市场部", "name","王五", "amount", 800));
data.put("rows", rows);
结果:前两行的「研发部」合并成一个跨 2 行的单元格。
merge必须写在被循环的模板行里,别写在表头行(扫描从模板行上一行开始)。- 空值默认不参与合并;想把「连续空值合并」要先把它们统一成某个标记值,再用
ignore排除真正不该合并的。 by用文本匹配时只看首行,匹配失败会静默「多合并」;能用下标就用by="0"。
18.7 示例 6:勾选框 / 选中项(checkbox)
模板:
勾选展示:<aol:checkbox data="${depts}" value="${selected}" vk="CODE" tk="NM" vol="4"/>
纯文本 :<aol:checkbox data="${depts}" value="${selected}" vk="CODE" tk="NM" type="text" split=","/>
Java:
List<Map<String,Object>> depts = new ArrayList<>();
depts.add(map("CODE","D1", "NM","研发部"));
depts.add(map("CODE","D2", "NM","市场部"));
depts.add(map("CODE","D3", "NM","财务部"));
data.put("depts", depts);
data.put("selected", Arrays.asList("D1","D3")); // 多选;单选可传字符串 "D1"
结果:☑研发部 □市场部 ☑财务部(按 vol 每行 4 个换行);第二行输出纯文本 研发部,财务部。
- 比值只认
valueKey(vk):写成vk="CODE";rely只决定「是否为 true/checked/空串」的额外判断。 - 选项行里
rely列若是空串会被判为「选中」,白送一个勾——不打算用rely就别写它。 data必须是Collection<Map>,传单个 Map 什么都不输出;textKey默认NM。
18.8 示例 7:二维码与图片(qr / img)
模板:
<aol:qr value="${url}" style="width:120px;height:120px;"/>
<aol:img src="${logo}" style="width:150px;height:100px;"/>
Java——这两个标签必须额外注入依赖:
WDocument doc = new WDocument(tmp);
doc.setImager(myImager); // 二维码/条形码:自己实现 Imager,不注入就不插图
doc.setDownloader(myDownloader); // 远程图片:自己实现 Downloader,不注入则下载失败
doc.variable("url", "https://anyline.org");
doc.variable("logo", "D:/img/logo.png"); // 本地图片
// doc.variable("logo", "https://cdn.xx.com/logo.png"); // 远程图片(走 Downloader)
doc.save();
- 本库不自带
Imager/Downloader实现,不注入就静默不渲染,也不报错。 qr/bar生成的是临时文件,插入后立即删除,别依赖那个路径。style里的px会被去掉;img的src支持${}。
18.9 示例 8:图表(chart)
模板——chart 是自闭合标签,写在正文段落里即可:
<aol:chart type="bar" title="月度销量" data="${chartData}"
categoryKey="name" valueKey="value"
width="5715000" height="3429000"/>
Java:
List<Map<String,Object>> chartData = new ArrayList<>();
chartData.add(map("name","1月", "value", 120));
chartData.add(map("name","2月", "value", 200));
chartData.add(map("name","3月", "value", 150));
data.put("chartData", chartData);
结果:一张 600×360 像素左右的柱状图。
data必须整个属性就是一个${}(data="${chartData}");data="前缀${x}"不会求值。- 宽高单位是 EMU(1px≈9525):
5715000≈600px,写600会几乎看不见。 t/d别名和type/data冲突,一律写全名;type拼错会静默降级成饼图。
18.10 示例 9:分组小计(group + agg)
模板——先在空白段写一行分组指令,再用一个 3 列表格渲染:
<aol:group data="${rows}" by="dept" var="groups"/>
【表格第 1 行 = 表头】 部门 | 笔数 | 金额小计
【表格第 2 行 = 模板行】
第 1 格: <aol:for items="${groups}" var="g" scope="tr">${g.dept}
第 2 格: <aol:agg type="count" data="${g.items}"/>
第 3 格: <aol:sum data="${g.items}" p="amount" format="#,##0.00" nvl="0"/></aol:for>
Java(group 需要 DataSet):
DataSet rows = new DataSet();
rows.add(row("dept","研发部", "name","张三", "amount", 1000));
rows.add(row("dept","研发部", "name","李四", "amount", 2000));
rows.add(row("dept","市场部", "name","王五", "amount", 800));
data.put("rows", rows);
结果:研发部 | 2 | 3,000.00;市场部 | 1 | 800.00。
group只认DataSet,传List会静默不分组。group自己不输出任何文字,必须写var,再用for渲染。- 每个分组的成员放在固定的属性名
items里,即${g.items}。 extract="true"会把成员从原集合真正移除,要保留原数据就先传副本。
18.11 示例 10:去重拼接(concat)
模板:
参与部门:<aol:concat data="${rows}" p="dept" split="、" ds="dept"/>
Java:rows 同上(DataSet,dept 有重复行)。
结果:参与部门:研发部、市场部(已去重)。
concat必须DataSet,且必须写p(列名);用ds去重。- 不需要写在
for里,一个单元格 / 一个段落放一个即可。
18.12 示例 11:Excel 版(XWorkBook)
模板(report.xlsx 里):
A1: 销售报表
A3: 姓名 B3: 金额 C3: 部门
A4: <aol:for items="${rows}" var="r">${r.name}
B4: <aol:number value="${r.amount}" format="#,##0.00"/>
C4: ${r.dept}</aol:for>
Java:
File tmp = File.createTempFile("rpt", ".xlsx");
Files.copy(Paths.get("D:/tpl/report.xlsx"), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
XWorkBook book = new XWorkBook(tmp);
book.variable("title", "2026 年销售报表");
book.variable("rows", rows); // List
book.save(); // 合并占位符 → 解析标签 → 替换 → 写回原文件
结果:第 4 行按数据条数向下展开。
- 单元格里只有
aol:number/aol:date真正可用;sum / if / checkbox / …一律无效(它们只实现run(),而单元格走的是文本parse())。 - Excel 的
for只支持整行循环:scope / begin / end / fill / status全部无效;状态变量固定叫s,用${s.count}。 - 合计建议用 Excel 自己的公式(
=SUM(B4:B100)),或在 Java 侧算好用book.variable("合计", v)传进来。 - 公式、数字、布尔、日期单元格不参与替换,标签只能写在文本单元格里。
18.13 一页速查:模板片段 → 你会得到什么
| 示例 | 模板关键片段 | 关键 Java | 结果 |
|---|---|---|---|
| 1 明细表 | <aol:for items="${rows}" var="r" scope="tr" status="st"> | data.put("rows", List<Map>) | 表格按行展开 + 序号 + 千分位 |
| 2 合计 | <aol:sum data="${rows}" p="amount" end="1"/> | DataSet rows = new DataSet() | 总计 / 小计 / 条数 / 均值 / 最值 |
| 3 条件 | <aol:if test="..." value="是" else="否"/> | —— | 二选一文字 / 空数据提示 |
| 4 固定行数 | <aol:for items="${rows}" ... fill="7"> | —— | 固定 8 行,不足补空 |
| 5 合并 | <aol:merge scope="tc"/>${r.dept} | —— | 相邻同值单元格合并 |
| 6 勾选 | <aol:checkbox data="${depts}" value="${selected}" vk="CODE" tk="NM"/> | selected = List 或 字符串 | ☑/□ 勾选项;type="text" 出纯文本 |
| 7 图片码 | <aol:qr value="${url}"/> <aol:img src="${logo}"/> | setImager() setDownloader() | 二维码 / 图片 |
| 8 图表 | <aol:chart type="bar" data="${chartData}" .../> | —— | 原生 OOXML 图表 |
| 9 分组小计 | <aol:group data="${rows}" by="dept" var="groups"/> | DataSet + row(...) | 每部门一行 + 笔数 + 小计 |
| 10 拼接 | <aol:concat data="${rows}" p="dept" split="、" ds="dept"/> | DataSet | 去重后的字符串 |
| 11 Excel | <aol:for items="${rows}" var="r"> + <aol:number/> | new XWorkBook(tmp) | Excel 整行展开 + 格式化 |