☰
SpringBoot 实现 Word 转 PDF 再转图片的文档预览方案
2026/10/21 8:51:15 网站建设 项目流程

1. 项目背景与整体设计思路

1.1 这个需求到底在解决什么问题

先把这个项目的核心需求说清楚。业务场景大概是这样的:用户在系统里上传了一份 Word 文档(可能是合同、报告、说明书之类的),后端需要把这份 Word 转成 PDF,然后再把 PDF 按页拆成一张张图片,最后把这些图片传到前端做预览展示。整个链路是Word → PDF → 逐页图片 → 前端展示。

为什么非要绕这么一圈?直接让前端渲染 Word 不行吗?实际做过的人都知道,浏览器原生根本不支持解析.doc/.docx的复杂排版,市面上那些纯前端的 Word 预览库,遇到表格、页眉页脚、分页符、特殊字体就原形毕露,排版错乱是家常便饭。而 PDF 是固定版式,转成图片后前端只需要一个<img>标签就能展示,兼容性拉满,还能顺便做水印、防复制、防下载这些操作。所以这条链路虽然看起来"绕",但它是目前最稳、最可控的方案。

这个方案适合谁参考?主要是做企业级后台系统、OA、合同管理、在线文档预览这类业务的 Java 后端开发者。前端同学也能从中了解图片是怎么来的、为什么这么传。只要你的服务器是 Linux 环境,这篇文章里的每一步都能直接抄。

1.2 为什么选 SpringBoot 做这件事

SpringBoot 在这个场景里的价值不是"因为它流行",而是它能把文件上传、异步任务、静态资源映射、接口返回这几件事用最少的配置串起来。Word 转 PDF 和 PDF 转图片都是耗时操作,一份几十页的文档,转换可能要好几秒甚至十几秒。如果用同步接口,用户点一下上传,请求就卡在那里,体验极差,还容易触发网关超时。

所以整体设计上,我倾向于把流程拆成两段:上传接口只负责接收文件并落盘,返回一个任务 ID;真正的转换逻辑放到异步线程或者消息队列里跑;前端拿着任务 ID 轮询转换状态,转换完成后再去拉图片列表。这样接口响应快,转换慢的问题被异步消化掉了,用户看到的是一个"处理中"的进度提示,而不是一个转圈转到天荒地老的页面。

1.3 技术选型的核心考量

这条链路上有两个关键转换节点,选型直接决定成败。

Word 转 PDF这块,Java 生态里主流有三条路:一是用 Apache POI 读内容再自己画 PDF,工作量巨大且排版还原度差,基本不考虑;二是用 docx4j,它对 docx 支持不错但依赖重、配置繁琐;三是调用 LibreOffice 的命令行做无头转换。我实测下来,LibreOffice 的soffice --headless方案还原度最高、支持格式最全、维护成本最低,尤其是对.doc老格式和复杂表格的支持,明显优于纯 Java 库。缺点是需要服务器装 LibreOffice,但这个一次性成本完全可以接受。

PDF 转图片这块,主流是 Apache PDFBox 和 Icepdf。PDFBox 是 Apache 顶级项目,社区活跃、API 清晰、对字体和渲染的控制粒度细,我选它。Icepdf 也能用,但商用授权和渲染细节上不如 PDFBox 省心。

把这两个选型定下来,整条链路的技术底座就稳了:LibreOffice 负责格式转换,PDFBox 负责栅格化,SpringBoot 负责调度和对外接口。

2. 环境准备与核心依赖配置

2.1 Linux 服务器上的 LibreOffice 安装

这是整个项目最容易踩坑的一步,我把它放在最前面讲。很多人本地 Windows 测得好好的,一上 Linux 就报错,八成是 LibreOffice 没装对或者权限有问题。

在主流 Linux 发行版上,安装命令大致如下(以常见的包管理器为例):

# Debian / Ubuntu 系 sudo apt-get update sudo apt-get install -y libreoffice libreoffice-writer fonts-wqy-zenhei fonts-wqy-microhei # RHEL / CentOS 系 sudo yum install -y libreoffice libreoffice-headless libreoffice-writer wqy-zenhei-fonts

这里有两个关键点必须强调。第一,一定要装中文字体。LibreOffice 转换时如果找不到文档里用到的字体,会做字体替换,替换的结果往往是排版错位、文字变方块。fonts-wqy-zenhei这类文泉驿字体能覆盖大部分中文场景。第二,服务器上装的是无界面版本,转换必须加--headless参数,否则它会尝试启动图形界面然后直接失败。

装完之后验证一下:

soffice --headless --version

能打印出版本号就说明装好了。如果提示命令找不到,可能是soffice不在 PATH 里,用which soffice或者find / -name soffice找一下真实路径,后面代码里要配这个绝对路径。

提示:LibreOffice 转换时会占用一个用户配置目录,如果多个转换任务并发跑,可能会因为配置目录锁冲突而失败。解决办法是给每个转换任务指定独立的-env:UserInstallation参数,这个后面代码里会体现。

2.2 Maven 依赖清单

PDF 转图片用 PDFBox,版本建议用 2.0.x 的稳定版,3.x 虽然新但 API 变动较大,团队协作时容易踩到文档不一致的坑。

<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.29</version> </dependency> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>fontbox</artifactId> <version>2.0.29</version> </dependency>

至于 Word 转 PDF,因为我们走的是命令行调用 LibreOffice,所以不需要引入 POI 或 docx4j,只需要用 Java 的ProcessBuilder去执行外部命令即可。这一点很多人想复杂了,以为必须引一堆库,其实一个进程调用就搞定。

2.3 目录规划与配置项设计

文件流转需要一个清晰的目录结构,否则文件一多就乱套。我一般这样规划:

file: base-dir: /data/doc-convert upload-dir: /data/doc-convert/upload # 原始 Word pdf-dir: /data/doc-convert/pdf # 转换后的 PDF image-dir: /data/doc-convert/image # 拆页后的图片 libreoffice-path: /usr/bin/soffice # LibreOffice 可执行文件路径 image-dpi: 150 # 图片清晰度 image-format: png # 图片格式

把这些做成配置项而不是硬编码,好处是换服务器、调清晰度的时候不用改代码重新打包。image-dpi这个参数后面会重点讲,它直接决定图片清晰度和文件体积的平衡。

3. 核心实现:从 Word 到 PDF 的转换

3.1 用 ProcessBuilder 调用 LibreOffice

核心思路是拼一条命令,让 LibreOffice 把源文件转成 PDF 输出到指定目录。命令模板是这样的:

soffice --headless --norestore --invisible \ -env:UserInstallation=file:///tmp/lo_profile_xxx \ --convert-to pdf --outdir /输出目录 /源文件路径

每个参数都有它的意义,我逐个解释。--headless是无界面模式,服务器上必须加;--norestore是禁止它尝试恢复上次会话,避免卡住;--invisible让它彻底不显示任何窗口;-env:UserInstallation指定独立的用户配置目录,这是解决并发冲突的关键;--convert-to pdf声明目标格式;--outdir指定输出目录。

Java 代码实现:

public File convertWordToPdf(File sourceFile, String outputDir, String loPath) throws Exception { // 为每个任务生成独立的 profile 目录,避免并发锁冲突 String profileDir = "/tmp/lo_profile_" + UUID.randomUUID(); List<String> command = new ArrayList<>(); command.add(loPath); command.add("--headless"); command.add("--norestore"); command.add("--invisible"); command.add("-env:UserInstallation=file://" + profileDir); command.add("--convert-to"); command.add("pdf"); command.add("--outdir"); command.add(outputDir); command.add(sourceFile.getAbsolutePath()); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); Process process = pb.start(); // 读取输出,防止缓冲区满导致进程阻塞 StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } } // 设置超时,避免进程卡死 boolean finished = process.waitFor(60, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new RuntimeException("Word 转 PDF 超时"); } if (process.exitValue() != 0) { throw new RuntimeException("转换失败: " + output); } // LibreOffice 输出的文件名是源文件名改后缀 String pdfName = sourceFile.getName().replaceAll("\\.[^.]+$", "") + ".pdf"; File pdfFile = new File(outputDir, pdfName); if (!pdfFile.exists()) { throw new RuntimeException("PDF 未生成,检查 LibreOffice 日志"); } return pdfFile; }

3.2 转换环节的注意事项

这段代码里有几个细节是踩过坑才总结出来的,非常关键。

第一,必须读取进程的输出流。很多人只调process.waitFor()不读流,结果进程输出缓冲区满了之后直接卡死,表现为"转换永远不结束"。这是最隐蔽的坑之一。

第二,必须设置超时。LibreOffice 偶尔会因为文档损坏或字体问题卡住,没有超时机制的话线程就永久挂起了。60 秒对普通文档足够,超大文档可以适当放宽。

第三,输出文件名要自己推算。LibreOffice 转换后的文件名是"源文件名 + .pdf",但它对特殊字符的处理可能和你想的不一样。稳妥做法是转换后扫描输出目录,找到最新的 PDF 文件,而不是死板地拼文件名。

第四,profile 目录要定期清理。每个任务生成一个/tmp/lo_profile_xxx,跑久了/tmp会被塞满。建议在任务结束后异步删除,或者用定时任务清理超过一天的 profile 目录。

注意:如果服务器上同时跑多个转换任务,LibreOffice 本身是支持并发的,但前提是每个进程用独立的 profile 目录。共用 profile 会导致"另一个实例正在运行"的报错。

4. PDF 按页转图片的实现细节

4.1 PDFBox 渲染的核心逻辑

PDF 转图片的本质是:用 PDFBox 加载 PDF 文档,遍历每一页,把每一页渲染成一张位图,然后写出成 PNG 或 JPG 文件。核心类是PDFRenderer,它负责把 PDF 页面画到BufferedImage上。

public List<File> pdfToImages(File pdfFile, String imageDir, int dpi) throws IOException { List<File> imageFiles = new ArrayList<>(); try (PDDocument document = PDDocument.load(pdfFile)) { PDFRenderer renderer = new PDFRenderer(document); int pageCount = document.getNumberOfPages(); for (int i = 0; i < pageCount; i++) { // dpi 转成缩放比例,72 是 PDF 的基准 dpi float scale = dpi / 72f; BufferedImage image = renderer.renderImageWithDPI(i, dpi); String imageName = String.format("page_%03d.png", i + 1); File imageFile = new File(imageDir, imageName); ImageIO.write(image, "png", imageFile); imageFiles.add(imageFile); } } return imageFiles; }

renderImageWithDPI这个方法比renderImage更好用,因为它直接接受 DPI 参数,内部帮你算好了缩放比例,不用自己手动乘。页码从 0 开始,但输出文件名我从 1 开始编号,这样前端展示时更符合人的直觉。

4.2 DPI 参数怎么选才合理

这是整个项目里最需要权衡的参数,没有之一。DPI 太低,图片模糊,文字看不清;DPI 太高,图片体积爆炸,传输慢、内存占用高,甚至可能 OOM。

我做过一组实测对比,以一份 A4 大小的普通文档单页为例:

DPI单页图片尺寸(约)单页 PNG 体积(约)文字清晰度适用场景
72595 x 84280 KB偏糊,小字难辨缩略图预览
96794 x 1123150 KB一般快速预览
1501240 x 1754400 KB清晰常规展示(推荐)
2001654 x 2339750 KB很清晰需要放大查看
3002480 x 35081.6 MB印刷级打印、存档

我的建议是默认用 150 DPI,这是清晰度和体积的最佳平衡点。如果业务明确要求能放大看细节,上 200;如果只是列表里的小缩略图,96 甚至 72 就够了。千万别无脑上 300,一份 50 页的文档转出来就是 80 MB 图片,前端加载会哭的。

4.3 内存与性能优化

PDF 转图片是内存杀手,尤其是大文档。几个优化点必须做。

第一,用 try-with-resources 确保 PDDocument 关闭。PDFBox 加载的文档对象持有大量内存,不关闭的话,几次转换下来堆就满了。

第二,逐页处理,不要一次性把所有 BufferedImage 都留在内存里。上面的代码是渲染一页、写一页、释放一页,这是正确的做法。如果你先把所有页渲染成 List 再统一写,大文档必 OOM。

第三,合理设置 JVM 堆内存。转换服务建议单独部署,堆内存给到 2G 以上,-Xmx2g。如果和主业务混部,很容易互相影响。

第四,考虑图片格式。PNG 无损但体积大,JPG 有损但小很多。如果文档以文字为主,PNG 更清晰;如果含大量图片,JPG 更省空间。我一般文字文档用 PNG,图文混排用 JPG(质量 0.85)。

// JPG 输出示例,需要处理透明背景 BufferedImage rgbImage = new BufferedImage( image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); rgbImage.createGraphics().drawImage(image, 0, 0, Color.WHITE, null); ImageIO.write(rgbImage, "jpg", imageFile);

提示:PNG 支持透明通道,但 JPG 不支持。如果直接把带透明的 BufferedImage 写成 JPG,背景会变成黑色。所以要先画一层白色背景,这是很多人忽略的细节。

5. 异步任务与前端交互设计

5.1 为什么必须异步化

前面反复提到异步,这里说清楚为什么。一份 30 页的 Word,LibreOffice 转换大概 3 到 8 秒,PDF 转图片每页 0.3 到 1 秒,加起来十几秒很正常。如果同步接口,用户浏览器会一直转圈,Nginx 默认 60 秒超时,网关可能 30 秒就断了,用户看到的是"请求失败",但后端其实还在跑,体验极差。

异步化的做法是:上传接口接收文件后立即返回一个taskId,把转换任务丢给线程池执行,任务状态存在内存或 Redis 里。前端拿到taskId后每隔一两秒轮询一次状态接口,状态从PROCESSING变成SUCCESS后,再去拉图片列表。

5.2 任务状态管理

用一个简单的枚举加一个并发 Map 就能实现,生产环境建议换成 Redis 以便多实例共享。

public enum TaskStatus { PROCESSING, SUCCESS, FAILED } @Component public class TaskManager { private final Map<String, TaskStatus> statusMap = new ConcurrentHashMap<>(); private final Map<String, List<String>> resultMap = new ConcurrentHashMap<>(); public void setProcessing(String taskId) { statusMap.put(taskId, TaskStatus.PROCESSING); } public void setSuccess(String taskId, List<String> imageUrls) { statusMap.put(taskId, TaskStatus.SUCCESS); resultMap.put(taskId, imageUrls); } public void setFailed(String taskId) { statusMap.put(taskId, TaskStatus.FAILED); } public TaskStatus getStatus(String taskId) { return statusMap.getOrDefault(taskId, TaskStatus.FAILED); } public List<String> getResult(String taskId) { return resultMap.get(taskId); } }

5.3 线程池配置

不要用Executors.newFixedThreadPool这种快捷方法,它用的是无界队列,任务堆积时会 OOM。手动创建ThreadPoolExecutor,明确核心线程数、最大线程数、队列容量和拒绝策略。

@Bean("convertExecutor") public ThreadPoolExecutor convertExecutor() { return new ThreadPoolExecutor( 2, // 核心线程数,转换是 CPU 密集型,不宜过多 4, // 最大线程数 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(50), // 有界队列,防止无限堆积 new ThreadFactoryBuilder().setNameFormat("convert-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行 ); }

核心线程数为什么是 2 到 4?因为 LibreOffice 转换和 PDF 渲染都是 CPU 密集型,线程开太多反而互相抢 CPU,整体吞吐下降。一般按 CPU 核数来定,4 核机器给 2 到 4 个线程比较合适。

5.4 图片的对外访问

图片生成后存在服务器本地目录,前端要能访问到,有两种方式。一是配置 SpringBoot 的静态资源映射,把图片目录暴露成 URL 路径;二是写一个下载接口,通过流的方式返回图片。前者简单高效,适合内网;后者可以做权限校验,适合对安全有要求的场景。

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${file.image-dir}") private String imageDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/preview/**") .addResourceLocations("file:" + imageDir + "/"); } }

这样前端就能通过/preview/page_001.png访问到图片了。注意file:后面跟的路径要以斜杠结尾,否则映射会出问题,这是个小坑。

6. 常见问题排查与避坑实录

6.1 转换失败问题速查表

实际运维中遇到的问题五花八门,我整理了一张速查表,覆盖了九成以上的故障场景。

现象可能原因排查方向解决方案
转换后 PDF 不存在LibreOffice 未装或路径错手动执行命令看报错确认soffice绝对路径
中文变方块缺中文字体检查系统字体列表安装文泉驿等中文字体
并发时报"实例已运行"profile 目录冲突查看是否共用配置目录每任务独立 UserInstallation
进程卡死不返回输出流未读取检查是否读了 stdout必须消费进程输出流
图片全黑透明背景写 JPG检查图片格式先铺白底再写 JPG
大文档 OOM一次性加载所有页看堆内存曲线逐页处理及时释放
图片模糊DPI 太低检查配置调到 150 以上
前端图片 404资源映射路径错看映射配置路径以斜杠结尾

6.2 几个血泪教训

教训一:不要在生产环境用 root 跑转换。LibreOffice 以 root 运行时会有额外的权限检查,有时会拒绝执行。建议用专门的普通用户跑服务,profile 目录也放在该用户有权限的地方。

教训二:文件名里的中文和空格要处理。上传的 Word 文件名如果带中文或空格,传给 LibreOffice 时可能出问题。稳妥做法是上传后立即重命名成 UUID,原始文件名单独存数据库,转换全程用 UUID 文件名。

教训三:临时文件一定要清理。上传的 Word、中间 PDF、profile 目录,这些都是临时的。如果不清理,磁盘很快就被塞满。建议在任务完成后删除 Word 和 PDF(如果不需要保留),图片按业务需求定期清理。

教训四:转换服务要能优雅降级。如果 LibreOffice 挂了,接口不能直接 500,要返回明确的错误提示,并且记录日志方便排查。最好加个健康检查,转换服务不可用时前端能给出友好提示。

6.3 性能调优的实操心得

如果你的文档量很大,几个调优点值得试。一是预热 LibreOffice,第一次调用会慢一些,可以在服务启动时跑一次空转换预热。二是图片懒加载,前端不要一次性把所有页图片都请求下来,滚动到哪页加载哪页。三是加缓存,同一份文档如果被多次预览,转换结果可以缓存起来,用文件 MD5 做 key,避免重复转换。四是图片压缩,PNG 可以用工具再压一道,体积能再降 30% 左右。

我在实际项目里还遇到过一个情况:某些 Word 文档里嵌了 OLE 对象或者宏,LibreOffice 转换时会弹安全提示导致卡住。解决办法是在 LibreOffice 配置里关掉宏安全警告,或者转换前用工具把宏剥离掉。这个坑比较冷门,但一旦遇到就很头疼。

7. 完整流程串联与部署要点

7.1 从上传到展示的完整时序

把前面所有环节串起来,一次完整的预览请求是这样的:前端上传 Word 文件,后端保存到 upload 目录并生成 taskId,立即返回;线程池异步执行,先调 LibreOffice 转 PDF 到 pdf 目录,再用 PDFBox 逐页转图片到 image 目录;每完成一步更新任务状态;前端轮询到 SUCCESS 后,拿到图片 URL 列表,用<img>标签逐页展示。

这个流程里,任务状态是前端和后端之间的唯一契约,所以状态设计要清晰,建议至少包含 PROCESSING、SUCCESS、FAILED 三态,FAILED 时最好带上失败原因,方便前端提示用户。

7.2 部署清单

上线前对照这份清单检查一遍,能省掉很多线上事故。

  • 服务器已安装 LibreOffice 及中文字体,soffice --headless --version可正常输出
  • 配置文件中libreoffice-path指向正确的绝对路径
  • 上传、PDF、图片三个目录已创建且有写权限
  • JVM 堆内存配置合理,建议-Xmx2g起步
  • 线程池参数按服务器核数调整过
  • 静态资源映射路径正确,前端能访问到图片
  • 有定时任务清理临时文件和过期图片
  • 日志级别配置合理,转换失败时能定位到具体原因

7.3 后续可扩展的方向

这套方案跑通之后,还有不少可以深挖的地方。比如加水印,在 PDF 转图片时用 PDFBox 在页面上叠加文字或图片水印,实现防泄密;比如支持更多格式,LibreOffice 其实还能转 Excel、PPT,稍作改造就能支持;比如接入对象存储,图片不存本地而是传到云存储,配合 CDN 加速,前端加载更快;再比如转换进度反馈,把每页转换的进度实时推给前端,用户体验会更好。

我个人在实际操作中的体会是,这条链路的技术难点不在代码本身,而在环境配置和异常处理。LibreOffice 的坑、字体的坑、并发的坑、内存的坑,每一个都可能让你排查半天。但只要把上面这些细节都照顾到,整套方案是相当稳定的,我们线上跑了很久,日常几千次转换基本没出过问题。最后再分享一个小技巧:转换服务的日志一定要打详细,把 LibreOffice 的原始输出、PDFBox 的异常堆栈都记下来,出问题时这些日志就是救命稻草。

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

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

立即咨询