Java动态导出Word文档:基于Apache POI的模板替换方案详解
1. 项目概述:为什么我们需要动态导出Word文档?
在Java后端开发中,生成报告、合同、通知等文档是再常见不过的需求。早期,我们可能会用字符串拼接HTML再转PDF,或者直接硬编码文档内容,但这些方法在遇到格式复杂、数据动态变化频繁的场景时,维护起来简直就是一场灾难。想象一下,产品经理拿着最新一版合同模板过来,要求调整十几个地方的格式和数据展示逻辑,而你面对的是满屏的字符串和令人费解的坐标计算代码,那种无力感我深有体会。
后来,模板引擎(如Freemarker、Velocity)结合XML的方式流行起来,但处理Word这种富文本格式时,对样式的控制依然不够直观和精细。直到我开始深入使用Apache POI来处理Word文档,才真正找到了一个在程序可控性和文档保真度之间取得平衡的方案。所谓“动态导出”,核心在于数据与样式的分离。我们预先设计好一个包含占位符的Word模板,程序运行时,只需将业务数据精准地填充到对应的占位符,并保持模板原有的所有格式(字体、颜色、表格、列表等),从而高效、准确地生成千变万化的最终文档。
Apache POI是Apache软件基金会的开源项目,它提供了完整的Java API来操作Microsoft Office格式文件。对于Word文档,我们主要使用XWPF组件来处理.docx格式(Office 2007及以上版本)。相较于旧的.doc格式(HWPF组件),XWPF基于OOXML(Office Open XML)标准,以ZIP包的形式组织XML文件,这使得我们能够以结构化的方式读取和修改文档内容,功能更强大,也更符合现代开发需求。
2. 核心思路与方案选型:为什么是POI模板替换?
面对动态导出需求,通常有几种技术路线:
- 纯POI API从头构建:使用
XWPFDocument、XWPFParagraph、XWPFRun等对象,像搭积木一样一句一句地创建文档。这种方式控制力最强,但代码量巨大,任何样式调整都需要修改代码,不适合内容结构复杂的文档。 - XML直接操作:将
.docx文件解压,直接修改底层的document.xml等文件。这种方式效率极高,但需要对OOXML规范有深入理解,开发门槛高,且容易因格式不兼容导致文档损坏。 - 模板替换(本文核心):预先在Word中设计好模板,在需要插入数据的地方使用特定的占位符(例如
${userName}、${orderList})。程序读取模板文件,定位这些占位符,并将其替换为真实数据。这种方法将样式设计工作交还给专业的Word工具,开发者只需关注数据绑定逻辑,极大地提升了开发效率和模板的可维护性。
显然,对于大多数业务场景,模板替换方案是最优解。它的优势在于:
- 开发效率高:业务人员或设计师用Word制作模板,开发者无需关心具体样式实现。
- 维护成本低:模板样式变更,只需替换模板文件,通常无需修改代码。
- 灵活性好:可以处理文本、图片、表格、列表等多种复杂格式。
- 保真度高:最大程度保留了原始模板的设计效果。
在POI中实现模板替换,核心在于遍历文档的所有结构元素(段落、表格、单元格、文本行),查找并替换占位符文本。这听起来简单,但实际会遇到不少挑战,比如跨段落的占位符、表格内的循环、图片的插入等,这些正是我们需要深入解决的细节。
3. 环境准备与基础工具类搭建
3.1 项目依赖引入
首先,在你的Maven项目pom.xml中引入Apache POI的依赖。由于我们需要操作.docx,所以主要引入poi-ooxml。建议同时引入poi-scratchpad以备不时之需(处理旧格式),并引入commons-io来简化文件操作。
<dependencies> <!-- Apache POI Core --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.5</version> <!-- 请使用最新稳定版本 --> </dependency> <!-- Apache POI for OOXML (Word .docx, Excel .xlsx) --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <!-- 可选:用于处理图片等OLE对象 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>5.2.5</version> </dependency> <!-- Apache Commons IO 用于文件操作 --> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.15.1</version> </dependency> </dependencies>注意:版本号请定期查看官方仓库更新。不同大版本间API可能有差异,本文基于5.x版本编写。
3.2 设计一个健壮的模板工具类
一个好的工具类应该职责清晰、易于扩展。我们设计一个WordTemplateUtil类,它核心的方法就是generate,接收模板文件路径、数据模型和输出路径。但在这之前,我们需要先解决如何定位和替换占位符。
POI文档对象模型是树形结构的:XWPFDocument包含多个XWPFParagraph(段落)和XWPFTable(表格)。段落中包含多个XWPFRun(文本运行块),真正的文本存储在XWPFRun中。一个占位符可能被拆分成多个XWPFRun,这是替换操作中最常见的坑。
我们先编写一个核心的文本替换方法,它能处理跨XWPFRun的占位符:
import org.apache.poi.xwpf.usermodel.*; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; import java.util.*; import java.io.*; public class WordTemplateUtil { /** * 在单个段落中搜索并替换占位符 * @param paragraph 段落对象 * @param placeholder 占位符,如 ${name} * @param replacement 要替换的文本 */ private static void replacePlaceholderInParagraph(XWPFParagraph paragraph, String placeholder, String replacement) { // 获取段落中所有的文本运行块 List<XWPFRun> runs = paragraph.getRuns(); if (runs == null || runs.isEmpty()) { return; } // 第一步:合并段落内所有Run的文本,以便查找占位符 StringBuilder paragraphText = new StringBuilder(); for (XWPFRun run : runs) { String text = run.getText(0); if (text != null) { paragraphText.append(text); } } String fullText = paragraphText.toString(); // 如果整个段落文本中不包含占位符,直接返回 if (!fullText.contains(placeholder)) { return; } // 第二步:清空原有Run的文本 for (XWPFRun run : runs) { run.setText("", 0); } // 第三步:在第一个Run中插入替换后的完整文本,并尽量保留原格式 // 这里选择第一个Run的样式作为基准 if (!runs.isEmpty()) { String newText = fullText.replace(placeholder, replacement); runs.get(0).setText(newText, 0); } // 更复杂的做法是:按占位符位置拆分文本,并分配到不同的Run中以保留更多局部格式,但复杂度激增。 // 对于大多数“占位符独立成词”的场景,上述方法已足够。 } /** * 在整个文档中替换简单文本占位符 * @param document Word文档对象 * @param dataMap 数据映射,key为占位符(不含${}),value为替换值 */ public static void replaceTextPlaceholders(XWPFDocument document, Map<String, String> dataMap) { // 1. 处理所有段落 for (XWPFParagraph paragraph : document.getParagraphs()) { for (Map.Entry<String, String> entry : dataMap.entrySet()) { String placeholder = "${" + entry.getKey() + "}"; // 构造完整的占位符 replacePlaceholderInParagraph(paragraph, placeholder, entry.getValue()); } } // 2. 处理所有表格中的段落 for (XWPFTable table : document.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { for (Map.Entry<String, String> entry : dataMap.entrySet()) { String placeholder = "${" + entry.getKey() + "}"; replacePlaceholderInParagraph(paragraph, placeholder, entry.getValue()); } } } } } } }这个基础工具类已经能处理大部分简单的文本替换了。但它的replacePlaceholderInParagraph方法采用了“合并-替换-写回”的策略,这会丢失除第一个Run外的所有局部格式(如某个词加粗、变色)。如果你的占位符本身是独立且无特殊格式的,这没问题。但如果占位符前后文字格式不同,或者占位符本身有格式,就需要更精细的算法来分割和重建Run。这是一个重要的取舍点,在项目初期就要和设计模板的人员约定好。
4. 进阶功能实现:表格循环与动态行处理
简单的文本替换只是开始,动态导出最强大的功能之一是根据数据列表动态生成表格行。例如,一份订单详情文档,需要列出订单中的商品,商品数量不定。
4.1 定义表格循环占位符语法
我们需要一套简单的语法来标识模板中的循环区域。一种常见的约定是:
- 在模板中,准备两行表格:表头行和循环模板行。
- 在循环模板行的某个单元格(通常是第一个)内,写入一个特殊的标记,如
{{#foreach goods}},表示循环开始,goods是数据模型的List键名。 - 在循环模板行内,使用普通的占位符如
${goodsName}、${price}。 - 在循环模板行之后,可能需要一个结束标记,如
{{/foreach}},但POI的DOM操作中,我们通常通过识别开始标记和模板行结构来处理。
4.2 实现表格循环渲染逻辑
假设我们的数据模型如下:
Map<String, Object> data = new HashMap<>(); data.put("orderId", "ORD20240521001"); List<Map<String, String>> goodsList = new ArrayList<>(); goodsList.add(new HashMap<String, String>() {{ put("name", "商品A"); put("price", "100.00");}}); goodsList.add(new HashMap<String, String>() {{ put("name", "商品B"); put("price", "200.00");}}); data.put("goods", goodsList);模板Word中有一个表格,第二行是模板行,其第一个单元格内容为{{#foreach goods}}。
实现步骤:
- 遍历文档所有表格。
- 遍历每个表格的所有行。
- 查找包含循环开始标记
{{#foreach ...}}的行,记为目标行(模板行)。 - 获取该行索引,以及循环变量名(如
goods)。 - 从数据模型中取出对应的List。
- 为List中的每一项,复制模板行,生成新行,并替换新行中的占位符。
- 删除原始的模板行(或清空其标记单元格)。
- 继续处理其他占位符。
public class WordTemplateUtil { // ... 之前的代码 ... /** * 处理表格循环 * @param document 文档对象 * @param dataMap 数据模型 */ public static void processTableLoops(XWPFDocument document, Map<String, Object> dataMap) { for (XWPFTable table : document.getTables()) { List<XWPFTableRow> rows = table.getRows(); // 使用倒序遍历,因为我们在循环中可能会插入新行,正序遍历会导致索引错乱 for (int i = rows.size() - 1; i >= 0; i--) { XWPFTableRow templateRow = rows.get(i); String loopInfo = getLoopStartMarker(templateRow); if (loopInfo != null) { // 解析出变量名,例如从 “{{#foreach goods}}” 中解析出 “goods” String listKey = parseListKeyFromMarker(loopInfo); Object listData = dataMap.get(listKey); if (listData instanceof List) { // 找到模板行,开始复制和填充 int templateRowIndex = table.getRows().indexOf(templateRow); // 先清空或移除标记单元格的内容,避免残留 clearMarkerCell(templateRow, loopInfo); List<?> itemList = (List<?>) listData; for (int itemIndex = 0; itemIndex < itemList.size(); itemIndex++) { Object item = itemList.get(itemIndex); // 在模板行之后插入新行 XWPFTableRow newRow = table.insertNewTableRow(templateRowIndex + 1 + itemIndex); // 复制模板行的单元格结构和样式 copyRowStyle(templateRow, newRow); // 填充数据:这里假设item是Map if (item instanceof Map) { fillRowWithData(newRow, (Map<String, String>) item); } } // 所有数据行插入完成后,可以选择删除原模板行,或者保留它作为无数据的模板 // table.removeRow(templateRowIndex); // 如果删除 } } } } } private static String getLoopStartMarker(XWPFTableRow row) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { String text = p.getText(); if (text != null && text.trim().startsWith("{{#foreach")) { return text.trim(); } } } return null; } private static String parseListKeyFromMarker(String marker) { // 简单解析,例如 “{{#foreach goods}}” -> “goods” return marker.replace("{{#foreach", "").replace("}}", "").trim(); } private static void clearMarkerCell(XWPFTableRow row, String marker) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { if (p.getText() != null && p.getText().contains(marker)) { // 清空这个段落的所有Run for (XWPFRun run : p.getRuns()) { run.setText("", 0); } break; } } } } private static void copyRowStyle(XWPFTableRow sourceRow, XWPFTableRow targetRow) { // 确保目标行有足够数量的单元格 while (targetRow.getTableCells().size() < sourceRow.getTableCells().size()) { targetRow.addNewTableCell(); } // 这里可以复制单元格宽度、背景色等样式,是一个复杂操作,简化起见,我们只确保单元格数量一致。 // 实际项目中,可能需要深度复制CTTcPr等底层XML对象。 } private static void fillRowWithData(XWPFTableRow row, Map<String, String> itemData) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { String text = paragraph.getText(); if (text != null) { for (Map.Entry<String, String> entry : itemData.entrySet()) { String placeholder = "${" + entry.getKey() + "}"; if (text.contains(placeholder)) { // 使用之前写的段落替换方法 replacePlaceholderInParagraph(paragraph, placeholder, entry.getValue()); } } } } } } }实操心得:
table.insertNewTableRow(index)方法插入新行时,不会自动复制原行的单元格样式和宽度。这会导致新行的单元格可能和表头对不齐。一个更稳健的做法是:不删除模板行,而是直接清空模板行各单元格的内容,然后将其作为第一行数据填充。后续数据行,则通过table.createRow()创建,并手动从模板行复制CTTcPr(单元格属性)和CTTrPr(行属性)。这部分代码涉及POI底层API,较为繁琐,但能保证样式一致。在要求不高的场景,可以先保证功能,样式问题让模板设计者通过调整“模板行”的样式来间接控制。
5. 图片与复杂内容的动态插入
除了文本和表格,插入图片也是常见需求。例如,在报告中插入用户头像或产品示意图。
5.1 图片占位符设计
我们可以在模板中用一个特殊的文本作为图片占位符,例如{{@image:avatar}}。程序识别到这个占位符后,将其替换为指定的图片。
5.2 实现图片插入功能
POI中插入图片到段落,需要先获取图片的数据(字节数组),然后通过XWPFParagraph.createRun().addPicture方法添加。
public class WordTemplateUtil { // ... 之前的代码 ... /** * 处理图片占位符 * @param document 文档对象 * @param imageDataMap 图片数据映射,key为占位符标识(如avatar),value为图片字节数组或文件路径 */ public static void replaceImagePlaceholders(XWPFDocument document, Map<String, byte[]> imageDataMap) throws Exception { // 遍历所有段落 for (XWPFParagraph paragraph : document.getParagraphs()) { List<XWPFRun> runs = paragraph.getRuns(); for (int i = 0; i < runs.size(); i++) { XWPFRun run = runs.get(i); String text = run.getText(0); if (text != null && text.trim().startsWith("{{@image:")) { // 解析图片标识,例如 “{{@image:avatar}}” -> “avatar” String imageKey = parseImageKeyFromMarker(text.trim()); byte[] imageData = imageDataMap.get(imageKey); if (imageData != null) { // 1. 先清空这个Run的文本 run.setText("", 0); // 2. 在这个Run中插入图片 // addPicture参数:图片数据流,图片类型,文件名,宽度,高度 // 图片类型:XWPFDocument.PICTURE_TYPE_PNG, XWPFDocument.PICTURE_TYPE_JPEG等 // 宽度和高度单位是EMU(English Metric Unit),可以通过工具类转换 int pictureType = getPictureTypeByData(imageData); // 需要根据文件头判断类型 String fileName = imageKey + ".png"; int widthEmu = Units.toEMU(100); // 例如宽度100点 int heightEmu = Units.toEMU(100); // 高度100点 run.addPicture(new ByteArrayInputStream(imageData), pictureType, fileName, widthEmu, heightEmu); } } } } // 同样需要处理表格内的图片占位符,遍历逻辑类似,此处省略 } private static String parseImageKeyFromMarker(String marker) { return marker.replace("{{@image:", "").replace("}}", "").trim(); } private static int getPictureTypeByData(byte[] data) { // 简单的文件头判断,实际项目建议使用Files.probeContentType或Apache Tika if (data.length > 8 && data[0] == (byte) 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G') { return XWPFDocument.PICTURE_TYPE_PNG; } else if (data.length > 2 && data[0] == (byte) 0xFF && data[1] == (byte) 0xD8) { return XWPFDocument.PICTURE_TYPE_JPEG; } return XWPFDocument.PICTURE_TYPE_PNG; // 默认 } }注意事项:图片插入后,原始的占位符文本Run被清空,图片被添加到了这个Run里。这意味着如果占位符前后还有文字,它们会被拆分到不同的Run。如果希望图片单独成段,最好在模板中就让图片占位符独占一个段落。
6. 完整工作流整合与性能优化
现在,我们将各个模块整合起来,形成一个完整的文档生成流程。
6.1 主流程封装
public class WordExportService { /** * 根据模板和数据生成Word文档 * @param templatePath 模板文件路径 * @param outputPath 输出文件路径 * @param textData 文本数据映射 * @param listData 列表数据映射(用于循环) * @param imageData 图片数据映射 */ public void exportWord(String templatePath, String outputPath, Map<String, String> textData, Map<String, List<Map<String, String>>> listData, Map<String, byte[]> imageData) throws Exception { // 1. 加载模板 FileInputStream fis = new FileInputStream(templatePath); XWPFDocument document = new XWPFDocument(fis); fis.close(); // 2. 构建完整数据模型(为了简化,这里分开传递,实际可封装成一个对象) Map<String, Object> fullData = new HashMap<>(); fullData.putAll(textData); fullData.putAll(listData); // 3. 处理表格循环(优先,因为循环会产生新的文本占位符) WordTemplateUtil.processTableLoops(document, fullData); // 4. 处理图片占位符 if (imageData != null && !imageData.isEmpty()) { WordTemplateUtil.replaceImagePlaceholders(document, imageData); } // 5. 处理剩余文本占位符(最后处理,因为循环和图片可能已清空部分占位符) WordTemplateUtil.replaceTextPlaceholders(document, textData); // 6. 保存文档 FileOutputStream fos = new FileOutputStream(outputPath); document.write(fos); fos.close(); document.close(); } }6.2 性能考量与内存管理
处理大型文档或多数据量时,需要注意:
- 流式处理:POI的
XWPFDocument会将整个文档加载到内存。对于超大型文档,这可能引发OutOfMemoryError。如果文档结构极其复杂且数据量巨大,可以考虑:- 使用
SXSSF(对于Excel)的思路不适用于Word。对于Word,没有官方的流式API。 - 折中方案:将大文档拆分成多个小模板分别生成,最后用工具合并(如使用POI合并多个
XWPFDocument,但合并本身也复杂)。 - 最佳实践:从业务上限制单次导出的数据量,或引导用户分页导出。
- 使用
- 资源关闭:务必在finally块或使用try-with-resources语句确保
FileInputStream、FileOutputStream和XWPFDocument被正确关闭,避免资源泄漏。 - 模板缓存:如果模板不常变化,且生成请求频繁,可以将加载好的
XWPFDocument对象(或从其克隆的模板对象)缓存在内存中,避免重复的磁盘IO和解析开销。但要注意缓存失效和内存占用问题。
// 使用try-with-resources确保资源关闭 try (FileInputStream fis = new FileInputStream(templatePath); XWPFDocument document = new XWPFDocument(fis); FileOutputStream fos = new FileOutputStream(outputPath)) { // ... 处理逻辑 ... document.write(fos); } // 自动关闭资源7. 常见问题排查与实战技巧
在实际使用中,你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决方案。
7.1 格式丢失或混乱
- 问题描述:替换文本后,原来的加粗、颜色、字体等样式丢失。
- 根因分析:根本原因在于我们粗暴地清空了整个段落的Run,然后在第一个Run写回全部文本。一个
XWPFRun是样式的最小单位,清空后再写入,就只保留了第一个Run的样式。 - 解决方案:
- 约定优于配置:与模板制作方约定,占位符
${xxx}最好独立成一个段落,或者其前后不要有需要特殊格式的文字。这样替换时影响最小。 - 实现精细替换:编写更复杂的算法,只替换占位符对应的文本片段,而不是整个段落。这需要遍历所有Run,拼接文本并记录占位符的起止Run索引,然后只修改这些Run的文本。代码复杂度高,但能最大程度保留格式。
- 使用书签(Bookmark):这是POI官方更推荐的方式。在Word模板中,在需要插入内容的位置插入书签。代码中通过
document.getBookmarks()找到书签,然后在其位置插入新的段落或Run。这种方式能更好地控制插入位置,且不影响周围格式。但书签在模板制作上稍麻烦。
- 约定优于配置:与模板制作方约定,占位符
7.2 表格循环后样式错位
- 问题描述:动态生成的表格行,单元格宽度和表头对不齐,或者缺少边框。
- 根因分析:
insertNewTableRow方法创建的是空行,单元格属性(宽度、边框、底纹)需要从模板行复制。 - 解决方案:
- 深度复制单元格属性
CTTcPr。示例代码片段:
// sourceCell是模板行的单元格,targetCell是新行的单元格 CTTcPr targetCellProps = targetCell.getCTTc().getTcPr(); if (targetCellProps == null) { targetCellProps = targetCell.getCTTc().addNewTcPr(); } // 复制宽度 if (sourceCell.getCTTc().getTcPr() != null && sourceCell.getCTTc().getTcPr().getTcW() != null) { targetCellProps.setTcW(sourceCell.getCTTc().getTcPr().getTcW().copy()); } // 复制边框、底纹等属性,需要类似地复制CTBorder、CTShd等对象- 如果样式要求不高,可以调整模板:将表头行和模板行的样式设置得完全一致,并且确保模板行在数据填充后不被删除,而是作为首行数据。这样新创建的行会继承表格的默认样式,虽然可能不完美,但通常可接受。
- 深度复制单元格属性
7.3 中文乱码或特殊字符问题
- 问题描述:生成的文件中,中文变成问号或乱码。
- 根因分析:这通常不是POI的问题,而是文件读写时的编码问题,或者字体问题。
- 解决方案:
- 确保你的Java源文件编码是UTF-8(IDE设置)。
- 确保模板文件
.docx本身保存时没有编码问题(用Word正常保存即可)。 - 如果替换的文本中包含特殊字符或换行符
\n,注意Word中的换行是\r或<w:br/>对象。直接插入\n可能不生效。可以使用run.addCarriageReturn()或run.addBreak()来添加换行。 - 在某些极端情况下,如果生成的文档在别人的电脑上显示字体不对,可能是因为模板使用了对方系统没有的字体。可以在POI中强制设置运行块的字体:
run.setFontFamily("宋体")。
7.4 性能瓶颈
- 问题描述:数据量较大时,生成文档速度很慢。
- 排查与优化:
- 减少DOM操作:避免在多层循环中频繁调用
document.getParagraphs()或table.getRows(),尽量在一次遍历中完成所有查找和替换。 - 使用缓存:如前述,缓存模板文档对象。
- 评估数据量:是否真的需要一次性导出成千上万行数据?考虑分页或异步导出。
- Profile工具:使用JProfiler等工具定位热点代码,看时间是消耗在POI的API调用上,还是在你自己的查找替换算法上。
- 减少DOM操作:避免在多层循环中频繁调用
7.5 与其他技术的对比与选型思考
在项目技术选型时,除了Apache POI,你可能还会听到以下方案:
- Freemarker + XML:将Word另存为XML,在XML中放置Freemarker标签。这种方式非常灵活,能实现所有逻辑,但需要开发者懂Word的XML结构,调试困难。
- Poi-tl:这是一个基于POI的开源模板引擎,它定义了一套更强大的标签语法(类似于本文实现的循环、条件判断),功能丰富,社区活跃。如果你的需求非常复杂(多级循环、嵌套条件、图片动态缩放等),强烈建议直接使用
poi-tl,避免重复造轮子。 - JasperReports:老牌报表工具,功能强大,支持多种输出格式(Word、PDF、Excel)。但学习曲线较陡,配置繁琐,更适合固定的、复杂的报表场景,而非灵活的文档生成。
我的建议是:对于简单的文本替换和单层表格循环,自己基于POI封装一个轻量级工具足够用,可控性强。一旦需求涉及条件判断、嵌套循环、列表渲染等,应优先考虑poi-tl这类成熟模板引擎。
最后,再分享一个调试小技巧:当你对POI操作后的文档效果不满意时,可以将生成的.docx文件后缀改为.zip,解压后查看word/document.xml文件。这样你能最直观地看到POI到底对你的文档做了什么修改,比盲目猜测代码有效得多。这招在我解决复杂格式问题时屡试不爽。