去年接了一个需求,数据库里的报表数据要生成 Word 文档发给用户,表格行数不固定、表头要合并、金额要分列、每段说明文字还要带不同格式。翻译过来,就是标题里这句话:用 Java 代码转 Word 文档,做动态生成和数据填充。那段时间我把 Apache POI 的 XWPF 从上到下摸了一遍,踩了不少坑,也整理出几条能直接“抄作业”的经验。这篇文章就从方案选型开始讲,一直讲到表格合并、列宽控制、真实数据源接入和生成后的常见问题,希望能帮正在做类似需求的你少走点弯路。
1. 动态生成 Word 别急着写代码:三种技术方案的取舍
1.1 先说清楚什么场景才需要“动态生成”
很多人一听到“Java 导出 Word”,第一反应是找模板、搞占位符。这确实是最常见的做法,但不能覆盖所有需求。拿我当时的场景来说:报表的明细行数完全由数据库查询结果决定,可能 3 行,也可能 3000 行;表头第一行需要跨列合并;某个字段为空时整行不显示;金额超过一定数字要加粗标红。这些逻辑如果用“模板 + 文本替换”来做,会非常痛苦,因为你得提前在模板里摆好表格行数,而实际上行数根本不确定。
所以,先判断需求的形态,再决定技术方案。我的经验是:如果内容是“填空式”的,位置固定、长短固定,用模板替换最省事;如果内容是“结构式”的,需要根据数据生成段落、行、合并单元格,那就要用代码动态构建文档结构,也就是标题里说的“动态生成”。
1.2 主流方案优缺点对比
市面上常见的 Java 生成 Word 方案,我列一个对比表,方便你选型:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Word 模板 + 书签/占位符替换(POI XWPF) | 样式在 Word 里做好,美观;代码量少 | 循环、条件渲染难做;模板维护是个坑 | 合同、通知、固定格式报告 |
| XML 模板 + Freemarker/Velocity | 支持循环和条件判断,纯文本模板易维护 | 要懂 docx 内部 XML 结构;样式丢失问题常见 | 复杂动态文档、大批量报表 |
| 纯 POI XWPF 代码生成 | 完全可控,适合动态行数和复杂合并 | 代码量大,样式设置繁琐 | 动态报表、数据导出、流程单据 |
| Aspose / Spire 等商业库 | API 友好、功能全、兼容性好 | 收费,开源项目有版权风险 | 企业级项目、有预算的情况 |
我那次需求最后用的是“纯 POI XWPF 代码生成”,原因就一条:表格行数、合并规则、段落增删全部是动态的,模板反而束缚手脚。如果你也遇到类似情况,别犹豫,直接用代码生成。
1.3 依赖选择:POI 版本别乱填
Apache POI 的版本更新很快,API 也有变化。我的建议是直接用 5.x 系列,比如 5.2.5,不要再用网上老教程里的 3.x、4.x。旧版很多方法已经被标记过时,而且对 JDK 版本的要求比较老。
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>注意 poi-ooxml 会自动依赖 poi、poi-ooxml-lite、xmlbeans 等模块,一般情况下不用手动加全。如果你的项目里还有老的 xmlbeans 依赖,一定要排除掉,否则运行时会出现NoClassDefFoundError之类的诡异问题。我见过有人因为版本冲突卡了整整一天,最后把 pom 里的旧 xmlbeans 排除才解决。
2. 先搞懂 XWPF 的对象模型,填数据才不会迷路
2.1 docx 本质上是一个 zip 包,XWPF 是它的内存视图
刚开始接触 POI 的人,容易把 XWPFDocument 当成一个“高级字符串”,以为往里面塞文本就行。其实 docx 文件本质上是一个 zip 压缩包,里面装着一堆 XML 文件:
word/document.xml:正文内容word/styles.xml:样式定义word/numbering.xml:编号定义
XWPF 做的事情,就是把document.xml解析成一组 Java 对象,你操作这些对象,最后再序列化成 docx。理解这一点很重要,因为很多诡异问题都出在“我明明设置了这个属性,为什么打开 Word 没生效?”——很可能是因为你操作的对象层级不对,或者某个属性在 XML 里被折叠了。
2.2 六个核心对象必须记牢
POI XWPF 的对象模型和 Word 文档结构是一一对应的:
| Word 概念 | POI 类 | 作用 |
|---|---|---|
| 文档 | XWPFDocument | 文档根对象,创建段落/表格入口 |
| 段落 | XWPFParagraph | 一个段落,包含多个 Run |
| 文本片段 | XWPFRun | 一段连续相同格式的文本 |
| 表格 | XWPFTable | 文档中的一张表 |
| 表格行 | XWPFTableRow | 表中的一行 |
| 单元格 | XWPFTableCell | 行中的一个格子 |
这里要特别强调 XWPFRun。在 Word 内部,不是“一个段落整段是一个文本对象”,而是“格式相同的一小段文本是一个 Run”。比如一段话前面是宋体黑色,后面是黑体红色,那它就至少是两个 Run。所以填充数据时,如果你希望某段文字是统一的格式,最好在一个 Run 上完成设置,不要随便 createRun 好几次。
2.3 最小可运行骨架
先把最简单的骨架跑通,后面再往里面填东西。这个骨架的本质是:创建文档对象、写入内容、输出到文件流。
import org.apache.poi.xwpf.usermodel.*; import java.io.FileOutputStream; public class WordDemo { public static void main(String[] args) throws Exception { // 1. 创建文档对象 XWPFDocument document = new XWPFDocument(); // 2. 创建一个段落 XWPFParagraph paragraph = document.createParagraph(); // 3. 创建一个 Run 并设置文本 XWPFRun run = paragraph.createRun(); run.setText("你好,动态 Word"); // 4. 输出到文件 try (FileOutputStream fos = new FileOutputStream("demo.docx")) { document.write(fos); } document.close(); } }这段代码生成的 demo.docx,用 Word 打开就能看到一行文字。虽然内容简单,但它验证了整个链路的正确性:依赖没问题、文档能创建、文件能打开。后面所有复杂功能都是在这个骨架上长出来的。
3. 段落级数据填充:字体、换行这些细节决定了文档好不好看
3.1 创建标题和正文段落要区分处理
动态生成 Word,第一个高频操作是写标题。标题通常居中、字号大、加粗;正文则两端对齐、首行缩进、行距固定。代码上,这些都是通过 XWPFParagraph 的属性和 XWPFRun 的字体属性控制的。
// 标题 XWPFParagraph titlePara = document.createParagraph(); titlePara.setAlignment(ParagraphAlignment.CENTER); titlePara.setSpacingAfter(200); // 段后间距,单位是 twips XWPFRun titleRun = titlePara.createRun(); titleRun.setText("2024 年度销售报表"); titleRun.setBold(true); titleRun.setFontSize(22); titleRun.setFontFamily("微软雅黑");// 正文 XWPFParagraph bodyPara = document.createParagraph(); bodyPara.setAlignment(ParagraphAlignment.BOTH); bodyPara.setFirstLineIndent(480); // 首行缩进 2 字符约 480 twips bodyPara.setSpacingLines(1.5); XWPFRun bodyRun = bodyPara.createRun(); bodyRun.setText("以下数据来自业务系统,统计周期为 2024 年 1 月至 12 月。"); bodyRun.setFontSize(12);这里的单位容易让人晕:setSpacingAfter(200)的单位是 twips(1 磅 = 20 twips),200 就是 10 磅;setFirstLineIndent(480)也是 twips,大约是 2 个小四号字符的宽度。第一次用的时候最好拿实际 Word 打开确认一下效果,心里就有谱了。
3.2 中文字体不生效?因为没设置 eastAsia 字体
很多人在 POI 中设置 FontFamily 后,发现中文字体变成默认宋体或者乱套。原因是run.setFontFamily("微软雅黑")设置的是 ASCII 字体的字体名,而中文文本走的是 East Asian 字体。正确做法是需要设置CTFonts的eastAsia属性:
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; CTRPr rpr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); CTFonts fonts = rpr.isSetRFonts() ? rpr.getRFonts() : rpr.addNewRFonts(); fonts.setEastAsia("微软雅黑");这段代码看起繁琐,但它是所有中文字体问题的根源。你也可以自己封装一个工具方法,把setFontFamily和setEastAsia一起调用,后面写起来就舒服很多。
3.3 换行用 addBreak,不要往 setText 里塞 “\n”
这是新手最容易踩的坑。你会发现:
run.setText("第一行\n第二行");生成的 Word 文档里根本没有换行,那两行文本连着显示。原因在于 docx 的 XML 文本节点不会把\n解释成换行。正确做法是:
run.setText("第一行"); run.addBreak(); run.setText("第二行");或者使用run.addBreak(BreakType.TEXT_WRAPPING),在需要分页时还可以用BreakType.PAGE。如果你的数据是数组或列表,循环调用即可,这和后面表格动态填充的思路是一样的。
3.4 动态拼接长文本时的格式统一问题
业务场景里经常出现这种需求:一段总结文字由好几个变量拼接而成,比如“本期新增客户 X 家,合同总额 Y 万元,同比增长 Z%”。如果直接用一个 Run 拼出来,格式统一没问题;但如果你想“数字加粗、其余正常”,就必须拆多个 Run。
XWPFRun r1 = para.createRun(); r1.setText("本期新增客户 "); r1.setFontSize(12); XWPFRun r2 = para.createRun(); r2.setText(String.valueOf(customerCount)); r2.setFontSize(12); r2.setBold(true); r2.setColor("FF0000"); XWPFRun r3 = para.createRun(); r3.setText(" 家。"); r3.setFontSize(12);这种写法在报表生成中非常常见。核心逻辑就是“不同的格式,开不同的 Run”。
4. 表格才是重头戏:动态行、合并单元格、列宽控制
4.1 先创建一个规则表格并设置整体宽度
Word 表格用 XWPFTable 表示。创建一张 N 行 M 列的表很简单:
XWPFTable table = document.createTable(5, 4);但创建出来之后直接写入文档,你会发现表格宽度、列宽、位置都不一定符合预期。原因很简单:POI 默认生成的表格没有显式设置布局方式,Word 打开时会按自动调整策略重新计算列宽。
要保证列宽按我们设计来,第一件事是设置表格总宽度和固定布局:
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; import java.math.BigInteger; CTTblPr tblPr = table.getCTTbl().getTblPr(); // 表格总宽度,单位是 twips;A4 纸可用宽度一般约 9000 twips CTTblWidth tblWidth = tblPr.addNewTblW(); tblWidth.setType(STTblWidth.DXA); tblWidth.setW(BigInteger.valueOf(9000)); // 设置固定布局,这一步很重要 CTTblLayoutType layout = tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED);设置了 fixed 布局之后,Word 就不会根据内容自动调整列宽了,所有列的宽度完全由我们后续设置的单元格宽度决定。
4.2 列宽设置:每个单元格都要设 tcW,别只设一列
我最初做表格时,只给第一列设置了宽度,结果 Word 打开后第一列是预设值,后面几列被自动均分,完全不是我想要的。排查了半天发现,POI 中列宽的设置是“单元格级”而不是“列级”的。也就是说,每一行的每一个单元格,都要显式设置tcW:
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcWidth; private void setCellWidth(XWPFTableCell cell, int widthTwips) { CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); CTTcWidth tcW = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); tcW.setType(STTblWidth.DXA); tcW.setW(BigInteger.valueOf(widthTwips)); }调用的时候,对每一行的每个单元格都要执行一次。虽然麻烦,但这才是可靠的方案。一篇 5 列的表格,每行 5 个单元格,循环设置即可,反正宽度值可以存在一个数组里。
还有一点要注意:所有列宽加起来最好小于等于表格总宽度。如果超过了,Word 会把表格撑出页面边缘;如果差得太多,右边会有一大块空白。我通常的做法是总宽度设 9000 twips(约 15.9 厘米),列宽数组加起来正好 9000。
4.3 动态插入行:createRow 不会复制样式
实际数据是动态的,表格不能只靠 createTable 时的固定行数,因为 createTable 创建的行是“无内容空行”,而且没有继承表头样式。动态行数的常规做法是:先给表格一个表头行,然后根据数据条数循环 createRow:
// 先创建表头(1 行 5 列) XWPFTable table = document.createTable(1, 5); XWPFTableRow headerRow = table.getRow(0); String[] headers = {"序号", "项目名称", "金额", "日期", "备注"}; for (int i = 0; i < headers.length; i++) { headerRow.getCell(i).setText(headers[i]); } // 动态追加数据行 for (int i = 0; i < dataList.size(); i++) { XWPFTableRow row = table.createRow(); // 这个行没有样式 row.getCell(0).setText(String.valueOf(i + 1)); row.getCell(1).setText(dataList.get(i).getName()); row.getCell(2).setText(dataList.get(i).getAmount()); row.getCell(3).setText(dataList.get(i).getDate()); row.getCell(4).setText(dataList.get(i).getRemark()); }坑就在createRow()这个方法:它生成的行是从“表格定义”里复制出来的空白行,不会复制你手动设置过的单元格字体、对齐、高度等样式。所以你在表头设置的格式,默认情况下跟数据行完全没关系。如果想统一格式,有两个办法:
一是循环里一行一行重新设置,简单但代码多一些,每创建一个 data 行,就给它设置字体、对齐、行高。二是先把模板行做好,然后用底层的 XML 复制节点功能把那一行深拷贝出来。复制节点功能写法相对复杂,但数据量大的时候收益明显,因为样式设置不用做 N 遍。如果只是几百行数据,我建议用第一种,可读性好,改起来也直观。
4.4 合并单元格:横向和纵向的两种写法
合并单元格是动态报表里非常常见的需求。比如表头跨列合并、多行同类项纵向合并。POI 从 4.1.2 开始提供了XWPFTable.setCellMerge方法,用起来非常顺手:
// 横向合并:把第 0 行的第 0~1 列合并为一个单元格 table.setCellMerge(0, 0, 0, 1, STMerge.CONTINUE);参数含义分别是:起始行、起始列、结束行、结束列、合并方向类型。横向合并时,起始列到结束列是同一行;纵向合并时,起始行到结束行是同一列:
// 纵向合并:把第 1~3 行第 0 列合并为一个单元格 table.setCellMerge(1, 0, 3, 0, STMerge.CONTINUE);提示:合并操作必须在填充数据之前或者之后做,但要注意单元格数量变化。如果先合并再给单元格 setText,要小心你操作的 Cell 对象是否还指向有效位置。
纵向合并后,一般只需要在第一个(上面那个)单元格里写内容,下面被合并的单元格内容即使写了,Word 也可能只显示第一个的内容,所以最好统一在合并前写内容,或者只给起始单元格写内容。
4.5 单元格内多行文本:setText 之后再用 addBreak
表格单元格里有时需要写多行内容,比如“联系人:张三\n电话:13800000000”。直接在 setText 里拼\n依然无效。正确方式:
XWPFTableCell cell = row.getCell(0); cell.setText(""); // 清掉已有段落 XWPFParagraph p = cell.getParagraphs().get(0); XWPFRun run1 = p.createRun(); run1.setText("联系人:张三"); run1.addBreak(); run1.setText("电话:13800000000");注意cell.setText("")这个操作相当于清空单元格里的第一个段落文本,但段落对象仍在。之后用cell.getParagraphs().get(0)可以拿到这个空段落继续填充。如果你直接 new 一个 XWPFParagraph,它和 cell 并没有关联。
5. 把真实数据源接进来:从 List 到表格行的一次完整映射
5.1 一个完整的报表导出方法
段落、表格、合并、样式都掌握了,就可以把它们组合成一个完整的业务方法。这个方法接收一个数据列表,输出一个 docx 文件。核心逻辑就是:标题段落 -> 说明段落 -> 动态表格。
public void exportReport(List<ReportItem> items, OutputStream out) throws Exception { XWPFDocument doc = new XWPFDocument(); // 1. 大标题 XWPFParagraph title = doc.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun titleRun = title.createRun(); titleRun.setText("项目执行情况汇总"); titleRun.setBold(true); titleRun.setFontSize(20); setEastAsiaFont(titleRun, "微软雅黑"); // 2. 表格 XWPFTable table = doc.createTable(items.size() + 1, 5); String[] headers = {"序号", "项目名称", "合同金额", "完成日期", "备注"}; for (int i = 0; i < headers.length; i++) { table.getRow(0).getCell(i).setText(headers[i]); } // 3. 数据行 for (int i = 0; i < items.size(); i++) { ReportItem item = items.get(i); int rowIndex = i + 1; XWPFTableRow row = table.getRow(rowIndex); row.getCell(0).setText(String.valueOf(i + 1)); row.getCell(1).setText(item.getName()); row.getCell(2).setText(formatAmount(item.getAmount())); row.getCell(3).setText(item.getCompleteDate() == null ? "" : item.getCompleteDate().format(DateTimeFormatter.ISO_LOCAL_DATE)); row.getCell(4).setText(item.getRemark() == null ? "" : item.getRemark()); } doc.write(out); doc.close(); }5.2 null 和格式问题一定要提前兜底
真实业务数据不会像测试数据那么规范,这里有几个高频问题:
- 单元格 setText 传 null 会抛 NPE,所以所有可能为 null 的字段都要用空串兜底。
- 金额如果从数据库拿的是 BigDecimal,不要直接 toString,因为科学计数法会把 1000000 变成
1E+6。可以用toPlainString()或者 DecimalFormat 格式化成带千分位的样式。 - 日期如果是 LocalDate/LocalDateTime,优先用 DateTimeFormatter 转字符串,而不是拼
toString(),否则格式会带一个大写的英文字母。
private String formatAmount(BigDecimal amount) { if (amount == null) { return "0.00"; } DecimalFormat df = new DecimalFormat("#,##0.00"); return df.format(amount); }这些细节看似小,但真正使用这套导出服务的运营同学,最在意的就是“数字别变成科学计数法”、“日期格式统一”。返工成本很高的。
5.3 数据量大时怎么办:分页、性能、卡顿
动态表格如果只有几十行,怎么折腾都没问题。但一旦上千行,你马上会遇到两个问题:一是 POI 是内存型文件对象,图片、样式、Run 对象堆积太多会占比较大内存;二是生成的 Word 打开时如果包含大量未压缩的图片,Word 本身会变得很慢,关闭时也容易卡顿。
我个人的经验是:单文档表格控制在 2000 行以内比较稳妥。超过 2000 行,可以考虑按业务维度拆分成多个文档,或者在合适的位置手动插入分页符,让 Word 渲染的负担分散到多页:
// 每 500 行插入一个分页符 if (i > 0 && i % 500 == 0) { XWPFParagraph pageBreak = doc.createParagraph(); XWPFRun run = pageBreak.createRun(); run.addBreak(BreakType.PAGE); }如果你需要插入数据图片,一定要先压缩再塞进文档,不要让用户上传什么尺寸,你就原样往 Word 里写。一张 5MB 的手机照片塞进去,文档体积直接爆炸,打开、关闭、翻页都会卡。这个“卡顿”问题很多时候不是 POI 代码的问题,而是资源没有瘦身。
5.4 复用工具方法,减少重复代码
做了一些项目之后,我把常用的操作抽成了一个工具类,例如WordStyleUtil.setFont(run, "微软雅黑", 12, false, "FF0000")、WordStyleUtil.setCellWidth(cell, 2000)、WordStyleUtil.setEastAsiaFont(run, "宋体")。如果你要长期和 Word 生成打交道,强烈建议也做这件事。因为 POI 的 API 风格偏底层,散落在业务代码里很难维护,抽出来之后,至少不会每写一个报表都重头查一遍“eastAsia 怎么设”。
6. 生成之后的现实问题:文档打不开、列宽失效、文件膨胀
6.1 生成的文件打不开,先怀疑这三件事
第一,doc和docx混用。XWPFDocument 只能处理.docx,你如果给它起个.doc后缀,双击大概率打不开。让用户上传 Word 模板时,也要明确限制为.docx。第二,document.write(out)之后又去修改 document,或者没有正确关闭流,可能导致 zip 包结构不完整。第三,表格合并操作产生了“非规则”结构,Word 打开时会提示“文件损坏,是否修复”。
排查顺序我一般是这样:先用压缩工具打开生成的 docx,如果 zip 结构可以正常解压,说明文件本身没坏;再用 Word 自带的“打开并修复”功能试一次;最后用 POI 重新读取这个文件,看能不能读出来。这三步能定位大部分问题。
6.2 列宽失效的完整排查链路
列宽问题出现时,我建议按下面的顺序排查,不要瞎试:
- 是否设置了
tblLayout为FIXED?没有的话,Word 会按内容自动调整列宽,你设置的宽度只是“参考值”。 - 是否设置了表格总宽度
tblW?总宽度缺失时,个别列的宽度比例可能被重算。 - 每一行的每个单元格是否都设置了
tcW?只设置第一行不代表其他行跟随。 - 单元格宽度与表格总宽度是否匹配?假设总宽 9000,五列每列 2000,加起来 10000,明显超标,Word 会做压缩。
- 代码里是否在设置完列宽后又执行了让表格自动调整为内容的操作?比如某些复制行、合并单元格的操作,可能会重置宽度属性。
如果以上都排除了,列宽还不对,这时候在 Word 里很可能存在“表格列宽无法拖动”的现象,因为 fixed 布局本身就限制了手动拖动。这是正常表现,如果你的业务希望用户拿到文档后还能自由调列宽,就不要用 fixed 布局,而是采用“自动调整 + 设置宽度参考值”的混合策略。
6.3 Linux 服务器上的字体问题
很多 Java 服务部署在 Linux 上,用 POI 生成 Word 时,字体的指定其实只是“把字体名字写进 XML”,生成过程本身不依赖服务器是否安装字体。所以文档在 Windows 上打开时,字体一般还是你设置的那个,因为渲染由打开方完成。但如果你在服务器端把 docx 转 PDF(比如用 LibreOffice),那就依赖服务器字体库了。Linux 上缺中文字体,转出来的 PDF 会有方块或乱码。解决办法就是安装常用中文字体包,或者把字体文件放进服务器的字体目录并刷新字体缓存。
6.4 文件体积膨胀和 Word 卡顿
生成几十页的文档时,文件体积如果异常大,先看有没有插入了大图片。其次看是不是每行每单元格都重复创建了大量 Run 对象。其实 Word 里同样一段文字,如果是一个 Run,体积很小;如果是十个 Run,每个都要写一段 XML。数据行多时,这种膨胀会被放大。优化方向是:能复用非空段落的就复用,不要每个单元格都setText("")后再 createRun。创建一个 Run 设为空文本,能少写很多 XML 属性。
6.5 生成后的自检清单
我每次写完一个 Word 导出功能,会按这份清单过一遍再交付:
- 内容:表格行数和数据库记录数是否一致,空字段是否被兜底成空串。
- 格式:中文字体是否按预期显示,数字是否出现科学计数法,日期格式是否统一。
- 结构:合并单元格后,打开文档有没有“无法读取的内容”提示。
- 体积:生成的 docx 是否在合理大小,图片是否需要压缩。
- 性能:1000 行数据时生成耗时是否可接受,内存是否有明显增长。
- 兼容:用 WPS、Microsoft Word 各打开一次,确认布局没有明显错乱。
这串检查不需要每次都全跑,但是核心的“打开无报错”、“列宽正确”、“字体正常”三项,每次都要确认。因为这三项恰恰是 POI 动态生成最容易翻车的地方。
根据我的实际经验,做 Java 动态生成 Word,最大的门槛其实不是 API 不会用,而是对 docx 的“对象结构”和“单位体系”不熟。只要理解了 Run、Table、Cell 的关系,理解了 twips、eastAsia 字体这些底层逻辑,遇到问题基本都能自己定位。最后给你一个小技巧:开发时可以把 document.write 输出到一个 byte 数组,然后直接转换成一个输入流,扔给浏览器下载做快速验证,避免频繁写临时文件。这个迭代速度对调试表格布局特别有用。