简介:针对Java开发中需要动态生成Word文档的场景,这套源码项目基于POI,实现了不依赖模板文件的图片插入与目录生成,并细分为简单模式与复杂模式,便于不同文档需求灵活选用。项目来自生产环境且代码包含注释与示例,特别适合在OA系统、合同管理等场景中生成Word文件,也适合正在使用POI进行Word图文混排、目录生成或希望绕开模板方案的开发者借鉴。资源以rar压缩包发布,整体约22.61MB,共97个文件,包括35个Java源文件、38个class文件及18个依赖包,同时附有配置文件和IDE工程文件,导入后即可运行调试,省去手动配置依赖的麻烦。目前已有535人学习或下载,通过这套经过实际检验的示例,可以快速掌握动态Word生成中的图片定位、文字插入与目录构建思路。 做Java后端的人,迟早会被Office文档处理折磨一次。业务方经常甩过来一个需求:把数据库里的数据导出成Word报告,要带目录、要插图、字体要好看。Apache POI是绕不过去的方案,但真正动手搞过几天就会明白一个道理——网上的资料很多,能一次跑通的细节很少。这篇文章我从底层拆一遍,把POI生成Word过程中最麻烦的图片插入、文字样式、目录生成三个硬骨头全部打通,附能直接跑起来的源码,顺便把Word里“下划线上打字但线不动”的经典问题一起解决了。如果你正在为导出带目录的Word文档发愁,或者想搞懂POI对OOXML内容的真实控制方式,这篇应该能省你不少时间。
1. 动手之前:先把需求拆透,再决定技术方案
1.1 核心需求解构
先别急着写代码,把需求里头的东西拆出来。标题里的“poi word 图片 文字 目录 源码”,翻译成实际开发场景,通常意味着这么几件事:
- 程序动态生成一个docx文档,内容包含大量格式化文字,比如标题、正文、加粗、下划线、缩进。
- 需要往文档里插入若干张图片,图片要能控制尺寸,最好还能居中或文字环绕。
- 文档开头需要带一个目录,章节多了以后点一下跳转、看页码,这是硬性要求。
- 如果涉及表格,还得处理单元格宽度、列宽这类看起来简单、其实很坑的操作。
这些需求单独看都不复杂,但凑在一起,就会碰到POI不同API层次的问题。文字和图片用XWPF系列接口就能做,目录却需要直接操作底层XML标签,因为POI原生没有提供“插入一个目录对象”的现成API。
1.2 技术选型与方案取舍
POI处理Word有两套方案:操作老格式.doc的HWPF,以及处理新格式.docx的XWPF。别犹豫,新的项目一律选XWPF。原因很直接:.doc格式对样式、图片的处理能力非常弱,API也很久没大更新;而.docx本质上是一个zip压缩包,里面是各种XML文件,XWPF能比较完整地覆盖段落、表格、图片等核心元素。
另一个容易踩的坑是版本选择。我推荐现在用5.x系列,比如5.2.5,因为4.x对某些OOXML标签的封装不够,团队在自定义XML时经常要绕路。5.x对Java 8以上支持良好,稳定性经过了很长时间验证。如果公司环境特别老,只能JDK7,那才考虑4.1.2,否则别回头用老版本。
2. 环境准备:依赖与核心API基础
2.1 Maven依赖与版本选择
既然是源码实战,环境先搞定。理论上只需要一个核心依赖,POI会把其他依赖带进来:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>这个坐标同时包含了poi核心、ooxml-schema、XMLBeans这些基础库。实际开发中还可能要引入poi-scratchpad用来支持.doc老格式,但这里用不到。
有一点必须单独强调:poi和poi-ooxml的版本必须强制一致。我见过很多次因为项目里其他地方依赖了旧版poi,导致运行时NoSuchMethodError或者ClassNotFoundException,排查半天发现是jar包版本冲突。一旦出现这类问题,先检查依赖树里是否混入了不同版本的poi。
2.2 XWPF核心对象与文档结构
XWPF的对象模型和Word文档结构是一一对应的。XWPFDocument对应整个文档,XWPFParagraph对应一个段落,XWPFRun对应段落里同一格式的一段文字,XWPFTable对应一个表格。图片和文字都挂在Run上,表格里又有行和单元格。
理解这个层级关系,写代码就有章法了:设置段落样式用XWPFParagraph的CTP底层对象,设置字体用XWPFRun,插入图片也走XWPFRun,表格定宽则要同时操作表格对象和底层CTTbl节点。后面所有源码都是围绕这个结构展开的。
3. 从零构造一份可用的Word报告
3.1 创建文档骨架:标题、段落与中文字体避坑
直接上代码。以下是最基础的骨架,包含标题和正文段落:
XWPFDocument document = new XWPFDocument(); XWPFParagraph title = document.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); title.setSpacingAfter(200); XWPFRun titleRun = title.createRun(); titleRun.setText("项目技术方案报告"); titleRun.setBold(true); titleRun.setFontSize(22); titleRun.setFontFamily("微软雅黑");这里有个坑,光setFontFamily不行。POI设置中文字体时,这个方法只改了西文字体,中文字体还需要单独设置eastAsia属性,否则中文在Word里可能默认变成等线或宋体,和你想要的效果不一样:
private static void setChineseFont(XWPFRun run, String fontName) { run.setFontFamily(fontName); CTFonr rFonts = run.getCTR().isSetRFonts() ? run.getCTR().getRFonts() : run.getCTR().addNewRFonts(); rFonts.setEastAsia(fontName); }这个方法建议直接沉淀成工具,后续所有段落都调它。正文段落的常规操作也一样,无非是fontSize、setSpacingAfter、行距这些。行距注意用setSpacingLineRule,比如1.5倍行距对应LineSpacingRule.AUTO,同时配setSpacingLine(360),这里的单位是240分之一磅。
3.2 图片插入:尺寸换算与图文混排
图片是另一个新手重灾区。POI的XWPFRun.addPicture方法需要传入宽度和高度,但单位是EMU,不是你熟悉的px或cm。换算关系是:1厘米 = 360000 EMU,1像素在96DPI下约等于9525 EMU。
如果直接拿图片原始像素传进去,Word里显示会异常大。正确做法是读取图片宽高,按想要的显示宽度等比例计算高度,再把厘米转成EMU:
private static void addPictureToParagraph(XWPFParagraph paragraph, String imgPath, double targetWidthCm) throws Exception { BufferedImage image = ImageIO.read(new File(imgPath)); int type = imgPath.toLowerCase().endsWith(".png") ? XWPFDocument.PICTURE_TYPE_PNG : XWPFDocument.PICTURE_TYPE_JPEG; int emuWidth = (int) (targetWidthCm * 360000); int emuHeight = (int) (emuWidth * image.getHeight() / (double) image.getWidth()); try (FileInputStream fis = new FileInputStream(imgPath)) { paragraph.createRun().addPicture(fis, type, imgPath, emuWidth, emuHeight); } }这里通过BufferedImage先拿原始宽高,再按显示宽度等比缩放,这样图片不会变形。注意addPicture会读取整个InputStream,传完再关闭,所以用try-with-resources包裹文件流非常稳妥。图片类型也要和文件真实格式一致,JPEG格式传PNG类型或者反过来,打开Word时都可能报错。
3.3 段落对齐与分页控制
图文混排时,图片所在段落一般需要居中:
paragraph.setAlignment(ParagraphAlignment.CENTER);分页则用BreakType.PAGE:
XWPFParagraph pageBreak = document.createParagraph(); pageBreak.createRun().addBreak(BreakType.PAGE);这些虽然都是小操作,但是不写的话,生成的文档排版会很乱。真实报告往往是标题、正文、图片、表格、附录这种结构,每一节都需要明确控制。
4. 目录生成:POI没有直接API,怎么办
4.1 目录字段的原理
这是全篇最核心的部分。POI没有XWPFDirectory这种现成类,但Word里的目录本质上是“域(Field)”的一种。在OOXML里,目录靠一组fldChar标签实现,结构是这样的:
<w:r> <w:fldChar w:fldCharType="begin"/> </w:r> <w:r> <w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText> </w:r> <w:r> <w:fldChar w:fldCharType="separate"/> </w:r> <w:r> <w:t>目录内容占位区</w:t> </w:r> <w:r> <w:fldChar w:fldCharType="end"/> </w:r>这段XML就是Word中“目录”域的内部结构。TOC开头的指令告诉Word:去扫描所有应用了内置标题样式“标题1”到“标题3”的段落,把它们收集成目录,并显示页码。所以要让目录自动识别章节,文档里的标题段落必须应用系统内置标题样式,而不是仅手动调大字号加粗。
4.2 源码实现:在文档中插入TOC指令
POI暴露了底层CTP对象,我们可以通过拼接上面的域代码实现目录。代码如下:
private static XWPFParagraph createTOCPlaceholder(XWPFDocument document) { XWPFParagraph paragraph = document.createParagraph(); CTP ctp = paragraph.getCTP(); CTR r1 = ctp.addNewR(); CTFldChar begin = r1.addNewFldChar(); begin.setFldCharType(STFldCharType.BEGIN); CTR r2 = ctp.addNewR(); CTText instr = r2.addNewInstrText(); instr.setStringValue(" TOC \\o \"1-3\" \\h \\z \\u "); CTR r3 = ctp.addNewR(); CTFldChar separate = r3.addNewFldChar(); separate.setFldCharType(STFldCharType.SEPARATE); CTR r4 = ctp.addNewR(); CTText placeholder = r4.addNewT(); placeholder.setStringValue("请右键点击此处,选择“更新域”生成目录。"); CTR r5 = ctp.addNewR(); CTFldChar end = r5.addNewFldChar(); end.setFldCharType(STFldCharType.END); return paragraph; }这里一个关键点是,begin、separate、end三个fldChar必须分别位于独立的CTRun里,不能混在同一个Run中。否则Word打开文档时会认为域结构损坏,直接报错或提示修复文档。
4.3 让Word打开时自动提示更新域
只插入TOC指令还不够。Word出于安全考虑,默认不会在打开文档时自动执行更新域的指令,用户打开后可能只看到提示文字,看不到真正的目录。解决办法是在document.xml的settings部分加上updateFields配置:
private static void enableAutoUpdateFields(XWPFDocument document) { CTDocument1 ct = document.getDocument(); CTSettings settings = ct.isSetSettings() ? ct.getSettings() : ct.addNewSettings(); CTOnOff updateFields = settings.isSetUpdateFields() ? settings.getUpdateFields() : settings.addNewUpdateFields(); updateFields.setVal(true); }加了这一段之后,用户用Word或WPS打开文档,会弹出一个“此文档包含的域可能需要更新”的提示,确认后目录就能自动生成。如果没弹出来,右键目录区域选“更新域”,或者按F9,也能手动刷新。建议在生成的文档开头加一句使用说明,免得非技术同事打开后以为没生成目录。
5. 表格、下划线样式与布局控制
5.1 表格宽度与单元格定宽的真正写法
POI设置表格宽度,是很多人的噩梦。只设置cell宽度经常没用,因为表格宽度、列宽、单元格宽度三个值必须逻辑一致,缺一个都白搭。正确的姿势是同时设置表格总宽、gridCol各列宽和每个单元格的宽:
XWPFTable table = document.createTable(3, 3); table.setWidth("100%"); CTTbl ctTbl = table.getCTTbl(); CTTblPr tblPr = ctTbl.getTblPr() == null ? ctTbl.addNewTblPr() : ctTbl.getTblPr(); // 固定表格布局 TblLayout layout = tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); // 表格总宽,单位是twip,1厘米约等于567 twip CTTblWidth tblW = tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW(); tblW.setW(BigInteger.valueOf(2835)); // 5cm tblW.setType(STTblWidth.DXA); // 列宽 CTTblGrid grid = ctTbl.getTblGrid() == null ? ctTbl.addNewTblGrid() : ctTbl.getTblGrid(); for (int i = 0; i < 3; i++) { CTGridCol col = grid.addNewGridCol(); col.setW(BigInteger.valueOf(945)); } // 每个单元格宽度 for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); CTTblWidth tcW = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); tcW.setW(BigInteger.valueOf(945)); tcW.setType(STTblWidth.DXA); } }如果不设置表格布局为FIXED,Word在排版时还是会根据内容自动调整列宽,导致你设的值失效。写表格时这个细节一定要加。
5.2 用下边框实现“线不动输入”的填空效果
很多做记录表、合同模板的需求会遇到这个问题:用户希望在空白的下划线上方打字,但下划线不能跟着文字往后跑。用文字加下划线的方式做不到,因为文字撑开下划线字段长度,线条一定会动。
正确做法是用“段落下边框”模拟下划线。给段落底部画一条横线,用户在线上方输入任意长度的文字,底线始终固定在段落底部:
private static void addBottomBorderLine(XWPFParagraph paragraph) { CTPPr pPr = paragraph.getCTP().getPPr() == null ? paragraph.getCTP().addNewPPr() : paragraph.getCTP().getPPr(); CTPBdr pBdr = pPr.isSetPBdr() ? pPr.getPBdr() : pPr.addNewPBdr(); CTBorder bottom = pBdr.isSetBottom() ? pBdr.getBottom() : pBdr.addNewBottom(); bottom.setVal(STBorder.SINGLE); bottom.setSz(BigInteger.valueOf(8)); bottom.setColor("000000"); paragraph.createRun().setText(""); }这样一个空段落底部就有一条直线,效果和真正下划线几乎一致,又不会因为输入内容而移动。表格里做填空题、签名栏时,这个方法可以说是解锁了刚需场景。
6. 完整源码示例
6.1 综合示例代码
上面所有功能点整合起来,从零生成一份带目录、图片、表格和填空线的Word文档。完整可运行代码如下:
public class WordReportGenerator { public static void main(String[] args) throws Exception { XWPFDocument document = new XWPFDocument(); // 1. 标题 XWPFParagraph title = document.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun titleRun = title.createRun(); titleRun.setText("POI生成Word实战示例"); titleRun.setBold(true); titleRun.setFontSize(22); setChineseFont(titleRun, "微软雅黑"); // 2. 目录区 createTOCPlaceholder(document); enableAutoUpdateFields(document); // 3. 一级标题 XWPFParagraph h1 = document.createParagraph(); XWPFRun h1Run = h1.createRun(); h1Run.setText("第一章 概述"); h1Run.setBold(true); h1Run.setFontSize(16); setChineseFont(h1Run, "微软雅黑"); h1.getCTP().getPPr().addNewPStyle().setVal("Heading1"); // 应用标题1样式 // 4. 正文段落 XWPFParagraph body = document.createParagraph(); XWPFRun bodyRun = body.createRun(); bodyRun.setText("这是正文内容,用于演示POI插入普通文字段落。"); bodyRun.setFontSize(12); setChineseFont(bodyRun, "宋体"); // 5. 插入图片 XWPFParagraph imgPara = document.createParagraph(); imgPara.setAlignment(ParagraphAlignment.CENTER); addPictureToParagraph(imgPara, "cover.png", 12); // 6. 表格 XWPFTable table = document.createTable(2, 2); setupTableWidth(table, 5, 5); // 7. 填空题下划线 XWPFParagraph linePara = document.createParagraph(); addBottomBorderLine(linePara); try (FileOutputStream fos = new FileOutputStream("report.docx")) { document.write(fos); } document.close(); System.out.println("生成成功"); } // 本章前面所有工具方法都粘贴到这里即可运行 }6.2 运行结果验证
生成后直接双击打开report.docx,正常能看到标题格式、图片居中、表格宽度固定,以及目录占位区。如果Word弹窗询问是否更新域,点“是”就能看到真正的目录。
很多人打开文档发现目录区只有“请右键点击此处更新域”这几句话,就以为代码失败了,其实不是。POI生成的是目录域指令,页签到Word/WPS里去刷新。这是所有动态生成Word目录的方案都无法绕开的机制。
7. 常见问题与排查技巧实录
7.1 高频问题速查表
把实际开发里经常出现的几个坑整理成一张表,直接对着排查:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 打开文档提示文件损坏或需要修复 | TOC字段的begin/end顺序或Run结构不正确 | 检查fldChar是否各自独占一个CTR,严格按begin、instrText、separate、end顺序生成 |
| 中文显示正常但字体不是想要的 | 只设置了西文字体,未设置eastAsia | 用rFonts.setEastAsia显式设置中文字体 |
| 图片插入后尺寸异常大 | 直接传了像素值,没有换算成EMU | 参考厘米转EMU的公式,按比例计算 |
| 表格宽度设置后无效 | 只设置了单元格,未设置表格布局及总宽度 | 表格布局设为FIXED,同时维护tblW、gridCol、tcW |
| 打开文档目录只有文字没页码 | Word未更新域或没启用updateFields | 添加updateFields配置,或打开后按F9手动更新 |
| 运行时报NoSuchMethodError | 项目里poi核心包版本冲突 | 用mvn dependency:tree检查版本,统一poi与poi-ooxml版本 |
| 大文档生成时内存溢出 | 大量图片或超长内容撑爆内存 | 调整ZipSecureFile限制或分批写入段落,必要时升级到64位JVM |
7.2 排查思路与避坑建议
遇到POI相关的问题,我的排查顺序永远是“先简化场景,再验证最小代码”。比如目录不生效,就新建一个只含标题+TOC指令的文件,看能不能更新出来;图片显示不对,就单独写一个只插入图片的类跑。快速隔离变量,比反复查看生成结果直观得多。
另外,生成完docx后,建议把文件名后缀改成zip解压,直接看word/document.xml里的内容。POI输出的XML和标准Word结构哪里有偏差,肉眼对照一次就明白了。这个习惯能帮你解决80%的隐藏问题。
实际跑下来,用POI生成Word这件事,最大的成本不是API不熟,而是细节太多。中文字体、图片单位、表格宽度、目录域,每一个都能让人卡两三个小时。把这些基础工具函数沉淀好,后面所有报告模板都可以复用同一套代码,效率会高很多。最后再分享一个小技巧:代码里所有setText之后如果发现文字丢失,多半是CTText的xml:space没有设置preserve,遇到时检查一下源码生成出的XML属性,很快能找到问题。
本文还有配套的精品资源,点击获取