Apache POI 导出 Word 踩坑实录:Word 分栏为什么做不好左右平铺

Apache POI 导出 Word 踩坑实录:Word 分栏为什么做不好左右平铺?

文章目录:

前言

最近做一个 Word 导出需求:一页内容左右排布,每行展示两个数据块,数据条数不固定

理想效果如下:

text 复制代码
设备A    设备B
设备C    设备D
设备E    设备F

一开始我以为这就是 Word 的"双栏"场景,直接用分栏即可。

真正实践后才发现:

Word 分栏适合文章排版,不适合业务数据平铺。

如果你也在用 Java 导出 Word,并且遇到"左右分栏平铺"的需求,这篇文章可以帮你少踩不少坑。


一、需求本质:要的是网格,不是分栏

业务期望的是这种布局:

text 复制代码
1 | 2
3 | 4
5 | 6

也就是说:

  • 第 1 条放左边
  • 第 2 条放右边
  • 第 3 条换到下一行左边
  • 第 4 条换到下一行右边

这本质上是 二维网格布局

但 Word 的"分栏"并不是按行排,它是 流式排版,逻辑更像报纸:

  • 先填满左栏
  • 左栏满了再流到右栏
  • 不保证同一行左右对应

所以结论很明确:

Word 分栏 ≠ 左右平铺


二、为什么直接用 Word 分栏不靠谱?

1)POI 对分栏支持并不友好

XWPFDocumentXWPFParagraph 这些常用 API 里,你基本找不到直接设置"双栏"的高层方法。

如果一定要做,只能下钻到底层 XML,操作 CTSectPrCTColumns

例如:

java 复制代码
CTSectPr sectPr = document.getDocument().getBody().isSetSectPr()
        ? document.getDocument().getBody().getSectPr()
        : document.getDocument().getBody().addNewSectPr();

CTColumns cols = sectPr.isSetCols() ? sectPr.getCols() : sectPr.addNewCols();
cols.setNum(BigInteger.valueOf(2));
cols.setSpace(BigInteger.valueOf(720));

看起来好像设置成功了,但这只是告诉 Word:这是双栏结构

它并不会帮你实现"左右配对平铺"。


2)双栏是流式的,不是左右配对的

你以为它会排成:

text 复制代码
A | B
C | D

实际上更可能是:

text 复制代码
A
B
C
D
...
左栏排满后才进入右栏

这就是最核心的问题:

业务要的是"同行左右对应",Word 给的是"内容自动流动"。


3)内容高度不一致时,布局很容易失衡

真实业务里,数据块很少完全一样高:

  • 标题长短不同
  • 文本行数不同
  • 某些块带图片、二维码
  • 字段可能换行

一旦左右内容高度不一致,视觉上就会非常乱。

左边可能很长,右边很短,整页看起来不整齐。


4)分页控制难,兼容性也不稳定

当你把 分栏 + 动态内容 + 分页 放在一起时,问题会更多:

  • 内容被拆到下一页
  • 一页留大块空白
  • 左右栏分页不同步
  • Word 和 WPS 显示效果不完全一致

如果还涉及打印场景,维护成本会更高。


5)绝对定位不适合动态数据

有些人会想到用文本框、浮动对象、绝对坐标来排版。

理论上可行,但不适合动态列表场景。

因为它会涉及:

  • 锚点
  • 坐标偏移
  • 文本环绕
  • 换页行为
  • 软件兼容性

如果不是固定模板、固定数据量,一般不建议这样做。


三、最终稳定方案:用 2 列表格模拟"左右平铺"

踩完坑之后,最终最稳的方案其实很简单:

不要真用 Word 分栏,而是用 2 列表格模拟左右平铺。

实现方式:

  • 每次取 2 条数据
  • 生成 1 行 2 列
  • 左单元格放第 1 条
  • 右单元格放第 2 条
  • 如果是奇数条,最后一个单元格留空

这样天然就是:

text 复制代码
A | B
C | D
E | F

这才是真正符合业务语义的布局。


四、为什么表格方案更稳?

因为这个需求本质上就是 网格布局,而 Word 里最稳定的网格容器就是表格。

表格方案的优势

1. 天然支持左右成对展示

它本来就是行列结构,不是流式布局。

2. 内容高度不一致也不会跑位

左右内容在同一行,整体更稳定。

3. 分页更可控

你可以按页生成表格,而不是交给 Word 自动处理。

4. Word / WPS 兼容性更好

相比浮动对象和复杂分栏,表格通常更稳定。


五、实现思路

核心思路只有一句话:

不要让 Word 自己排"分栏",而是你自己按"2 列 1 行"喂数据。

流程如下:

  1. 创建一个 2 列表格
  2. 设置固定布局,防止列宽被内容撑开
  3. 每两条数据生成一行
  4. 左右单元格分别渲染卡片内容
  5. 奇数条时,最后一个右单元格置空

六、Maven 依赖

xml 复制代码
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>

七、完整示例代码

java 复制代码
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.*;

import java.io.FileOutputStream;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.List;

public class WordTileDemo {

    // A4 纸张大小(twips)
    private static final int A4_WIDTH = 11906;
    private static final int A4_HEIGHT = 16838;
    private static final int MARGIN = 720;
    private static final int PAGE_WIDTH = A4_WIDTH - MARGIN * 2;
    private static final int COL_WIDTH = PAGE_WIDTH / 2;

    public static void main(String[] args) throws Exception {
        List<CardData> list = Arrays.asList(
                new CardData("设备A", "温室1", "在线", "25.6℃"),
                new CardData("设备B", "温室2", "离线", "23.1℃"),
                new CardData("设备C", "温室3", "在线", "24.8℃"),
                new CardData("设备D", "温室4", "在线", "26.0℃"),
                new CardData("设备E", "温室5", "维护中", "22.5℃")
        );

        try (XWPFDocument document = new XWPFDocument()) {
            setupPage(document);

            XWPFTable table = createTileTable(document);

            int rowIndex = 0;
            for (int i = 0; i < list.size(); i += 2) {
                XWPFTableRow row = rowIndex == 0 ? table.getRow(0) : table.createRow();
                row.setCantSplitRow(true);

                XWPFTableCell leftCell = row.getCell(0);
                XWPFTableCell rightCell = row.getCell(1);

                setCellWidth(leftCell, COL_WIDTH);
                setCellWidth(rightCell, COL_WIDTH);

                renderCard(leftCell, list.get(i));

                if (i + 1 < list.size()) {
                    renderCard(rightCell, list.get(i + 1));
                } else {
                    clearCell(rightCell);
                }

                rowIndex++;
            }

            try (FileOutputStream out = new FileOutputStream("左右平铺-demo.docx")) {
                document.write(out);
            }
        }
    }

    private static void setupPage(XWPFDocument document) {
        CTSectPr sectPr = document.getDocument().getBody().isSetSectPr()
                ? document.getDocument().getBody().getSectPr()
                : document.getDocument().getBody().addNewSectPr();

        CTPageSz pageSize = sectPr.isSetPgSz() ? sectPr.getPgSz() : sectPr.addNewPgSz();
        pageSize.setW(BigInteger.valueOf(A4_WIDTH));
        pageSize.setH(BigInteger.valueOf(A4_HEIGHT));

        CTPageMar pageMar = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar();
        pageMar.setTop(BigInteger.valueOf(MARGIN));
        pageMar.setBottom(BigInteger.valueOf(MARGIN));
        pageMar.setLeft(BigInteger.valueOf(MARGIN));
        pageMar.setRight(BigInteger.valueOf(MARGIN));
    }

    private static XWPFTable createTileTable(XWPFDocument document) {
        XWPFTable table = document.createTable(1, 2);

        CTTblPr tblPr = table.getCTTbl().getTblPr();
        if (tblPr == null) {
            tblPr = table.getCTTbl().addNewTblPr();
        }

        // 表格总宽度
        CTTblWidth tblWidth = tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW();
        tblWidth.setType(STTblWidth.DXA);
        tblWidth.setW(BigInteger.valueOf(PAGE_WIDTH));

        // 固定布局,防止内容撑乱列宽
        CTTblLayoutType layoutType = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout();
        layoutType.setType(STTblLayoutType.FIXED);

        // 去掉边框
        CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders();
        borders.addNewTop().setVal(STBorder.NONE);
        borders.addNewBottom().setVal(STBorder.NONE);
        borders.addNewLeft().setVal(STBorder.NONE);
        borders.addNewRight().setVal(STBorder.NONE);
        borders.addNewInsideH().setVal(STBorder.NONE);
        borders.addNewInsideV().setVal(STBorder.NONE);

        table.setCellMargins(80, 80, 80, 80);
        return table;
    }

    private static void setCellWidth(XWPFTableCell cell, int width) {
        CTTcPr tcPr = cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr()
                : cell.getCTTc().addNewTcPr();

        CTTblWidth tcW = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW();
        tcW.setType(STTblWidth.DXA);
        tcW.setW(BigInteger.valueOf(width));

        cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.TOP);
    }

    private static void renderCard(XWPFTableCell cell, CardData data) {
        clearCell(cell);

        XWPFParagraph title = cell.getParagraphs().get(0);
        title.setSpacingBefore(0);
        title.setSpacingAfter(80);

        XWPFRun titleRun = title.createRun();
        titleRun.setBold(true);
        titleRun.setFontSize(12);
        titleRun.setText(data.getName());

        addLine(cell, "区域:", data.getArea());
        addLine(cell, "状态:", data.getStatus());
        addLine(cell, "温度:", data.getTemp());
    }

    private static void addLine(XWPFTableCell cell, String label, String value) {
        XWPFParagraph p = cell.addParagraph();
        p.setSpacingBefore(0);
        p.setSpacingAfter(0);

        XWPFRun labelRun = p.createRun();
        labelRun.setBold(true);
        labelRun.setText(label);

        XWPFRun valueRun = p.createRun();
        valueRun.setText(value == null ? "" : value);
    }

    private static void clearCell(XWPFTableCell cell) {
        int size = cell.getParagraphs().size();
        for (int i = size - 1; i >= 0; i--) {
            cell.removeParagraph(i);
        }
        cell.addParagraph();
    }

    static class CardData {
        private final String name;
        private final String area;
        private final String status;
        private final String temp;

        public CardData(String name, String area, String status, String temp) {
            this.name = name;
            this.area = area;
            this.status = status;
            this.temp = temp;
        }

        public String getName() {
            return name;
        }

        public String getArea() {
            return area;
        }

        public String getStatus() {
            return status;
        }

        public String getTemp() {
            return temp;
        }
    }
}

八、几个很容易忽略的细节

1)一定要设置表格固定布局

这行代码非常关键:

java 复制代码
layoutType.setType(STTblLayoutType.FIXED);

如果不设置固定布局,Word 会根据内容自动调整列宽。

一旦某列出现长文本,整个表格就可能被撑变形。


2)单元格默认段落要先清掉

Word 单元格默认会自带一个段落。

如果不先清理,常见问题就是:

  • 顶部多空行
  • 内容不对齐
  • 间距异常

所以在渲染内容前,建议先执行:

java 复制代码
clearCell(cell);

3)段前段后间距尽量手动控制

Word 默认段落间距并不稳定,建议统一设置:

java 复制代码
paragraph.setSpacingBefore(0);
paragraph.setSpacingAfter(0);

标题如果需要留白,可以单独设置 spacingAfter


4)图片、二维码尺寸要统一

如果卡片里还有:

  • 二维码
  • logo
  • 设备图片

建议统一宽高,否则单元格高度会差异很大,排版效果容易变差。


5)如果要控制"每页固定几个块",建议按页生成表格

例如你想做到:

  • 每页固定 8 个块
  • 每页 4 行 2 列

最稳的做法不是在一张超长表格里强行分页,而是:

  1. 每页生成一个独立表格
  2. 填满后插入分页符
  3. 再生成下一页

这样稳定性会明显更好。


九、结论

这次实践之后,我的结论非常明确:

1. Word 的"分栏"不是"左右平铺"

它是流式排版,不是网格布局。

2. 动态内容场景下,真分栏很难稳定

尤其是内容高度不一致、还要分页时,控制成本很高。

3. 2 列表格才是更适合业务场景的方案

它天然符合"左右并排、按行展示"的需求。

4. 真正影响稳定性的关键点是这些

  • 固定表格布局
  • 固定列宽
  • 清理默认段落
  • 控制段落间距
  • 按页分表

十、最后一句

分栏是文章排版,表格才是业务布局。

如果你的需求是 Java 导出 Word,实现左右平铺、标签打印、卡片列表、二维码并排展示,优先考虑表格方案,通常会更稳,也更容易维护。

相关推荐
HashData酷克数据2 小时前
官宣:Apache Cloudberry (Incubating) 2.1.0 正式发布!
apache
唐青枫2 小时前
C#.NET MediatR 深入解析:进程内消息分发、CQRS、通知事件与管道行为实战
c#·.net
weixin_394758034 小时前
直播间小程序码生成问题修复代码清单
android·小程序·apache
njsgcs12 小时前
拆分多实体到装配体 solidworks c#
c#
何以解忧唯有撸码13 小时前
C# 视频录制监控系统
c#·winform
xiaoshuaishuai819 小时前
C# modbustcp的ack包通信延迟原因
网络·tcp/ip·c#
hixiong12320 小时前
使用C#自制一个截屏工具
c#
少控科技1 天前
小数典应用:小诗典
windows·c#