1. 需求拆解:文档预览为什么要走"docx → PDF → 图片"这条路
先把这个需求说透。Apache POI 是一个能读能写 Word、Excel、PowerPoint 的 Java 库,但你指望它一行代码把 Word 变成 PDF,那是不可能的。POI 本身不是一个渲染引擎,它管的是 OOXML 包结构、段落、表格、图片这些内容层面的东西,而"把内容排成页面"这件事,需要另一套布局引擎来干。
所以真实项目里凡是做 Word 转 PDF、PDF 转图片的,基本都是把几条开源链路拼起来用。我之前做一个企业合同预览系统,需求是用户在网页上直接看 Word 合同内容,不能下载、不能依赖办公软件,还要支持按页查看、加水印。最后落地的方案就是:docx 转 PDF,再按页转成 PNG 图片返回前端。整套链路里,Apache POI 负责的是最前面的内容预处理,PDF 渲染和图片输出分别交给 docx4j 和 PDFBox。
1.1 浏览器不能直接渲染 docx
一个非常朴素的事实:浏览器可以原生渲染 PDF,可以通过<img>标签展示图片,但没有哪个主流浏览器能直接打开 .docx 并保持和 Word 里一模一样的排版。docx 本质是一个 zip 压缩包,里面塞了一堆 XML 文件,浏览器拿到它只会触发下载,用户体验极差。
你也许会想,那我直接给前端装一个文档预览插件?麻烦事更多。很多预览组件要么依赖微软的 Office Online 服务,要求文档必须有一个可公网访问的 URL,要么是收费的商业组件,要么在复杂排版面前直接翻车。企业内网系统里最常见的做法,反而是服务端先把 docx 转成 PDF,让浏览器直接内嵌预览;如果业务上还需要逐页审批、打水印、做缩略图,那就再走一步,把 PDF 渲染成图片。
1.2 为什么 PDF 还不够,还要转图片
这是很多人一开始想不明白的点。PDF 已经能在浏览器里看了,为什么非要多此一举转成图片?
我遇到的实际场景是这样的:合同预览页需要每一页右下角盖一个"仅供预览"的半透明水印,还要支持用户拖拽调整页面缩放。如果直接给 PDF,水印要么在服务端用 PDF 库去盖,要么在前端用 PDF.js 渲染后再叠 Canvas,两条路都不省心。转成图片之后,水印直接用 Java 2D 画上去就行,前端一个<img>标签全搞定,缩放、懒加载、缩略图都用现成方案,性能也稳。
另外还有一个很实际的原因:PDF 渲染到图片之后,页面布局被"钉死"了。PDF 里字体没嵌入的话,用户机器上缺字体,看到的就是一堆方框;图片则完全不受客户端环境影响,打开什么样就是什么样。对于审批类、法务类系统,这种"所见即所得"很关键。
1.3 Apache POI 在这条链路里的真实定位
既然 POI 不能直接转换,为什么标题里还要带上它?因为在真实项目里,你要转换的 Word 文档很少是"拿来就能转"的。最常见的需求是模板套打:合同模板里有一堆${customerName}、${signDate}这样的占位符,得先把系统里的业务数据填进去,填完之后才能转 PDF。填数据这一步,就是 Apache POI 的主场。
POI 的XWPFDocument可以精确读写 docx 里的段落、Run、表格、页眉页脚,替换占位符、删除多余空行、调整表格列宽,这些都是 docx4j 不太擅长、或者说做起来比较别扭的事情。我的处理链路通常是:
- POI 负责读模板、替换占位符、生成一份"填好数据的临时 docx"
- docx4j 加载这份临时 docx,导出 PDF
- PDFBox 把 PDF 逐页渲染成 PNG
整条链路都是开源库,没有外部服务依赖,部署就是一台普通 Linux 服务器加一套 Java 环境。下面我把每个环节为什么这么选、具体怎么实现,全部展开讲。
2. 选型对比:docx 转 PDF 的四条路线,谁更适合你
docx 转 PDF 是整个需求里最核心、也最容易踩坑的一段。市面上的方案大致能分成四条路线,我分别说下优缺点和适用场景,最后讲我怎么选的。
2.1 路线一:POI + OpenPDF 手动排版
用 POI 把 docx 里的段落、表格、图片全部读出来,然后用 OpenPDF(iText 的开源分支)手动把内容画到 PDF 页面上。这个方案看起来"纯 Java、零外部依赖",实际上是在重新实现一个 Word 排版引擎。
Word 的排版逻辑非常复杂,段落间距、首行缩进、制表位、分页规则、表格边框、图片环绕、页眉页脚,每一项都要自己处理。我见过有人针对固定模板做了一套这样的转换器,光处理表格行高和跨页就写了三千行代码,而且换一个模板立刻崩溃。
所以这个路线的结论很明确:只有当你需要转换的文档是高度定制的、格式极度固定的模板时,才值得考虑。优点是输出完全可控,可以精确到每一个像素;缺点是开发量大、维护成本高、对模板变化极其敏感,不适合做通用方案。
2.2 路线二:docx4j 走 XSL-FO 导出
docx4j 是 Java 生态里专门处理 OOXML 文档的库,和 POI 是互补关系。它的开源导出路径是把 docx 里的内容转换成 XSL-FO 格式,再交给 Apache FOP 渲染成 PDF。
这条路线的最大优点是纯 JVM 内完成,不需要服务器上装任何额外软件,部署非常方便。对段落、标题、列表、普通表格这类常规排版的还原度相当不错,而且 docx4j 已经把 OOXML 解析、字体映射、图片提取这些脏活都包好了,你只要几行代码就能调用。
缺点是 Apache FOP 对复杂版式的还原能力有限。合并单元格特别多的表格、文本框、复杂页眉页脚、浮动的图片,转换出来经常会出现位置偏移或者错位。所以这个方案适合"文档结构相对规范"的场景——比如合同、公文、报告模板这类有固定样式的文档;不适合用来转换用户随便用 Word 做的花哨文档。
2.3 路线三:LibreOffice headless 转换
LibreOffice 安装之后自带一个 headless(无界面)运行模式,可以直接在命令行执行:
soffice --headless --norestore --convert-to pdf --outdir /output /input.docx在 Java 里用ProcessBuilder起一个子进程就能调用。这条路线对排版保真度是最好的,毕竟 LibreOffice 内置了完整的办公排版引擎,合并单元格、文本框、批注、页眉页脚都能比 FOP 处理得更好,而且同时支持 .doc 和 .docx。
缺点也很明显:服务器上必须安装 LibreOffice,镜像体积一下子就大了几百 MB;每次转换要起 JVM 外的进程,首次转换要初始化用户配置,速度偏慢;如果并发量大,还得自己管理进程池,防止多个 soffice 同时启动互相打架。此外,LibreOffice 用 headless 模式转换时,偶尔会有挂起的情况,必须设置超时时间并强制杀进程。
2.4 路线四:商业库
Aspose.Words、Spire.Doc 这类商业库,转换保真度最高、性能最好,API 也很简洁,基本是"一行代码"解决问题。但价格不便宜,Aspose 是按开发者授权收费的,一套下来动辄几万块;Spire.Doc 免费版有页数和文档大小限制。如果项目预算充足,又对转换质量有硬性要求,商业库确实是最省心的选择。但需要注意授权模式,很多商业库不允许服务端无限制使用,或者要求购买独立部署授权。
2.5 我的选择逻辑
我当时的需求是合同模板套打,文档结构相对固定——标题、正文段落、签名区、一个信息表格,几乎没有文本框和复杂合并单元格。基于这个前提,我选了路线二(docx4j + FOP),原因很简单:纯 Java 部署、开源免费、对常规表格和段落排版完全够用。
我的建议是按文档复杂度划分:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 固定模板、结构规范的 docx | docx4j + FOP | 开源、部署轻、常规排版够用 |
| 用户自由编辑的复杂 docx | LibreOffice headless | 保真度最高,能处理复杂版式 |
| 对转换质量要求极高且不差钱 | 商业库 | 性能好、还原度高、省维护成本 |
| 极简固定模板 | POI + OpenPDF | 可完全定制,但只适合最简单场景 |
如果你的系统里两种文档都有,那也别纠结,直接做成双引擎:先判断文档结构复杂度,简单走 docx4j,复杂走 LibreOffice。我为这个切换逻辑写过一个小分类器,判断依据就是 docx 里有没有文本框、合并单元格的数量、图片的浮动方式。这个后面可以单独再聊。
3. 前半段落地:POI 预处理 + docx4j 导出 PDF
选定 docx4j 路线之后,具体怎么落地?我按从依赖到代码的顺序,把每一步都讲清楚。这里先说好,以下代码基于 Java 8+、Apache POI 5.x、docx4j 8.3.x,生产环境我实测跑过,可以直接用。
3.1 Maven 依赖和版本约定
<properties> <poi.version>5.2.5</poi.version> <docx4j.version>8.3.9</docx4j.version> </properties> <!-- Apache POI:解析和处理 docx 内容 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>${poi.version}</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>${poi.version}</version> </dependency> <!-- 处理 .doc 老格式时才需要 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>${poi.version}</version> </dependency> <!-- docx4j:docx 转 PDF 的核心 --> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j</artifactId> <version>${docx4j.version}</version> </dependency> <!-- docx4j 的 XSL-FO 导出模块,转 PDF 必须带上 --> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j-export-fo</artifactId> <version>${docx4j.version}</version> </dependency>版本这块有两个坑提醒一下。第一,docx4j8.3.x 和 11.x 的模块结构不一样,11.x 拆成了docx4j-core、docx4j-export-fo等更多子模块,groupId 还是org.docx4j,但如果你从网上抄到的是老版本写法,注意核对。第二,docx4j 传递依赖里会带一份老的xmlgraphics-commons和 FOP,如果项目里同时用了其他报表库,可能会出现类冲突。我遇到过 FOP 版本冲突导致的NoClassDefFoundError,最终是靠排除传递依赖、显式指定版本解决的。
3.2 用 POI 做文档内容预处理
POI 的用法很多,我这里只说和转换链路最相关的场景:替换占位符。一个典型的合同模板长这样:
import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.util.Map; public class DocxTemplater { /** * 将模板中的 ${key} 占位符替换为 value * 注意:占位符可能被拆散在多个 Run 中,所以要单独处理 */ public static void fillTemplate(String templatePath, String outputPath, Map<String, String> data) throws Exception { try (InputStream in = new FileInputStream(templatePath); XWPFDocument doc = new XWPFDocument(in)) { // 遍历正文段落 for (XWPFParagraph paragraph : doc.getParagraphs()) { replaceInParagraph(paragraph, data); } // 遍历表格里的段落(合同里很多占位符藏在表格中) for (var table : doc.getTables()) { for (var row : table.getRows()) { for (var cell : row.getTableCells()) { for (var paragraph : cell.getParagraphs()) { replaceInParagraph(paragraph, data); } } } } try (OutputStream out = new FileOutputStream(outputPath)) { doc.write(out); } } } private static void replaceInParagraph(XWPFParagraph paragraph, Map<String, String> data) { String text = paragraph.getText(); if (text == null || !text.contains("${")) { return; } for (Map.Entry<String, String> entry : data.entrySet()) { text = text.replace("${" + entry.getKey() + "}", entry.getValue()); } // 清空原有 Run,重新写回 for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) { paragraph.removeRun(i); } XWPFRun run = paragraph.createRun(); run.setText(text); } }这里有个特别容易踩的坑:Word 在编辑时,${customerName}这种字符串经常会被拆到多个 Run 里,比如${customer一个 Run、Name}另一个 Run。你如果只遍历单个 Run 做replace,会发现替换不成功。我上面的代码是先取整个段落的getText(),替换完之后清空所有 Run 再一次性写回,这样能绕开拆分问题。代价是段落内原有的局部字体样式会丢失,所以对样式敏感的场景,你得先把第一个 Run 的字体信息拷贝到新建的 Run 上,或者干脆要求模板作者不要在同一段内混用多种字体。
另外,页眉页脚里的占位符也要处理。POI 访问页眉页脚的 API 是doc.getHeaderFooterPolicy(),不同版本的 POI 这个方法返回可能为 null(当文档没有显式页眉时),调用前记得判空。
3.3 docx4j 正式导出 PDF
POI 填好数据、生成临时 docx 之后,就该 docx4j 上场了。代码非常简单:
import org.docx4j.Docx4J; import org.docx4j.fonts.BestMatchingMapper; import org.docx4j.fonts.PhysicalFonts; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import java.io.File; import java.io.FileOutputStream; import java.io.OutputStream; public class DocxToPdfConverter { public static File convert(File docxFile, File pdfFile) throws Exception { // 1. 加载 docx WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(docxFile); // 2. 中文字体处理(后面详细讲) PhysicalFonts.discoverPhysicalFonts(); wordMLPackage.setFontMapper(new BestMatchingMapper()); // 3. 导出 PDF try (OutputStream out = new FileOutputStream(pdfFile)) { Docx4J.toPDF(wordMLPackage, out); } return pdfFile; } }Docx4J.toPDF内部做的事情,是把 WordprocessingMLPackage 里的内容转成 XSL-FO,再交给 Apache FOP 渲染成 PDF。整个过程都在 JVM 内存里完成,不需要外部进程。如果你想排查排版问题,可以先用Docx4J.toFO(wordMLPackage, new FileOutputStream("debug.fo"))导出一份 FO 文件看看,FOP 渲染之前的中间状态一目了然。
有一点要说明:Docx4J.toPDF在 docx4j 8.x 里会提示 deprecated,但功能完全正常,生产环境跑没问题。它是 docx4j 自己提供的便捷入口,内部封装了导出模块的调用,比手动 new 各种转换对象省事得多。
3.4 .doc 老格式怎么办
POI 的 HWPF 模块可以读取 .doc 老格式的文本和部分结构,但如果你指望 HWPF 把 .doc 转成清晰可用的 PDF,基本可以死心。HWPF 对 .doc 二进制格式的解析能力远不如 XWPF 对 .docx 的解析,很多布局信息读不出来。
我的建议是:遇到 .doc 文件,直接跳过 POI 和 docx4j,用 LibreOffice headless 转换。或者更细一点:先用 LibreOffice 把 .doc 转换成 .docx,再走上面的 docx4j 流程。两条路本质上都是在依赖 LibreOffice 的兼容层。Java 里调用很简单:
import java.io.File; import java.util.concurrent.TimeUnit; public class LibreOfficeConverter { public static File convertToPdf(File input, File outputDir) throws Exception { ProcessBuilder pb = new ProcessBuilder( "/usr/bin/soffice", "--headless", "--norestore", "--convert-to", "pdf", "--outdir", outputDir.getAbsolutePath(), input.getAbsolutePath() ); pb.redirectErrorStream(true); Process process = pb.start(); // 必须设置超时,LibreOffice 偶尔会挂起 boolean finished = process.waitFor(120, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new RuntimeException("LibreOffice 转换超时"); } if (process.exitValue() != 0) { throw new RuntimeException("LibreOffice 转换失败,exit code=" + process.exitValue()); } String name = input.getName().replaceAll("(?i)\\.doc$", "") + ".pdf"; return new File(outputDir, name); } }注意,这个soffice路径在 Linux 上一般是/usr/bin/soffice,在 macOS 上通常在/Applications/LibreOffice.app/Contents/MacOS/soffice,最好做成可配置项。另外,LibreOffice 第一次启动时会初始化用户配置文件目录,耗时可能达到十几秒,生产环境建议提前手动跑一次,让配置目录初始化好。
3.5 中文字体与基础排版保真
这是整条链路里最折磨人的问题。默认情况下,在纯英文 Linux 服务器上跑 docx4j 转换,输出 PDF 里的中文全变成方框或者直接消失。根因是系统里没有中文字体,Java 的 AWT 字体发现机制找不到匹配的字体,FOP 自然也就渲染不出来。
解决思路分三步,缺一不可。
第一步,服务器安装中文字体。Debian/Ubuntu 上执行:
apt-get update apt-get install -y fontconfig fonts-noto-cjk fc-cache -ffonts-noto-cjk是 Google 的 Noto CJK 字体,覆盖简体、繁体、日文、韩文,体积略大但省心。装完之后用fc-list | grep -i cjk验证一下,能列出一堆 Noto CJK 字体就说明成功了。
第二步,在代码里让 docx4j 重新扫描系统字体:
PhysicalFonts.discoverPhysicalFonts(); wordMLPackage.setFontMapper(new BestMatchingMapper());discoverPhysicalFonts()会调用 Java AWT 的字体枚举,把系统里所有可用字体注册到 docx4j 的字体注册表里。BestMatchingMapper做的是模糊匹配:docx 里写的字体是"宋体",系统里没有宋体但有 Noto Sans CJK SC,它会按相似度匹配过去。
第三步,如果系统字体复杂,BestMatchingMapper匹配到不想要的字体,可以手动指定映射关系:
import org.docx4j.fonts.PhysicalFont; import org.docx4j.fonts.IdentityPlusMapper; IdentityPlusMapper mapper = new IdentityPlusMapper(); PhysicalFont notoCjk = PhysicalFonts.get("Noto Sans CJK SC"); mapper.put("宋体", notoCjk); mapper.put("SimSun", notoCjk); mapper.put("黑体", notoCjk); mapper.put("SimHei", notoCjk); wordMLPackage.setFontMapper(mapper);IdentityPlusMapper会在原有的"按名字精确匹配"基础上,增加你手动添加的别名映射。这个做法适合模板里字体种类固定、需要精确控制的场景。
还有一个容易被忽略的细节:docx 里如果设置了很多自定义字号、行距、缩进,FOP 未必能百分之百还原。尤其是"段前段后间距"和"首行缩进字符数"这两个属性,OOXML 和 XSL-FO 的表达方式不同,docx4j 的 FO 导出器做了转换,但复杂情况下会有几个像素级别的偏差。这个无解,只能接受,或者对排版要求更高的文档走 LibreOffice 路线。
4. 后半段落地:PDFBox 渲染出高清图片
docx 变成 PDF 之后,下一步是把 PDF 逐页转成图片。这一步我用的是 Apache PDFBox,它自带一个PDFRenderer类,底层基于 Java 2D 把 PDF 页面绘制成BufferedImage,效果稳定,也是社区里最主流的做法。
4.1 引入 PDFBox 并完成首张渲染
先加依赖:
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>3.0.2</version> </dependency>PDFBox 3.x 和 2.x 的 API 有一点差异,最明显的是加载文件的方式。2.x 用PDDocument.load(File),3.x 改成了Loader.loadPDF(File)。我第一次升级的时候没注意,编译直接报错找不到load方法。下面是 3.x 的写法:
import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; public class PdfToImageConverter { public static void renderFirstPage(File pdfFile, File outputImage) throws Exception { try (PDDocument document = Loader.loadPDF(pdfFile)) { PDFRenderer renderer = new PDFRenderer(document); BufferedImage image = renderer.renderImageWithDPI(0, 144, ImageType.RGB); ImageIO.write(image, "png", outputImage); image.flush(); } } }renderImageWithDPI(0, 144, ImageType.RGB)三个参数分别是页码(从 0 开始)、DPI 和颜色类型。一个 A4 页面在 144 DPI 下大约是 1190×1684 像素,作为网页预览清晰度已经合格。
4.2 图片清晰度的核心:DPI 与图片格式
很多人在"word 转 pdf 如何不压缩图片"这个问题上纠结,其实要分三段来看。
第一段,源头 docx 里的图片。Word 在保存文档时,默认会压缩插入的图片。要保留原始画质,需要在 Word 里设置:文件 → 选项 → 高级 → 图像大小和质量,勾选"不压缩文件中的图像"。如果源头图片已经被 Word 压过了,后面任何工具都救不回来。
第二段,docx 转 PDF 时。docx4j 和 LibreOffice 默认都会把文档里的图片以原始分辨率嵌入 PDF,不会主动压缩。所以这一步通常不会掉画质。
第三段,PDF 转图片时。这里才是"看起来变模糊"的高发区。PDF 里的图片是一个矢量页面上的位图对象,你把它渲染成多少像素,取决于你设置的 DPI,而不是 PDF 里图片本身的分辨率。DPI 设置低了,渲染出来的图片像素不够,再用 CSS 拉伸到页面的显示尺寸,自然就糊了。
DPI 怎么选?我给了个参考表:
| DPI | A4 页面像素(约) | 用途 |
|---|---|---|
| 72 | 595 × 842 | 快速缩略图,够看出轮廓 |
| 144 | 1190 × 1684 | 网页预览,清晰且省流量 |
| 200 | 1654 × 2339 | 高清晰预览,适合放大查看 |
| 300 | 2480 × 3508 | 印刷级,图片体积很大 |
计算公式很简单:页面物理宽度(英寸)× DPI = 像素宽度。A4 宽 8.27 英寸,高 11.69 英寸,200 DPI 就是 1654×2338 像素左右。生产环境我一般用 150~200 DPI,视业务对清晰度的要求浮动。
图片格式方面,PNG 是无损压缩,适合文字和图表;JPEG 体积小但有损,适合大图片。文档预览我建议用 PNG,因为文字边缘更锐利。如果嫌 PNG 体积大,可以控制输出 DPI,或者对纯灰度页面做一次BufferedImage.TYPE_BYTE_GRAY灰度转换,体积能降一半以上。
4.3 多页文档与长图拼接
一份合同通常好几页甚至几十页。逐页输出的逻辑很简单,但实际项目里常常还需要把一个 PDF 拼成一张长图,方便前端纵向滑动浏览。实现思路也不复杂:
import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.util.ArrayList; import java.util.List; public class PdfToLongImage { public static File renderAsLongImage(File pdfFile, int dpi) throws Exception { try (PDDocument document = Loader.loadPDF(pdfFile)) { PDFRenderer renderer = new PDFRenderer(document); int pageCount = document.getNumberOfPages(); List<BufferedImage> pages = new ArrayList<>(); int totalHeight = 0; int width = 0; for (int i = 0; i < pageCount; i++) { BufferedImage pageImage = renderer.renderImageWithDPI(i, dpi, ImageType.RGB); pages.add(pageImage); totalHeight += pageImage.getHeight(); width = Math.max(width, pageImage.getWidth()); } BufferedImage longImage = new BufferedImage(width, totalHeight, BufferedImage.TYPE_INT_RGB); var graphics = longImage.createGraphics(); int y = 0; for (BufferedImage page : pages) { graphics.drawImage(page, 0, y, null); y += page.getHeight(); page.flush(); } graphics.dispose(); File output = new File(pdfFile.getParent(), "long.png"); ImageIO.write(longImage, "png", output); return output; } } }这里要注意,如果你直接把所有页面BufferedImage都存进 List 再拼接,内存占用是"单页图片 × 页数"。一份 50 页的合同,200 DPI 下单页图片约 11 MB,50 页就是 550 MB,很容易堆炸。所以我上面的代码虽然为了拼接先收集了所有页面,但每页用完就flush(),并且推荐在大文档场景下改用"边渲染边写入固定高度画布"或者干脆逐页输出单独文件。真要做长图,先把所有页面渲染到磁盘上的临时图片文件,再用 ImageIO 逐张读入拼接到目标画布,内存只会占一份画布加一页图片。
逐页输出还有一个好处:可以配合前端做懒加载。比如后端生成一个图片 URL 列表,前端滚动到第几页才加载第几页的图,用户体验比一次性加载一张几十 MB 的长图好得多。
4.4 并发与内存控制
PDFBox 的PDDocument不是线程安全的,每个线程必须持有自己加载的实例。如果你的转换接口允许并发调用,建议用线程池限流,控制同时进行渲染的任务数。我生产环境的经验是,单台 4 核 8 GB 的服务器,并发数控制在 4 以内比较稳,再多就容易把堆内存吃满。
另外,PDFRenderer.renderImageWithDPI是 CPU 密集型操作。大文档渲染 200 DPI 图片,单页耗时可能在几百毫秒到一两秒。如果业务允许,先做一次低 DPI 的快速预览,用户点击放大时再重新按高 DPI 渲染,这是一个性价比非常高的优化策略。配合缓存,把已经渲染好的图片按文档 ID 加页码作为 key 存到本地磁盘或对象存储,重复访问几乎零成本。
5. 完整工具类:docx 到图片一条龙
前面分了两段讲,这里我把它们串成一个完整工具类。这个类我直接改自生产项目,去掉了业务相关的代码,保留了核心逻辑,你复制到项目里调整一下路径就能用。
5.1 工具类代码
import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.docx4j.Docx4J; import org.docx4j.fonts.BestMatchingMapper; import org.docx4j.fonts.PhysicalFonts; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.*; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Map; public class WordToImageConverter { private final String tempDir; private final int dpi; public WordToImageConverter(String tempDir, int dpi) { this.tempDir = tempDir; this.dpi = dpi; } /** * docx -> pdf -> 多张 png */ public File[] convert(String docxPath, Map<String, String> placeholders) throws Exception { String name = new File(docxPath).getName().replaceAll("(?i)\\.docx?$", ""); // 1. POI 填充占位符,生成临时 docx File filledDocx = new File(tempDir, name + "_filled.docx"); fillTemplate(docxPath, filledDocx.getAbsolutePath(), placeholders); // 2. docx4j 转 PDF File pdfFile = new File(tempDir, name + ".pdf"); docxToPdf(filledDocx, pdfFile); // 3. PDFBox 渲染图片 File[] images = pdfToImages(pdfFile, name); // 4. 清理中间文件 Files.deleteIfExists(filledDocx.toPath()); Files.deleteIfExists(pdfFile.toPath()); return images; } private void fillTemplate(String templatePath, String outputPath, Map<String, String> data) throws IOException { try (InputStream in = new FileInputStream(templatePath); XWPFDocument doc = new XWPFDocument(in)) { for (XWPFParagraph paragraph : doc.getParagraphs()) { replaceInParagraph(paragraph, data); } for (var table : doc.getTables()) { for (var row : table.getRows()) { for (var cell : row.getTableCells()) { for (var paragraph : cell.getParagraphs()) { replaceInParagraph(paragraph, data); } } } } try (OutputStream out = new FileOutputStream(outputPath)) { doc.write(out); } } } private void replaceInParagraph(XWPFParagraph paragraph, Map<String, String> data) { String text = paragraph.getText(); if (text == null || !text.contains("${")) { return; } for (Map.Entry<String, String> entry : data.entrySet()) { text = text.replace("${" + entry.getKey() + "}", entry.getValue()); } for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) { paragraph.removeRun(i); } XWPFRun run = paragraph.createRun(); run.setText(text); } private void docxToPdf(File docxFile, File pdfFile) throws Exception { WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(docxFile); PhysicalFonts.discoverPhysicalFonts(); wordMLPackage.setFontMapper(new BestMatchingMapper()); try (OutputStream out = new FileOutputStream(pdfFile)) { Docx4J.toPDF(wordMLPackage, out); } } private File[] pdfToImages(File pdfFile, String baseName) throws Exception { File outputDir = new File(tempDir, baseName + "_images"); if (!outputDir.exists() && !outputDir.mkdirs()) { throw new IOException("无法创建图片目录: " + outputDir); } try (PDDocument document = Loader.loadPDF(pdfFile)) { PDFRenderer renderer = new PDFRenderer(document); int pageCount = document.getNumberOfPages(); File[] images = new File[pageCount]; for (int i = 0; i < pageCount; i++) { BufferedImage image = renderer.renderImageWithDPI(i, dpi, ImageType.RGB); File imageFile = new File(outputDir, String.format("%s_%03d.png", baseName, i + 1)); ImageIO.write(image, "png", imageFile); image.flush(); images[i] = imageFile; } return images; } } public static void main(String[] args) throws Exception { WordToImageConverter converter = new WordToImageConverter("./tmp", 180); File[] images = converter.convert("contract.docx", Map.of( "customerName", "某某科技有限公司", "signDate", "2024-06-01" )); for (File image : images) { System.out.println("生成图片: " + image.getAbsolutePath()); } } }5.2 临时文件与异常处理
这个工具类有一个容易踩的细节:docx4j 的WordprocessingMLPackage.load()和 PDFBox 的Loader.loadPDF()在加载文件时,都会把整个文件读进内存。如果这个文件恰好是带宏的 docm 或者损坏的 docx,加载阶段就会抛异常。所以调用方必须做好异常兜底,不要让转换失败拖垮整个请求线程。
临时文件的管理也要注意。filled.docx和中间的 PDF 是给转换链路用的,转换完就该清理。如果中途抛异常,Files.deleteIfExists可能没执行到,临时垃圾就会越积越多。建议在main方法那个入口,用 try-catch-finally 包裹,在 finally 里做清理,或者干脆用一个统一的临时目录,启动任务时先清空这个目录里的历史文件。
5.3 生产环境的调用策略
生产环境不要每次请求都现场转换。同一个合同模板,换几个变量就要重新走一遍 POI + docx4j + PDFBox 全流程,很费 CPU。我的做法是加一层缓存:以"文档内容哈希 + 业务数据 hash"作为 key,第一次转换后把图片存起来,后面直接返回缓存结果。业务数据一变,哈希就变,缓存自然失效,不需要手动维护。
另一个建议是把转换任务做成异步。用户上传模板或触发转换请求后,立刻返回"处理中",后台线程池慢慢跑,完成后通过消息或轮询通知前端。转换 20 页以内的文档通常在 2 到 5 秒内能完成,但用户不会愿意在页面上干等,异步是更稳的交互方式。
6. 实测排坑:我踩过的四个典型问题
这部分是真正的干货。我把生产环境里遇到过的四个高频问题,按"现象 → 排查链路 → 根因 → 解决"的顺序写出来,你照着排查能省很多时间。
6.1 中文变方框的完整排查链路
现象:用 docx4j 转出来的 PDF 里,中文全部显示为方块,英文和数字正常。
排查链路:
- 先用文本编辑器打开生成的 PDF,检查字体信息。FOP 如果找不到字体会在日志里打印 WARN,提示"Font X not found",先看日志。
- 登录服务器执行
fc-list :lang=zh,查看系统有没有中文字体。很多精简版 Docker 镜像连 fontconfig 都没装,这一步基本一眼看出问题。 - 确认代码里有没有执行
PhysicalFonts.discoverPhysicalFonts()。如果没执行,docx4j 的字体注册表是空的,FOP 拿不到任何字体。 - 确认字体映射策略。
BestMatchingMapper会做模糊匹配,但要求系统里至少有相近的中文字体。
根因基本逃不出这三层:系统没字体、docx4j 没扫描、映射策略不对。解决就是我 3.5 节写的那三步:装fonts-noto-cjk、discoverPhysicalFonts()、setFontMapper。
有个很容易忽略的点:JVM 的java.awt.headless属性。生产环境跑GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()时,如果服务器没有图形界面又没有设置 headless 模式,会抛HeadlessException。所以你最好在启动参数里显式加-Djava.awt.headless=true,或者代码里System.setProperty("java.awt.headless", "true")。
6.2 转换出来图片模糊
现象:页面上显示的图片看着发虚,文字边缘有锯齿,放大后更明显。
排查链路:
- 先确认源头图片没被压缩。把 docx 用 zip 工具解开,看
word/media/目录下的原始图片分辨率和大小。如果原图本身就只有 300×200,那转出来注定不清晰,和工具没关系。 - 确认 PDF 里的图片分辨率。把 PDF 用 PDFBox 加载,遍历页面资源里的 XObject,看每个图片对象的宽度和高度(单位是像素)以及它在页面上的显示尺寸(单位是点)。如果像素远小于显示尺寸,说明 PDF 嵌入的图就是低分辨率的,问题出在 docx → PDF 这段。
- 确认 PDF → 图片的 DPI。这是最常出问题的一环,很多人用默认的 72 DPI 渲染,然后在网页上按页面宽度拉伸显示,必然糊。把 DPI 提到 150 以上,肉眼差距非常明显。
根因九成是 DPI 设置不合理。我遇到过好几个人说是"图片被压缩了",结果一看代码,renderImage(0)用了默认 72 DPI。
解决:按展示场景选择 DPI。网页缩略图 96~144,详细预览 150~200,需要放大细看的场景统一 200。这样既保证清晰度,又不至于让单页 PNG 冲到几十 MB。
6.3 大文件内存溢出
现象:转换一个几十 MB、内含大量高清图片的 docx 时,JVM 抛出OutOfMemoryError: Java heap space。
根因:前面说过,POI 的XWPFDocument和 docx4j 的WordprocessingMLPackage都会把整个文档加载进内存。一份 50 MB 的 docx,解压后 XML 和图片在内存里的占用可能膨胀到 300~400 MB。如果文档里有几百张大图,内存直接失控。
解决路径:
- 增大 JVM 堆。治标,但最直接。生产机器内存够的话,
-Xmx2g或更高是合理配置。 - 控制并发。线程池并发数别开太大,4 核机器上转换任务并发 2~3 个比较稳。
- 对超大文档,改用 LibreOffice headless。LibreOffice 是独立进程,不占 JVM 堆,内存由操作系统管理,转换完进程退出内存就释放,反而更不容易把 Java 应用拖垮。
- 如果必须用 docx4j,且文档结构允许,可以考虑先对文档里的图片做降采样再转换。POI 可以遍历文档里的内嵌图片,把超过一定尺寸的图缩小后重新写入,能显著降低后续内存占用的峰值。
6.4 表格合并单元格错位
现象:docx 里一个正常的表格,合并了两行三列,用 docx4j 转 PDF 后,单元格位置错乱、边框缺失。
根因:XSL-FO 的表格模型和 Word 的表格模型不是一一对应的。Word 里用gridSpan、vMerge描述合并单元格,FOP 对垂直合并的支持很有限,渲染时经常把跨行合并的单元格打散,导致行高和列宽全部错位。
排查与解决:
- 先看
debug.fo,确认 docx4j 生成的 FO 文件里表格结构是否正确。如果 FO 本身就丢了 vMerge 信息,那是 docx4j 的 FO 导出器问题,无解,只能换方案。 - 如果 FO 里结构正确但渲染错乱,那是 FOP 的渲染问题。一个绕弯的办法是把表格"拍平":用 POI 遍历表格,把合并单元格的文字复制到所有被合并的单元格里,去掉合并属性再转换。这样表格变成规整的矩阵,FO 能正常渲染,代价是边框线没法完美还原。
- 最省心的方案:合并单元格多的文档,直接从 docx4j 切到 LibreOffice。LibreOffice 对 Word 表格的兼容性比 FOP 好一个量级,基本不会错位。
这个坑的教训是:选型之前一定要先拿你的真实文档做一轮转换测试,别等上线了才发现某个固定模板的表格转出来是歪的。
7. 延伸:nodejs 方案、pdf 转 word 与前端预览的取舍
最后聊几个相关但方向不同的问题。你搜"java word 转 pdf"的时候,肯定也会看到 nodejs 和前端预览的方案,这里一并说清楚。
7.1 nodejs 怎么实现 word 转 pdf
Node.js 生态里没有像 POI 那样成熟的 docx 解析库,所以 word 转 pdf 基本是两条路。一条是用 npm 包libreoffice-convert,它本质上是封装了 LibreOffice 的命令行调用,跟你我在 Java 里直接调soffice是一样的原理,只是换了个语言壳子。另一条是 Windows 环境下用word包通过 COM 接口调用本机 Office,这个只适合 Windows 服务器,而且并发能力很弱。
所以如果你已经在用 Java 技术栈,没必要为了这个功能专门引入 Node 服务。如果团队是 Node 技术栈,则建议直接用libreoffice-convert,服务器的中文字体照样要配好,否则同样会遇到方框问题。
7.2 pdf 转 word 是另一个维度的问题
网上搜"pdf 转 word"的人不少,但这和"word 转 pdf"完全是两码事。Word 转 PDF 是渲染问题,把流式排版变成固定页面;PDF 转 Word 是逆向布局重建问题,要从固定页面里识别出段落、表格、图片的位置和逻辑顺序,难度高一个量级。
用 PDFBox 的PDFTextStripper可以提取 PDF 里的纯文本,但提取出来是"一行接一行"的流水账,没有段落结构,也没有表格。要恢复出接近原始 Word 的排版,基本得靠商业库(比如前面的 Aspose)或者在线服务。如果你只是想拿到文字内容做检索、比对,PDFBox 提取文本就够了;如果你想完美还原可编辑文档,别指望开源方案能免费实现。
7.3 什么时候根本不需要后端转图片
最后说一个反方向的建议:有些场景其实不需要后端把 PDF 转成图片。PDFBox 转图片是有成本的,无论是 CPU 还是存储。如果业务只是让用户在线看文档,不需要加水印、不需要逐页审批、不需要缩略图,那直接后端把 PDF 返回给前端,用浏览器内置 PDF 查看器或者 pdf.js 渲染就够了。pdf.js 在前端渲染 PDF,用户能自由缩放、选择文字、打印,体验比图片好不少。
我的原则是:能用 PDF 直接预览的,就尽量别转图片;只有当业务上需要"钉死页面"(水印、审批、对比标注)或者"看不了 PDF"(某些老旧浏览器、内嵌到流程表单里)的时候,才让后端渲染图片。这样既能保证体验,又能省掉一大笔不必要的资源开销。
回到最开始那个合同预览系统,最终定型的方案就是:docx 模板用 POI 填数,docx4j 转 PDF,PDFBox 按需渲染图片,同时保留 PDF 直出预览的开关。整套链路跑了一年多,除了偶尔有排版偏差需要调整模板外,整体稳定。后来我又把同样的思路用在了投标文件预览、培训材料转码、电子签章系统里,模式完全可以复用。如果你在实施过程中遇到什么奇怪的排版问题,先别急着怀疑代码,先检查源文档的复杂度,再决定到底该调 FOP、加字体、还是切 LibreOffice——这比盲目调参有效得多。