跨平台word生成方案poi-tl

5,062 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前言

工作中经常会遇到生成word这样的需求, 以前都是使用FreeMarker来生成的 我们先了解一下FreeMarker是如何生成word的

  • 制作好word模板
  • 转换为xml文档并格式化, 文件后缀改为ftl(这一步生成的xml格式可能会错乱...)
  • 在模板代码(上万行)中加各种逻辑代码来实现需求
  • FreeMarker模板引擎渲染生成word(doc格式的)
  • doc转docx

ps: word doc格式和docx格式的区别 doc为二进制文件, 非常大, 打开速度非常慢; docx为压缩文件, 非常小, 打开速度很快

上万行的xml代码简直是维护地狱, 要是这时候老板觉得这个word模板不太好看, 改改字体, 换下颜色, 调整表格样式等等, 除非你对word的xml结构特比特别熟悉, 否则你只能重复上面的步骤再来一次, 心态崩了!!!

11.png 有没有一种更容易维护的方式来生成word呢, 答案就是: POI-TL

poi-tl简介

poi-tl(poi template language)是基于Apache POI的Word模板引擎,纯Java组件,跨平台,代码短小精悍,通过插件机制使其具有高度扩展性。

注意: POI-TL可能会和项目中使用到xml相关操作的包产生冲突 建议将POI-TL单独写成一个服务, 包装需要使用到的相关特性以避免冲突

poi-tl使用

使用步骤十分简单:

  • 准备好word模板
  • 准备好数据 下面我们来试一下

maven

<!-- poi-tl -->
<dependency>
    <groupId>com.deepoove</groupId>
    <artifactId>poi-tl</artifactId>
    <version>1.12.0</version>
</dependency>
<!-- spire.doc -->
<dependency>
    <groupId>e-iceblue</groupId>
    <artifactId>spire.doc.free</artifactId>
    <version>3.9.0</version>
</dependency>
<!-- spring el -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-expression</artifactId>
    <version>5.3.18</version>
</dependency>

先看下我的模板

1d40ebfd42150acf776c3bf2b1c5012.jpg

这是生成后的word效果

da8ae6f9676249f060a1c17f2804732.jpg 我在模板中尝试了POI-TL的以下特性

  • 链接 97cba2a9f2cabe1de8ec4fecbd79de4.png
  • 图片 语法: {{@logo}} image.png
  • 表格 POI-TL表格中列加超链接没有找到相关的插件, 所以我扩展了LoopRowTableRenderPolicy插件, 实现了给表格中的列添加超链接的功能
/**
 * 表格内设置超链接
 */
@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
public class LoopRowTableHyperlinkRenderPolicy extends LoopRowTableRenderPolicy {

    /**
     * 列
     */
    private int hyperlinkIndex;
    /**
     * 超链接url key
     */
    private String hyperlinkName = "url";
    /**
     * 超链接text key
     */
    private String hyperlinkValue;

    public LoopRowTableHyperlinkRenderPolicy(int hyperlinkIndex, String hyperlinkValue) {
        this.hyperlinkIndex = hyperlinkIndex;
        this.hyperlinkValue = hyperlinkValue;
    }

    @Override
    protected void afterloop(XWPFTable table, Object data) {
        try {
            List<JSONObject> jsonObjectList = JSONArray.parseArray(JSON.toJSONString(data), JSONObject.class);
            List<XWPFTableRow> rows = table.getRows();
            // index从1开始, 跳过表头
            for (int i = 1; i < rows.size(); i++) {
                XWPFTableCell cell = rows.get(i).getCell(hyperlinkIndex);
                List<XWPFParagraph> paragraphs = cell.getParagraphs();
                for (XWPFParagraph paragraph : paragraphs) {
                    JSONObject js = jsonObjectList.get(i - 1);
                    String url = String.valueOf(js.get(hyperlinkName));
                    String value = String.valueOf(js.get(hyperlinkValue));
                    if (StrUtil.isBlank(url) || "null".equals(url)) {
                        // 不满足条件, 正常填充值
                        cell.setText(value);
                    } else {
                        // 满足条件, 设置超链接
                        setLink(url, value, paragraph);
                    }
                    // 居中
                    paragraph.setAlignment(ParagraphAlignment.CENTER);
                    paragraph.setVerticalAlignment(TextAlignment.CENTER);
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    private void setLink(String url, String text, XWPFParagraph e) {
        String id = e.getDocument().getPackagePart().addExternalRelationship(url,
                XWPFRelation.HYPERLINK.getRelation()).getId();
        CTHyperlink ctHyperlink = e.getCTP().addNewHyperlink();
        ctHyperlink.setId(id);
        CTText ctText = CTText.Factory.newInstance();
        // 设置值
        ctText.setStringValue(text);
        CTR ctr = CTR.Factory.newInstance();
        CTRPr rpr = ctr.addNewRPr();
        CTColor color = CTColor.Factory.newInstance();
        // 设置颜色
        color.setVal("0000FF");
        rpr.setColorArray(new CTColor[]{color});
        // 单下划线
        // rpr.addNewU().setVal(STUnderline.SINGLE);
        // 无下划线
        rpr.addNewU().setVal(STUnderline.NONE);
        ctr.setTArray(new CTText[]{ctText});
        ctHyperlink.setRArray(new CTR[]{ctr});
    }
}

image.png image.png

  • 柱状图/饼图(柱状图可以在模板中调整柱间距来改变柱的粗细) image.png
  • 嵌套 语法: {{+nested}}

sub.docx

这里是{{province}}
    {{city}} - {{district}}

image.png

  • 循环
{{?sections}}集合第[{{_index}}]个元素: {{name}}{{/sections}}

image.png

  • 区块对
{{?isEmpty}}
******数据不为null******
{{/}}
{{?isEmpty == null}}
******数据为null******
{{/}}
  • SpingEL表达式
{{new java.text.SimpleDateFormat('yyyy-MM-dd HH:mm:ss').format(time)}}

配置并生成文件

Configure config = Configure.builder()
        // isStrict = false , 关闭严格模式, 严格模式下key不存在会报错
        // 关闭后key不存在会被忽略
        .useSpringEL(false)
        // 表格绑定policy
        .bind("books", policy)
        .build();
XWPFTemplate template = XWPFTemplate.
        compile(getFile("", "template/template.docx"), config).render(data);
String path = OUT_PATH + IdUtil.fastSimpleUUID() + ".docx";
template.writeToFile(path);
private File getFile(String path, String name) throws IOException {
    InputStream input = this.getClass().getClassLoader().getResourceAsStream(path + name);
    File file = new File(name);
    FileUtils.copyInputStreamToFile(input, file);
    return file;
}

POI-TLTOCRenderPolicy插件还不太好使(Beta实验功能:目录,打开文档时会提示更新域) 这里我使用的是spire.doc来更新目录, 为了避免更新目录造成内容位置错乱, 我在模板文件的目录后加了一个分页符

/**
 * 更新目录
 *
 * @param source 源文件
 * @param dir    目录
 * @return 更新后文件
 */
public static File updateTOCBySpire(String source, String dir) {
    Document document = new Document(source);
    String path = newPath(dir);
    File file = new File(path);
    try {
        document.updateTableOfContents();
        document.saveToFile(path, FileFormat.Docx_2013);
    } finally {
        document.close();
    }
    return file;
}

注意spire.doc免费版的最多支持500个段落, 25个表格, 超过部分会被截断(一般情况下这个已经能够满足了)

也可以使用docx4j来更新目录(文档很多页会特别慢, 并且DTM ID可能被耗尽, 导致无法使用)

如果真的是文档特别大的话可以使用jacob(不能跨平台, 只能在windows系统下使用)或者使用付费版的spire.doc(可以跨平台的)