anyline-office 从零开始(重点:aol 标签)

面向没有写过 Java 模板引擎、也没见过 OOXML 的同学:从「什么是模板引擎」讲到「写出一个能跑的报表」, 再逐个拆解每一个 aol 标签。

代码位置:标签实现 org.anyline.office.tag.*(注册表在 TagUtil 静态块), 语法/解析在 TagUtil,docx 入口 docx.entity.WDocument,xlsx 入口 xlsx.entity.XWorkBook。

0. 导读:怎么用这份文档

0.1 适合谁看

  • 你会用 Word / Excel 排版,但没接触过「模板引擎」;
  • 公司项目里有一堆 .docx 模板,里面写着 ${} 和 <aol:...>,你想弄明白它们是什么意思;
  • 你想自己做一个「数据 → 自动出报表」的功能。

0.2 需要的前置知识

知识要求程度不会怎么办
Java 基础语法能看懂类、方法、变量就行先补 Java 入门,本文不会讲语法
Maven知道 pom.xml 是加依赖的地方看第 2 节,直接抄
Word / Excel会打字、会插表格——
JSON知道 [{"name":"张三"}] 长什么样看第 1 节术语表

0.3 30 分钟速通路线

  1. 第 1 节:把 8 个术语看懂(10 分钟);
  2. 第 3 节:把「Hello World」例子的代码复制运行,看到结果(10 分钟);
  3. 第 5 节 + 9.1 for:搞懂 ${} 和循环,这两样能解决 80% 的需求(10 分钟);
  4. 剩下的章节当成字典,用到时按目录查。
先记住这一句 anyline-office 做的事只有一件:把 .docx / .xlsx 当模板, 把里面的 ${占位符} 和 <aol:标签> 换成数据,生成新文档。 字体、表格边框、页眉页脚这些排版,全部由你在 Word 里设计好,引擎不负责画样式,只负责填内容。

1. 一句话原理与术语表

1.1 它到底怎么工作的

整体流程
你在 Word 里做的模板(tpl.docx)        你在 Java 里准备的数据
┌──────────────────────────┐          ┌──────────────────────────┐
│  员工:${name}            │          │  name = "张三"            │
│  ┌────┬──────┬────────┐  │          │  list = [ {name:张三,…},  │
│  │序号│ 姓名  │ 部门    │  │          │           {name:李四,…} ] │
│  ├────┼──────┼────────┤  │          └──────────────────────────┘
│  │<aol:for …>              │                       │
│  └────┴──────┴────────┘  │                       │
└──────────────────────────┘                       │
              └──────────► 引擎渲染 ◄───────────────┘
                             │
                             ▼
              员工:张三
              ┌────┬──────┬────────┐
              │ 1  │ 张三  │ 研发部  │
              │ 2  │ 李四  │ 市场部  │
              └────┴──────┴────────┘
                     结果 out.docx
模板文件本身不会被另存为新文件:引擎是「读出来 → 改内容 → 写回同一个文件」,所以要先复制一份(第 3 节会强调)。

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>
商用请注意许可 本项目是双许可:AGPL-3.0(个人学习/开源项目)或商业许可(闭源集成、SaaS、商业分发)。 如果你在公司闭源产品里用,需要走商业许可。

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. 写回 zipZipUtil.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 内容合并到占位符位置(保留原来的样式),常用于「合同附件」「公章文件」拼接。
优先级与分工 同名 key 时: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 flattendocx xlsx 仅行循环9.1
<aol:if>条件判断 / 二选一输出test value else var remove scope仅 run9.2
<aol:sum>求和data property format scale nvl min/max仅 run9.3
<aol:avg>平均值data property scale round仅 run9.3
<aol:max> <aol:min>取最大 / 最小的那一行data property var仅 run9.3
<aol:agg>万能聚合(sum/avg/max/min/count/median/stdev/var/first/last)type data property scale format empty仅 run9.3
<aol:number>数字格式化value formatdocx xlsx 可用9.4
<aol:money>金额转中文大写value仅 run9.4
<aol:date>日期格式化value format property evldocx xlsx 可用9.4
<aol:concat>把一列值拼成一个字符串data property split仅 run9.5
<aol:group>按列分组data by var extract仅 run9.5
<aol:pivot>行转列(长表转宽表),对应 DataSet.pivotdata pk class value var extract sep classVar仅 run9.5
<aol:grid>检测项网格:按 组/检测项/轴/值类型 把行数据透视成表格(列式可选 layout=item 项沿行每项K行 / layout=block 轴值横向块)data dir layout axis typeField vol types ph miss empty scope order merge split仅 run9.12
<aol:set>给变量赋值(含类型推断)var value data仅 run9.5
<aol:select>数据集筛选(预留,未实现)——9.11
<aol:checkbox>勾选项(☑/☐ 或纯文本)data value type vol rely valueKey/textKey仅 run9.6
<aol:merge>表格单元格合并(延迟执行)scope by ignore仅 run9.7
<aol:img>插入图片src style仅 run xlsx 可用9.8
<aol:html>把一段 HTML 渲染进文档标签体仅 run9.8
<aol:qr>二维码value style width/height仅 run(需 Imager)9.8
<aol:bar>条形码value style width/height仅 run(需 Imager)9.8
<aol:page>插入分页符position仅 run9.9
<aol:mark>插入 Word 书签name仅 run9.11
<aol:chart>原生图表(OOXML DrawingML)type data title categoryKey/valueKey …仅 run xlsx 可用9.10
工作表级标签(不在注册表里) xlsx 专用的 <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 数据源与切片

属性别名作用
datad items is数据源。写 ${变量} 时按上下文取值;直接写字面量时:[{...}] 解析为 DataSet,a,b,c 解析为列表
propertyp要处理的列名(sum/avg/concat/group by 等用它)。可写多列:p="A,B"
beginstart b起始下标(默认 0)
ende结束下标(含)。不写则到末尾
qtyq只取多少条
indexi只取指定下标的那一条
selectorst只保留指定列(BeanUtil.selects),减少无关字段
distinctds按指定列去重(多列逗号分隔)
formatDatefd把数据源里 property 指定字段先按该格式转成字符串(仅 DataSet)
处理顺序(照着查错很快) formatDate → selector → index(有则只取一条)→ begin/end/qty 切片 → distinct 去重。 所以「index 和 begin 一起写」时只有 index 生效。

8.2 输出控制

属性别名作用
var—把结果写进变量而不是直接输出文本。常用于 set、group、max/min、if。写 var 时标签自身不产生可见文本
valueKeyvk「选项的值」取哪个字段(默认主键列)。checkbox、chart 用它
textKeytk「显示文本」取哪个字段(默认 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 份,每份里 ${} 取当前这一轮的数据。 它是绝大多数报表真正干活的标签。

属性表

属性别名默认说明
itemsdata d is无数据源,见 8.1
var—无迭代变量名。绑「当前行」;计数循环时绑「当前下标数字」
statuss无循环状态变量名,值为 index / count / prev / next
scopespbody复制范围:body 标签体 / tc(td)整列 / tr 整行 / table 整表
beginstart b0起始下标(越界自动纠正到 0)
ende数据末尾结束下标(含)。如果没有 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 无效
flattenflat flfalse把当前行的列「平铺」进上下文,模板里可直接写 ${列名}

四种 scope

scope标签写在哪复制什么典型用途
body(默认)段落里标签体之间的内容一段文字重复、小票条目行
tr表格某一行(模板行)的单元格内整行(含格式)明细表按行展开(最常用)
tc / td模板列的单元格内整列横向展开(如 1~12 月横着排)
table表格内整张表一个客户一张表
scope=tr / tc 的关键规则 Word 里 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();

坑

  1. 不写 var 就没有当前行变量:Context.variable(null, item) 会被忽略,循环体里只能用 status。
  2. index 是绝对下标:配 begin="2" 时第一条的 index 是 2 而不是 0,序号请用 count(从 1 开始)。
  3. prev/next 会越界为空:首条没有 prev,末条没有 next,取值前建议判空。
  4. JSON 字符串只认 [{...}]:"[a,b,c]" 这种不会被解析,日志会提示「数据源仅支持集合形式」。
  5. 数据源必须是集合:单个 Map / 单个 Bean 不行(会被当字符串处理),请包成 List。
  6. fill 补出来的是空行:只补行数,不会给占位符任何值(会走 placeholderDefault)。
  7. fill="N" 出来的是 N+1 行(begin=0、step=1 时):源码做的是 end = max(end, fill),所以 fill 是结束下标而不是行数。已用最小模板实测确认 2 条数据 + fill="4" 得到 5 行。
  8. 循环里绝对不要写 i(index):i 是「只取第 N 条」的通用切片属性,会把数据源变成单条记录,for 随即报「数据源仅支持集合形式」并且整段标签体被清空(已实测:模板段落里什么都不剩)。序号请用 status 的 count / index。
  9. qty(q)对 for 无效:源码里 for 取数据时走的是「不切片」的模式,已实测 qty="1" 仍然把两条数据全渲染出来。要限量请用 end,或干脆在 Java 侧裁好集合。
  10. 计数循环会残留一份模板体:不写 items、只用 begin/end 时,源码在循环里把 remove 置成了 false,因此模板本身不会被删除。实测 scope="tr"、begin="1" end="3" 得到 4 行(3 行 ROW1/2/3 + 1 行未替换的 ROW),scope=body 同样多出一份。写「每页 5 行」这类需求时务必把 end 减 1,并注意多出来的那份里 ${i} 是空的。
  11. vol / align 是「死属性」:源码里解析了但没有任何地方使用,写上去不会报错也不会生效,别照着属性名猜效果。
  12. flatten 不会覆盖控制变量:index/count/prev/next 与 var/status 同名时前者优先;其它同名列会遮蔽外层变量,退出本轮自动恢复。
  13. 循环里不要重复用同一个 var 名:内层会遮蔽外层,建议内层用不同名字。

想更细地了解 var / status / flatten,可看专门文档:aol:for 的 var 属性使用说明。

9.2 if(判断)

<aol:if>

干什么:算一个布尔值,然后决定「保留标签体 / 换成一段文字 / 整段删掉」。 一句话概括它的优先级:有 value 就用 value,没有 value 才去执行标签体。

属性表

属性别名默认说明
test—无判断表达式。可以是 true/false 字面量,也可以是 OGNL 表达式。写成 ${...} 也能识别(会自动去掉外壳)
valuev无为真时输出的文本。写了就不再执行标签体
else—""为假时输出的文本。不写则整段删除
var—无把布尔结果写进变量,标签体整段删除、不输出任何文字
removermfalse为假时,是否连所在单元格 / 所在行一起删掉。⚠ 当前版本该功能已损坏(见下方 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 -->

坑

  1. 表达式写错只是打日志:OGNL 抛异常时 chk 保持 false,表现为「一直走 else 分支」,页面上看不出错误。先用 ${} 把值打印出来确认。
  2. value 和 else 都会被当成模板文本输出:里面写 ${} 是支持的,但不要写标签。
  3. var 会「吃掉」标签体:写了 var 就不要指望标签体被渲染出来(源码里 box.remove(true))。
  4. remove / scope 当前不可用:源码先摘子树再反查父级,必然取到 null 并抛 NullPointerException(异常被 TagUtil 捕获打日志,所以「静默地不生效」)。所以「不满足条件就整行不显示」这个常见需求,暂时只能靠 Java 侧先把数据过滤掉,再交给 aol:for。
  5. 不写 else 时,条件为假会把标签体删掉、段落/行本身留下:表现为「多了个空行」,这不是 bug,是设计如此。
  6. 比较字符串要加引号:test="type == 'A'",别写 test="type == A"(A 会被当变量)。
  7. 真假判断看 isBoolean:test="true"/test="false" 直接当布尔;其它一律走 OGNL 求值,结果必须是 Boolean。

9.3 数值类:sum / avg / max / min / agg

这五个是「算一个数出来」。数据源怎么写见 8.1,这里只讲各自的差异。

<aol:sum> 求和

属性别名默认说明
propertyp无要累加的字段名。不写就累加每个元素本身(元素必须是数字)
formatf无输出格式,如 #,##0.00。写法同 9.4 number
nvln无取不到值时输出的兜底文本(只有 n 一个别名)
min—无下限,结果小于它时按它输出(没有别名,别写 mn)
max—无上限(没有别名)
scales无保留小数位(与 format 二选一)
roundr无舍入模式(BigDecimal 常量,4 = 四舍五入)。只有配 scale 时才起作用
def—无累加初始值。源码里只解析、从不使用(注释写着「预留」),写了没有任何效果
合计:<aol:sum data="${明细}" p="金额" format="#,##0.00"/>
数量:<aol:sum data="${明细}" p="数量" nvl="0"/>
纯数字列表:<aol:sum data="${数字列表}"/>

<aol:avg> 平均值

属性别名默认说明
datad无必须是 DataSet(List<Map> 也可以,其它集合不行)
propertyp无要平均的字段名(不写则取不到值,输出为空)
scales2小数位
roundr4舍入模式(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> 取最大 / 最小的那一行

属性别名默认说明
datad无必须是 DataSet
propertyp无按哪个字段比大小
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 都真实生效。

属性别名默认说明
typetsum算法,见下表。大小写不敏感,且会自动去掉 _DECIMAL/_DOUBLE 之类后缀
propertyp无参与计算的字段;不写则把元素本身当数字
formatf无输出格式(同 number)
scales见说明小数位。sum/max/min/count 不写则保持原精度;avg/median/stdev/var 不写则按 10 位输出
roundr四舍五入舍入模式(BigDecimal 常量,4 = 四舍五入)
nvln无兜底文本(无数据 / 结果为 null 时输出)
min / max—无对计算结果做上下限截断(不是用来选最大/最小值的,那要用 type="max")
emptyinfalse空值是否当 0 参与运算。为 true 时:count 直接把集合长度当结果、avg 的分母用总条数、其它算法把 null 当 0
data 系列见 8.1begin/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 / 上下限
平均、最大、最小aggavg/max/min 只认 DataSet 且不生效 format
要拿到「整行」去取别的列max / min + varagg 只返回值
计数 / 中位数 / 标准差 / 方差agg只有它支持
数据源是 List<数字> 或 List<Bean>agg / sumavg/max/min 会静默输出空

坑

  1. avg / max / min 只认 DataSet:数据源是别的集合类型时不报错、直接输出空,很容易误判为「数据没传进去」。
  2. 字段名要和数据里的 key 完全一致:DataSet 默认做了大小写归一(通常大写),如果 p="amount" 取不到就试 p="AMOUNT"。
  3. 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。
  4. avg / median / stdev / var 默认 10 位小数:不写 scale 时结果形如 34.3333333333,并且会去掉末尾多余的 0(实测 median 输出 2 而不是 2.0000000000)。报表上基本都要显式写 scale 或 format。
  5. number / date / money 的 value 没有 v 别名:只有 value 一个写法(有 v 别名的是 qr/bar/checkbox)。写成 v="..." 会取不到值,输出空。
  6. 空数据的表现要显式兜底:sum 无数据且没写 nvl 时输出空串,报表上就是一个空洞,建议统一写 nvl="0"。
  7. 先切数据再算:begin/end/qty/distinct 会真正影响计算结果(切片在计算之前完成),做「小计」时很实用,但别误以为只影响显示。

9.4 格式化类:number / money / date

这三个的共同点:它们不改变文档结构,只把一个值变成一段好看的文字, 所以也是少有的「在 Excel 单元格里也能用」的标签(number / date)。

<aol:number> 数字格式化

属性别名默认说明
formatf无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> 日期格式化

属性别名默认说明
formatf无SimpleDateFormat 模式,如 yyyy-MM-dd
value无标签体日期值/日期字符串;不写时取标签体。没有 v 别名(写 v= 会取不到值)
evl—false为 true 且值为空时,用当前时间(做「生成日期」很方便)
propertyp无对传入的行/数据集对象,就地格式化其某个日期字段
签章日期:<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)
valuev标签体要赋的值;不写时取标签体文本
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> 把一列拼成一个字符串

属性别名默认说明
propertyp无取哪一列
splits,连接符
datad无必须是 DataSet
客户:<aol:concat data="${明细}" p="客户" split="、"></aol:concat>
输出位置 concat 结果是替换到标签所在的段落(删掉标签体、写回文本), 所以放在表格单元格里就能得到「一列合并成一个格」的效果。

<aol:group> 按列分组

属性别名默认说明
var—无结果存到哪个变量(不写就没法用)
by—无分组字段,多个用逗号:by="部门,岗位"
extract—false布尔值。为 true 时每组的成员会从原集合中移除(做「先处理一组、再处理下一组」的分批场景用)
datad无必须是 DataSet
<aol:group data="${订单}" var="按客户" by="客户"/>
<aol:group data="${订单}" var="按部门岗位" by="部门,岗位" extract="true"/>

group 自己不输出可见文本,只是把 DataSet.group(...) 的结果存进变量: 结果本身还是一个 DataSet——每组一行,组内明细挂在嵌套字段里, 配合 for 就能做「一个客户一段」的渲染。

<aol:pivot> 行转列(透视)

属性别名默认说明
datad items is无必须是 DataSet(长表)
pkpks primary无唯一标识 key(如 姓名),多个用逗号:pk="编号,姓名"
classclassKey classKeys ck by无分类 key(如 科目),透视后作为新列名,多个用逗号
valuevalueKey valueKeys vk无取值 key(如 分数);不写时把整行作为值
var—无结果存到哪个变量(不写则什么都不做)
extract—true是否删除 pk 之外的其他列
sepseparator-新列名分隔符。DataSet.pivot 固定用 - 拼接(如 2010-数学),用 sep="_" 可改成 2010_数学 便于占位符访问
classVarcolsVar 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 或纯文本。 常用于调查表、审批单、合同勾选项。

属性表

属性别名默认说明
datad is无选项数据集,必须是 Collection<Map>(如 DataSet)
valuev无已选中的值。可以是单个值,也可以是集合(多选)
rely—依次回退到 property → valueKey不参与「值比对」,只用于两件事:① 判断该字段的值是否为字符串 "true"/"checked"(或空串);② 当 value 是「Map 的集合」时,从每个 Map 里取哪个字段来组成待比对的值列表
valueKeyvk主键列名选项的值取哪个字段,真正和 value 比对的就是这一列
textKeytkNM选项显示的文本取哪个字段。支持 {字段名} 运行时模板,如 tk="{NAME}({CODE})"
typetcheckboxcheckbox 输出 ☑/☐;text 只输出文本
vol—0每行显示几个选项,0 表示一行到底
splits空选项之间的分隔符(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\"]}"));

选中判定规则(按源码顺序)

对每个选项行依次判断,只要满足下面任意一条就显示 ☑:

  1. rely 字段(不写 rely 时回退到 valueKey)的值为空字符串;
  2. rely 字段的值是字符串 "true" 或 "checked"(不受 value 影响,可用于「数据里直接带勾选状态」的场景);
  3. 该选项 valueKey 列的值 equals value;多选时 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 决定。

坑

  1. data 不是集合就直接返回:传单个 DataRow / Map 时什么都不输出,也不报错。
  2. 值比对字段是 valueKey,不是 rely:不写 vk 时默认拿「主键列」去和 value 比;选项数据里主键列名不是默认主键时,务必显式写 vk="CODE"(很多模板只写了 rely,于是永远勾不上)。
  3. rely 字段值为空串会「白送」一个勾:源码第一条判断就是 "".equals(rely字段值),所以选项行里被用来做 rely 的那一列如果是空值,该项会被判为选中。
  4. 源码里还有 label / headText / headValue 字段,但当前没有从模板读取,所以「自定义选项 HTML」和「头部默认选项」暂时用不了,只能靠 textKey 的 {字段} 模板来定制文本。
  5. vol 只是插 <br/>:换行靠 HTML 解析成段落,效果受模板段落样式影响,想要严格网格请用表格。

9.7 merge(单元格合并)

<aol:merge>

干什么:把「值相同的相邻单元格」合并成一个。 这是报表里最容易做错、也最容易被这个标签救回来的需求。

它什么时候执行:这是它最关键的设计。merge 在 run() 阶段只登记「哪个表格、哪一列、模板行在哪」, 真正的合并放到所有标签(尤其是 for)跑完之后, 由 WDocument.executeMerges() 统一执行。 所以你不需要操心「数据还没填完就合并了」这个问题。

属性表

属性别名默认说明
scopestctc/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 要写在被循环的模板行里,不要写在表头行; 表头行不参与合并(合并扫描从模板行的上一行开始)。

坑

  1. 合并后的空单元格样式会「继承第一个」:Word 只保留第一个单元格的边框/底纹,跨页显示时建议手动检查。
  2. 空值默认不合并:值为空(null 或空串)时会被跳过,所以「连续几个空值合并成一个大格」不会发生;想合并请把它们统一成某个标记值,再靠 ignore 排除真正不该合并的。
  3. by 用文本匹配时只看首行:匹配失败(找不到列)时不报错,等价于没有约束,会「多合并」。
  4. 横向合并从右往左扫描:这是为了避免删单元格造成下标错位,理解这点有助于排查「合并结果少了一格」。
  5. 必须写在表格里:标签不在 <table> 内(或不在 tc 里)时直接 return,静默失效。

9.8 富文本与图形:img / html / qr / bar

<aol:img> 插入图片

干什么:把标签本身(连同 src、style)当作一段 HTML 的 <img> 交给 HTML 渲染器,插入到当前位置。

属性说明
src图片地址。支持 file: 本地路径、url:/http(s): 远程地址,也支持 ${} 占位符
styleCSS 样式,常用 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;"/>
远程图片需要 Downloader 源码不给默认下载器:没调用 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> 二维码 / 条形码

属性别名默认说明
valuev标签体要编码的内容(网址、单号、文本都行)
style—无CSS,从中读 width/height(px 会被去掉)
widthw100像素宽
heighth100像素高
<aol:qr value="https://anyline.org" style="width:120px;height:120px;"/>
<aol:bar value="${order.NO}" width="200" height="60"/>
三个必须知道的前提
  1. 必须注入 Imager:doc.setImager(...)。 它是本库的一个接口(qr(content,w,h) / bar(content,w,h)), 本库不提供默认实现;没注入时标签被静默移除,不留任何图。
  2. 生成的是临时图片文件,插入后立即删除,所以不要依赖那个文件路径。
  3. 条形码内容建议纯数字/大写字母;不同 Imager 实现对字符集限制不同。

9.9 page(分页符)

<aol:page>

属性默认说明
positionafter分页符位置。当前只有 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。

属性表

属性别名默认说明
typet chartTypepie图表类型,见下表。不做校验,未知类型会静默降级成饼图
datad items is无数据源。必须是「整个属性就是一个 ${变量}」才会被求值,否则当字符串处理
titlet name无图表标题
categoryKeyck catKeyname分类(X 轴 / 扇区标签)取哪个字段
valueKeyvk valKeyvalue数值取哪个字段
textKeytkname文本字段(源码中已解析,当前未参与取数)
sizeKeysk bubbleKey无气泡大小取哪个字段(仅气泡图)
holeSizehs50圆环图中心孔洞百分比(仅 doughnut)
widthw约 13.9cm宽,单位是 EMU(1px ≈ 9525 EMU)。默认 5274310
heighth约 8.1cm高,单位 EMU。默认 3076575
dimensiondim2D2D / 3D,大小写不敏感
shapesbox3D 柱形形状:box / cone / cylinder / pyramid(仅 3D 柱状/条形生效)
wireframewffalse曲面图是否画线框(仅 2D 曲面图)
复合饼图(type="ofPie" 或饼图 + split)专用
split-type—无非空时才启用复合饼图(pos / val / percent / custom)
bystpos分割依据
split-possp3d分割阈值
split-second-sizesps75第二张子图大小百分比
split-gapgw150主图与子图间距

上面这些 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 会覆盖标签属性 ChartData 上的 title(仅当标签 title 为空时)、 type、width、height、dimension、 shape、split、wireframe 会覆盖标签上的同名设置。 想「模板说了算」就别在 ChartData 上设这些。

坑

  1. 数据不是集合就静默删掉标签:传单个 DataRow / Map 时,图表标签被移除,不报错。必须包成 List / DataSet。
  2. data 属性必须是「完整的一个 ${}」:data="前缀${x}" 不会被求值。
  3. 宽度高度单位是 EMU,不是像素:写 width="600" 会得到一张几乎看不见的图。要么写全 EMU(600*9525),要么用 ChartData.width("600px")。Excel 里相反:width/height 就是 px(1px = 9525 EMU),写 width="400" 即 400px。
  4. 未知 type 静默变饼图:拼错类型不会报错,只会发现「画出来是饼图」。
  5. 重复 save() 会累积图表部件:每次 save() 都会追加新的 chartN.xml 与关系项,反复保存同一个模板文件会越滚越大。
  6. Excel 里也能用:写在单元格里即可,引擎会生成 xl/charts/chartN.xml 并额外建一个隐藏工作表承载数据(图表与单元格联动),详见 11.6。
  7. 无法自定义图例位置/坐标轴标题:图例固定在右侧,轴标题、数据标签、配色等都取默认值。

9.11 mark 与「写了没用」的标签

<aol:mark> 插入书签

属性别名默认说明
namen无书签名称
<aol:mark name="合同金额">${amount}</aol:mark>

它会在标签体内容前后插入 bookmarkStart / bookmarkEnd, 书签 id 自动取「文档中已有书签 id 的最大值 + 1」。 用途:给后续的「按书签替换内容」「按书签定位表格」提供锚点。

注意 标签本身的 head / foot 元素在插入书签后会被移除,所以 <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)。

属性

属性别名默认说明
datad items is无(必填)数据源,必须是集合(DataSet / List / JSON 数组串)
dirdirectionhh 行式;v(也认 col / 转置 / 对调)列式
layoutmodeitem仅列式:item 项沿行每项 K 行 / block(也认 blk / axis / 块)轴值横向块
axis—自动推断轴字段名,对应数据行(列式是轴头行)里的 ${样品}。不写则取首个含占位符的格;都不写按「虚拟单轴」处理
typeFieldkindField kind无值类型字段名,逗号分隔可写多列。不写按「单值类型」处理
volv行式:项名格数;列式:1行式=每块放几个检测项(超出会分块,组名格纵向合并);列式=每行最多几个轴块
typestype t无值类型序列(逗号分隔),表头行文本拿不到类型时用它
phplaceholder(无检测项)块内项位不足时的占位文本
missmissing空串该(项, 轴, 类型)没有记录时的填充文本
empty—showhide 时整行无值就跳过(仅行式支持,列式会 warn 后按 show)
scope—全量并集group 时每组只用自己的轴值(仅行式)
order—none轴值排序 none/asc/desc,自然比较("10月"排在"9月"之后)
mergespantrue组名格纵向合并(仅行式且「组名列 + 轴值列」双列布局生效)
split—truefalse 时给生成的行加 cantSplit(不允许跨页断行)

数据规则

  1. 检测项为空的记录跳过并 warn;轴值 / 值类型为空时,已声明对应字段的记录跳过并 warn,未声明的按单轴 / 单类型处理。
  2. 同名(组+项+轴+类型)重复的记录,后值覆盖前值并 warn。
  3. 数据为空时整个模板区(名称行 / 表头行 / 数据行)会被删除。
最容易踩的坑
  1. 值类型数 K 拿不到 = 直接不生成:表头行文本、types 属性、数据里的类型顺序三者都取不到时,只打 error 就返回,表格原样不动。
  2. 值格没写占位符就不填:值区每个格必须写 ${结果} 这类占位符,漏写的格保持模板原样(不会报错)。
  3. 值区宽度不够:数据行值区宽度 < K 时直接 error 返回;vol 超出模板容量会被下调并 warn。
  4. 名称行与数据行列结构要对齐:不一致时会 warn「名称行项区跨列总数与数据行值区宽度不一致」,项名和值会错位。
  5. 模板区不能嵌套其它标签:模板区里再写 <aol:xxx> 不会被解析(只 warn 一次)。
  6. 数据行第 1 格就是轴值(没有单独的组名列)时,组名纵向合并不可用,merge 被忽略并 warn。

9.13 全标签属性总表(一站式速查)

前面的卡片是「按需求讲」,这一节是「按属性查」—— 把源码里 真正被解析的每一个属性都列出来,包括官方一路没写进文档、 以及解析了却不生效的那几个。查表规则:

  • 「别名」栏:写哪个都行,按 fetchAttributeString 里列出的先后顺序取第一个出现的;
  • 「必填」栏:是 = 不写就完全不工作;否 = 有默认行为;
  • 「生效」栏:生效 / 不生效, 「不生效」的别浪费时间试。
两类容易误判的属性 ① 属性名区分大小写,valueKey 和 valuekey 是两个属性; ② 下表「不生效」的属性不会报错,写了就像没写,最容易被当成「引擎有 bug」。

共有的「取数 + 切片」属性

下面这些由基类 AbstractTag 统一解析,凡是从集合取数的标签都认(for / sum / avg / max / min / agg / concat / group / checkbox / chart):

属性别名默认说明
datad items is无数据源。${变量} 取值;[{...}] 解析成 DataSet;a,b,c 解析成列表
propertyp无处理的列名,可用逗号写多列 p="A,B"
beginstart b0起始下标
ende末尾结束下标(含)
qtyq无最多取几条
indexi无只取指定下标的一条(变成单条记录,所以 for 里绝不能用)
selectorst无只保留指定列
distinctds无按指定列去重(逗号分隔)
formatDatefd无先按该格式把 property 字段转成字符串(仅 DataSet)
var—无把结果写进变量而非直接输出
valueKeyvk主键列「值」取哪一列(checkbox / chart)
textKeytkNM「显示文本」取哪一列(checkbox)
pre / ref—无片段注册 / 引用(见 6.6)

各标签的「专属属性」逐条清单

标签属性别名必填默认含义 / 生效情况
for var—否无迭代变量名(不写则循环体里没有「当前行」变量)
statuss否无状态变量名,提供 index/count/prev/next
scopesp否bodybody/tc/td/tr/table
beginstart b否0起始下标
ende否末尾结束下标(含);无 items 时变成计数循环
step—否1步长
fill—否无把 end 撑到该值(是结束下标,不是行数)
flattenflat fl否false把当前行列平铺进上下文
for(无效) vol—否无不生效 已解析,无任何使用
align—否false不生效 同上
for remove—否true无数据时是否删模板;有数据时源码强制改回 true
if test—是无判断表达式,可写 true/false 或 OGNL,也可带 ${} 外壳
valuev否无为真时输出;写了就不再执行标签体
else—否""为假时输出;不写则删掉标签体
var—否无把布尔结果存成变量,不输出文字
removerm否false不生效 当前会抛 NPE(见 9.2)
scope—否tc不生效 同上
sum propertyp否无累加列;不写则累加元素本身(元素必须是数字)
formatf否无输出格式
nvln否无无值兜底(无 nv 别名)
min—否无结果下限(无别名)
max—否无结果上限(无别名)
scales否无小数位
roundr否无舍入模式,需先有 scale
def—否无不生效 源码注释即「预留」
avg propertyp是无要平均的列(不写取不到值)
scales否2小数位
roundr否4舍入模式
format—否无不生效 读了但没赋给成员变量
max / min propertyp是无按哪一列比大小
var—否无写:存「整行」;不写:只输出该列值
无 format / scale / nvl,且数据源必须是 DataSet,否则输出空
agg typet否sum算法,大小写不敏感,自动去 _DECIMAL 等后缀
propertyp否无参与计算的列;不写则元素本身当数字
formatf否无输出格式(真的生效)
scales否见说明sum/max/min/count 默认保原精度;avg/median/stdev/var 默认 10 位
roundr否四舍五入舍入模式
nvln否无无数据 / 结果为 null 时的兜底
min—否无对计算结果下限截断(不是选最小值)
max—否无对计算结果上限截断
emptyin否false空值是否当 0;count 时直接把集合长度当结果
number formatf否无不写则原样输出(看起来像「没格式化」)。值用 value(无别名)或标签体
money value无否标签体金额转大写。只有 value,无 v;内部 parseDouble,带千分位会截断
date formatf否无SimpleDateFormat 模式
value无否标签体无 v 别名(写 v= 取不到)
evl—否false为 true 且值为空时用当前时间
propertyp否无半生效 会就地改对象字段,但最终输出的仍是对象本身,别指望它输出格式化后的日期
set var—是无不写就什么都不做
valuev否标签体类型自动推断(布尔 / 数字 / 字符串)
select 空实现 无任何属性生效,标签文本会原样留在文档里
concat splits否,连接符
取数列用通用属性 property(p),数据源必须是 DataSet
group by—是无分组列,逗号写多列
var—是无结果变量名(不写就没法用)
extract—否false为 true 时组内成员从原集合移除
checkbox datad is是无选项集合,必须是 Collection<Map>
valuev否无已选值,单值或集合
rely—否回退 property→valueKey不参与值比对,只用于「是否为 true/checked/空串」判断
valueKeyvk否主键列真正和 value 比对的列
textKeytk否NM显示文本列,支持 {列名} 模板
typet否checkboxcheckbox / text
vol / splits(仅 split)否0 / 空每行几个 / 分隔符
merge scopes否tctc/td 纵向 · tr 横向
by—否无参考列(下标或首行文本)
ignore—否无忽略值,逗号分隔
img src / style / type—src 是无整段当 HTML 的 <img> 交给渲染器,三个属性都支持 ${}
html 无属性,内容写在标签体里;先跑一遍标签/占位符,再当 HTML 插入
qr / bar valuev否标签体编码内容
style / width/w / height/h—否100/100尺寸,style 里的 px 会被去掉
page position—否after仅 after 生效,before 渲染成空 div
mark namen是无书签名称
chart typet chartType否pie图表类型,拼错静默降级成饼图
titlet name否无标题(t 与 type 冲突,别用别名)
datad items is否无必须是「完整的 ${变量}」或 JSON 字面量
categoryKeyck catKey否name分类字段
valueKeyvk valKey否value数值字段
textKeytk否name不生效 已解析但未参与取数
sizeKeysk bubbleKey否无气泡大小字段(仅气泡图)
holeSizehs否50圆环孔洞百分比
widthw否5274310宽,单位 EMU(1px≈9525)
heighth否3076575高,单位 EMU
dimensiondim d否2D2D/3D(d 与 data 冲突)
shapes否box3D 柱形形状
wireframewf否false曲面图线框
split-type—否无非空才启用复合饼图
by / split-posst / sp否pos / 3d分割依据 / 阈值
split-second-sizesps否75第二子图大小
split-gapgw否150主图与子图间距

子标签:<aol:split>

复合饼图的参数也可以写成 chart 标签体里的子标签,属性与上表后半段一一对应:

子标签属性对应父属性说明
typesplit-typepos / val / percent / custom
byby分割依据
possplit-pos阈值
secondSizesplit-second-size第二子图大小
gapsplit-gap间距
子标签只在「父标签没写同名属性」时生效 源码会先读父标签的 split-* 属性;只有父标签没写、且标签体里出现了 <aol:split> 时,才去解析子标签。 两者同时写时父属性优先。

「解析了却不生效」清单(照这个避坑)

属性所属标签实际情况
vol / alignfor解析后无任何引用,写了等于没写
defsum源码注释就是「预留」,从不参与计算
formatavg读了但没赋值给成员变量,输出不带格式
remove / scopeif执行顺序问题导致必然抛 NullPointerException(被吞)
position(除 after)pageHTML 渲染器只识别 page-break-after
textKeychart解析了,但取数时未使用
propertydate会改对象字段,但输出的是对象本身,不是格式化结果
一句话总结 真正「想怎么用就怎么用」的标签只有 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:...> 合并回来,绝大多数情况能自动救回来。

但它救不了下面这些情况,请从源头避免:

  1. 占位符跨段落:${ 在上一段、} 在下一段 —— 无解,请保证一个 ${} 在一个段落内。
  2. 标签跨段落:同理,<aol:for ...> 的尖括号也必须在同一段落内(标签体可以跨段落,标签本身的 <> 不行)。
  3. 占位符里有换行/软回车:写 ${a+Shift+Enter+b} 会解析失败。
  4. 中文输入法中途切换:容易把 $ 输成全角。请确认是半角 $ { }。
  5. 把 ${} 拆开后再改字体:改字体是最容易造成拆分的操作,建议先写占位符、最后统一改格式,或者干脆用「全选 → 清除格式」重来。
验证方法 把模板后缀改成 .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

核心原则 引擎只负责把内容放到正确的位置,不负责排版。 字体、字号、行距、表格边框、底纹、缩进、页边距 —— 全部在 Word 里用样式设计好。 想批量改样式,就改 Word 的「样式」(Styles)面板,别指望用标签控制。

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-alignsuperscript / subscript
font-weight / font-family / font-size 等字体相关(由 StyleUtil.font 处理)
border-*边框(由 StyleUtil.border 处理)
list-style-type / list-lvl / numFmt列表样式与层级
page-size-orient横向 / 纵向(landscape 之外都算纵向)
HTML 不是网页 不要指望 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 的 ${} 不会被拆散 Excel 的富文本虽然也会把一段文字拆成多个 <r>,但引擎有专门的 mergePlaceholder() 自动合并,所以 Excel 模板比 Word 模板省心。 唯一要求:${} 要写在同一个单元格里。

11.3 单元格里能用的标签

这是最容易掉进去的坑 Excel 的单元格处理走的是 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() 统一合并,所以不用担心「数据还没填完就合并了」。

属性默认说明
scopetctc/td = 纵向(同列),tr = 横向(同行)
by无参考列:只有当该列的值在合并范围内也相同时才合并
ignore无忽略值(逗号分隔),这些值不参与比较

11.6 图表(xlsx 版 chart)

<!-- 写在单元格里(和 ${} 一样,必须写在同一个单元格内) -->
<aol:chart type="column" title="销售分布" data="${sales}"
           categoryKey="name" valueKey="value" width="400" height="300"/>

引擎按顺序做四件事:

  1. 生成图表部件 xl/charts/chartN.xml(序号与模板里已有的图表连续,不会覆盖);
  2. 新建一个隐藏工作表(ChartData1、ChartData2…)把图表数据写进去;
  3. 在工作表关联的 xl/drawings/drawingN.xml 里加 xdr:graphicFrame,锚定到标签所在单元格;
  4. 补齐 [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 等属性);图表配色 / 图例位置取默认值
和 docx 的区别 docx 里图表是正文段落里的 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 已知的「静默失败」(最需要小心)

这些情况不会抛异常
  1. 标签名/命名空间写错 → 标签文本原样留在文档里;
  2. avg/max/min 的数据源不是 DataSet → 输出空;
  3. 只实现 run() 的标签写在 Excel 单元格里 → 无任何效果;
  4. if 表达式异常 → 走 else 分支;
  5. chart 的 data 不是集合 / type 拼错 → 标签被移除或降级成饼图;
  6. merge 不在表格内 / by 匹配失败 → 不合并;
  7. 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}随机数 / 时间戳注册名重复,实现不确定,不要依赖
实践建议 凡是有业务含义的「动态值」(流水号、唯一 ID、随机码), 一律在 Java 侧算好再 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();                    // 清理临时文件
    }
}
文档到此结束 建议的阅读顺序:第 1 章跑通 → 第 5 章搞懂 ${} → 第 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" 应该能取到 AMOUNTDataSet 列名做了归一(通常大写)字段名与数据里的 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/dimensiontype、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 打开一次验收
把这一节当「反直觉清单」收藏 遇到「明明写对了却没效果」,先在这里扫一遍——大概率命中其中一条; 没命中的话,就去 第 12 章按「症状 → 原因」表排查。

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 时最简单
DataSetDataSet 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 或 DataSet 都可以
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 整行展开 + 格式化
建议的上手顺序 先跑通示例 1 + 示例 2(覆盖 80% 的报表需求), 再按业务需要加 示例 3 / 4 / 5; 示例 6~11 属于「用到再抄」。 每个示例都刻意避开了第 17 章列出的那些坑,直接照抄即可。
回到顶部