像大多数被“HTML 转 Word”折磨过的后端开发一样,我曾经也以为这条路只有 POI 硬啃或者 LibreOffice 转码两条道。直到项目里必须在内网离线环境处理一堆来自 CMS 的富文本内容,还要保表格、保图片、保基础样式,我才认真试了试 docx4j 这套组合拳。今天这篇就把我从搭建到踩坑的完整过程写出来,希望能帮那些正在选型或者已经被转换质量搞得焦头烂额的人省点时间。
docx4j 是一个基于 Java 的 docx(Office Open XML)操作库,它最大的特点是直接面向 docx 的底层 XML 结构做操作,而不是像 POI 那样在高层 API 和底层结构之间来回折腾。配合 docx4j-ImportXHTML 这个扩展模块,它能将 XHTML 内容解析并映射成 docx 的段落、表格、图片等元素,从而实现从 HTML 到 Word 的自动化转换。这套方案适合的典型场景包括:CMS 内容导出为 Word 报告、在线编辑器内容存档为 docx、批量生成标准化合同或公文初稿。如果你也在做类似的功能,而且受限于技术栈纯 Java、不能上容器服务、又对输出格式有一定要求,那这篇文章就是为你准备的。
在开始之前,先交代一下我的实际环境:JDK 8(生产环境老项目,别笑)、Spring Boot 2.x、Maven 管理依赖、内网部署无外网。这些限制直接影响了后面很多选型决策。以下所有代码示例都基于这个环境做过完整验证,但核心 API 在 JDK 11 甚至 17 上使用也没有问题,只是需要注意模块化和依赖版本。
1. 为什么是 docx4j 而不是 POI 或 LibreOffice
1.1 现有方案的痛点对比
做 Java 的人第一反应肯定是 Apache POI,它在读写 xls、xlsx 方面确实统治级,但到了 Word 这块,情况微妙得多。POI 的 XWPF 组件对 docx 的支持主要集中在段落、表格、图片等基础元素,对于复杂样式、嵌套结构、页眉页脚的处理相当吃力。更致命的是,POI 没有一个官方的“HTML 转 Word”方案,你只能自己写解析器把 HTML 的节点树翻译成 XWPF 的 run 和 paragraph。我自己第一次试的时候,光是处理嵌套列表和合并单元格就写了近千行代码,最后效果还不稳定。
LibreOffice 的 headless 转换是另一个常见思路,它能做到很好的保真度,但问题是:它是个独立的桌面应用,需要部署在服务器上,占用几百 MB 内存,而且中文字体渲染依赖系统的字体库。在内网环境里装这些依赖,运维同学大概率会给你脸色看。此外,通过命令行转换意味着多一次进程调用,性能上没法跟纯 Java 库比。
docx4j 和 docx4j-ImportXHTML 的组合,恰好把这两个方向的痛点都补上了:它是纯 Java 库,无外部依赖;它有专门的 XHTML 导入模块,能把解析和转换这件事收敛到配置和映射上,而不是你自己从头造轮子;它的底层直接操作 docx 的 XML,理论上只要是 Word 能表示的格式,它都能映射。
1.2 docx4j 的核心模型与优势
要理解 docx4j 为什么适合这个场景,得先简单了解它的架构。docx4j 把 docx 文件看作一个 package,里面有 word/document.xml 存储正文,word/media/ 存储图片等资源,word/styles.xml 存储样式表。docx4j 的WordprocessingMLPackage就是这个 package 的 Java 对象模型,所有操作最终都是对这个模型的增删改查,保存时再序列化回 docx 文件。
docx4j-ImportXHTML 的工作方式,是借用 XHTML 的 DOM 树,把它逐节点翻译成 docx 的元素。它内部使用 XmlUtils 工具类,支持 XPath 定位、节点克隆等操作,在需要微调时特别方便。这也意味着,如果你对 docx 的 XML 结构有一定了解,你就能非常精准地控制转换结果;反过来说,如果完全不懂 XML,调试时会比较痛苦。这一点在我后面处理样式丢失问题时体会特别深,也算是提前给后来者打个预防针。
2. 环境准备:Maven 依赖与版本选择的那些坑
2.1 依赖坐标与版本匹配
docx4j 有两个主要版本线,8.x 和 11.x。8.x 是很多老项目的首选,稳定、资料多,但对较新的 JDK 支持一般;11.x 重构了包名和部分 API,适合新项目。我生产环境是 JDK 8,所以用了 8.3.3 版本,搭配 docx4j-ImportXHTML 8.3.3,版本号保持一致最省心。
<dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j</artifactId> <version>8.3.3</version> </dependency> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-ImportXHTML</artifactId> <version>8.3.3</version> </dependency>注意 docx4j 8.x 依赖的org.slf4j:slf4j-api和log4j相关包,如果你在 Spring Boot 里碰到了日志冲突,记得排除掉它自带的 log4j,统一用 logback。还有就是,docx4j 8.x 会用到javax.xml.bind,JDK 8 自带没问题,但如果你用的是 JDK 9+,需要额外引入 JAXB 依赖。这个坑在官方文档里其实没有写得很醒目,我一开始在 JDK 11 的本地环境测试时,启动直接报了ClassNotFoundException: javax.xml.bind.JAXBElement,排查了很久才发现是版本和 JDK 的兼容性问题。
2.2 字体与基础设置
HTML 转 Word 一大难点是字体映射。docx4j 默认提取 XHTML 里的font-family,但如果系统里没有对应字体,Word 打开时会做字体替换,版式大概率会乱。我的做法是在转换前,把常见中文字体名做一层映射:页面里写“宋体”“SimSun”,映射成 docx 里的宋体;“微软雅黑”映射为Microsoft YaHei;“黑体”映射为SimHei。这个映射我是在fontFamily解析阶段通过自定义ConversionOption处理的,后面会详细说代码。
另外,docx4j 生成的 docx 默认页面大小是 A4,这在大多数国内业务场景是合适的,但如果你碰到要 Letter 纸型的需求,可以通过WordprocessingMLPackage的 section 属性调整。这些细节不提前摸清楚,后面交付到业务方手里,反馈肯定是“格式不对”“字体变了”这类让人头大的问题。
3. 核心实现:从 HTML 字符串到 docx 文件
3.1 最简可运行版本
先上一个最简单、能直接跑的代码。我这里把核心逻辑封装成一个HtmlToWordConverter类:
import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.openpackaging.parts.WordprocessingML.NumberingDefinitionsPart; import org.docx4j.convert.in.xhtml.XHTMLImporterImpl; import org.docx4j.convert.in.xhtml.XHTMLImporter; import java.io.File; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Paths; public class HtmlToWordConverter { public static void convert(String htmlContent, OutputStream out) throws Exception { WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.createPackage(); // 数字编号相关:列表转换需要 NumberingDefinitionsPart ndp = new NumberingDefinitionsPart(); wordMLPackage.getMainDocumentPart().getContents() .getBody().getEGBlockLevelElts().add(ndp); wordMLPackage.getMainDocumentPart().addTargetPart(ndp); XHTMLImporter importer = new XHTMLImporterImpl(wordMLPackage); // 最关键的一行:把 XHTML 转成 docx 的内容元素 wordMLPackage.getMainDocumentPart() .getContent().addAll( importer.convert(htmlContent, null) ); wordMLPackage.save(out); } public static void main(String[] args) throws Exception { String html = "<html><body>" + "<h1>测试标题</h1>" + "<p>这是<strong>加粗</strong>和<em>斜体</em>的测试</p>" + "<table border='1'><tr><td>单元格A</td><td>单元格B</td></tr></table>" + "</body></html>"; try (OutputStream fos = Files.newOutputStream(Paths.get("output.docx"))) { convert(html, fos); } } }这里有三点要说明。第一,NumberingDefinitionsPart的初始化是必须的,虽然示例里没有列表,但实际业务 HTML 里出现<ul>或<ol>的概率极高,不提前把它加到包结构里,遇到列表直接报空指针。第二,convert方法的第二个参数是XHTMLImporter.ConversionOption数组,可以用来控制解析行为,最简单的场景传 null 即可。第三,importer.convert()返回的是List<Object>,需要用addAll添加到主文档内容中,这个设计是为了支持一次转换插入多个块级元素。
3.2 处理文章正文中的图片
HTML 内嵌图片有两种常见形式:外链 URL 和 base64 编码。docx4j 的 ImportXHTML 对这两种形式的支持程度不一样,也是我在实际项目中花时间最多的地方之一。
对于 base64 编码的<img src="data:image/png;base64,...">,docx4j 8.3.3 自带支持较好,不需要额外处理,它会自动把图片数据解出来存到 docx 的 media 目录并建立关系。但需要注意图片格式识别,jpeg/png 没问题,如果是 gif,docx4j 在生成时会因为 Word 不支持动图而退化成静态图,基本能让它显示第一帧,但有些场景可能不满足需求。
对于外链 URL,docx4j 默认不会主动去下载网络图片,你需要在转换前把图片下载到本地,然后替换 img 标签的src为本地文件路径。这里有一个经验:尽量把图片压缩到合理尺寸再嵌入,不然生成的 docx 动辄几十 MB,Word 打开会卡成幻灯片。我通常会把超过 200KB 的图片统一压到 1200px 宽度以内再塞进去,这样既保证了打印清晰度,也控制了文件体积。
// 图片下载与替换示例 Map<String, String> urlToPath = new HashMap<>(); // 遍历 html 中所有 img 标签,自行下载后记录映射 // 使用 Jsoup 处理比较方便: Document doc = Jsoup.parse(htmlContent); for (Element img : doc.select("img")) { String src = img.attr("src"); if (src.startsWith("http")) { String localPath = downloadAndCompress(src); urlToPath.put(src, localPath); } } // 然后把 html 中 src 替换为本地文件路径再传给 importer这个流程我从一开始就放在了转换前置阶段,因为 docx4j 处理本地文件路径的 img 标签时,会根据文件后缀和探测到的 MIME 类型自动存储。需要提醒的是,如果你把src换成本地文件路径,注意路径分隔符用正斜杠,Windows 下的反斜杠转义有时候会出问题。
3.3 转换选项与返回结果控制
有时候我们不需要 HTML 整个页面转过去,只需要某个区域的内容,比如只转换<div id="content">内部。docx4j 的 XHTMLImporter 本身不对 HTML 做选择器级别的裁剪,所以我的做法是前置处理:用 Jsoup 解析 HTML,提取出目标节点,再序列化成一个新的 HTML 片段传给 importer。
还有一种常见需求是转换后拿到 Word 的各部分对象,而不是直接生成文件,比如要额外加封面页或签名域。这时候不要直接save,而是操作wordMLPackage,通过getMainDocumentPart().getContent()获取列表,然后往前插入或追加内容。我做过一个合同生成功能,就是在 HTML 正文转换后,再从模板里复制一段签署页 XML 加进去,整个过程不需要模板引擎就能完成。
4. 样式处理与格式还原:表格、字体与间距
4.1 表格转换的稳定方案
HTML 表格转 docx 表格是保真需求里最容易翻车的点。docx4j 的 ImportXHTML 对<table>、<tr>、<td>有基本映射,但生成的表格默认没有边框、没有列宽约束,合并单元格rowspan/colspan的支持也是阉割版。我在最初测试时发现,最稳妥的做法是先让 importer 生成基础表格,再用 docx4j 的 API 后处理表格样式。
以边框为例,docx4j 生成的表格tblPr里如果没有tblBorders,看起来就是无边框的。可以通过遍历文档里的Tbl对象,统一加上边框:
import org.docx4j.wml.Tbl; import org.docx4j.wml.TblPr; import org.docx4j.wml.TcPr; import org.docx4j.wml.Tr; import org.docx4j.wml.CTBorder; import org.docx4j.wml.STBorder; public static void setTableBorders(Tbl table, String color, int size) { TblPr tblPr = table.getTblPr(); if (tblPr == null) { tblPr = new TblPr(); table.setTblPr(tblPr); } org.docx4j.wml.TblBorders borders = new org.docx4j.wml.TblBorders(); CTBorder border = new CTBorder(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(size)); border.setSpace(BigInteger.valueOf(0)); border.setColor(color); borders.setTop(border); borders.setBottom(border); borders.setLeft(border); borders.setRight(border); borders.setInsideH(border); borders.setInsideV(border); tblPr.setTblBorders(borders); }这里有个 Word 的尺寸坑:sz的单位是 1/8 磅,而不是像素。比如你想设 1 磅边框,size就是 8;2 磅就是 16。这个单位换算我从 Word 的 XML 规范里翻到过一次,之后每次用都要心里默念三遍“8 是 1 磅”。
列宽控制也是高频需求。热词里有“word 表格列宽无法拖动”,这在手动编辑时代就是个痛点,在程序生成时更需要显式设置。docx4j 设置列宽的核心是TblGrid里的GridCol,以及每个TcPr里的TcW。如果这两处不一致,Word 打开后列宽就会按内容自适应。我的处理方式是先清空原有TblGrid,再按比例重新添加GridCol,同时逐单元格设置tcW,两者必须对得上,否则还是会被 Word 忽略。
4.2 段落与字体样式映射
HTML 里的p、h1~h6、ul、ol、blockquote这些元素,docx4j 都有内置映射。其中标题会映射到 docx 的 Heading 样式,这意味着生成的文档在 Word 的导航窗格里可以直接看到目录层级,这一点对长文档特别有用。
不过内置映射对字体的处理非常粗糙,它基本只认font-family和font-size,而且单位换算偶尔会出偏差。Word 里w:sz的单位是半磅,而 HTML 的px转过去时如果没做系数修正,字会偏小。我是自己在XHTMLImporterImpl的setConversionOption里覆盖了字体大小处理逻辑,核心思路是把 px 转成 pt(公式:pt = px * 72 / 96),再传给 docx。
行间距和段前后距也是容易被忽略的地方。HTML 里margin-bottom: 20px和 Word 的w:spacing并不是一一对应关系,docx4j 会根据一定规则转换,但实际效果往往和浏览器里的视觉排版有差距。我的建议是不要过度依赖 HTML 的样式细节,而是把重点放在文档结构正确、标题层级清晰上。记住一件事:Word 文档追求的往往是可编辑性和结构完整性,而不是像素级的网页还原度,如果你要像素级还原,那该导出 PDF 而不是 docx。
4.3 列表与编号的映射问题
列表是最容易出“看起来没问题,但打开就乱”的地方。XHTML 里的<ul>和<ol>转换后对应 docx 的numPr,而numPr依赖NumberingDefinitionsPart里的抽象编号定义。我前面代码里提前创建了NumberingDefinitionsPart,就是为了这一步。
如果你不提前初始化,遇到列表时会抛类似NullPointerException或者生成的文档里列表项没有编号但缩进还在。另一种情况是,多个独立列表被 Word 合并成同一编号序列,文档一打开,第二个列表从头编号变成继续编号。解决方案是,在转换前检查 HTML 中多个兄弟列表的目标,或者转换后通过 docx4j API 给各列表的numPr指定不同的numId。
// 给所有列表项重新分配 numId 的示例思路 int maxNumId = getMaxNumId(wordMLPackage); // 扫描现有 numId for (P p : allParagraphsWithNumPr) { if (shouldStartNewList(p)) { maxNumId++; setNumId(p, maxNumId); } }这个shouldStartNewList的判断逻辑,我在生产里简单处理为“每次遇到第一个<ul>或<ol>之后的段落就开新编号”,虽然不够智能,但对 CMS 产出的内容来说足够稳定了。
5. 实操过程:从 HTML 到 Word 的完整项目记录
5.1 一个典型业务需求的完整流程
我这里以“从 CMS 文章生成 Word 红头文件初稿”为例,演示完整代码流程。这个需求里 HTML 输入包含标题、作者、正文、表格、图片,输出要求:A4 竖版、正文宋体小四、标题黑体二号、图片居中、表格统一加边框、页脚加页码。
第一步是准备WordprocessingMLPackage并设置默认页面和样式。docx4j 的createPackage()默认自带一个空文档,但我需要先调整 section 的页面尺寸和页边距。具体做法是取出mainDocumentPart的content里的sectPr,修改pgSz和pgMar。
第二步是用 Jsoup 预处理 HTML,这一步非常必要。我会先去掉无用的script、style标签,把相对路径的图片链接补全为绝对路径或直接替换成 base64,把class属性里和 Word 无关的清理掉。做完这步再交给 docx4j,能少很多运行时异常。
第三步才是调用XHTMLImporterImpl完成主体转换,然后遍历文档统一补充表格边框和字体映射。
第四步是页脚页码。这里 docx4j 需要手动添加 footer part,并往里面插入P和FldSimple类型的页码域。直接操作 XML 链比较长,但网上关于FldSimple的示例很多,核心就是把PAGEREF或PAGE域写进 footer。
这个流程单跑一次大约耗时 1~2 秒,其中图片处理和样式后处理占了大部分。作为对比,同样逻辑如果用 LibreOffice headless 转换,耗时至少 5 秒以上,且内存占用明显偏高。这里我并不是说 1 秒多有多快,而是在说明纯 Java 库在这种场景下的开销优势对于批量任务来说是可以线性扩展的。
5.2 错过多层嵌套时的转换补救
还有一种常见输入是 HTML 里三层、四层嵌套的<div>,里面还混着<span>、<p>、<table>。docx4j 对多层嵌套的容忍度有限,遇到太深的嵌套或非标准标签(比如<br/>在<td>里的某些写法),生成的 docx 会有内容丢失或结构错乱。
我的补救方案是:在交给 docx4j 之前,先做一次“扁平化”处理。把所有<div>开标签替换为<p>,闭合标签替换为</p>,让内容结构更接近 Word 的块级段落模型。同时,把<br/>转成<p>或插入换行符。这个操作听着粗暴,但实际操作下来,转换失败率从 30% 降到了接近 0。
如果你对转换结果不满意或报错,docx4j 还提供了一个很有用的调试手段:它能生成中间 XHTML 和 docx 的心理模型打印。在转换过程里加入log4j的 DEBUG 日志,能直接看到每个元素被映射成了什么类型,定位问题非常高效。
5.3 模板化处理的扩展思路
有些场景并不是完全自由转换,而是把数据库里的数据填充到一个 Word 模板,再导出。docx4j 本身支持复杂的模板替换(AltChunk、Content Control DataBinding 等)。在我做的项目里,一部分数据通过 HTML 标签融入富文本正文,一部分结构化字段通过docx4j的Text替换实现,两者的结合效果不错。
思路是:先准备一个 docx 模板,里面用{{title}}、{{content}}这类占位符,再把 HTML 转换产物替换到{{content}}位置。替换方式很简单:找到包含占位符的段落,把段落里的Text对象替换为从 importer 得到的元素列表。要注意的是,如果你的占位符在同一个段落里还有其他文字,替换逻辑就要小心的拆分 run,这个细节让我一度纠结,最后索性要求模板里占位符独占一行。
6. 常见问题与排查技巧
6.1 转换后的 docx 在 Word 中打开报错
这是我遇到最多的反馈,用户拿来生成的文件塞进 Word,直接弹窗“文件损坏”。大多数情况下,这不是转换逻辑坏了,而是生成的 docx 缺少必要的 part 或关系。
一个典型原因是没有正确初始化NumberingDefinitionsPart,导致文档引用了不存在的编号定义。这种情况 Word 的修复机制有时能自动恢复,有时直接拒绝打开。问题在于 docx4j 的导入转换并不会主动检查这个依赖,需要你在创建 package 时主动加好。
另一个常见原因是图片二进制数据损坏,或者图片关系没有正确建立。排查时优先解压生成的 docx,看word/media/下图片文件是否完整,再看word/document.xml里对应的r:embed是否能对上关系 ID。如果不想手动解压,用 docx4j 的Docx4J.getPackage重新打开再save一次,如果这步能成,文件结构基本是健康的。
6.2 字体大小和样式错乱
抓狂时刻主要集中在两个点:一是中文显示为方块或不生效,二是字体大小和 HTML 里对不上。第一个问题多半是 docx 里引用了系统不存在的字体。第二个问题是 px 和 pt 的单位换算误差。最佳实践是在转换前建立一份统一的字号映射表,把常用的 12px、14px、16px 分别对应到 Word 的小四、四号、三号,这样反而比依赖库内部的换算要稳定得多。
6.3 性能问题:文件过大或转换缓慢
如果你的 HTML 里有大量高清大图,生成的 docx 很容易超过 50MB。Word 打开大文件时卡顿的概率极高,而且对用户来说体验极差。热词里出现了“word关闭时卡顿”和“word在试图打开文件时遇到错误”,很多就是这种大文件问题衍生出来的。我的处理原则是,在进入转换前对图片做统一的压缩处理,目标单张不超过 300KB,宽度控制在 1400px 以内。这样即使文件内容很多,最终 docx 也能保持在 10MB 以内。
另一个性能点是 XHTML 解析本身。如果 HTML 字符串特别长(几十万字符),转换耗时能明显拉开差距。这种情况下建议使用XHTMLImporter分批处理的方式,把大 HTML 按div节点拆成多个片段,逐一转换后再 addAll,这样也方便对局部失败做降级处理。
6.4 CSV 表格和复杂 HTML 混合
有时候 HTML 里嵌了一个用<pre>包裹的文本表格,或者就是一段从 Excel 粘贴出来的富文本,转换后格式可能会乱。这个问题不是 docx4j 能完全解决的,因为<pre>的语义在 Word 里并没有等价物。我的建议是在预处理阶段就把<pre>改写成真正的<table>或者保持等宽字体和换行结构,替换成多行<p>。
7. 我的经验总结与建议
如果你问我这个方案值不值得用,我的答案是好用,但前提是你要接受一件事情:docx4j 和 docx4j-ImportXHTML 并不是“一键完美转换”的魔法,而是一套需要你根据业务内容持续调校的工具链。它最大的价值在于把“HTML 结构和 docx 结构之间的翻译”这件事收敛到了一个可维护的模型里,而不是像 POI 那样全凭你手写一堆胶水代码。
从架构角度看,我建议你为“HTML 到 Word”的功能单独抽象一个服务层。输入是 HTML 纯文本,输出是 docx 的字节流或文件对象,内部具体用 docx4j 还是什么实现,对上层完全透明。这样当业务侧提出新的格式要求(比如缩进、字体、页边距)时,你只需要在转换服务内部加规则,而不需要改动上层接口。
回过头看这个文档,我发现自己花在调样式上的时间,其实大于“转换”本身的时间。但这也正是这类自动化工具的常态:核心算法只是骨架,真正决定交付质量的永远是那些被一遍遍调优的细节。我在后来的项目里把常用的后处理过程抽成了一个PostProcessor链,表格边框、页脚页码、字体映射、图片压缩各占一个处理器,按顺序执行。这样一来,新增一个处理规则只需要写一个新的 Processor,完全不影响原有逻辑。这也是我今天想安利给你的做事方式:先用 docx4j 跑通主流程,然后把那些“高频、复现、易错”的细节沉淀成可配置的规则,最终你会发现,HTML 转 Word 这件事虽然琐碎,但完全可以做到稳健可靠。
最后说一个很多人忽略的小技巧:在交付给用户的 docx 里,尽量保留原始的 HTML 结构映射关系,也就是把对象里的w:bookmark或自定义属性留好。这样用户后续在 Word 里做二次编辑时,可以通过导航窗格快速跳转,整个文档的可用性会好很多。这个细节,是我在给一个出版社做稿件自动转换时得到的教训——用户拿到文件后要频繁定位到特定章节修改,没有书签的文档简直是一场噩梦。