☰
JDK17下Word转PDF实战:jar接入、字体嵌入与并发避坑
2026/10/27 8:58:01 网站建设 项目流程

简介:面向需要在 Java 项目中实现 Word 文档转 PDF 的开发者,这份工具包提供 aspose-words 21.11 的 jdk17 版本 jar 及配套示例代码,可用于合同、报表、公文等文档的批量输出与格式固化场景,适合具备一定 Java 工程经验、希望减少自行封装与调试成本的技术人员参考。压缩包共 2 个文件,包含 1 个 jar 依赖和 1 个 Java 示例,整体约 13.7MB,体量轻便,便于直接纳入本地工程或私有依赖目录管理。示例代码展示了依赖配置、转换调用及许可证(License)设置等关键内容,能帮助读者理解 jar 在项目中的接入位置、转换入口与基本配置要点,为后续结合业务场景调整和验证转换结果提供参考。目前已有 707 人学习下载,说明该版本组合在 Word 转 PDF 场景中具有一定实践价值,适合需要快速搭建转换能力、对照示例排查集成问题的 Java 开发者收藏使用。

1. 从一份 30 页合同转 PDF 说起:这个 jar 和示例能省掉哪些折腾

Word 转 PDF 在 Java 服务里最常见的翻车点,不是代码写不出来,而是线上 Word 模板一换字体、页眉一多,导出的 PDF 就花版。这个资源给的是一个aspose-words-21.11-jdk17.jar和一组可跑示例,定位很明确:在 JDK17 环境里把 doc/docx 稳定落成 PDF,适合需要保留分页、页眉页脚、目录域和嵌入字体的后台导出场景。

它不是教你怎么从零造轮子,而是把“能加载、能保存、参数怎么调、失败看哪里”这条链路先跑通。下面按依赖落地、最小转换、复杂文档参数、避坑排查和验证脚本拆开,新手能照着复现,熟手重点看字体、授权和并发边界。

2. 环境与依赖落地:JDK17 下把 jar 和示例接到项目里

2.1 先判断这份 jar 适合走本地仓库还是直接 files 引用

拿到一个独立 jar,第一反应不要是往src/main/resources里一塞,更不要随手写systemscope。systemscope 在 Maven 里看起来省事,实际会把路径写死,换一台构建机就容易找不到文件,CI 上尤其明显。常见做法是先把 jar 安装到本地仓库或公司内部制品库,再按普通依赖引用;如果只是临时跑示例,Gradle 的files或flatDir也能用,但不要把它当成长期方案。

这份资源的文件名里带jdk17,说明它面向 JDK17 运行时。接进项目前先确认三件事:项目编译目标是不是 17、运行容器里有没有同类的 Word 处理包、打包后 jar 有没有被 shaded 插件改名。尤其是已经引了其他文档转换库的项目,类路径冲突往往不是启动就报,而是转换到某个复杂模板才露出来。我一般会先在一个干净的空项目里跑通,再往业务工程里合,省得把依赖问题和文档问题混在一起查。

接入方式适用场景注意点
Maveninstall:install-file单机、CI、内网构建坐标自定义,别冒充官方坐标
内部制品库上传多人协作、多项目复用版本号固定,避免同名覆盖
Gradlefiles临时示例、小工具路径相对项目根,换目录要同步改
Mavensystemscope不建议长期使用路径硬编码,打包和 CI 容易翻车

2.2 Maven 本地仓库安装命令与坐标

先把 jar 放到一个固定目录,比如项目根下的libs/,然后在libs/目录执行安装命令。下面的坐标local.thirdparty只是本地仓库里的临时坐标,不是对外发布的坐标,目的是让 Maven 能按普通依赖解析。

# 在 jar 所在目录执行;Windows 下把反斜杠续行换成一行或使用 Git Bash mvn install:install-file \ -Dfile=aspose-words-21.11-jdk17.jar \ -DgroupId=local.thirdparty \ -DartifactId=aspose-words \ -Dversion=21.11-jdk17 \ -Dpackaging=jar

-Dfile指向实际 jar,-DgroupId、-DartifactId、-Dversion三者组成后续引用的坐标,-Dpackaging=jar告诉 Maven 这是 jar 包。安装成功后,本地仓库里会出现对应目录。这里不要把groupId写成某个看起来像官方的名字,否则以后换版本时容易混淆。安装完在pom.xml里按下面的方式引用:

<dependency> <groupId>local.thirdparty</groupId> <artifactId>aspose-words</artifactId> <version>21.11-jdk17</version> </dependency>

如果项目有父pom统一管理版本,把version提到dependencyManagement里即可,子模块只留groupId和artifactId。参数上唯一要盯的是版本号:21.11-jdk17是一个整体,不要只写21.11,否则本地仓库里可能找不到对应文件。安装命令执行一次就够,换机器或清空本地仓库后需要重装。

2.3 Gradle 项目里的两种接法

Gradle 项目如果只是跑示例,最直接的是把 jar 放进libs/,然后在build.gradle里用files引用。路径是相对项目根目录的,别写成绝对路径,否则换一台机器就断。

dependencies { // 适合临时示例:jar 放在项目根目录的 libs 下 implementation files('libs/aspose-words-21.11-jdk17.jar') }

如果项目里已经有多个本地 jar,可以用flatDir统一目录。flatDir只按文件名找包,不解析传递依赖,所以适合这种独立 jar,不适合依赖关系复杂的库。

repositories { flatDir { dirs 'libs' } } dependencies { // name 不带 .jar 后缀 implementation name: 'aspose-words-21.11-jdk17' }

两种方式二选一即可。files的优点是路径明确,缺点是多一个 jar 就要多写一行;flatDir的优点是目录统一,缺点是依赖解析能力弱。我的习惯是示例工程用files,正式工程走内部制品库,避免构建脚本里出现一堆本地路径。

2.4 授权与字体初始化的放置位置

这个库在未加载授权文件时会有评估版限制,常见表现是输出带水印或页数受控。正式使用前要把授权文件放到运行环境可读的位置,并在应用启动阶段初始化一次。下面的代码把授权和字体目录放在同一个入口里,避免每个转换方法都重复设置。

import com.aspose.words.FontSettings; import com.aspose.words.License; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; public final class ResourceBootstrap { private static volatile boolean initialized = false; public static synchronized void init(String licensePath, String fontsDir) throws Exception { if (initialized) { return; } // 有授权文件就加载;没有则跳过,先用小样本验证转换链路 if (licensePath != null && new File(licensePath).isFile()) { try (InputStream in = new FileInputStream(licensePath)) { License license = new License(); license.setLicense(in); } } // 字体目录建议独立挂载,不要依赖操作系统默认字体 if (fontsDir != null && new File(fontsDir).isDirectory()) { FontSettings.getDefaultInstance().setFontsFolder(fontsDir, true); } initialized = true; } }

licensePath指向授权文件,fontsDir指向字体目录,第二个参数true表示递归扫描子目录。初始化方法加synchronized和volatile是为了在多线程启动时只执行一次,避免并发下重复加载。授权文件不要写进代码仓库,走环境变量或配置中心更稳妥。没有授权文件时也能跑通最小示例,但别把带水印的输出直接交给业务方。

3. 最小转换链路:Document 加载、PDF 保存与参数落点

3.1 最小闭环:Document 加载、SaveFormat.PDF 保存

先把最窄的链路跑通:一个 docx 进去,一个 PDF 出来。不要一上来就加页眉页脚、PDF/A、并发和批量,先把Document能加载、save能落盘这两步确认掉。示例代码只做两件事,加载和保存,异常直接抛给调用方,方便在 IDE 里看到完整堆栈。

import com.aspose.words.Document; import com.aspose.words.SaveFormat; import java.nio.file.Path; public class MinimalWordToPdf { public static void main(String[] args) throws Exception { if (args.length < 2) { System.err.println("usage: MinimalWordToPdf <input.docx> <output.pdf>"); System.exit(2); } Path src = Path.of(args[0]); Path dst = Path.of(args[1]); // 加载 Word 文档;doc 和 docx 都走这个入口 Document doc = new Document(src.toString()); // SaveFormat.PDF 表示按 PDF 输出,不额外指定参数 doc.save(dst.toString(), SaveFormat.PDF); System.out.println("done: " + dst.toAbsolutePath()); } }

args[0]是输入文件,args[1]是输出文件,SaveFormat.PDF是固定枚举。这个版本适合验证 jar 是否接对、授权是否生效、输出目录是否有写权限。如果这一步就失败,先看异常第一行是文件找不到、格式不支持还是授权问题,不要急着调参数。最小闭环跑通后,再往PdfSaveOptions上加东西。

3.2 PdfSaveOptions 的参数落点:先调字体、图片和输出优化

PdfSaveOptions是 Word 转 PDF 时最常动的入口。参数很多,但第一轮只需要盯字体嵌入、图片质量、输出优化和合规级别。下面的示例给出一组保守起点,适合大多数后台导出场景。

import com.aspose.words.Document; import com.aspose.words.PdfCompliance; import com.aspose.words.PdfSaveOptions; public class PdfTuning { public static void main(String[] args) throws Exception { Document doc = new Document(args[0]); PdfSaveOptions opt = new PdfSaveOptions(); // 完整嵌入字体,减少换机器后字体替换导致的版式漂移 opt.setEmbedFullFonts(true); // JPEG 质量从 90 起步,太高会增大文件,太低会糊图 opt.setJpegQuality(90); // 优化输出结构,通常对体积和打开速度都有帮助 opt.setOptimizeOutput(true); // 只有下游明确要求 PDF/A 时才打开,打开后要单独验收 opt.setCompliance(PdfCompliance.PDF_A_1_B); doc.save(args[1], opt); } }
参数作用建议起点注意点
setEmbedFullFonts(true)嵌入完整字体中文模板建议开文件会变大,需验收体积
setJpegQuality(90)控制图片压缩质量80 到 92扫描件多时优先保质量
setOptimizeOutput(true)优化 PDF 输出结构默认开个别老阅读器要回归测试
setCompliance(PDF_A_1_B)输出 PDF/A 合规按下游要求不是所有文档都能直接过

setEmbedFullFonts(true)解决的是字体替换问题,不是字体缺失问题。如果运行环境根本没有安装模板里的字体,嵌入也无从谈起,所以字体目录要单独准备。setJpegQuality只影响图片型内容,文字不会因为这一项变糊。setCompliance打开后,输出要拿目标系统实际验收,别只看文件能不能生成。

3.3 包装成 CLI:参数校验、目录创建和失败退出码

示例跑通后,下一步是包成可重复执行的命令行工具。命令行工具的好处是能把输入、输出、退出码固定下来,批量脚本和调度系统都能接。下面的版本加了参数数量校验、输入文件存在性检查、输出目录创建和异常退出码。

import com.aspose.words.Document; import com.aspose.words.PdfSaveOptions; import java.nio.file.Files; import java.nio.file.Path; public class WordToPdfCli { public static void main(String[] args) { if (args.length < 2) { System.err.println("usage: WordToPdfCli <input.docx> <output.pdf>"); System.exit(2); } Path src = Path.of(args[0]).toAbsolutePath().normalize(); Path dst = Path.of(args[1]).toAbsolutePath().normalize(); if (!Files.isRegularFile(src)) { System.err.println("input not found: " + src); System.exit(3); } try { // 输出目录不存在时自动创建,避免调度任务因目录缺失失败 Files.createDirectories(dst.getParent()); Document doc = new Document(src.toString()); PdfSaveOptions opt = new PdfSaveOptions(); opt.setOptimizeOutput(true); doc.save(dst.toString(), opt); System.out.println("OK " + dst); } catch (Exception e) { // 打印完整堆栈,调度系统按非 0 退出码重试或告警 e.printStackTrace(); System.exit(1); } } }

退出码约定很实用:2表示参数不对,3表示输入文件不存在,1表示转换异常。批量脚本里只判断退出码,不用去解析日志。toAbsolutePath().normalize()是为了把相对路径统一成绝对路径,避免工作目录不同导致输出跑到别处。Files.createDirectories对已存在目录不会报错,适合重复执行。

3.4 输出完整性和原子落盘

转换失败时最怕留下半个 PDF,下游扫描到文件就当成成功。常见做法是先写临时文件,转换成功后再原子移动到目标路径。这样即使进程中途被杀,目标目录里也不会出现损坏文件。

import com.aspose.words.Document; import com.aspose.words.SaveFormat; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; public class AtomicWordToPdf { public static void convert(Path src, Path dst) throws Exception { Files.createDirectories(dst.getParent()); Path tmp = Files.createTempFile(dst.getParent(), "word2pdf-", ".pdf"); try { Document doc = new Document(src.toString()); doc.save(tmp.toString(), SaveFormat.PDF); // 同一文件系统内优先原子移动,失败时回退到替换 Files.move(tmp, dst, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } finally { Files.deleteIfExists(tmp); } } }

createTempFile把临时文件放在目标目录下,保证后续移动尽量落在同一文件系统。ATOMIC_MOVE不是所有文件系统都支持,所以加上REPLACE_EXISTING作为兜底。finally里删临时文件,避免失败后留下垃圾。这个模式在批量转换里很值,尤其是定时任务和消息队列消费场景,能省掉很多“文件存在但打不开”的排查。

4. 字体、页眉页脚与域更新:复杂 Word 转 PDF 的参数控制

4.1 字体目录:中文、符号字体和嵌入策略

中文文档转 PDF 最玄学的问题就是字体。开发机上有宋体、黑体,服务器上只有基础字体,导出的 PDF 就可能方块字、行距变化、页数偏移。解决办法不是把字体一个个塞进系统目录,而是准备一个独立字体目录,在应用启动时注册给文档引擎。

import com.aspose.words.FontSettings; public class FontConfig { public static void useLocalFonts(String fontsDir) { // 第二个参数 true 表示递归扫描子目录 FontSettings.getDefaultInstance().setFontsFolder(fontsDir, true); } }

fontsDir建议挂载到容器里的固定路径,比如/data/fonts,权限设为只读。字体文件要覆盖模板里实际用到的中文字体、符号字体和西文字体。只放一个中文字体不够,页眉里的特殊符号、公式里的数学字体都可能缺。FontSettings是全局设置,初始化一次即可,不要在每个转换方法里反复设置。多线程环境下,建议在启动阶段调用,再提交转换任务。

字体嵌入和字体目录是两件事:字体目录解决“找得到”,setEmbedFullFonts(true)解决“带得走”。如果下游只在网页里看,嵌入常用字体就够;如果要归档或打印,完整嵌入更稳。代价是 PDF 体积上涨,扫描件和图片多的文档更明显,所以批量任务要抽样看体积分布。

4.2 域更新和页码:先 updateFields 再保存

Word 模板里的目录、页码、总页数、交叉引用都是域。文档在 Word 里打开时会自动更新,但程序加载后不一定是最新值。常见做法是在保存 PDF 前调用updateFields(),再触发一次页面布局,确保页码和总页数按最终排版计算。

import com.aspose.words.Document; public class FieldAndPageCheck { public static int prepare(Document doc) { // 更新目录、页码、交叉引用等域 doc.updateFields(); // 触发布局计算,getPageCount 才有意义 doc.updatePageLayout(); return doc.getPageCount(); } }

updateFields()放在加载之后、保存之前,顺序不要反。updatePageLayout()会按当前字体和页面设置重新排版,getPageCount()返回计算后的页数。页数可以用来做验收:源文档在 Word 里显示 30 页,输出 PDF 突然变成 28 页,优先查字体替换、分节符和页边距,而不是怀疑保存格式。

目录域还有一个边界:如果模板里的目录需要手动更新才准确,程序更新后仍可能和 Word 里看到的不完全一致。验收时拿一份带目录、带总页数、带交叉引用的模板做样本,别只拿纯文字文档测试。样本覆盖越接近真实模板,后面批量越少返工。

4.3 页眉页脚与分节符:不要用 PDF 后期补

页眉页脚、分节符、奇偶页不同、首页不同,这些都属于 Word 排版层。转 PDF 时不要想着先转出来再补页眉,后期补很容易和分页对不上。正确做法是把模板改好,让 Word 里看到什么,PDF 就输出什么。程序侧只负责加载、更新域和保存,不插手版面结构。

问题位置Word 侧处理转换侧注意
页眉横线粗细不一致统一段落边框和样式不要用图片替代横线
分节后页码重新开始检查节页码设置保存前更新域
奇偶页页眉不同开启奇偶页不同并分别设置样本要覆盖奇偶页
首页不显示页眉勾选首页不同验收第一页和第三页

如果下游要求 PDF/A,页眉页脚里的透明图片、特殊字体和颜色空间都可能成为不合规点。setCompliance(PDF_A_1_B)只是让输出朝合规方向走,最终是否通过要用目标系统验收。我的习惯是 PDF/A 单独做一条转换链路,不和普通 PDF 混在一起,省得参数互相牵制。

4.4 大文档流式处理与线程池边界

大文档转换时,内存峰值往往出现在加载和保存两个阶段。能用流就用流,避免先把整个文件读进byte[]。下面的写法从InputStream加载,往OutputStream保存,适合文件已经落在磁盘上的场景。

import com.aspose.words.Document; import com.aspose.words.PdfSaveOptions; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; public class StreamConvert { public static void convert(Path src, Path dst) throws Exception { try (InputStream in = Files.newInputStream(src); OutputStream out = Files.newOutputStream(dst)) { Document doc = new Document(in); PdfSaveOptions opt = new PdfSaveOptions(); opt.setOptimizeOutput(true); doc.save(out, opt); } } }

流式处理减少的是应用层缓冲,不代表内存占用一定低,文档模型本身仍会占内存。批量任务要限制并发数,常见起点是 2 到 4 个线程,再根据单文档大小和容器内存调。不要共享同一个Document跨线程保存,Document实例按任务创建、按任务释放。授权和字体设置可以全局初始化一次,转换对象不要全局复用。

线程池提交任务时给每个任务独立的输入输出路径,失败任务记录源文件、目标文件、异常类型和耗时。只记录“转换失败”四个字,后面排查等于没有线索。大文档建议单独队列,不要和小文件抢同一批线程,否则一个小文件超时会把整批拖慢。

5. 避坑与排查:水印、乱码、OOM、并发和路径的五次翻车

5.1 现象:导出 PDF 带评估水印或只转出前几页

现象:本地测试输出正常,换到测试环境后 PDF 右上角出现水印,或者长文档只转了前几页。原因通常不是 jar 损坏,而是授权文件没有加载,或者授权文件路径在容器里不可读。评估版限制不会在加载文档时报错,往往要到保存后才看出来,所以很容易被当成排版问题。

解决:在应用启动阶段加载授权文件,加载失败要打日志,不要静默吞异常。授权文件用只读挂载,路径通过环境变量传入。转换任务开始前做一次自检:输出一份已知页数的样本,检查页数和水印。如果授权文件是 XML,确认文件没有被 BOM 或换行破坏。没有授权时不要拿评估版输出直接上线,先用小样本验证链路,再换正式授权环境跑回归。

5.2 现象:中文变成方块或宋体变默认字体

现象:开发机导出正常,Linux 容器里中文变成方块,或者标题字体从宋体变成默认无衬线,行距跟着变,页数也变了。原因是运行环境没有模板使用的字体,文档引擎做了字体替换。字体替换不一定是报错,可能只是“看起来差不多”,但分页已经偏了。

解决:准备独立字体目录,启动时调用FontSettings.getDefaultInstance().setFontsFolder(fontsDir, true),并确认容器内路径可读。把模板里用到的字体列出来,逐个核对是否存在。转换参数里打开setEmbedFullFonts(true),减少下游打开时的二次替换。验收不要只看第一页,拿带中文标题、正文、表格、页眉的样本对比页数和关键段落位置。字体目录不要放在临时目录,容器重启后容易丢。

5.3 现象:大批量转换时 OOM 或进程被系统杀掉

现象:单文件转换正常,批量跑几十个后 Java 进程内存持续上涨,最后 OOM,或者容器直接被系统杀掉,日志里没有业务异常。原因常见有三种:一次性把大文件读进内存、并发数开太高、Document对象没有及时释放。文档模型比普通 POJO 重,几十 MB 的 docx 展开后占用可能翻几倍。

解决:优先用流式加载和保存,批量任务限制并发,单任务处理完及时让对象失去引用。JVM 参数按容器内存设置上限,不要用默认堆。把大文件和小文件分队列,大文件串行或低并发处理。日志里记录每个文件的源大小、耗时和峰值内存不好拿,就至少记录耗时和失败率。出现 OOM 后先降并发,再查是否有超大图片或嵌入对象。不要用“加大堆内存”当唯一手段,堆越大,单次回收越慢。

5.4 现象:并发后偶发输出损坏、页数不对

现象:单线程跑一百个文件都正常,开到 8 个线程后偶发 PDF 打不开、页数少一页、目录页码错乱。原因是多个线程共享了同一个Document或同一个输出流,或者全局字体设置在转换过程中被反复修改。文档对象不是线程安全的,保存过程中共享实例会出现很难复现的玄学问题。

解决:一个任务一个Document,一个任务一个输出流,不跨线程复用。授权和字体设置在启动阶段完成,转换阶段只读。线程池大小从 2 开始压测,观察失败率和内存曲线。输出先写临时文件再原子移动,避免下游读到半成品。失败任务保留源文件和临时文件一段时间,方便复现。并发问题不要靠重试掩盖,先确认共享状态是否清理干净。

5.5 现象:Windows 能跑 Linux 报找不到文件或字体

现象:开发在 Windows 上跑示例正常,部署到 Linux 后报文件找不到,或者字体目录明明存在却加载不到。原因通常是路径分隔符、大小写、权限和挂载点不一致。Windows 对路径大小写不敏感,Linux 敏感;Windows 用反斜杠,Linux 用正斜杠;容器里的/data/fonts可能没挂上。

解决:代码里统一用Path和正斜杠,不手拼字符串。配置项写容器内绝对路径,启动时打印实际解析后的路径和是否存在。字体目录做启动自检,目录不存在直接失败,不要等到转换时才报。输出目录给写权限,临时目录单独挂载并定期清理。跨平台脚本里注意 classpath 分隔符,Linux 是冒号,Windows 是分号。路径问题看着低级,但血泪经验是它最容易被“本地正常”拖到上线才发现。

6. 进阶验证与批处理技巧:三页样本、页数核对和一条验收脚本

批量转换前,我一般先做三件事:拿一份三页以上的真实模板做样本,确认字体、页眉页脚、目录域都能正常输出;用同一份样本分别跑单线程和双线程,确认并发下输出一致;检查输出文件头是不是%PDF-,页数是否和源文档预期一致。不要拿纯文字文档当唯一验收样本,真实模板里的表格、分节符、图片和域才是问题源头。

#!/usr/bin/env bash set -euo pipefail jar="libs/aspose-words-21.11-jdk17.jar" mkdir -p out for f in samples/*.docx; do base="$(basename "${f%.*}")" out="out/${base}.pdf" # Linux 下 classpath 用冒号;Windows 改用分号 java -cp "$jar:cli-classes" WordToPdfCli "$f" "$out" # 只做最低限度完整性检查:文件头和文件大小 test "$(head -c 5 "$out")" = "%PDF-" || { echo "bad pdf: $out"; exit 1; } test -s "$out" || { echo "empty pdf: $out"; exit 1; } echo "checked: $out" done

这段脚本的jar指向本地依赖,cli-classes是编译后的类目录,samples/*.docx是样本目录。head -c 5检查 PDF 文件头,test -s检查非空。它不能替代视觉验收,但能挡住“任务显示成功、文件其实损坏”的低级问题。正式批处理再加一层页数核对:把源文档预期页数写进清单,转换后比对,偏差超过阈值就进人工队列。

从那以后我每次接 Word 转 PDF 的活,都先拿三页样本走一遍字体、域和授权检查,再开并发;这个习惯救过我好几次。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询