ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Apache POI操作Word实战:表格、模板与图表全解析

Apache POI操作Word实战:表格、模板与图表全解析 如果你是一个Java后端最近被“把服务器上的数据整理成Word文档导出”这种需求缠住了那么Apache POI大概率是你绕不过去的坎。这里的POI不是地图上的兴趣点Polygon of Interest而是Apache旗下那个快二十岁的Java开源组件库。网上关于POI操作Excel的教程一抓一大把但操作Word的完整案例反而零散很多细节要靠自己翻源码、试错才能弄明白。这篇东西我就把自己用POI生成Word、改Word模板、处理表格宽度和图表数据替换的实战经验一次性倒出来目标很明确让看完的人能直接动手写一个能上生产的Word导出工具。先说清楚整篇围绕的是POI操作.docx格式XWPF模块不是老的.docHWPF模块。前者是Office Open XML标准数据结构本质是一个zip包里面有document.xml、styles.xml、numbering.xml等一堆XML文件POI做的事情就是帮你把这堆XML包成一个易用的对象模型。理解了这一层后面遇到的很多诡异问题就都好解释了。1. POI操作Word的真实边界能做什么别抱什么期望很多初学者把POI当成“能用Java写Word的万金油”上手就想去动态排一个几十页的精美标书。我的建议是趁早打消这个念头。POI对Word的支持程度和它自己对Excel的支持程度完全不在一个量级。Excel那边可以折腾透视表、图表、条件格式Word这边能稳定驾驭的是段落、文字、表格、图片、页眉页脚、基础样式和域。像“首行缩进两个字符”“标题自动编号”“目录自动更新”这种排版细节要么需要你自己拼底层XML要么干脆做不到位。表格这块我要单独拎出来强调因为POI生成Word里九成以上的技术问题都出在表格上。单元格合并、列宽控制、行高设置、单元格内边距这些需求都能做但写法非常反直觉直接调XWPFTable的setWidth方法往往不生效后面我会专门讲。此外POI对Word中的复杂列表尤其是中文多级编号“一、一1.”这种支持很差我的经验是不要在代码里动态做多级编号而是把编号样式提前做在模板里生成时只填内容。这是很多项目的通用做法也是效率最高的方案。还有一点容易踩POI对宏和VBA的支持非常有限涉及宏的Word处理基本不要考虑用POI做。如果客户给了一个带宏的docm文件让你程序替换数据最靠谱的办法是生成新文件之后再用其他工具去跑宏或者直接和客户确认能否用纯docx格式。版本方面我建议直接用4.1.2以上的poi-ooxml目前5.x也很稳。旧版本里很多API和方法都变了网上老教程的代码经常编译不过。如果你用Maven注意poi-ooxml在4.1.2之后默认可能不带完整schema类如果出现ClassNotFoundException补一个poi-ooxml-full依赖就行。2. 用XWPF读写段落与文字先让文档能跑起来一个最简的POI生成Word流程很简单创建XWPFDocument创建段落创建RunsetText写文件。但这里有几个细节会影响你后续所有的代码设计。先看基础代码import org.apache.poi.xwpf.usermodel.*; import java.io.FileOutputStream; public class DemoWord { public static void main(String[] args) throws Exception { try (XWPFDocument doc new XWPFDocument()) { XWPFParagraph p1 doc.createParagraph(); XWPFRun r1 p1.createRun(); r1.setText(这是一段测试文字); r1.setBold(true); r1.setFontSize(14); XWPFParagraph p2 doc.createParagraph(); XWPFRun r2 p2.createRun(); r2.setText(第二段内容); r2.setColor(FF0000); try (FileOutputStream out new FileOutputStream(test.docx)) { doc.write(out); } } } }这里面最关键的概念是Run。在OpenXML模型里一个段落Paragraph由若干Run组成每个Run代表一段连续、共享相同格式的文本。所以你想在同一个段落里让一部分字变红、一部分加粗就要拆成两个Run分别设置而不是一个Run里切来切去。很多人在一个Run里连续调用多次setText结果发现只有最后一次生效。正确做法是XWPFRun run paragraph.createRun(); run.setText(第一行); run.addBreak(); run.setText(第二行);addBreak是在Run内部插入换行符这样两行字会出现在同一个段落里。如果你确实需要多个段落就要createParagraph创建多个段落对象。2.1 中文宋体、黑体不生效的根源这是POI新手必踩的坑明明调用了run.setFontFamily(宋体)生成的Word里中文还是默认的等线字体。原因是Word的字体体系里西文字体和中文字体是两个独立字段。setFontFamily(宋体)设置的其实是ASCII字体ascii和High Ansi字体hAnsi而中文走的是EastAsia字段。解决办法有两种// 方式一POI封装的枚举 run.setFontFamily(宋体, XWPFRun.FontCharRange.eastAsia); // 方式二直接操作底层CTRPr CTRPr rpr run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); rpr.addNewRFonts().setEastAsia(宋体);千万别小看这个细节很多“生成的文档打开以后字很怪”的反馈根子就在这里。2.2 标题和正文结构用pStyle代替手工调字号加粗Word的标题之所以是标题不是因为字号大、加粗了而是它的段落样式StylepStyle值是Heading1、Heading2。这个区别决定了你能不能生成“真正结构化”的文档目录能识别、导航窗格能跳转、多级列表能关联。XWPFParagraph heading doc.createParagraph(); heading.setStyle(Heading1); XWPFRun hr heading.createRun(); hr.setText(第一章 项目概述);如果你的模板里没有定义Heading1样式这句话不生效Word会用默认样式。所以更稳的做法是操作预先设计好的模板或者手动给段落设置大纲级别CTP ctp heading.getCTP(); ctp.addNewPPr().addNewOutlineLvl().setVal(BigInteger.valueOf(0)); // 0是一级1是二级outlineLvl决定了这个大纲在Word导航窗格里属于第几级这是“生成几十页结构化文档”时真正起作用的东西。3. 表格单元格宽度POI里最折磨人的一件事如果你在网上搜“POI 设置Word表格单元格宽度”能找到大量帖子说明这个问题有多烦。我用了三四年POI也直到最近才彻底弄明白它背后的机制。先说结论单纯调用table.setWidth()或cell.setWidth()在多数情况下不会按你的预期生效因为Word表格存在自动调整autofit机制它会忽略你设置的单元格宽度自行根据内容伸缩。要让宽度设置真正生效必须同时满足三个条件表格布局必须是固定布局fixed layout也就是底层XML里w:tblLayout w:typefixed/。表格整体宽度tblW要设置。每个单元格的宽度tcW要设置不能只设表头。而这三个条件在POI里的API很隐晦。XWPFTable.setWidth(String)设置的是tblWXWPFTableCell.setWidth(String)设置的是tcW但固定布局的type得去底层XML对象上设置。我写了一个工具方法直接搬过去就能用import org.apache.poi.xwpf.usermodel.*; import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; import java.math.BigInteger; import java.util.Arrays; import java.util.List; public class WordTableUtil { /** * 设置表格固定布局 各列宽度 * 单位twips1厘米 567twips1英寸 1440twips */ public static void setTableFixedWidth(XWPFTable table, int[] columnWidthsTwips) { // 1. 表格布局fixed CTTblPr tblPr table.getCTTbl().isSetTblPr() ? table.getCTTbl().getTblPr() : table.getCTTbl().addNewTblPr(); CTTblLayoutType layout tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); // 2. 表格总宽度 int totalWidth Arrays.stream(columnWidthsTwips).sum(); table.setWidth(String.valueOf(totalWidth)); // 3. 表格网格定义tblGrid里的列宽 CTTblGrid tblGrid table.getCTTbl().getTblGrid(); CTTblGridCol[] gridCols tblGrid.getGridColArray(); for (int i 0; i gridCols.length; i) { if (i columnWidthsTwips.length) { gridCols[i].setW(BigInteger.valueOf(columnWidthsTwips[i])); } } // 4. 每一行的每个单元格都设置tcW for (XWPFTableRow row : table.getRows()) { ListXWPFTableCell cells row.getTableCells(); for (int i 0; i cells.size(); i) { if (i columnWidthsTwips.length) { cells.get(i).setWidth(String.valueOf(columnWidthsTwips[i])); } } } } }3.1 为什么明明设了宽度Word打开还是不对我把踩过的坑总结成两类你们可以自查第一类tblGrid的数量和实际单元格列数对不上。当你用table.createRow()、table.createCell()的方式动态建表时POI底层生成的tblGrid很可能没有同步补充新的gridCol或者多了一些空列。这样Word在解析时就会拿tblGrid的列数去套实际单元格导致错位。解决方法是建完表格后检查tblGrid的gridCol数组长度不够就用tblGrid.addNewGridCol()补多了就删掉。第二类没有考虑页面可用宽度。A4纸宽度是21厘米换算成twips大概是11907但页面左右默认有1英寸约1440twips的页边距所以表格可用宽度实际只有约9027twips。如果你把表格总宽设成12000甚至更宽Word打开后会显示表格溢出页面边界拖到页面外看起来就像“列宽拖动失效了”。我习惯在写表格前先算好int pageWidthTwips 11907; // A4 int leftMargin 1440; // 取决于模板/节边距 int rightMargin 1440; int tableMaxWidth pageWidthTwips - leftMargin - rightMargin;然后把各列宽度的总和控制在这个值以内。这里还没有算单元格边距和边框如果设置了较大的cellMargin实际还要再扣一部分。3.2 合并单元格之后列宽又乱了怎么办表格里做合并是很常见的需求。POI合并不是“合并两个cell对象”而是通过底层xml在单元格上追加w:gridSpan或w:vMerge。比如横向合并两列XWPFTableCell cell row.getCell(0); CTTcPr tcPr cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); tcPr.addNewGridSpan().setVal(BigInteger.valueOf(2));然后第二列那个被合并的单元格需要删除或留空。但这里有个坑合并后tblGrid的列数不变但某些行的实际单元格数变少了你如果还拿固定的widths[i]去给每个cell设置宽度就会把被合并单元格的宽度错配到下一格上。我的经验是合并场景下只给每个实际存在单元格按合并跨度累加设置tcW。例如第一列宽2000、第二列宽1500合并后那个跨两列的cell直接设width为3500后续单元格从3500之后继续排。4. 生成图表与图表数据替换两条靠谱的路线热搜里有个很经典的问题“java poi word能生成图表吗”。直接回答POI本身能做但能力比较弱且复杂。POI从4.x开始引入了XWPFChart可以往Word里塞内置图表对象但你得先创建一个chart部件绑定一个内嵌的Excel工作簿然后定义系列、类目、数据引用……那个复杂度自己写一次就知道有多酸爽。所以我的建议是除非你的图表需求非常简单且固定否则不要用POI去从头创建图表。实际项目里更常见的是模板里预先放好了一个Word图表比如“月度销售额趋势图”程序跑完数据后去更新这个图的源数据让Word重新计算并显示新数据。这条路线是行的通的但需要理解docx的图表结构。4.1 docx里的图表数据到底存在哪一个包含图表的docx解开zip后你会看到类似这样的结构word/charts/chart1.xml图表定义包括类型、系列、类目、以及用于渲染的历史缓存数据numCache/strCache。word/embeddings/EmbeddedObject1.xlsx图表关联的内嵌Excel数据源里面有图表引用的单元格区域。Word打开文档时图表显示的其实是chart1.xml里的缓存数据cache而不是直接读EmbeddedObject。但如果你想刷新图表数据正确方式是把EmbeddedObject里的单元格改掉同时把chart1.xml里的cache也改掉否则会出现“图没变”或者“Word报错修复”。这里要特别小心只改其中一个另一个就会和数据源不一致轻则显示旧数据重则文件损坏打不开。我的一个稳定做法是直接用ZipInputStream读docx的entry找到word/embeddings/EmbeddedObject*.xlsx用XSSFWorkbook把它读进来改对应Sheet的单元格再写回ByteArrayOutputStream然后重写整个zip包。chart1.xml里的cache可以同步修改也可以选择不修改但必须保证和源数据一致。如果嫌麻烦一个更讨巧的办法是图表不保留数据联动而是用Java图形库比如JFreeChart、XChart把数据画成图片再插入Word。这样肯定能渲染出漂亮的图但代价是图片不能编辑。三条路线对比如下路线可维护性图表可编辑性实现成本适用场景用XWPFChart新建图表中好高图表类型固定、要求原生图表替换模板中图表的内嵌Excel数据中好中模板固定、批量替换数值用图形库生成图片插入Word高无图片低样式要求自由、不要求可编辑4.2 替换内嵌Excel数据源的核心代码思路下面这个代码片段展示了读取docx压缩包中嵌入的xlsx并替换数据的核心逻辑实际使用时要自己封装成工具类import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.openxml4j.opc.OPCPackage; import java.io.*; import java.util.zip.*; public class ReplaceWordChartData { public static void replaceChartData(String templatePath, String outputPath) throws Exception { try (ZipInputStream zin new ZipInputStream(new FileInputStream(templatePath)); ZipOutputStream zout new ZipOutputStream(new FileOutputStream(outputPath))) { ZipEntry entry; while ((entry zin.getNextEntry()) ! null) { String name entry.getName(); if (name.matches(word/embeddings/EmbeddedObject\\d\\.xlsx)) { // 读取嵌入的xlsx byte[] xlsxBytes zin.readAllBytes(); XSSFWorkbook wb new XSSFWorkbook(new ByteArrayInputStream(xlsxBytes)); var sheet wb.getSheetAt(0); // 假设数据在B2:B8直接覆盖 for (int i 2; i 8; i) { sheet.getRow(i).getCell(1).setCellValue(/*你的新数值*/); } ByteArrayOutputStream baos new ByteArrayOutputStream(); wb.write(baos); wb.close(); zout.putNextEntry(new ZipEntry(name)); zout.write(baos.toByteArray()); } else { zout.putNextEntry(new ZipEntry(name)); byte[] buf new byte[4096]; int len; while ((len zin.read(buf)) 0) { zout.write(buf, 0, len); } } zout.closeEntry(); } } } }注意这个方案要求模板里的图表本身是“内置图表”不是粘贴的图片。判断方法很简单word里有charts目录和embeddings目录就说明图表是内置的。4.3 关于chart缓存不同步导致文件打不开的问题如果你只用上面的方式改xlsx没有动chart1.xml那么Word打开时可能有两种表现。一是打开后图表显示旧值但点击图表“编辑数据”能看到新值说明缓存和新数据不一致。二是更严重的Word直接提示“文件已损坏是否修复”某些情况下修复后图表丢失。根因在于chart1.xml里的cache和嵌入xlsx的内容不一致。开源世界里做这个改动的项目不少但实现都很克制。我自己稳妥的做法是在更新EmbeddedObject的xlsx时同时解析chart1.xml找到c:numCache结构把里面的c:pt值同步替换成新数值。这个操作更精细但需要熟悉chart XML的结构。如果你们的项目没有严格的原生图表编辑需求我真的建议走图片插入路线省心太多。5. 多级标题、页码、页边距让文档不像是程序生成的自动生成的Word文档最容易被嫌弃的一点就是“一眼假”标题层级混乱目录不显示页脚没有页码字体不统一。这一节我专门说说POI怎么处理这些容易被忽略的排版细节。5.1 多级标题与多级列表编号前面提到多级标题的核心是pStyle配合Heading1/Heading2样式。文档里的“多级自动编号”比如1、1.1、1.1.1其实依靠的是numbering.xml里定义的多级列表编号规则。POI对numbering.xml的创建支持非常弱在代码里动态构建一套完整的中文多级编号太痛苦了成功率和Word版本兼容性也很难保证。我强烈建议的做法是在Word里先做好一个“模板.docx”把多级列表编号、标题样式、目录域、页眉页脚全部预设好然后POI只负责往里面填内容。这样生成出来的文档排版稳定运行速度快逻辑也简单。模板技术我会在下一节展开。如果你确实需要在代码里保证某个段落按“二级标题”显示直接这样paragraph.setStyle(Heading2);如果担心模板里没定义Heading2就用CTP去设置outlineLvl为1。这至少能保证文档结构上有层级即使显示样式不完美导航窗格和目录也能识别。5.2 页脚页码域的插入方法页脚的页码不是普通文字它是域Field。Word里按CtrlF9插入的那种花括号就是域。POI里要插入页码域直接往页脚段落里塞“PAGE”这个域指令import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; XWPFHeaderFooterPolicy policy doc.createHeaderFooterPolicy(); XWPFParagraph footerPara policy.createFooter(XWPFHeaderFooterPolicy.DEFAULT).createParagraph(); CTP ctp footerPara.getCTP(); // 域开始 CTFldChar begin ctp.addNewFldChar(); begin.setFldCharType(STFldCharType.BEGIN); // 域指令文本 CTText instrText ctp.addNewInstrText(); instrText.setStringValue(PAGE \\* MERGEFORMAT); // 域结束 CTFldChar end ctp.addNewFldChar(); end.setFldCharType(STFldCharType.END);需要注意的是doc.createHeaderFooterPolicy()这个API在POI的不同版本里行为有差异。如果报错可以尝试直接操作doc.getDocument().getBody().addNewSectPr()下的headerReference/footerReference来追加。这里面的XML引用关系比较绕建议先把Policy用起来跑不动再去查引用。5.3 页边距与全局样式页边距对应的是section属性里的pgMar。在document.xml的sectPr里设置CTSectPr sectPr doc.getDocument().getBody().addNewSectPr(); CTPageMar pageMar sectPr.addNewPgMar(); pageMar.setTop(BigInteger.valueOf(1440)); pageMar.setBottom(BigInteger.valueOf(1440)); pageMar.setLeft(BigInteger.valueOf(1440)); pageMar.setRight(BigInteger.valueOf(1440));单位同样是twips。1440就是1英寸。很多小伙伴设置完页面边距后发现表格宽度的计算又要跟着变没错这两者往往是联动的。如果你先设置了固定页边距再套用我前面的setTableFixedWidth方法记得把边距值代入总宽计算不能拿默认值硬算。还有一个小技巧设置文档默认字体可以通过操作styles.xml的docDefaults节点。在POI里可以用doc.getStyles()拿到XWPFStyles对象再取默认字体。不过很多模板的docDefaults已经由Word写好了你不要去覆盖它只改自己要改的段落即可。否则会影响到模板里已经调好的中文标题样式。6. 模板占位符替换与批量处理写一个能上线的工具类真正的生产级需求很少是“从零新建一个空文档”。更多场景是拿到一个做好的Word模板里面有公司名、合同编号、表格明细、甚至嵌好的图表程序只需把对应占位符替换掉输出成新文件。用模板有几个好处。第一版式好控制字体字号缩进这些都在Word里调好。第二样式体系稳定多级编号、页眉页脚、图表、目录都现成。第三性能上通常比代码硬画一套文档要好因为样式定义都在第一次加载时解析。6.1 占位符为什么会被替换失败最常见的做法是在模板里写${companyName}这种占位符然后程序扫描所有段落、表格单元格里的文本发现占位符就替换。逻辑听起来简单实际跑起来经常发现“有的替换了有的没替换”。原因是Word会把一段看似连续的文本拆成多个Run——这取决于用户编辑时的输入法、样式切换、修订记录等。比如“${companyName}”这个字符串在XML里可能被拆成三个Run${compan、yName、}。你如果直接循环每个Run找包含${companyName}的Run永远找不到完整字符串。解决思路是在每个段落级别先把所有Run的文本拼出来看拼起来的整段是否包含占位符如果包含则选取第一个Run或新建一个Run写入替换后的完整文本其余Run清空。注意保留第一个Run的字体格式这样替换后的中文字体、字号才和原来一致。大概逻辑如下public static void replaceTextInParagraph(XWPFParagraph para, String placeholder, String replacement) { StringBuilder sb new StringBuilder(); for (XWPFRun run : para.getRuns()) { sb.append(run.text()); } if (!sb.toString().contains(placeholder)) { return; } String newText sb.toString().replace(placeholder, replacement); // 清空所有run把替换文本写到第一个run ListXWPFRun runs para.getRuns(); for (int i 1; i runs.size(); i) { runs.get(i).setText(, 0); } runs.get(0).setText(newText, 0); }还要注意替换文本本身如果有换行比如要填入多行地址不要把\n直接setTextWord里不会有换行效果。要新建Run然后调用addBreak()或者直接插入一个w:br/。6.2 遍历文档中的所有段落和表格替换占位符时不能只遍历doc.getParagraphs()因为表格里的单元格也包含段落它们是嵌套在表格节点里的。正确做法是遍历body elementfor (IBodyElement element : doc.getBodyElements()) { if (element instanceof XWPFParagraph) { replaceInParagraph((XWPFParagraph) element); } else if (element instanceof XWPFTable) { for (XWPFTableRow row : ((XWPFTable) element).getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph para : cell.getParagraphs()) { replaceInParagraph(para); } } } } }如果表格里还嵌套了表格就要写成递归别只做一层。6.3 模板复用、并发与多文档导出如果你们的接口要同时处理几十个模板、几百条数据有个性能和安全问题必须提前设计XWPFDocument不是线程安全的。同一个模板文件不能被多个线程同时解析修改。两种常用策略每次生成一个文档从模板文件new一个XWPFDocument实例互不干扰。代价是每次都要重新读模板、解析XML如果模板很多、很大性能会受影响。提前把模板读成byte[]缓存每次请求来了new ByteArrayInputStream(bytes)再new XWPFDocument这样避免了重复IO线程之间也不共享可变对象是生产环境里最常用的做法。另外批量导出多个文档时你看热词里也有“多表单导出”建议每个文档生成后立即用ZipOutputStream校验一下能不能正常打开最简单的校验是重新打开生成的docxtry (XWPFDocument check new XWPFDocument(new FileInputStream(output))) { // 至少能解析成功 }然后再发送给下游。这个校验成本不高但能把“生成完才发现文档损坏”的事故挡在出口之前。7. 踩坑实录修改数据后打不开、列宽拖不动这类问题的排查链路我把自己和身边同事在POI Word上遇到的高频问题、排查链路和根因整理成下面这张表。如果你也遇到类似问题照着这个顺序排查能省很多时间。问题现象常见根因排查链路解决方向修改图表数据后生成的Word无法打开chart1.xml缓存与内嵌xlsx数据不一致或者重写zip时entry损坏先用压缩软件打开docx看word/charts和word/embeddings是否完整再用XML工具校验chart1.xml里的cache是否和xlsx数值匹配同步更新cache或者改用图片图表重写zip时不要重复建同名entry表格列宽拖不动或者拖动后乱跳表格不是fixed布局或tblGrid没有正确同步打开document.xml搜tblLayout看type是autofit还是fixed看tblGrid列数和每行列数是否一致用上面的setTableFixedWidth方法整体设置中文变成方框或乱码字体没设置EastAsia字段看生成文档里该段落的rFonts节点确认eastAsia属性设置run.setFontFamily(宋体, FontCharRange.eastAsia)Word提示“文件已损坏是否恢复”用了简化的zip写入丢失了docx必需的文件或[Content_Types].xml被改动用7-Zip对比原始docx和新生成docx的entry列表看有没有多出/缺失的文件不要手工拼zip尽量基于原始模板做替换设置了标题样式但目录不显示标题生成的段落没有真正使用Heading样式或outlineLvl缺失在Word里按住Ctrl点击目录看“更新目录”能否出来检查段落样式是否Heading1设置pStyle或设置outlineLvl替换占位符后不见了但文本还在替换逻辑把Run清空时把有内容的run误清检查所有Run合并后的文本和清空逻辑是否所有run都被清空只剩第一个只保留第一个run写入其他run的text清空但保留格式这里我特别想展开“修改数据后无法打开生成的Word”这个case因为这几年遇到的人特别多。很多人改了内嵌xlsx后会想当然地用一个自己拼的ZipOutputStream重写docx结果漏掉了zip包里的目录项Central Directory或压缩方式异常导致Word解不开。排查办法很简单先把生成的docx用7-Zip/WinRAR打开看能不能正常列出所有文件再双击document.xml看能不能预览。如果压缩包本身解不开那问题大概率不在POI而在你写zip的代码上。另外有同事用OPCPackage去打开docx然后save到新文件结果把模板里一些关系的target路径改坏了也会导致打不开。这种场景下推荐把docx当普通zip处理不要用POI的OPCPackage做整体另存。7.1 一次完整的修复过程复盘一次真实经历我们有一个合同模板里面嵌了一个“年度费用走势图”程序跑完数据后把EmbeddedObject里的数据替换了。本地开发测试都正常到了客户机器上有一台Office版本较旧的电脑报“word无法打开”。一开始以为是Office版本问题后来用7-Zip对比新旧docx发现所有文件都在但chart1.xml里的c:ptCount值和xlsx里的行数不一致——因为旧版Office对缓存中的ptCount校验更严格直接判定文件结构异常。修复方式在替换xlsx数据的同时按新的行数重算c:ptCount并同步改写每个c:pt idx节点的v值。改完后在客户机器上验证通过。这个案例说明POI生态下Word生成常常不是“写对了就行”而是要确保生成结果符合Word自身的数据一致性校验。遇到打不开永远第一个怀疑docx内部xml的引用关系和数据一致性而不是去怀疑Office装坏了。8. 最后多聊几句怎么设计一个真正好用的Word导出模块工程上我倾向于把“Word导出”单独设计成一个模块而不是散落在业务代码里。模块里至少包含三层第一层是模板管理。所有Word模板统一放在resources或文件系统模板文件必须有版本号模板的改动要能追溯。因为模板里的占位符一旦被业务代码引用任何人都不能随意改名否则线上必炸。第二层是数据装配。把业务数据转换成模板需要的Map结构这个过程要和实际模板解耦。不要让业务Service直接去操作XWPFDocument而是让Service返回一个MapString, Object由Word模块统一渲染。这样以后换模板、换样式业务代码一行不用动。第三层是产物校验。生成完docx后统一做一次“重新打开”校验并记录文件大小、生成耗时、占位符是否全部替换完毕。尤其要注意替换完成后要检查是否还有残留的${字符那通常意味着数据没配上if (doc.getParagraphs().toString().contains(${)) { throw new IllegalStateException(存在未替换的占位符); }另外如果你需要把生成的docx转成PDF供预览或归档POI本身不负责这件事推荐用LibreOffice headless模式转换命令行大概是这样soffice --headless --convert-to pdf output.docx可以在CI流水线里每次构建后自动把样例输出转成PDF用PDF渲染结果来检查排版。这比肉眼打开Word一个个检查快得多。根据我个人经验POI生成Word项目能不能少踩坑很大程度取决于一开始就选择模板驱动而不是代码硬排离开这个原则再多的技巧都救不了复杂版式。最后再分享一个小技巧如果占位符需要替换的值特别多比如几百个建议做一次完整的Map遍历时统一跑到同一个段落处理器里避免页面上下文被重复读取这样性能能提升不少。如果你正在规划一个Word导出功能能从这篇里带走那几个表格宽度的底层逻辑我就很满足了。
返回列表