Java实现Word转PDF再转图片:POI+docx4j+PDFBox全链路实战

发布时间:2026/9/18 5:00:59
Java实现Word转PDF再转图片:POI+docx4j+PDFBox全链路实战 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 负责读模板、替换占位符、生成一份填好数据的临时 docxdocx4j 加载这份临时 docx导出 PDFPDFBox 把 PDF 逐页渲染成 PNG整条链路都是开源库没有外部服务依赖部署就是一台普通 Linux 服务器加一套 Java 环境。下面我把每个环节为什么这么选、具体怎么实现全部展开讲。2. 选型对比docx 转 PDF 的四条路线谁更适合你docx 转 PDF 是整个需求里最核心、也最容易踩坑的一段。市面上的方案大致能分成四条路线我分别说下优缺点和适用场景最后讲我怎么选的。2.1 路线一POI OpenPDF 手动排版用 POI 把 docx 里的段落、表格、图片全部读出来然后用 OpenPDFiText 的开源分支手动把内容画到 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 部署、开源免费、对常规表格和段落排版完全够用。我的建议是按文档复杂度划分场景推荐方案理由固定模板、结构规范的 docxdocx4j FOP开源、部署轻、常规排版够用用户自由编辑的复杂 docxLibreOffice 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.version5.2.5/poi.version docx4j.version8.3.9/docx4j.version /properties !-- Apache POI解析和处理 docx 内容 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version${poi.version}/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version${poi.version}/version /dependency !-- 处理 .doc 老格式时才需要 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-scratchpad/artifactId version${poi.version}/version /dependency !-- docx4jdocx 转 PDF 的核心 -- dependency groupIdorg.docx4j/groupId artifactIddocx4j/artifactId version${docx4j.version}/version /dependency !-- docx4j 的 XSL-FO 导出模块转 PDF 必须带上 -- dependency groupIdorg.docx4j/groupId artifactIddocx4j-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, MapString, 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, MapString, String data) { String text paragraph.getText(); if (text null || !text.contains(${)) { return; } for (Map.EntryString, 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 正式导出 PDFPOI 填好数据、生成临时 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 groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version3.0.2/version /dependencyPDFBox 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 怎么选我给了个参考表DPIA4 页面像素约用途72595 × 842快速缩略图够看出轮廓1441190 × 1684网页预览清晰且省流量2001654 × 2339高清晰预览适合放大查看3002480 × 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(); ListBufferedImage 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 MB50 页就是 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, MapString, 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, MapString, 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, MapString, String data) { String text paragraph.getText(); if (text null || !text.contains(${)) { return; } for (Map.EntryString, 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 :langzh查看系统有没有中文字体。很多精简版 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.headlesstrue或者代码里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 转 pdfNode.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 转 PDFPDFBox 按需渲染图片同时保留 PDF 直出预览的开关。整套链路跑了一年多除了偶尔有排版偏差需要调整模板外整体稳定。后来我又把同样的思路用在了投标文件预览、培训材料转码、电子签章系统里模式完全可以复用。如果你在实施过程中遇到什么奇怪的排版问题先别急着怀疑代码先检查源文档的复杂度再决定到底该调 FOP、加字体、还是切 LibreOffice——这比盲目调参有效得多。